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
+31
View File
@@ -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 () => ({