diff --git a/apps/api/src/auth/introspection.ts b/apps/api/src/auth/introspection.ts new file mode 100644 index 0000000..667a4f8 --- /dev/null +++ b/apps/api/src/auth/introspection.ts @@ -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 { + 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 }; + }; +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 9187b0e..b4c877a 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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 () => ({ diff --git a/apps/api/tests/auth-flow.test.ts b/apps/api/tests/auth-flow.test.ts new file mode 100644 index 0000000..717cdb4 --- /dev/null +++ b/apps/api/tests/auth-flow.test.ts @@ -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) { + 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"); + }); +}); diff --git a/apps/api/tests/introspection.test.ts b/apps/api/tests/introspection.test.ts new file mode 100644 index 0000000..16476ea --- /dev/null +++ b/apps/api/tests/introspection.test.ts @@ -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("not json", { 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); + }); +}); diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index d3f563d..71ea29f 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -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(),