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 };
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import Fastify from "fastify";
|
||||
import { envSchema } from "@siax/c0py-config";
|
||||
import { createIntrospector } from "./auth/introspection.js";
|
||||
|
||||
async function main() {
|
||||
const env = envSchema.parse(process.env);
|
||||
@@ -11,6 +12,24 @@ async function main() {
|
||||
timestamp: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
const introspectionConfigured =
|
||||
env.ZITADEL_INTROSPECTION_CLIENT_ID !== undefined &&
|
||||
env.ZITADEL_INTROSPECTION_CLIENT_SECRET !== undefined;
|
||||
if (!introspectionConfigured && env.NODE_ENV === "production") {
|
||||
server.log.warn(
|
||||
"ZITADEL_INTROSPECTION_CLIENT_ID/SECRET not set — running Bearer-presence-only auth",
|
||||
);
|
||||
}
|
||||
const introspect = introspectionConfigured
|
||||
? createIntrospector({
|
||||
issuer: env.ZITADEL_ISSUER,
|
||||
clientId: env.ZITADEL_INTROSPECTION_CLIENT_ID as string,
|
||||
clientSecret: env.ZITADEL_INTROSPECTION_CLIENT_SECRET as string,
|
||||
expectedAudience: env.ZITADEL_EXPECTED_AUDIENCE,
|
||||
logger: server.log,
|
||||
})
|
||||
: null;
|
||||
|
||||
server.addHook("preHandler", async (request, reply) => {
|
||||
if (request.url === "/health") return;
|
||||
const auth = request.headers.authorization;
|
||||
@@ -18,6 +37,18 @@ async function main() {
|
||||
await reply.code(401).send({ error: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
if (introspect) {
|
||||
const token = auth.slice("Bearer ".length).trim();
|
||||
const result = await introspect(token);
|
||||
if (!result.active) {
|
||||
await reply
|
||||
.code(401)
|
||||
.header("WWW-Authenticate", `Bearer error="invalid_token"`)
|
||||
.send({ error: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
(request as never as { user?: { sub?: string } }).user = { sub: result.sub };
|
||||
}
|
||||
});
|
||||
|
||||
server.get("/", async () => ({
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import { envSchema } from "@siax/c0py-config";
|
||||
import { createIntrospector } from "../src/auth/introspection.js";
|
||||
import type { IntrospectionDeps } from "../src/auth/introspection.js";
|
||||
|
||||
const env = envSchema.parse({
|
||||
CL0UD_BASE_URL: "https://cl0ud.siax.io",
|
||||
ZITADEL_ISSUER: "https://id-customers.siax.io",
|
||||
ZITADEL_AUDIENCE: "c0py-api.siax.io",
|
||||
DATABASE_URL: "postgresql://localhost:5432/c0py",
|
||||
ZITADEL_INTROSPECTION_CLIENT_ID: "cid",
|
||||
ZITADEL_INTROSPECTION_CLIENT_SECRET: "csec",
|
||||
ZITADEL_EXPECTED_AUDIENCE: "proj-1",
|
||||
NODE_ENV: "test",
|
||||
});
|
||||
|
||||
function mockIntrospect(body: unknown) {
|
||||
return vi.fn().mockResolvedValue(body);
|
||||
}
|
||||
|
||||
function buildApp(introspectImpl: (token: string) => Promise<unknown>) {
|
||||
const server = Fastify({ logger: false });
|
||||
server.get("/health", async () => ({ status: "ok" }));
|
||||
server.addHook("preHandler", async (request, reply) => {
|
||||
if (request.url === "/health") return;
|
||||
const auth = request.headers.authorization;
|
||||
if (!auth || !auth.startsWith("Bearer ")) {
|
||||
await reply.code(401).send({ error: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
const result = (await introspectImpl(auth.slice("Bearer ".length).trim())) as {
|
||||
active: boolean;
|
||||
sub?: string;
|
||||
};
|
||||
if (!result.active) {
|
||||
await reply.code(401).send({ error: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
(request as never as { user?: { sub?: string } }).user = { sub: result.sub };
|
||||
});
|
||||
server.get("/v1/c0py/registries", async (request) => ({
|
||||
user: (request as never as { user?: { sub?: string } }).user,
|
||||
}));
|
||||
return server;
|
||||
}
|
||||
|
||||
describe("preHandler auth (fail-closed)", () => {
|
||||
it("401 without Bearer", async () => {
|
||||
const app = buildApp(mockIntrospect({ active: true }));
|
||||
const res = await app.inject({ method: "GET", url: "/v1/c0py/registries" });
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("exempts /health", async () => {
|
||||
const app = buildApp(mockIntrospect({ active: false }));
|
||||
const res = await app.inject({ method: "GET", url: "/health" });
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("401 when introspection denies", async () => {
|
||||
const app = buildApp(mockIntrospect({ active: false, reason: "audience_mismatch" }));
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/c0py/registries",
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("200 with valid token and sub propagated", async () => {
|
||||
const app = buildApp(mockIntrospect({ active: true, sub: "svc-user" }));
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/c0py/registries",
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ user: { sub: "svc-user" } });
|
||||
});
|
||||
|
||||
it("env schema accepts new introspection vars", () => {
|
||||
expect(env.ZITADEL_INTROSPECTION_CLIENT_ID).toBe("cid");
|
||||
expect(env.ZITADEL_EXPECTED_AUDIENCE).toBe("proj-1");
|
||||
});
|
||||
|
||||
it("createIntrospector is importable and typed", async () => {
|
||||
expect(typeof createIntrospector).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { createIntrospector } from "../src/auth/introspection.js";
|
||||
import type { IntrospectionDeps } from "../src/auth/introspection.js";
|
||||
|
||||
function mockFetch(status: number, body: unknown) {
|
||||
return vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const baseDeps: IntrospectionDeps = {
|
||||
issuer: "https://id-customers.siax.io",
|
||||
clientId: "cid",
|
||||
clientSecret: "csec",
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("createIntrospector", () => {
|
||||
it("accepts an active token", async () => {
|
||||
const fetchImpl = mockFetch(200, { active: true, sub: "u1", aud: ["cid"] });
|
||||
const introspect = createIntrospector({ ...baseDeps, fetchImpl });
|
||||
const r = await introspect("tok");
|
||||
expect(r.active).toBe(true);
|
||||
expect(r.sub).toBe("u1");
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
"https://id-customers.siax.io/oauth/v2/introspect",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an inactive token (fail-closed)", async () => {
|
||||
const introspect = createIntrospector({ ...baseDeps, fetchImpl: mockFetch(200, { active: false }) });
|
||||
const r = await introspect("tok");
|
||||
expect(r.active).toBe(false);
|
||||
expect(r.reason).toBe("token_inactive");
|
||||
});
|
||||
|
||||
it("fails closed on non-200 (Zitadel returns 400 on bad client auth)", async () => {
|
||||
const introspect = createIntrospector({ ...baseDeps, fetchImpl: mockFetch(400, {}) });
|
||||
const r = await introspect("tok");
|
||||
expect(r.active).toBe(false);
|
||||
expect(r.reason).toBe("introspection_http_400");
|
||||
});
|
||||
|
||||
it("fails closed when introspection is unreachable", async () => {
|
||||
const fetchImpl = vi.fn().mockRejectedValue(new Error("fetch failed"));
|
||||
const introspect = createIntrospector({ ...baseDeps, fetchImpl });
|
||||
const r = await introspect("tok");
|
||||
expect(r.active).toBe(false);
|
||||
expect(r.reason).toBe("introspection_unreachable");
|
||||
});
|
||||
|
||||
it("fails closed on invalid JSON", async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue(
|
||||
new Response("<html>not json</html>", { status: 200 }),
|
||||
);
|
||||
const introspect = createIntrospector({ ...baseDeps, fetchImpl });
|
||||
const r = await introspect("tok");
|
||||
expect(r.active).toBe(false);
|
||||
expect(r.reason).toBe("introspection_invalid_response");
|
||||
});
|
||||
|
||||
it("accepts a token without aud (RFC 7662)", async () => {
|
||||
const introspect = createIntrospector({
|
||||
...baseDeps,
|
||||
expectedAudience: "proj-1",
|
||||
fetchImpl: mockFetch(200, { active: true, sub: "u1" }),
|
||||
});
|
||||
const r = await introspect("tok");
|
||||
expect(r.active).toBe(true);
|
||||
});
|
||||
|
||||
it("enforces asserted aud against expected audience", async () => {
|
||||
const mismatch = createIntrospector({
|
||||
...baseDeps,
|
||||
expectedAudience: "proj-1",
|
||||
fetchImpl: mockFetch(200, { active: true, aud: ["other", "cid"] }),
|
||||
});
|
||||
const r = await mismatch("tok");
|
||||
expect(r.active).toBe(false);
|
||||
expect(r.reason).toBe("audience_mismatch");
|
||||
|
||||
const match = createIntrospector({
|
||||
...baseDeps,
|
||||
expectedAudience: "proj-1",
|
||||
fetchImpl: mockFetch(200, { active: true, aud: ["cid", "proj-1"] }),
|
||||
});
|
||||
const r2 = await match("tok");
|
||||
expect(r2.active).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,9 @@ export const envSchema = z.object({
|
||||
ZITADEL_ISSUER: z.string().url(),
|
||||
ZITADEL_AUDIENCE: z.string(),
|
||||
DATABASE_URL: z.string(),
|
||||
ZITADEL_INTROSPECTION_CLIENT_ID: z.string().optional(),
|
||||
ZITADEL_INTROSPECTION_CLIENT_SECRET: z.string().optional(),
|
||||
ZITADEL_EXPECTED_AUDIENCE: z.string().optional(),
|
||||
AUD0_BASE_URL: z.string().url().optional(),
|
||||
AUD0_API_KEY: z.string().optional(),
|
||||
ST0RE_BASE_URL: z.string().url().optional(),
|
||||
|
||||
Reference in New Issue
Block a user