feat: role-based access via Zitadel project roles
Replace the ALLOWED_USER_IDS sub-allowlist with a Zitadel project role check: tokens must carry the role from REQUIRED_ROLE (default "user") in the urn:zitadel:iam:org:project[:id]:roles claim. Roles are granted per account in Zitadel (project ClaudeDo), where access is now managed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,15 +6,31 @@ 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[];
|
||||
/** Token must carry this Zitadel project role (granted in Zitadel, asserted into the access token). */
|
||||
requiredRole: string;
|
||||
/** Test seam: resolve the verification key directly instead of fetching JWKS. */
|
||||
keyResolver?: (token: string) => Promise<KeyInput>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zitadel asserts project roles into JWT access tokens as
|
||||
* `urn:zitadel:iam:org:project:roles` (and a `…:project:<id>:roles` variant),
|
||||
* each an object keyed by role key.
|
||||
*/
|
||||
export function rolesFromClaims(payload: JWTPayload): Set<string> {
|
||||
const roles = new Set<string>();
|
||||
for (const [claim, value] of Object.entries(payload)) {
|
||||
if (!/^urn:zitadel:iam:org:project:(\d+:)?roles$/.test(claim)) continue;
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
for (const role of Object.keys(value)) roles.add(role);
|
||||
}
|
||||
}
|
||||
return roles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Zitadel access-token verifier. Verifies signature + issuer + expiry via JWKS,
|
||||
* then enforces audience and owner-sub allowlists. Throws on any failure.
|
||||
* then enforces the audience and the required project role. Throws on any failure.
|
||||
*/
|
||||
export function makeVerifier(cfg: VerifierConfig) {
|
||||
const jwks: JWTVerifyGetKey | null = cfg.keyResolver
|
||||
@@ -29,8 +45,8 @@ export function makeVerifier(cfg: VerifierConfig) {
|
||||
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");
|
||||
if (!rolesFromClaims(payload).has(cfg.requiredRole)) {
|
||||
throw new Error(`required role "${cfg.requiredRole}" not granted`);
|
||||
}
|
||||
return payload;
|
||||
};
|
||||
@@ -51,7 +67,7 @@ export function getVerifier() {
|
||||
_cached = makeVerifier({
|
||||
issuer: process.env.ZITADEL_ISSUER || "https://auth.kuns.dev",
|
||||
audiences: splitCsv(process.env.ZITADEL_AUDIENCE),
|
||||
allowedSubs: splitCsv(process.env.ALLOWED_USER_IDS),
|
||||
requiredRole: process.env.REQUIRED_ROLE || "user",
|
||||
});
|
||||
}
|
||||
return _cached;
|
||||
|
||||
Reference in New Issue
Block a user