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:
200 + null body, even with valid headers on the request.
Fix
Two pieces:
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.
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:
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").
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.
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.ts — extractUserFromHeaders 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).
🔄 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>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
📋 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.11← Head:fix/header-auth-session-bridge📝 Commits (1)
806cf40fix: 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 tomainautomatically.Bug
The middleware populated
context.locals.userfrom 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-sessioncall hit Better Auth's handler — which only reads its session cookie — and gotnull. 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:
200 +
nullbody, even with valid headers on the request.Fix
Two pieces:
src/lib/auth-header-plugin.ts— a small Better Auth plugin that exposesPOST /api/auth/sign-in/header. The endpoint validates the trusted headers viaauthenticateWithHeaders, creates a real session row viainternalAdapter.createSession, and attaches theSet-CookieviasetSessionCookie. This is the same pattern the magic-link, anonymous, and phone-number plugins use after their respective verification steps.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 theSet-Cookieonto the outbound response, and populatescontext.localsfrom 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:
/api/auth/get-session→ cookies only")./api/auth/header-statusonly checks env-var presence; the green badge on the Authentication settings page does the same;context.locals.userpopulates correctly server-side. Code reading or status-endpoint smoke checks never surface the bug.Test plan
bun test— 289 pass, 4 skip, 0 fail (270 prior + 19 new across 3 new files).auth-header.test.ts—extractUserFromHeadersandisHeaderAuthEnabledunit 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).astro check+ Docker build.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, exposessignInWithHeaderendpoint.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, forwardsSet-Cookieto 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.