fix(auth): harden OIDC browser transactions

This commit is contained in:
2026-08-17 06:18:49 +02:00
parent 4fe51cbeb1
commit 8573500121
9 changed files with 384 additions and 59 deletions
+76 -18
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { randomBytes } from "node:crypto";
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import type { AuthenticationConfigProvider, LoadedAuthConfig, OidcAuthenticationConfig, Role } from "./types.js";
import type { LocalUserRecord, LocalUserRegistry } from "./local-registry.js";
import type { AuthSessionStore } from "./session-store.js";
@@ -17,6 +17,9 @@ const MAX_PASSWORD_LENGTH = 1024;
const MAX_LIMIT_ENTRIES = 10_000;
const MAX_OIDC_CALLBACK_QUERY_LENGTH = 4096;
const OIDC_CALLBACK_PATH = "/api/auth/oidc/callback";
const OIDC_TRANSACTION_COOKIE = "__Host-thothii_oidc_tx";
const OIDC_TRANSACTION_COOKIE_SECONDS = TEN_MINUTES_MS / 1000;
const OIDC_VALUE_PATTERN = /^[A-Za-z0-9_-]{43}$/;
export interface AuthRouteDependencies {
authMode: "local" | "oidc" | "upstream" | "none" | "mock";
@@ -188,9 +191,13 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen
app.get("/auth/oidc/login", async (request, reply) => {
const loaded = captureAuthConfigSnapshot(request, deps.authentication);
const configured = currentOidcConfig(loaded, deps);
if (!configured || !deps.sessionStore) return unavailable(reply);
if (!configured || !deps.sessionStore) {
clearOidcTransactionCookie(reply);
return unavailable(reply);
}
const nonce = randomOidcValue();
const codeVerifier = randomOidcValue();
const browserTransaction = randomOidcValue();
try {
const created = await deps.sessionStore.createOidcState({
nonce,
@@ -198,24 +205,28 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen
returnTo: "/",
authConfigRevision: configured.loaded.revision,
issuer: configured.config.oidc.issuer,
browserTransactionDigest: oidcTransactionDigest(browserTransaction).toString("hex"),
});
try {
const location = await configured.protocol.authorizationUrl({ state: created.state, nonce, codeVerifier });
reply.setCookie(OIDC_TRANSACTION_COOKIE, browserTransaction, oidcTransactionCookieOptions());
return reply.redirect(location.href);
} catch {
await deps.sessionStore.consumeOidcState(created.state).catch(() => undefined);
clearOidcTransactionCookie(reply);
return unavailable(reply);
}
} catch {
clearOidcTransactionCookie(reply);
return unavailable(reply);
}
});
app.get("/auth/oidc/callback", async (request, reply) => {
const loaded = captureAuthConfigSnapshot(request, deps.authentication);
if (!loaded || !deps.sessionStore) return oidcCallbackFailed(reply);
const callback = oidcCallbackUrl(request, loaded.value.publicUrl);
if (!callback) return oidcCallbackFailed(reply);
clearOidcTransactionCookie(reply);
const callback = oidcCallbackUrl(request, loaded?.value.publicUrl);
if (!deps.sessionStore || !callback.state) return oidcCallbackFailed(reply);
let state;
try {
state = await deps.sessionStore.consumeOidcState(callback.state);
@@ -223,7 +234,9 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen
return oidcCallbackFailed(reply);
}
const configured = currentOidcConfig(loaded, deps);
if (!configured || !state || state.returnTo !== "/" || state.authConfigRevision !== configured.loaded.revision
if (!callback.currentUrl || !configured || !state || state.returnTo !== "/"
|| !oidcTransactionMatches(request.cookies[OIDC_TRANSACTION_COOKIE], state.browserTransactionDigest)
|| state.authConfigRevision !== configured.loaded.revision
|| state.issuer !== configured.config.oidc.issuer) {
return oidcCallbackFailed(reply);
}
@@ -234,11 +247,13 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen
nonce: state.nonce,
codeVerifier: state.codeVerifier,
});
if (identity.issuer !== configured.config.oidc.issuer) return oidcCallbackFailed(reply);
if (identity.issuer !== configured.config.oidc.issuer || !Array.isArray(identity.groups)
|| identity.groups.length === 0) return oidcCallbackFailed(reply);
const roles = oidcRoles(identity.groups, configured.config);
const now = new Date();
const absoluteTtlMs = Math.min(
configured.config.session.oidcTtlSeconds * 1000,
identity.tokenExpiresAt.getTime() - Date.now(),
identity.tokenExpiresAt.getTime() - now.getTime(),
);
if (!Number.isSafeInteger(absoluteTtlMs) || absoluteTtlMs <= 0) return oidcCallbackFailed(reply);
const created = await deps.sessionStore.create({
@@ -255,7 +270,7 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen
authConfigRevision: configured.loaded.revision,
idleTtlMs: configured.config.session.regularIdleSeconds * 1000,
absoluteTtlMs,
});
}, now);
reply.setCookie(sessionCookieName(), created.token, cookieOptions(configured.loaded, false));
return reply.redirect(state.returnTo);
} catch {
@@ -370,26 +385,69 @@ function randomOidcValue(): string {
return randomBytes(32).toString("base64url");
}
function oidcCallbackUrl(request: FastifyRequest, publicUrl: string): { currentUrl: URL; state: string } | undefined {
if (request.url.length > MAX_OIDC_CALLBACK_QUERY_LENGTH) return undefined;
function oidcTransactionDigest(value: string): Buffer {
return createHash("sha256").update(value, "utf8").digest();
}
function oidcTransactionMatches(value: string | undefined, expectedDigest: string): boolean {
const canonical = typeof value === "string" && OIDC_VALUE_PATTERN.test(value);
const expectedCanonical = /^[a-f0-9]{64}$/.test(expectedDigest);
const supplied = oidcTransactionDigest(canonical ? value : "");
const expected = expectedCanonical ? Buffer.from(expectedDigest, "hex") : Buffer.alloc(32);
const matches = timingSafeEqual(supplied, expected);
return canonical && expectedCanonical && matches;
}
function oidcTransactionCookieOptions() {
return {
httpOnly: true,
sameSite: "lax" as const,
path: "/",
secure: true,
maxAge: OIDC_TRANSACTION_COOKIE_SECONDS,
};
}
function clearOidcTransactionCookie(reply: FastifyReply): void {
reply.clearCookie(OIDC_TRANSACTION_COOKIE, {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: true,
});
}
function oidcCallbackUrl(
request: FastifyRequest,
publicUrl: string | undefined,
): { currentUrl?: URL; state?: string } {
if (request.url.length > MAX_OIDC_CALLBACK_QUERY_LENGTH) return {};
let supplied: URL;
let target: URL;
try {
supplied = new URL(request.url, "http://callback.invalid");
target = new URL(OIDC_CALLBACK_PATH, publicUrl);
} catch {
return undefined;
return {};
}
if (supplied.pathname !== "/auth/oidc/callback") return undefined;
if (supplied.pathname !== "/auth/oidc/callback") return {};
const allowed = new Set(["code", "state", "error", "error_description", "error_uri", "iss"]);
const copied = new URLSearchParams();
let state: string | undefined;
let valid = true;
for (const [key, value] of supplied.searchParams) {
if (!allowed.has(key) || value.length > 2048 || copied.has(key)) return undefined;
if (key === "state" && state === undefined && OIDC_VALUE_PATTERN.test(value)) state = value;
if (!allowed.has(key) || value.length > 2048 || /\p{Cc}/u.test(value) || copied.has(key)) {
valid = false;
continue;
}
copied.set(key, value);
if (key === "state") state = value;
}
if (!state || !/^[A-Za-z0-9_-]{43}$/.test(state)) return undefined;
if (!state || !valid || copied.get("state") !== state || publicUrl === undefined) return { state };
let target: URL;
try {
target = new URL(OIDC_CALLBACK_PATH, publicUrl);
} catch {
return { state };
}
target.search = copied.toString();
return { currentUrl: target, state };
}