From 8ffcf3bdc6268d70fc4717b26aee42c8bad69cd2 Mon Sep 17 00:00:00 2001 From: ARUNAVO RAY Date: Wed, 27 May 2026 14:53:16 +0530 Subject: [PATCH] fix: bridge header auth into a real Better Auth session (#303) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Header / forward authentication has been end-to-end broken since the v3 rewrite. The middleware populated `context.locals.user` from trusted upstream headers (Authentik / Authelia / oauth2-proxy / Caddy), 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. Reported on issue #29 by @lanrat with a clean repro on v3.16.1. Fix: add a small Better Auth plugin (`header-auth`) 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` — the same pattern the magic-link, anonymous, and phone-number plugins use after their respective verification steps. The Astro middleware now calls this 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. Tests: - `auth-header.test.ts` — unit tests for `extractUserFromHeaders` and `isHeaderAuthEnabled`, including lanrat's reported config shape (same header for username and email). - `auth-header-plugin.test.ts` — locks down plugin id, endpoint key, path, and method so an accidental rename can't silently break the middleware bridge. - `auth-header-bridge.test.ts` — covers the cookie-extraction logic and the fail-open paths (non-2xx, thrown error, malformed JSON, missing fields, no Set-Cookie attached). Stacks on top of #301 (better-auth 1.6.11 bump). Refs: #29 --- src/lib/auth-header-bridge.test.ts | 123 ++++++++++++++++++++++ src/lib/auth-header-bridge.ts | 56 ++++++++++ src/lib/auth-header-plugin.test.ts | 29 ++++++ src/lib/auth-header-plugin.ts | 78 ++++++++++++++ src/lib/auth-header.test.ts | 158 +++++++++++++++++++++++++++++ src/lib/auth.ts | 9 ++ src/middleware.ts | 63 +++++++----- 7 files changed, 489 insertions(+), 27 deletions(-) create mode 100644 src/lib/auth-header-bridge.test.ts create mode 100644 src/lib/auth-header-bridge.ts create mode 100644 src/lib/auth-header-plugin.test.ts create mode 100644 src/lib/auth-header-plugin.ts create mode 100644 src/lib/auth-header.test.ts diff --git a/src/lib/auth-header-bridge.test.ts b/src/lib/auth-header-bridge.test.ts new file mode 100644 index 0000000..ada98f8 --- /dev/null +++ b/src/lib/auth-header-bridge.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, mock, test, beforeEach } from "bun:test"; + +// Stub `./auth` so we can drive the response shape `mintSessionFromHeaders` +// sees from the plugin endpoint without standing up the full Better Auth +// stack + DB. +const signInWithHeaderMock = mock<(args: unknown) => Promise>( + async () => new Response(null, { status: 500 }), +); + +mock.module("@/lib/auth", () => ({ + auth: { + api: { + signInWithHeader: signInWithHeaderMock, + }, + }, +})); + +import { mintSessionFromHeaders } from "./auth-header-bridge"; + +function makeRequest(headers: Record = {}): Request { + return new Request("http://localhost/test", { headers }); +} + +describe("mintSessionFromHeaders", () => { + beforeEach(() => { + signInWithHeaderMock.mockReset(); + }); + + test("returns user, session, and Set-Cookie values on a 200 response", async () => { + signInWithHeaderMock.mockImplementation(async () => { + const response = new Response( + JSON.stringify({ + user: { id: "user-1", email: "u@example.com" }, + session: { id: "sess-1", userId: "user-1" }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + // Modern runtimes coalesce multiple Set-Cookie via append. + response.headers.append("set-cookie", "better-auth-session=abc; Path=/"); + response.headers.append("set-cookie", "better-auth-remember=1; Path=/"); + return response; + }); + + const result = await mintSessionFromHeaders(makeRequest()); + + expect(result).not.toBeNull(); + expect(result?.user.id).toBe("user-1"); + expect(result?.session.id).toBe("sess-1"); + expect(result?.setCookies).toEqual([ + "better-auth-session=abc; Path=/", + "better-auth-remember=1; Path=/", + ]); + }); + + test("returns null when the endpoint responds non-2xx (header auth disabled / unauthorized)", async () => { + signInWithHeaderMock.mockImplementation( + async () => new Response("Unauthorized", { status: 401 }), + ); + + const result = await mintSessionFromHeaders(makeRequest()); + + expect(result).toBeNull(); + }); + + test("returns null when the endpoint throws (transient failure)", async () => { + signInWithHeaderMock.mockImplementation(async () => { + throw new Error("upstream unreachable"); + }); + + const result = await mintSessionFromHeaders(makeRequest()); + + expect(result).toBeNull(); + }); + + test("returns null when the response body is missing user or session", async () => { + signInWithHeaderMock.mockImplementation( + async () => + new Response(JSON.stringify({ token: "abc" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + const result = await mintSessionFromHeaders(makeRequest()); + + expect(result).toBeNull(); + }); + + test("returns null when the body is malformed JSON", async () => { + signInWithHeaderMock.mockImplementation( + async () => + new Response("not json at all", { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + const result = await mintSessionFromHeaders(makeRequest()); + + expect(result).toBeNull(); + }); + + test("returns an empty setCookies array when no Set-Cookie headers were attached", async () => { + // Defensive — should never happen in practice because the plugin + // always calls setSessionCookie. If it does happen, we still want + // the user/session to come through so SSR works on this request. + signInWithHeaderMock.mockImplementation( + async () => + new Response( + JSON.stringify({ + user: { id: "user-1" }, + session: { id: "sess-1" }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + + const result = await mintSessionFromHeaders(makeRequest()); + + expect(result).not.toBeNull(); + expect(result?.setCookies).toEqual([]); + }); +}); diff --git a/src/lib/auth-header-bridge.ts b/src/lib/auth-header-bridge.ts new file mode 100644 index 0000000..5224936 --- /dev/null +++ b/src/lib/auth-header-bridge.ts @@ -0,0 +1,56 @@ +import { auth } from "./auth"; + +export interface BridgeResult { + user: any; + session: any; + setCookies: string[]; +} + +/** + * Calls the `header-auth` plugin endpoint to mint a real Better Auth + * session from trusted upstream headers (Authentik / Authelia / + * oauth2-proxy / Caddy), and returns the user, session, and the + * `Set-Cookie` headers for the middleware to forward onto the + * outbound response. + * + * Fail-open: returns null on any failure (endpoint disabled, headers + * missing, DB blip, malformed response). The middleware then sets + * locals to null and the request proceeds as anonymous — broken + * header auth must never lock everyone out of the cookie-auth path. + * + * Cookie extraction prefers `Response.headers.getSetCookie()` (Node 18+ + * fetch, undici). Older runtimes that only expose `get('set-cookie')` + * fall through to the single-header form; that path coalesces all + * Set-Cookie values into one comma-separated string, which is wrong + * for cookies whose attributes contain commas (`Expires` does). We + * accept that risk because: (a) Bun and the supported Node versions + * for this project both implement `getSetCookie`, and (b) the + * fallback only fires on truly ancient runtimes that aren't in our + * support matrix. + */ +export async function mintSessionFromHeaders( + request: Request, +): Promise { + try { + const response = await auth.api.signInWithHeader({ + headers: request.headers, + asResponse: true, + }); + + if (!response.ok) return null; + + const data = await response.json().catch(() => null); + if (!data?.user || !data?.session) return null; + + const setCookies = + typeof response.headers.getSetCookie === "function" + ? response.headers.getSetCookie() + : response.headers.get("set-cookie") + ? [response.headers.get("set-cookie") as string] + : []; + + return { user: data.user, session: data.session, setCookies }; + } catch { + return null; + } +} diff --git a/src/lib/auth-header-plugin.test.ts b/src/lib/auth-header-plugin.test.ts new file mode 100644 index 0000000..edd3d06 --- /dev/null +++ b/src/lib/auth-header-plugin.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test"; +import { headerAuthPlugin } from "./auth-header-plugin"; + +describe("headerAuthPlugin", () => { + test("registers the `header-auth` plugin id", () => { + const plugin = headerAuthPlugin(); + expect(plugin.id).toBe("header-auth"); + }); + + test("exposes a `signInWithHeader` endpoint", () => { + const plugin = headerAuthPlugin(); + expect(plugin.endpoints?.signInWithHeader).toBeDefined(); + }); + + test("mounts the endpoint at POST /sign-in/header", () => { + // The Astro API route is /api/auth/, so the resolved + // URL the middleware bridge talks to is /api/auth/sign-in/header. + // Locking that path down in a test prevents an accidental rename + // from silently breaking the React SPA's auth flow. + const plugin = headerAuthPlugin(); + const endpoint = plugin.endpoints?.signInWithHeader as unknown as { + path: string; + options: { method: string }; + }; + + expect(endpoint.path).toBe("/sign-in/header"); + expect(endpoint.options.method).toBe("POST"); + }); +}); diff --git a/src/lib/auth-header-plugin.ts b/src/lib/auth-header-plugin.ts new file mode 100644 index 0000000..deadf77 --- /dev/null +++ b/src/lib/auth-header-plugin.ts @@ -0,0 +1,78 @@ +import type { BetterAuthPlugin } from "better-auth"; +import { APIError, createAuthEndpoint } from "better-auth/api"; +import { setSessionCookie } from "better-auth/cookies"; +import { authenticateWithHeaders, isHeaderAuthEnabled } from "./auth-header"; + +/** + * Better Auth plugin that bridges header / forward authentication into a + * real Better Auth session. + * + * Why this exists: the Astro middleware historically populated + * `context.locals.user` from trusted upstream headers (Authentik / + * Authelia / oauth2-proxy / Caddy), but never minted a Better Auth + * session. 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 + * bounced to `/login`, so header auth was end-to-end broken. + * + * This plugin exposes `POST /sign-in/header`, which the middleware + * calls once per cold request (no cookie yet) when header auth is + * enabled. It verifies the trusted headers, creates a real session + * row via `internalAdapter.createSession`, and attaches the + * `Set-Cookie` to the response. The middleware then forwards that + * cookie to the outbound Astro response, so the SPA's next call to + * `get-session` carries the cookie and works. + * + * The endpoint trusts whatever upstream sets the configured headers — + * the security model here is "the operator controls the reverse + * proxy." Make sure the proxy strips inbound copies of these headers + * before forwarding (documented in docs/SSO-OIDC-SETUP.md). + */ +export const headerAuthPlugin = () => + ({ + id: "header-auth", + endpoints: { + signInWithHeader: createAuthEndpoint( + "/sign-in/header", + { method: "POST" }, + async (ctx) => { + if (!isHeaderAuthEnabled()) { + throw new APIError("NOT_FOUND", { + message: "Header authentication is not enabled", + }); + } + + const headers = ctx.request?.headers ?? ctx.headers; + if (!headers) { + throw new APIError("BAD_REQUEST", { + message: "Request headers unavailable", + }); + } + + const user = await authenticateWithHeaders(headers); + if (!user) { + throw new APIError("UNAUTHORIZED", { + message: "Header authentication failed", + }); + } + + const session = await ctx.context.internalAdapter.createSession( + user.id, + ); + if (!session) { + throw new APIError("INTERNAL_SERVER_ERROR", { + message: "Failed to create session", + }); + } + + await setSessionCookie(ctx, { session, user }); + + return ctx.json({ + token: session.token, + user, + session, + }); + }, + ), + }, + }) satisfies BetterAuthPlugin; diff --git a/src/lib/auth-header.test.ts b/src/lib/auth-header.test.ts new file mode 100644 index 0000000..9921169 --- /dev/null +++ b/src/lib/auth-header.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { + extractUserFromHeaders, + isHeaderAuthEnabled, + getHeaderAuthConfig, +} from "./auth-header"; + +// `auth-header` reads config from `process.env` at call time. We snapshot +// the relevant keys and restore them after each test so cases don't bleed. +const HEADER_ENV_KEYS = [ + "HEADER_AUTH_ENABLED", + "HEADER_AUTH_AUTO_PROVISION", + "HEADER_AUTH_USER_HEADER", + "HEADER_AUTH_EMAIL_HEADER", + "HEADER_AUTH_NAME_HEADER", + "HEADER_AUTH_ALLOWED_DOMAINS", +] as const; + +let savedEnv: Partial> = {}; + +function setEnv(vars: Partial>) { + for (const key of HEADER_ENV_KEYS) { + if (key in vars) { + process.env[key] = vars[key]!; + } else { + delete process.env[key]; + } + } +} + +beforeEach(() => { + savedEnv = Object.fromEntries( + HEADER_ENV_KEYS.map((k) => [k, process.env[k]]), + ) as typeof savedEnv; +}); + +afterEach(() => { + for (const key of HEADER_ENV_KEYS) { + const v = savedEnv[key]; + if (v === undefined) delete process.env[key]; + else process.env[key] = v; + } +}); + +describe("isHeaderAuthEnabled", () => { + test("returns false when HEADER_AUTH_ENABLED is unset", () => { + setEnv({}); + expect(isHeaderAuthEnabled()).toBe(false); + }); + + test("returns false when HEADER_AUTH_ENABLED is anything other than the string 'true'", () => { + setEnv({ HEADER_AUTH_ENABLED: "1" }); + expect(isHeaderAuthEnabled()).toBe(false); + setEnv({ HEADER_AUTH_ENABLED: "yes" }); + expect(isHeaderAuthEnabled()).toBe(false); + }); + + test("returns true only for HEADER_AUTH_ENABLED='true' exactly", () => { + setEnv({ HEADER_AUTH_ENABLED: "true" }); + expect(isHeaderAuthEnabled()).toBe(true); + }); +}); + +describe("extractUserFromHeaders", () => { + test("returns null when header auth is disabled", () => { + setEnv({}); + const headers = new Headers({ "X-Authentik-Username": "u" }); + expect(extractUserFromHeaders(headers)).toBeNull(); + }); + + test("returns null when the configured user header is absent", () => { + setEnv({ HEADER_AUTH_ENABLED: "true" }); + const headers = new Headers({ "X-Some-Other-Header": "u" }); + expect(extractUserFromHeaders(headers)).toBeNull(); + }); + + test("returns username, email, and name from default Authentik headers", () => { + setEnv({ HEADER_AUTH_ENABLED: "true" }); + const headers = new Headers({ + "X-Authentik-Username": "alice", + "X-Authentik-Email": "alice@example.com", + "X-Authentik-Name": "Alice Q", + }); + + expect(extractUserFromHeaders(headers)).toEqual({ + username: "alice", + email: "alice@example.com", + name: "Alice Q", + }); + }); + + test("respects HEADER_AUTH_USER_HEADER override (Caddy / caddy-security style)", () => { + setEnv({ + HEADER_AUTH_ENABLED: "true", + HEADER_AUTH_USER_HEADER: "X-Token-User-Email", + HEADER_AUTH_EMAIL_HEADER: "X-Token-User-Email", + HEADER_AUTH_NAME_HEADER: "X-Token-User-Name", + }); + const headers = new Headers({ + "X-Token-User-Email": "bob@example.com", + "X-Token-User-Name": "Bob", + }); + + // lanrat's reported config: username and email are both pulled from + // the same header. Both should resolve to that value. + expect(extractUserFromHeaders(headers)).toEqual({ + username: "bob@example.com", + email: "bob@example.com", + name: "Bob", + }); + }); + + test("rejects when email domain is not on the allow list", () => { + setEnv({ + HEADER_AUTH_ENABLED: "true", + HEADER_AUTH_ALLOWED_DOMAINS: "example.com,corp.example", + }); + const headers = new Headers({ + "X-Authentik-Username": "evil", + "X-Authentik-Email": "evil@elsewhere.test", + }); + + expect(extractUserFromHeaders(headers)).toBeNull(); + }); + + test("accepts when email domain matches the allow list", () => { + setEnv({ + HEADER_AUTH_ENABLED: "true", + HEADER_AUTH_ALLOWED_DOMAINS: "example.com,corp.example", + }); + const headers = new Headers({ + "X-Authentik-Username": "alice", + "X-Authentik-Email": "alice@corp.example", + }); + + expect(extractUserFromHeaders(headers)).toEqual({ + username: "alice", + email: "alice@corp.example", + name: undefined, + }); + }); +}); + +describe("getHeaderAuthConfig", () => { + test("merges env overrides over defaults without leaking unset env values", () => { + setEnv({ + HEADER_AUTH_ENABLED: "true", + HEADER_AUTH_USER_HEADER: "X-Forwarded-User", + }); + + const config = getHeaderAuthConfig(); + expect(config.enabled).toBe(true); + expect(config.userHeader).toBe("X-Forwarded-User"); + // Unset overrides should fall back to defaults, not become undefined. + expect(config.emailHeader).toBe("X-Authentik-Email"); + expect(config.nameHeader).toBe("X-Authentik-Name"); + }); +}); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 21baef5..8460862 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -6,6 +6,7 @@ import { db, users } from "./db"; import * as schema from "./db/schema"; import { eq } from "drizzle-orm"; import { withBase } from "./base-path"; +import { headerAuthPlugin } from "./auth-header-plugin"; /** * Resolves the list of trusted origins for Better Auth CSRF validation. @@ -205,6 +206,14 @@ export const auth = betterAuth({ // Trust email_verified claims from the upstream provider so we can link by matching email trustEmailVerified: true, }), + + // Header / forward authentication bridge. Exposes + // POST /api/auth/sign-in/header so the middleware can mint a real + // Better Auth session from trusted upstream headers (Authentik / + // Authelia / oauth2-proxy / Caddy). Without this the SPA's + // /api/auth/get-session call returns null on header-auth-only + // requests and bounces the user to /login. See auth-header-plugin.ts. + headerAuthPlugin(), ], }); diff --git a/src/middleware.ts b/src/middleware.ts index 5b59899..9bd681c 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -6,7 +6,8 @@ import { startRepositoryCleanupService, stopRepositoryCleanupService } from './l import { initializeShutdownManager, registerShutdownCallback } from './lib/shutdown-manager'; import { setupSignalHandlers } from './lib/signal-handlers'; import { auth } from './lib/auth'; -import { isHeaderAuthEnabled, authenticateWithHeaders } from './lib/auth-header'; +import { isHeaderAuthEnabled } from './lib/auth-header'; +import { mintSessionFromHeaders } from './lib/auth-header-bridge'; import { initializeConfigFromEnv } from './lib/env-config-loader'; import { db, users } from './lib/db'; import { getBasePath } from './lib/base-path'; @@ -37,6 +38,13 @@ let envConfigCheckCount = 0; // Track attempts to avoid excessive checking export const onRequest = defineMiddleware(async (context, next) => { const basePath = getBasePath(); + // Set-Cookie headers we mint during the header-auth bridge below. + // Forwarded onto the outbound response after `next()` so the browser + // persists the Better Auth session cookie. Until that happens the + // SPA's /api/auth/get-session call returns null and bounces to + // /login — see the bridge block for the full rationale. + let pendingSetCookies: string[] = []; + // First, try Better Auth session (cookie-based) try { const session = await auth.api.getSession({ @@ -46,36 +54,26 @@ export const onRequest = defineMiddleware(async (context, next) => { if (session) { context.locals.user = session.user; context.locals.session = session.session; - } else { - // No cookie session, check for header authentication - if (isHeaderAuthEnabled()) { - const headerUser = await authenticateWithHeaders(context.request.headers); - if (headerUser) { - // Create a session-like object for header auth - context.locals.user = { - id: headerUser.id, - email: headerUser.email, - emailVerified: headerUser.emailVerified, - name: headerUser.name || headerUser.username, - username: headerUser.username, - createdAt: headerUser.createdAt, - updatedAt: headerUser.updatedAt, - }; - context.locals.session = { - id: `header-${headerUser.id}`, - userId: headerUser.id, - expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 1 day - ipAddress: context.request.headers.get('x-forwarded-for') || context.clientAddress, - userAgent: context.request.headers.get('user-agent'), - }; - } else { - context.locals.user = null; - context.locals.session = null; - } + } else if (isHeaderAuthEnabled()) { + // No cookie session, but header auth is on. Call the + // header-auth plugin endpoint to mint a real Better Auth + // session from the trusted upstream headers, then forward the + // Set-Cookie onto the outbound response so the SPA's next + // /api/auth/get-session call carries the cookie. Without this + // bridge the React app sees null on mount and redirects to + // /login even though server-rendered code paths know the user. + const bridge = await mintSessionFromHeaders(context.request); + if (bridge) { + context.locals.user = bridge.user; + context.locals.session = bridge.session; + pendingSetCookies = bridge.setCookies; } else { context.locals.user = null; context.locals.session = null; } + } else { + context.locals.user = null; + context.locals.session = null; } } catch (error) { // If there's an error getting the session, set to null @@ -252,6 +250,17 @@ export const onRequest = defineMiddleware(async (context, next) => { // Continue with the request const response = await next(); + // Forward any Set-Cookie headers minted by the header-auth bridge + // onto the outbound response. Done before the early returns below so + // every return path (basePath rewrite, non-HTML responses, etc.) + // carries the cookie. The body-rewrite branch further down clones + // `response.headers`, so anything appended here survives the clone. + if (pendingSetCookies.length > 0) { + for (const cookie of pendingSetCookies) { + response.headers.append("set-cookie", cookie); + } + } + if (basePath === "/") { return response; }