fix: bridge header auth into a real Better Auth session (#303)

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
This commit is contained in:
ARUNAVO RAY
2026-05-27 14:53:16 +05:30
committed by GitHub
parent a07af96f84
commit 8ffcf3bdc6
7 changed files with 489 additions and 27 deletions
+123
View File
@@ -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<Response>>(
async () => new Response(null, { status: 500 }),
);
mock.module("@/lib/auth", () => ({
auth: {
api: {
signInWithHeader: signInWithHeaderMock,
},
},
}));
import { mintSessionFromHeaders } from "./auth-header-bridge";
function makeRequest(headers: Record<string, string> = {}): 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([]);
});
});
+56
View File
@@ -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<BridgeResult | null> {
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;
}
}
+29
View File
@@ -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/<plugin-path>, 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");
});
});
+78
View File
@@ -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;
+158
View File
@@ -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<Record<(typeof HEADER_ENV_KEYS)[number], string | undefined>> = {};
function setEnv(vars: Partial<Record<(typeof HEADER_ENV_KEYS)[number], string>>) {
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");
});
});
+9
View File
@@ -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(),
],
});
+36 -27
View File
@@ -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;
}