import { createRemoteJWKSet, jwtVerify, type JWTPayload, type JWTVerifyGetKey } from "jose"; type KeyInput = Parameters[1]; export interface VerifierConfig { issuer: string; /** Token is accepted if its `aud` includes at least one of these. */ audiences: string[]; /** Token `sub` must be one of these (the single owner). */ allowedSubs: string[]; /** Test seam: resolve the verification key directly instead of fetching JWKS. */ keyResolver?: (token: string) => Promise; } /** * Build a Zitadel access-token verifier. Verifies signature + issuer + expiry via JWKS, * then enforces audience and owner-sub allowlists. Throws on any failure. */ export function makeVerifier(cfg: VerifierConfig) { const jwks: JWTVerifyGetKey | null = cfg.keyResolver ? null : createRemoteJWKSet(new URL(`${cfg.issuer}/oauth/v2/keys`)); return async function verify(token: string): Promise { const key: KeyInput = cfg.keyResolver ? await cfg.keyResolver(token) : (jwks as JWTVerifyGetKey); const { payload } = await jwtVerify(token, key, { issuer: cfg.issuer }); const aud = Array.isArray(payload.aud) ? payload.aud : payload.aud ? [payload.aud] : []; if (!cfg.audiences.some((a) => aud.includes(a))) { throw new Error("token audience not accepted"); } if (!cfg.allowedSubs.includes(String(payload.sub))) { throw new Error("subject not allowed"); } return payload; }; } function splitCsv(v: unknown): string[] { return String(v ?? "") .split(",") .map((s) => s.trim()) .filter(Boolean); } let _cached: ReturnType | null = null; /** Process-wide verifier built from environment (read at runtime, not baked at build). */ export function getVerifier() { if (!_cached) { _cached = makeVerifier({ issuer: process.env.ZITADEL_ISSUER || "https://auth.kuns.dev", audiences: splitCsv(process.env.ZITADEL_AUDIENCE), allowedSubs: splitCsv(process.env.ALLOWED_USER_IDS), }); } return _cached; }