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:
@@ -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 () => ({
|
||||
|
||||
Reference in New Issue
Block a user