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,8 +6,8 @@ DATABASE_URL=postgres://mika:CHANGEME@l8kogcggsc80sgcgk8kswww4:5432/claudedo
|
|||||||
ZITADEL_ISSUER=https://auth.kuns.dev
|
ZITADEL_ISSUER=https://auth.kuns.dev
|
||||||
# Comma-separated accepted audiences: web client id, desktop client id, project id
|
# Comma-separated accepted audiences: web client id, desktop client id, project id
|
||||||
ZITADEL_AUDIENCE=
|
ZITADEL_AUDIENCE=
|
||||||
# Comma-separated owner Zitadel user ids (the single owner's `sub`)
|
# Zitadel project role required for API access (default: user)
|
||||||
ALLOWED_USER_IDS=
|
REQUIRED_ROLE=user
|
||||||
# CORS: the web client origin (the app's own origin)
|
# CORS: the web client origin (the app's own origin)
|
||||||
WEB_ORIGIN=https://claudedo.kuns.dev
|
WEB_ORIGIN=https://claudedo.kuns.dev
|
||||||
|
|
||||||
|
|||||||
@@ -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) |
|
| `DATABASE_URL` | `postgres://mika:…@l8kogcggsc80sgcgk8kswww4:5432/claudedo` (shared PG, internal host) |
|
||||||
| `ZITADEL_ISSUER` | `https://auth.kuns.dev` |
|
| `ZITADEL_ISSUER` | `https://auth.kuns.dev` |
|
||||||
| `ZITADEL_AUDIENCE` | accepted audiences (CSV): web id, desktop id, project id |
|
| `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`) |
|
| `WEB_ORIGIN` | CORS allowed origin (`https://claudedo.kuns.dev`) |
|
||||||
|
|
||||||
Public web-client config is **baked at build time** (non-secret) via Dockerfile build args
|
Public web-client config is **baked at build time** (non-secret) via Dockerfile build args
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ try {
|
|||||||
humans[0] ??
|
humans[0] ??
|
||||||
owner;
|
owner;
|
||||||
} catch (e) {
|
} 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 = {
|
const result = {
|
||||||
@@ -159,7 +159,8 @@ const result = {
|
|||||||
ownerUserName: owner.userName,
|
ownerUserName: owner.userName,
|
||||||
env: {
|
env: {
|
||||||
ZITADEL_AUDIENCE: [webClientId, desktopClientId, projectId].filter(Boolean).join(","),
|
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,
|
NUXT_PUBLIC_ZITADEL_CLIENT_ID: webClientId,
|
||||||
PROJECT_AUDIENCE_SCOPE: `urn:zitadel:iam:org:project:id:${projectId}:aud`,
|
PROJECT_AUDIENCE_SCOPE: `urn:zitadel:iam:org:project:id:${projectId}:aud`,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,14 +27,15 @@ export default defineEventHandler(async (event) => {
|
|||||||
let claims: Record<string, unknown> = {};
|
let claims: Record<string, unknown> = {};
|
||||||
try {
|
try {
|
||||||
const c = decodeJwt(token);
|
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) {
|
} catch (de) {
|
||||||
claims = { not_a_jwt: String(de).slice(0, 80) };
|
claims = { not_a_jwt: String(de).slice(0, 80) };
|
||||||
}
|
}
|
||||||
console.error(
|
console.error(
|
||||||
"[auth] verify failed:", (e as Error).message,
|
"[auth] verify failed:", (e as Error).message,
|
||||||
"| claims:", JSON.stringify(claims),
|
"| 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,
|
"| ZITADEL_AUDIENCE:", process.env.ZITADEL_AUDIENCE,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,15 +6,31 @@ export interface VerifierConfig {
|
|||||||
issuer: string;
|
issuer: string;
|
||||||
/** Token is accepted if its `aud` includes at least one of these. */
|
/** Token is accepted if its `aud` includes at least one of these. */
|
||||||
audiences: string[];
|
audiences: string[];
|
||||||
/** Token `sub` must be one of these (the single owner). */
|
/** Token must carry this Zitadel project role (granted in Zitadel, asserted into the access token). */
|
||||||
allowedSubs: string[];
|
requiredRole: string;
|
||||||
/** Test seam: resolve the verification key directly instead of fetching JWKS. */
|
/** Test seam: resolve the verification key directly instead of fetching JWKS. */
|
||||||
keyResolver?: (token: string) => Promise<KeyInput>;
|
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,
|
* 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) {
|
export function makeVerifier(cfg: VerifierConfig) {
|
||||||
const jwks: JWTVerifyGetKey | null = cfg.keyResolver
|
const jwks: JWTVerifyGetKey | null = cfg.keyResolver
|
||||||
@@ -29,8 +45,8 @@ export function makeVerifier(cfg: VerifierConfig) {
|
|||||||
if (!cfg.audiences.some((a) => aud.includes(a))) {
|
if (!cfg.audiences.some((a) => aud.includes(a))) {
|
||||||
throw new Error("token audience not accepted");
|
throw new Error("token audience not accepted");
|
||||||
}
|
}
|
||||||
if (!cfg.allowedSubs.includes(String(payload.sub))) {
|
if (!rolesFromClaims(payload).has(cfg.requiredRole)) {
|
||||||
throw new Error("subject not allowed");
|
throw new Error(`required role "${cfg.requiredRole}" not granted`);
|
||||||
}
|
}
|
||||||
return payload;
|
return payload;
|
||||||
};
|
};
|
||||||
@@ -51,7 +67,7 @@ export function getVerifier() {
|
|||||||
_cached = makeVerifier({
|
_cached = makeVerifier({
|
||||||
issuer: process.env.ZITADEL_ISSUER || "https://auth.kuns.dev",
|
issuer: process.env.ZITADEL_ISSUER || "https://auth.kuns.dev",
|
||||||
audiences: splitCsv(process.env.ZITADEL_AUDIENCE),
|
audiences: splitCsv(process.env.ZITADEL_AUDIENCE),
|
||||||
allowedSubs: splitCsv(process.env.ALLOWED_USER_IDS),
|
requiredRole: process.env.REQUIRED_ROLE || "user",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return _cached;
|
return _cached;
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { SignJWT, generateKeyPair } from "jose";
|
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 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() {
|
async function setup() {
|
||||||
const { publicKey, privateKey } = await generateKeyPair("RS256");
|
const { publicKey, privateKey } = await generateKeyPair("RS256");
|
||||||
const verify = makeVerifier({
|
const verify = makeVerifier({
|
||||||
issuer: ISS,
|
issuer: ISS,
|
||||||
audiences: ["aud-web", "proj-1"],
|
audiences: ["aud-web", "proj-1"],
|
||||||
allowedSubs: ["owner-1"],
|
requiredRole: "user",
|
||||||
keyResolver: async () => publicKey,
|
keyResolver: async () => publicKey,
|
||||||
});
|
});
|
||||||
const sign = (claims: Record<string, unknown>) =>
|
const sign = (claims: Record<string, unknown>) =>
|
||||||
@@ -23,27 +25,33 @@ async function setup() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("token verification", () => {
|
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 { verify, sign } = await setup();
|
||||||
const t = await sign({ sub: "owner-1", aud: ["aud-web"] });
|
const t = await sign({ sub: "u1", aud: ["aud-web"], [ROLES_CLAIM]: { user: { org: "kuns.dev" } } });
|
||||||
await expect(verify(t)).resolves.toMatchObject({ sub: "owner-1" });
|
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 { verify, sign } = await setup();
|
||||||
const t = await sign({ sub: "owner-1", aud: "proj-1" });
|
const t = await sign({ sub: "u1", aud: "proj-1", [SCOPED_ROLES_CLAIM]: { user: { org: "kuns.dev" } } });
|
||||||
await expect(verify(t)).resolves.toMatchObject({ sub: "owner-1" });
|
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 { verify, sign } = await setup();
|
||||||
const t = await sign({ sub: "intruder", aud: ["aud-web"] });
|
const t = await sign({ sub: "u1", aud: ["aud-web"] });
|
||||||
await expect(verify(t)).rejects.toThrow();
|
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 () => {
|
it("rejects a token with no accepted audience", async () => {
|
||||||
const { verify, sign } = await setup();
|
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();
|
await expect(verify(t)).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,10 +60,10 @@ describe("token verification", () => {
|
|||||||
const verify = makeVerifier({
|
const verify = makeVerifier({
|
||||||
issuer: ISS,
|
issuer: ISS,
|
||||||
audiences: ["aud-web"],
|
audiences: ["aud-web"],
|
||||||
allowedSubs: ["owner-1"],
|
requiredRole: "user",
|
||||||
keyResolver: async () => publicKey,
|
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" })
|
.setProtectedHeader({ alg: "RS256" })
|
||||||
.setIssuer("https://evil.example")
|
.setIssuer("https://evil.example")
|
||||||
.setExpirationTime("5m")
|
.setExpirationTime("5m")
|
||||||
@@ -63,3 +71,22 @@ describe("token verification", () => {
|
|||||||
await expect(verify(t)).rejects.toThrow();
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user