[PR #302] [CLOSED] fix: bridge header auth into a real Better Auth session (#29) #6281

Closed
opened 2026-07-12 09:51:54 -05:00 by GiteaMirror · 0 comments
Owner

📋 Pull Request Information

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

Base: chore/bump-better-auth-1.6.11Head: fix/header-auth-session-bridge


📝 Commits (1)

  • 806cf40 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.

Stacked on top of #301 (better-auth 1.6.11 bump). Base is chore/bump-better-auth-1.6.11 — once #301 merges, this PR retargets to main automatically.

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, even though the upstream proxy had already authenticated the user.

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 — covered in detail in this comment:

  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 + Docker build.
  • 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

🤖 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/302 **Author:** [@arunavo4](https://github.com/arunavo4) **Created:** 5/27/2026 **Status:** ❌ Closed **Base:** `chore/bump-better-auth-1.6.11` ← **Head:** `fix/header-auth-session-bridge` --- ### 📝 Commits (1) - [`806cf40`](https://github.com/RayLabsHQ/gitea-mirror/commit/806cf40be297e465bbaab7b31e54751a2a0b723c) 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. **Stacked on top of #301** (better-auth 1.6.11 bump). Base is `chore/bump-better-auth-1.6.11` — once #301 merges, this PR retargets to `main` automatically. ## 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`, even though the upstream proxy had already authenticated the user. 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 — covered in detail [in this comment](https://github.com/RayLabsHQ/gitea-mirror/issues/29#issuecomment-4535915584): 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` + Docker build. - [ ] 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 🤖 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-07-12 09:51:54 -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#6281