[PR #303] [MERGED] fix: bridge header auth into a real Better Auth session (#29) #3861

Closed
opened 2026-06-01 22:39:59 -05:00 by GiteaMirror · 0 comments
Owner

📋 Pull Request Information

Original PR: https://github.com/RayLabsHQ/gitea-mirror/pull/303
Author: @arunavo4
Created: 5/27/2026
Status: Merged
Merged: 5/27/2026
Merged by: @arunavo4

Base: mainHead: fix/header-auth-session-bridge


📝 Commits (1)

  • 76b6ec7 fix: bridge header auth into a real Better Auth session

📊 Changes

7 files changed (+489 additions, -27 deletions)

View changed files

src/lib/auth-header-bridge.test.ts (+123 -0)
src/lib/auth-header-bridge.ts (+56 -0)
src/lib/auth-header-plugin.test.ts (+29 -0)
src/lib/auth-header-plugin.ts (+78 -0)
src/lib/auth-header.test.ts (+158 -0)
📝 src/lib/auth.ts (+9 -0)
📝 src/middleware.ts (+36 -27)

📄 Description

Summary

Fixes header / forward authentication (Authentik / Authelia / oauth2-proxy / Caddy), which has been end-to-end broken since the v3 rewrite. Reported on issue #29 by @lanrat with a clean v3.16.1 repro.

Originally opened as #302 stacked on #301 (the better-auth 1.6.11 bump). #301 merged; this PR is the bridge rebased onto main. The bridge depends on Better Auth 1.6.x exports (internalAdapter.createSession, setSessionCookie, createAuthEndpoint) which are already present in 1.5.5 too, but landing the bump separately gave us a clean version baseline.

Bug

The middleware populated context.locals.user from trusted upstream headers, but never minted a Better Auth session and never set a cookie. Server-rendered pages saw the user, but the React SPA's /api/auth/get-session call hit Better Auth's handler — which only reads its session cookie — and got null. The auth guard then redirected to /login.

lanrat's cleanest repro, no proxy involved, running inside the container:

$ docker exec git-mirror sh -c 'wget -qO- \
    --header="X-Token-User-Email: user@example.com" \
    --header="X-Token-User-Name: User" \
    http://localhost:4321/api/auth/get-session'
null

200 + null body, even with valid headers on the request.

Fix

Two pieces:

  1. src/lib/auth-header-plugin.ts — a small Better Auth plugin that exposes POST /api/auth/sign-in/header. The endpoint validates the trusted headers via authenticateWithHeaders, creates a real session row via internalAdapter.createSession, and attaches the Set-Cookie via setSessionCookie. This is the same pattern the magic-link, anonymous, and phone-number plugins use after their respective verification steps.

  2. src/middleware.ts + src/lib/auth-header-bridge.ts — the middleware now calls the plugin endpoint when no cookie session exists and header auth is enabled, forwards the Set-Cookie onto the outbound response, and populates context.locals from the minted session. After the first request the browser has the cookie; every subsequent request takes the normal cookie-auth fast path and the bridge doesn't fire.

Fail-open everywhere: any endpoint failure (header auth disabled, auth rejected, DB blip, malformed response) returns null from the bridge and the request proceeds as anonymous. A broken header-auth configuration must never lock everyone out of the cookie-auth path.

Why we missed this earlier

Three things stacked up:

  1. The v3 rewrite swapped the session layer to Better Auth, but the middleware → Better Auth bridge for header auth was never re-wired. The v2 contract ("middleware sets locals → SPA reads locals") doesn't survive the Better Auth model ("SPA reads /api/auth/get-session → cookies only").
  2. Every "is this on?" signal returns green when it isn't working end-to-end. /api/auth/header-status only checks env-var presence; the green badge on the Authentication settings page does the same; context.locals.user populates correctly server-side. Code reading or status-endpoint smoke checks never surface the bug.
  3. Most users don't hit the cold path. Anyone who set up via email/password first has a cookie that carries them through subsequent header-auth-decorated requests. Header auth is also niche relative to OIDC.

Test plan

  • bun test — 289 pass, 4 skip, 0 fail (270 prior + 19 new across 3 new files).
  • auth-header.test.tsextractUserFromHeaders and isHeaderAuthEnabled unit tests, including lanrat's reported config shape (same header for username and email).
  • auth-header-plugin.test.ts — locks down plugin id, endpoint key, path (/sign-in/header), and method (POST). An accidental rename can't silently break the middleware bridge.
  • auth-header-bridge.test.ts — cookie extraction logic and fail-open paths (non-2xx, thrown error, malformed JSON, missing fields, no Set-Cookie attached).
  • CI runs astro check, build, Docker, and E2E. (Original stacked PR #302 only got Docker because the slashed base branch didn't match the [ '*' ] filter; this PR targets main directly so all workflows fire.)
  • Manual verification once landed: lanrat's repro should now return a session payload instead of null, the SPA should not bounce to /login, and the cookie set on the first response should carry subsequent requests through the cookie-auth fast path.

Files

  • src/lib/auth-header-plugin.ts — new plugin, header-auth, exposes signInWithHeader endpoint.
  • src/lib/auth-header-bridge.ts — extracted middleware helper that calls the plugin endpoint and shapes the result for the middleware.
  • src/lib/auth.ts — registers the plugin.
  • src/middleware.ts — calls the bridge on cold requests, forwards Set-Cookie to the outbound response.
  • src/lib/auth-header.test.ts, src/lib/auth-header-plugin.test.ts, src/lib/auth-header-bridge.test.ts — new tests (19 total).

Refs: #29
Supersedes: #302

🤖 Generated with Claude Code


🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/RayLabsHQ/gitea-mirror/pull/303 **Author:** [@arunavo4](https://github.com/arunavo4) **Created:** 5/27/2026 **Status:** ✅ Merged **Merged:** 5/27/2026 **Merged by:** [@arunavo4](https://github.com/arunavo4) **Base:** `main` ← **Head:** `fix/header-auth-session-bridge` --- ### 📝 Commits (1) - [`76b6ec7`](https://github.com/RayLabsHQ/gitea-mirror/commit/76b6ec7095051aff824e0e0be93bf0f63f805959) fix: bridge header auth into a real Better Auth session ### 📊 Changes **7 files changed** (+489 additions, -27 deletions) <details> <summary>View changed files</summary> ➕ `src/lib/auth-header-bridge.test.ts` (+123 -0) ➕ `src/lib/auth-header-bridge.ts` (+56 -0) ➕ `src/lib/auth-header-plugin.test.ts` (+29 -0) ➕ `src/lib/auth-header-plugin.ts` (+78 -0) ➕ `src/lib/auth-header.test.ts` (+158 -0) 📝 `src/lib/auth.ts` (+9 -0) 📝 `src/middleware.ts` (+36 -27) </details> ### 📄 Description ## Summary Fixes header / forward authentication (Authentik / Authelia / oauth2-proxy / Caddy), which has been end-to-end broken since the v3 rewrite. Reported on [issue #29](https://github.com/RayLabsHQ/gitea-mirror/issues/29#issuecomment-4535915584) by @lanrat with a clean v3.16.1 repro. Originally opened as #302 stacked on #301 (the better-auth 1.6.11 bump). #301 merged; this PR is the bridge rebased onto main. The bridge depends on Better Auth 1.6.x exports (`internalAdapter.createSession`, `setSessionCookie`, `createAuthEndpoint`) which are already present in 1.5.5 too, but landing the bump separately gave us a clean version baseline. ## Bug The middleware populated `context.locals.user` from trusted upstream headers, but never minted a Better Auth session and never set a cookie. Server-rendered pages saw the user, but the React SPA's `/api/auth/get-session` call hit Better Auth's handler — which only reads its session cookie — and got `null`. The auth guard then redirected to `/login`. lanrat's cleanest repro, no proxy involved, running inside the container: ``` $ docker exec git-mirror sh -c 'wget -qO- \ --header="X-Token-User-Email: user@example.com" \ --header="X-Token-User-Name: User" \ http://localhost:4321/api/auth/get-session' null ``` 200 + `null` body, even with valid headers on the request. ## Fix Two pieces: 1. **`src/lib/auth-header-plugin.ts`** — a small Better Auth plugin that exposes `POST /api/auth/sign-in/header`. The endpoint validates the trusted headers via `authenticateWithHeaders`, creates a real session row via `internalAdapter.createSession`, and attaches the `Set-Cookie` via `setSessionCookie`. This is the same pattern the magic-link, anonymous, and phone-number plugins use after their respective verification steps. 2. **`src/middleware.ts`** + **`src/lib/auth-header-bridge.ts`** — the middleware now calls the plugin endpoint when no cookie session exists and header auth is enabled, forwards the `Set-Cookie` onto the outbound response, and populates `context.locals` from the minted session. After the first request the browser has the cookie; every subsequent request takes the normal cookie-auth fast path and the bridge doesn't fire. **Fail-open everywhere**: any endpoint failure (header auth disabled, auth rejected, DB blip, malformed response) returns null from the bridge and the request proceeds as anonymous. A broken header-auth configuration must never lock everyone out of the cookie-auth path. ## Why we missed this earlier Three things stacked up: 1. The v3 rewrite swapped the session layer to Better Auth, but the middleware → Better Auth bridge for header auth was never re-wired. The v2 contract ("middleware sets locals → SPA reads locals") doesn't survive the Better Auth model ("SPA reads `/api/auth/get-session` → cookies only"). 2. Every "is this on?" signal returns green when it isn't working end-to-end. `/api/auth/header-status` only checks env-var presence; the green badge on the Authentication settings page does the same; `context.locals.user` populates correctly server-side. Code reading or status-endpoint smoke checks never surface the bug. 3. Most users don't hit the cold path. Anyone who set up via email/password first has a cookie that carries them through subsequent header-auth-decorated requests. Header auth is also niche relative to OIDC. ## Test plan - [x] `bun test` — 289 pass, 4 skip, 0 fail (270 prior + 19 new across 3 new files). - [x] `auth-header.test.ts` — `extractUserFromHeaders` and `isHeaderAuthEnabled` unit tests, including lanrat's reported config shape (same header for username and email). - [x] `auth-header-plugin.test.ts` — locks down plugin id, endpoint key, path (`/sign-in/header`), and method (POST). An accidental rename can't silently break the middleware bridge. - [x] `auth-header-bridge.test.ts` — cookie extraction logic and fail-open paths (non-2xx, thrown error, malformed JSON, missing fields, no Set-Cookie attached). - [ ] CI runs `astro check`, build, Docker, and E2E. (Original stacked PR #302 only got Docker because the slashed base branch didn't match the `[ '*' ]` filter; this PR targets main directly so all workflows fire.) - [ ] Manual verification once landed: lanrat's repro should now return a session payload instead of `null`, the SPA should not bounce to `/login`, and the cookie set on the first response should carry subsequent requests through the cookie-auth fast path. ## Files - `src/lib/auth-header-plugin.ts` — new plugin, `header-auth`, exposes `signInWithHeader` endpoint. - `src/lib/auth-header-bridge.ts` — extracted middleware helper that calls the plugin endpoint and shapes the result for the middleware. - `src/lib/auth.ts` — registers the plugin. - `src/middleware.ts` — calls the bridge on cold requests, forwards `Set-Cookie` to the outbound response. - `src/lib/auth-header.test.ts`, `src/lib/auth-header-plugin.test.ts`, `src/lib/auth-header-bridge.test.ts` — new tests (19 total). Refs: #29 Supersedes: #302 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
GiteaMirror added the pull-request label 2026-06-01 22:39:59 -05:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/gitea-mirror#3861