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:
@@ -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<string, unknown>) =>
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user