From d4c734737b23c162065416a80581838757d156bc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 11:25:34 +0000 Subject: [PATCH] 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 --- .env.example | 4 +-- README.md | 2 +- scripts/provision-zitadel.ts | 5 ++-- server/middleware/1.auth.ts | 5 ++-- server/utils/auth.ts | 28 ++++++++++++++---- tests/auth.test.ts | 55 +++++++++++++++++++++++++++--------- 6 files changed, 72 insertions(+), 27 deletions(-) diff --git a/.env.example b/.env.example index a092c98..382e92c 100644 --- a/.env.example +++ b/.env.example @@ -6,8 +6,8 @@ DATABASE_URL=postgres://mika:CHANGEME@l8kogcggsc80sgcgk8kswww4:5432/claudedo ZITADEL_ISSUER=https://auth.kuns.dev # Comma-separated accepted audiences: web client id, desktop client id, project id ZITADEL_AUDIENCE= -# Comma-separated owner Zitadel user ids (the single owner's `sub`) -ALLOWED_USER_IDS= +# Zitadel project role required for API access (default: user) +REQUIRED_ROLE=user # CORS: the web client origin (the app's own origin) WEB_ORIGIN=https://claudedo.kuns.dev diff --git a/README.md b/README.md index e5c93b2..506a0e5 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ Server-only values are read from `process.env` at runtime (set them in Coolify): | `DATABASE_URL` | `postgres://mika:…@l8kogcggsc80sgcgk8kswww4:5432/claudedo` (shared PG, internal host) | | `ZITADEL_ISSUER` | `https://auth.kuns.dev` | | `ZITADEL_AUDIENCE` | accepted audiences (CSV): web id, desktop id, project id | -| `ALLOWED_USER_IDS` | owner `sub` allowlist (CSV) | +| `REQUIRED_ROLE` | Zitadel project role required for API access (default `user`; grant it to accounts in Zitadel) | | `WEB_ORIGIN` | CORS allowed origin (`https://claudedo.kuns.dev`) | Public web-client config is **baked at build time** (non-secret) via Dockerfile build args diff --git a/scripts/provision-zitadel.ts b/scripts/provision-zitadel.ts index fefdb80..357b99f 100644 --- a/scripts/provision-zitadel.ts +++ b/scripts/provision-zitadel.ts @@ -148,7 +148,7 @@ try { humans[0] ?? owner; } catch (e) { - console.error(`\n(could not list users: ${(e as Error).message}) — set ALLOWED_USER_IDS manually`); + console.error(`\n(could not list users: ${(e as Error).message}) — grant the "user" project role manually`); } const result = { @@ -159,7 +159,8 @@ const result = { ownerUserName: owner.userName, env: { ZITADEL_AUDIENCE: [webClientId, desktopClientId, projectId].filter(Boolean).join(","), - ALLOWED_USER_IDS: owner.id, + // Access is role-based: grant the project role below to each allowed account in Zitadel. + REQUIRED_ROLE: "user", NUXT_PUBLIC_ZITADEL_CLIENT_ID: webClientId, PROJECT_AUDIENCE_SCOPE: `urn:zitadel:iam:org:project:id:${projectId}:aud`, }, diff --git a/server/middleware/1.auth.ts b/server/middleware/1.auth.ts index c4bca75..05494fe 100644 --- a/server/middleware/1.auth.ts +++ b/server/middleware/1.auth.ts @@ -27,14 +27,15 @@ export default defineEventHandler(async (event) => { let claims: Record = {}; try { const c = decodeJwt(token); - claims = { iss: c.iss, sub: c.sub, aud: c.aud, azp: c.azp, exp: c.exp, alg_present: true }; + const roleClaims = Object.keys(c).filter((k) => k.includes(":project:") && k.endsWith(":roles")); + claims = { iss: c.iss, sub: c.sub, aud: c.aud, azp: c.azp, exp: c.exp, roleClaims, alg_present: true }; } catch (de) { claims = { not_a_jwt: String(de).slice(0, 80) }; } console.error( "[auth] verify failed:", (e as Error).message, "| claims:", JSON.stringify(claims), - "| ALLOWED_USER_IDS:", process.env.ALLOWED_USER_IDS, + "| REQUIRED_ROLE:", process.env.REQUIRED_ROLE || "user", "| ZITADEL_AUDIENCE:", process.env.ZITADEL_AUDIENCE, ); } diff --git a/server/utils/auth.ts b/server/utils/auth.ts index e31829f..b065812 100644 --- a/server/utils/auth.ts +++ b/server/utils/auth.ts @@ -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; } +/** + * Zitadel asserts project roles into JWT access tokens as + * `urn:zitadel:iam:org:project:roles` (and a `…:project::roles` variant), + * each an object keyed by role key. + */ +export function rolesFromClaims(payload: JWTPayload): Set { + const roles = new Set(); + 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; diff --git a/tests/auth.test.ts b/tests/auth.test.ts index 0250f53..90b99a8 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -1,15 +1,17 @@ import { describe, expect, it } from "vitest"; import { SignJWT, generateKeyPair } from "jose"; -import { makeVerifier } from "../server/utils/auth"; +import { makeVerifier, rolesFromClaims } from "../server/utils/auth"; const ISS = "https://auth.kuns.dev"; +const ROLES_CLAIM = "urn:zitadel:iam:org:project:roles"; +const SCOPED_ROLES_CLAIM = "urn:zitadel:iam:org:project:376787351902355727:roles"; async function setup() { const { publicKey, privateKey } = await generateKeyPair("RS256"); const verify = makeVerifier({ issuer: ISS, audiences: ["aud-web", "proj-1"], - allowedSubs: ["owner-1"], + requiredRole: "user", keyResolver: async () => publicKey, }); const sign = (claims: Record) => @@ -23,27 +25,33 @@ async function setup() { } describe("token verification", () => { - it("accepts an owner token with a valid audience", async () => { + it("accepts a token with the user role and a valid audience", async () => { const { verify, sign } = await setup(); - const t = await sign({ sub: "owner-1", aud: ["aud-web"] }); - await expect(verify(t)).resolves.toMatchObject({ sub: "owner-1" }); + const t = await sign({ sub: "u1", aud: ["aud-web"], [ROLES_CLAIM]: { user: { org: "kuns.dev" } } }); + await expect(verify(t)).resolves.toMatchObject({ sub: "u1" }); }); - it("accepts when aud is a single string in the allowed set", async () => { + it("accepts the role from the project-scoped claim variant", async () => { const { verify, sign } = await setup(); - const t = await sign({ sub: "owner-1", aud: "proj-1" }); - await expect(verify(t)).resolves.toMatchObject({ sub: "owner-1" }); + const t = await sign({ sub: "u1", aud: "proj-1", [SCOPED_ROLES_CLAIM]: { user: { org: "kuns.dev" } } }); + await expect(verify(t)).resolves.toMatchObject({ sub: "u1" }); }); - it("rejects a non-owner sub", async () => { + it("rejects a token without the required role", async () => { const { verify, sign } = await setup(); - const t = await sign({ sub: "intruder", aud: ["aud-web"] }); - await expect(verify(t)).rejects.toThrow(); + const t = await sign({ sub: "u1", aud: ["aud-web"] }); + await expect(verify(t)).rejects.toThrow(/required role/); + }); + + it("rejects a token whose roles do not include the required one", async () => { + const { verify, sign } = await setup(); + const t = await sign({ sub: "u1", aud: ["aud-web"], [ROLES_CLAIM]: { viewer: { org: "kuns.dev" } } }); + await expect(verify(t)).rejects.toThrow(/required role/); }); it("rejects a token with no accepted audience", async () => { const { verify, sign } = await setup(); - const t = await sign({ sub: "owner-1", aud: ["other"] }); + const t = await sign({ sub: "u1", aud: ["other"], [ROLES_CLAIM]: { user: { org: "kuns.dev" } } }); await expect(verify(t)).rejects.toThrow(); }); @@ -52,10 +60,10 @@ describe("token verification", () => { const verify = makeVerifier({ issuer: ISS, audiences: ["aud-web"], - allowedSubs: ["owner-1"], + requiredRole: "user", keyResolver: async () => publicKey, }); - const t = await new SignJWT({ sub: "owner-1", aud: ["aud-web"] }) + const t = await new SignJWT({ sub: "u1", aud: ["aud-web"], [ROLES_CLAIM]: { user: {} } }) .setProtectedHeader({ alg: "RS256" }) .setIssuer("https://evil.example") .setExpirationTime("5m") @@ -63,3 +71,22 @@ describe("token verification", () => { await expect(verify(t)).rejects.toThrow(); }); }); + +describe("rolesFromClaims", () => { + it("merges generic and project-scoped role claims", () => { + const roles = rolesFromClaims({ + [ROLES_CLAIM]: { user: {} }, + [SCOPED_ROLES_CLAIM]: { admin: {} }, + }); + expect(roles).toEqual(new Set(["user", "admin"])); + }); + + it("ignores malformed role claims and unrelated keys", () => { + const roles = rolesFromClaims({ + [ROLES_CLAIM]: "not-an-object", + "urn:zitadel:iam:org:project:roles:extra": { nope: {} }, + sub: "u1", + } as never); + expect(roles.size).toBe(0); + }); +});