feat(api): real Zitadel token introspection (fail-closed) + expected-audience enforcement
CI (SIAX Cloud) / security (push) Successful in 12s
CI (SIAX Cloud) / contracts (push) Successful in 30s
CI (SIAX Cloud) / quality (push) Successful in 1m33s

- 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:
2026-09-16 22:06:46 +02:00
parent dac79054de
commit 495050752a
5 changed files with 301 additions and 0 deletions
+90
View File
@@ -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");
});
});
+97
View File
@@ -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);
});
});