feat(api): real Zitadel token introspection (fail-closed) + expected-audience enforcement
- preHandler: Bearer tokens are introspected against Zitadel (RFC 7662) - any introspection failure (unreachable, non-200, bad JSON, inactive) = 401 - asserted aud must include ZITADEL_EXPECTED_AUDIENCE (project id); absent aud accepted per RFC 7662 - config: ZITADEL_INTROSPECTION_CLIENT_ID/SECRET + ZITADEL_EXPECTED_AUDIENCE (optional; Bearer-presence fallback logs warn in prod) - 13 new tests (introspection fail-closed matrix + preHandler flow)
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
|
||||
export interface IntrospectionResult {
|
||||
active: boolean;
|
||||
sub?: string;
|
||||
aud?: string[] | string;
|
||||
scope?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface IntrospectionDeps {
|
||||
issuer: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
expectedAudience?: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
timeoutMs?: number;
|
||||
logger?: FastifyBaseLogger;
|
||||
}
|
||||
|
||||
function audienceMatches(result: IntrospectionResult, expected: string): boolean {
|
||||
if (result.aud === undefined) {
|
||||
// RFC 7662: aud may be absent; only an asserted aud is enforced.
|
||||
return true;
|
||||
}
|
||||
const aud = Array.isArray(result.aud) ? result.aud : [result.aud];
|
||||
return aud.includes(expected);
|
||||
}
|
||||
|
||||
export function createIntrospector(deps: IntrospectionDeps) {
|
||||
const fetchImpl = deps.fetchImpl ?? fetch;
|
||||
const timeoutMs = deps.timeoutMs ?? 3000;
|
||||
const basic = Buffer.from(`${deps.clientId}:${deps.clientSecret}`).toString("base64");
|
||||
const endpoint = `${deps.issuer.replace(/\/$/, "")}/oauth/v2/introspect`;
|
||||
|
||||
// Fail-closed by construction: every non-active outcome returns active=false
|
||||
// with a reason, and callers must treat any reason as "no access".
|
||||
return async function introspect(token: string): Promise<IntrospectionResult> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchImpl(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Basic ${basic}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({ token }),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
} catch (err) {
|
||||
deps.logger?.warn({ err }, "zitadel introspection unreachable — fail-closed");
|
||||
return { active: false, reason: "introspection_unreachable" };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
// Zitadel returns 400 for bad client auth; any non-200 denies.
|
||||
deps.logger?.warn({ status: response.status }, "zitadel introspection non-200 — fail-closed");
|
||||
return { active: false, reason: `introspection_http_${response.status}` };
|
||||
}
|
||||
|
||||
let body: IntrospectionResult;
|
||||
try {
|
||||
body = (await response.json()) as IntrospectionResult;
|
||||
} catch (err) {
|
||||
deps.logger?.warn({ err }, "zitadel introspection invalid JSON — fail-closed");
|
||||
return { active: false, reason: "introspection_invalid_response" };
|
||||
}
|
||||
|
||||
if (body.active !== true) {
|
||||
return { active: false, reason: "token_inactive" };
|
||||
}
|
||||
|
||||
if (deps.expectedAudience && !audienceMatches(body, deps.expectedAudience)) {
|
||||
deps.logger?.warn({ sub: body.sub }, "zitadel introspection audience mismatch — fail-closed");
|
||||
return { active: false, reason: "audience_mismatch" };
|
||||
}
|
||||
|
||||
return { active: true, sub: body.sub, aud: body.aud, scope: body.scope };
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user