mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-08-06 08:53:35 +02:00
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:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user