fix(auth): harden OIDC browser transactions
This commit is contained in:
@@ -37,12 +37,15 @@ export interface OidcProtocolOptions {
|
||||
scopes: readonly string[];
|
||||
groupsClaim: string;
|
||||
fetch?: typeof globalThis.fetch;
|
||||
jwksTimeoutMs?: number;
|
||||
}
|
||||
|
||||
const MAX_GROUPS = 128;
|
||||
const MAX_GROUP_LENGTH = 256;
|
||||
const MAX_ID_TOKEN_LENGTH = 16 * 1024;
|
||||
const MAX_JWKS_BYTES = 1024 * 1024;
|
||||
const DEFAULT_JWKS_TIMEOUT_MS = 5_000;
|
||||
const MAX_JWKS_TIMEOUT_MS = 30_000;
|
||||
const text = (value: unknown, maximum = 2048): value is string =>
|
||||
typeof value === "string" && value.length > 0 && value.length <= maximum && !/\p{Cc}/u.test(value);
|
||||
|
||||
@@ -75,7 +78,7 @@ function groupsFromClaims(claims: Record<string, unknown>, name: string): string
|
||||
&& Object.prototype.hasOwnProperty.call(indirect, name))
|
||||
|| claims.hasgroups === true) throw new OidcProtocolError();
|
||||
const raw = claims[name];
|
||||
if (!Array.isArray(raw) || raw.length > MAX_GROUPS) throw new OidcProtocolError();
|
||||
if (!Array.isArray(raw) || raw.length === 0 || raw.length > MAX_GROUPS) throw new OidcProtocolError();
|
||||
const groups: string[] = [];
|
||||
const unique = new Set<string>();
|
||||
for (const group of raw) {
|
||||
@@ -161,6 +164,54 @@ function joseEcdsaSignatureToDer(signature: Buffer, partLength: number): Buffer
|
||||
return Buffer.concat([Buffer.from([0x30]), derLength(sequence.length), sequence]);
|
||||
}
|
||||
|
||||
async function readWithAbort(
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
signal: AbortSignal,
|
||||
): Promise<ReadableStreamReadResult<Uint8Array>> {
|
||||
signal.throwIfAborted();
|
||||
return await new Promise((resolve, reject) => {
|
||||
const aborted = () => {
|
||||
void reader.cancel().catch(() => undefined);
|
||||
reject(signal.reason);
|
||||
};
|
||||
signal.addEventListener("abort", aborted, { once: true });
|
||||
reader.read().then(resolve, reject).finally(() => signal.removeEventListener("abort", aborted));
|
||||
});
|
||||
}
|
||||
|
||||
async function boundedJwksBody(response: Response, signal: AbortSignal): Promise<string> {
|
||||
const declaredLength = response.headers.get("content-length");
|
||||
if (declaredLength !== null) {
|
||||
if (!/^\d+$/.test(declaredLength)) throw new OidcProtocolError();
|
||||
const length = Number(declaredLength);
|
||||
if (!Number.isSafeInteger(length) || length > MAX_JWKS_BYTES) throw new OidcProtocolError();
|
||||
}
|
||||
if (!response.body) throw new OidcProtocolError();
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await readWithAbort(reader, signal);
|
||||
if (done) break;
|
||||
if (value.byteLength > MAX_JWKS_BYTES - total) {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
throw new OidcProtocolError();
|
||||
}
|
||||
total += value.byteLength;
|
||||
chunks.push(Buffer.from(value));
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks, total));
|
||||
} catch {
|
||||
throw new OidcProtocolError();
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyIdTokenSignature(
|
||||
idToken: unknown,
|
||||
config: Configuration,
|
||||
@@ -176,14 +227,16 @@ async function verifyIdTokenSignature(
|
||||
|| !metadata.id_token_signing_alg_values_supported.includes(header.alg)
|
||||
|| !text(metadata.jwks_uri, 2048)) throw new OidcProtocolError();
|
||||
const jwksUrl = httpsEndpoint(metadata.jwks_uri);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), options.jwksTimeoutMs ?? DEFAULT_JWKS_TIMEOUT_MS);
|
||||
timeout.unref();
|
||||
let response: Response;
|
||||
try {
|
||||
response = await (options.fetch ?? globalThis.fetch)(jwksUrl, {
|
||||
headers: { accept: "application/json" }, redirect: "error",
|
||||
headers: { accept: "application/json" }, redirect: "error", signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) throw new OidcProtocolError();
|
||||
const body = await response.text();
|
||||
if (Buffer.byteLength(body, "utf8") > MAX_JWKS_BYTES) throw new OidcProtocolError();
|
||||
const body = await boundedJwksBody(response, controller.signal);
|
||||
const parsed = JSON.parse(body) as { keys?: unknown };
|
||||
if (!Array.isArray(parsed.keys) || parsed.keys.length === 0 || parsed.keys.length > 16) throw new OidcProtocolError();
|
||||
const matching = parsed.keys.filter((key): key is Record<string, unknown> =>
|
||||
@@ -209,6 +262,8 @@ async function verifyIdTokenSignature(
|
||||
} catch (error) {
|
||||
if (error instanceof OidcProtocolError) throw error;
|
||||
throw new OidcProtocolError();
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,7 +272,9 @@ export function createOidcProtocol(options: OidcProtocolOptions): OidcProtocol {
|
||||
const callbackUrl = configuredUrl(options.callbackUrl);
|
||||
if (!text(options.clientId, 512) || !text(options.clientSecret, 4096)
|
||||
|| !text(options.groupsClaim, 128) || options.scopes.length === 0 || options.scopes.length > 16
|
||||
|| options.scopes.some((scope) => !text(scope, 128))) throw new OidcProtocolError();
|
||||
|| options.scopes.some((scope) => !text(scope, 128))
|
||||
|| (options.jwksTimeoutMs !== undefined && (!Number.isSafeInteger(options.jwksTimeoutMs)
|
||||
|| options.jwksTimeoutMs < 1 || options.jwksTimeoutMs > MAX_JWKS_TIMEOUT_MS))) throw new OidcProtocolError();
|
||||
|
||||
let discovered: Promise<Configuration> | undefined;
|
||||
const configuration = async (): Promise<Configuration> => {
|
||||
|
||||
+76
-18
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ export interface OidcStateCreateInput {
|
||||
returnTo: "/";
|
||||
authConfigRevision: string;
|
||||
issuer: string;
|
||||
browserTransactionDigest: string;
|
||||
}
|
||||
|
||||
export interface CreatedOidcState {
|
||||
@@ -192,6 +193,7 @@ const oidcStateRecordSchema = z.strictObject({
|
||||
returnTo: z.literal("/"),
|
||||
authConfigRevision: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
issuer: z.string().min(1).max(2048).refine((value) => !/\p{Cc}/u.test(value)),
|
||||
browserTransactionDigest: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
createdAt: timestamp,
|
||||
expiresAt: timestamp,
|
||||
}).superRefine((record, context) => {
|
||||
@@ -232,6 +234,7 @@ const oidcStateInputSchema = z.strictObject({
|
||||
returnTo: z.literal("/"),
|
||||
authConfigRevision: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
issuer: z.string().min(1).max(2048).refine((value) => !/\p{Cc}/u.test(value)),
|
||||
browserTransactionDigest: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
});
|
||||
|
||||
function sameFileIdentity(left: FileIdentity, right: FileIdentity): boolean {
|
||||
@@ -935,6 +938,7 @@ export function createFileAuthSessionStore(
|
||||
returnTo: validated.returnTo,
|
||||
authConfigRevision: validated.authConfigRevision,
|
||||
issuer: validated.issuer,
|
||||
browserTransactionDigest: validated.browserTransactionDigest,
|
||||
createdAt: isoAt(nowMs),
|
||||
expiresAt: isoAt(expiresMs),
|
||||
};
|
||||
|
||||
@@ -81,6 +81,7 @@ export interface OidcStateRecord {
|
||||
returnTo: "/";
|
||||
authConfigRevision: string;
|
||||
issuer: string;
|
||||
browserTransactionDigest: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user