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:
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:
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, 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).
🔄 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>
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/303
Author: @arunavo4
Created: 5/27/2026
Status: ✅ Merged
Merged: 5/27/2026
Merged by: @arunavo4
Base:
main← Head:fix/header-auth-session-bridge📝 Commits (1)
76b6ec7fix: 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.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.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:
/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, 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.)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
Supersedes: #302
🤖 Generated with Claude Code
🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.