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,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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user