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>
169 lines
6.1 KiB
TypeScript
169 lines
6.1 KiB
TypeScript
// Provision (idempotently) the ClaudeDo Zitadel project + two PKCE apps, and resolve the
|
|
// owner user id. Run: ZITADEL_SERVICE_TOKEN=... bun run provision:zitadel
|
|
//
|
|
// Prints the values needed for the app + desktop env. Creates nothing twice: an existing
|
|
// "ClaudeDo" project and its apps (matched by name) are reused.
|
|
|
|
const ISSUER = (process.env.ZITADEL_ISSUER || "https://auth.kuns.dev").replace(/\/$/, "");
|
|
const PAT = process.env.ZITADEL_SERVICE_TOKEN;
|
|
const WEB_REDIRECT = process.env.WEB_REDIRECT || "https://claudedo.kuns.dev/auth/callback";
|
|
const WEB_POST_LOGOUT = process.env.WEB_POST_LOGOUT || "https://claudedo.kuns.dev";
|
|
// Desktop is a native/loopback client; register a few common loopback ports.
|
|
const DESKTOP_REDIRECTS = [
|
|
"http://localhost:8765/callback",
|
|
"http://127.0.0.1:8765/callback",
|
|
"http://localhost:0/callback",
|
|
];
|
|
|
|
if (!PAT) {
|
|
console.error("ZITADEL_SERVICE_TOKEN not set");
|
|
process.exit(1);
|
|
}
|
|
|
|
const PROJECT_NAME = "ClaudeDo";
|
|
const WEB_APP_NAME = "ClaudeDo Web";
|
|
const DESKTOP_APP_NAME = "ClaudeDo Desktop";
|
|
|
|
async function api(path: string, body?: unknown, method = "POST") {
|
|
const res = await fetch(`${ISSUER}${path}`, {
|
|
method,
|
|
headers: { Authorization: `Bearer ${PAT}`, "Content-Type": "application/json" },
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
});
|
|
const text = await res.text();
|
|
let json: any = null;
|
|
try {
|
|
json = text ? JSON.parse(text) : null;
|
|
} catch {
|
|
json = text;
|
|
}
|
|
if (!res.ok) {
|
|
throw new Error(`${method} ${path} -> ${res.status}: ${typeof json === "string" ? json : JSON.stringify(json)}`);
|
|
}
|
|
return json;
|
|
}
|
|
|
|
async function findProject(): Promise<string | null> {
|
|
const r = await api("/management/v1/projects/_search", {
|
|
queries: [{ nameQuery: { name: PROJECT_NAME, method: "TEXT_QUERY_METHOD_EQUALS" } }],
|
|
});
|
|
const hit = (r?.result ?? []).find((p: any) => p.name === PROJECT_NAME);
|
|
return hit?.id ?? null;
|
|
}
|
|
|
|
async function findApp(projectId: string, name: string): Promise<{ appId: string; clientId: string } | null> {
|
|
const r = await api(`/management/v1/projects/${projectId}/apps/_search`, {
|
|
queries: [{ nameQuery: { name, method: "TEXT_QUERY_METHOD_EQUALS" } }],
|
|
});
|
|
const hit = (r?.result ?? []).find((a: any) => a.name === name);
|
|
if (!hit) return null;
|
|
return { appId: hit.id, clientId: hit.oidcConfig?.clientId ?? "" };
|
|
}
|
|
|
|
async function createProject(): Promise<string> {
|
|
const existing = await findProject();
|
|
if (existing) {
|
|
console.error(`project "${PROJECT_NAME}" exists: ${existing}`);
|
|
return existing;
|
|
}
|
|
const r = await api("/management/v1/projects", {
|
|
name: PROJECT_NAME,
|
|
projectRoleAssertion: false,
|
|
projectRoleCheck: false,
|
|
hasProjectCheck: false,
|
|
privateLabelingSetting: "PRIVATE_LABELING_SETTING_UNSPECIFIED",
|
|
});
|
|
console.error(`created project: ${r.id}`);
|
|
return r.id;
|
|
}
|
|
|
|
async function createWebApp(projectId: string): Promise<string> {
|
|
const existing = await findApp(projectId, WEB_APP_NAME);
|
|
if (existing?.clientId) {
|
|
console.error(`web app exists: ${existing.clientId}`);
|
|
return existing.clientId;
|
|
}
|
|
const r = await api(`/management/v1/projects/${projectId}/apps/oidc`, {
|
|
name: WEB_APP_NAME,
|
|
redirectUris: [WEB_REDIRECT],
|
|
postLogoutRedirectUris: [WEB_POST_LOGOUT],
|
|
responseTypes: ["OIDC_RESPONSE_TYPE_CODE"],
|
|
grantTypes: ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE"],
|
|
appType: "OIDC_APP_TYPE_USER_AGENT",
|
|
authMethodType: "OIDC_AUTH_METHOD_TYPE_NONE", // PKCE, public client
|
|
version: "OIDC_VERSION_1_0",
|
|
devMode: false,
|
|
accessTokenType: "OIDC_TOKEN_TYPE_JWT",
|
|
accessTokenRoleAssertion: true,
|
|
});
|
|
console.error(`created web app: ${r.clientId}`);
|
|
return r.clientId;
|
|
}
|
|
|
|
async function createDesktopApp(projectId: string): Promise<string> {
|
|
const existing = await findApp(projectId, DESKTOP_APP_NAME);
|
|
if (existing?.clientId) {
|
|
console.error(`desktop app exists: ${existing.clientId}`);
|
|
return existing.clientId;
|
|
}
|
|
const r = await api(`/management/v1/projects/${projectId}/apps/oidc`, {
|
|
name: DESKTOP_APP_NAME,
|
|
redirectUris: DESKTOP_REDIRECTS,
|
|
responseTypes: ["OIDC_RESPONSE_TYPE_CODE"],
|
|
grantTypes: ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN"],
|
|
appType: "OIDC_APP_TYPE_NATIVE",
|
|
authMethodType: "OIDC_AUTH_METHOD_TYPE_NONE", // PKCE (mandatory for native)
|
|
version: "OIDC_VERSION_1_0",
|
|
devMode: true, // permit http loopback redirect variations
|
|
accessTokenType: "OIDC_TOKEN_TYPE_JWT",
|
|
accessTokenRoleAssertion: true,
|
|
});
|
|
console.error(`created desktop app: ${r.clientId}`);
|
|
return r.clientId;
|
|
}
|
|
|
|
async function listHumanUsers(): Promise<{ id: string; userName: string; email: string }[]> {
|
|
const r = await api("/management/v1/users/_search", { query: { offset: "0", limit: 200, asc: true } });
|
|
return (r?.result ?? [])
|
|
.filter((u: any) => u.human)
|
|
.map((u: any) => ({
|
|
id: u.id,
|
|
userName: u.userName,
|
|
email: u.human?.email?.email ?? "",
|
|
}));
|
|
}
|
|
|
|
const projectId = await createProject();
|
|
const webClientId = await createWebApp(projectId);
|
|
const desktopClientId = await createDesktopApp(projectId);
|
|
|
|
let owner = { id: "", userName: "", email: "" };
|
|
try {
|
|
const humans = await listHumanUsers();
|
|
console.error("\nhuman users:");
|
|
for (const u of humans) console.error(` ${u.id} ${u.userName} ${u.email}`);
|
|
owner =
|
|
humans.find((u) => u.userName === "mika") ??
|
|
humans.find((u) => u.email?.includes("mika")) ??
|
|
humans[0] ??
|
|
owner;
|
|
} catch (e) {
|
|
console.error(`\n(could not list users: ${(e as Error).message}) — grant the "user" project role manually`);
|
|
}
|
|
|
|
const result = {
|
|
projectId,
|
|
webClientId,
|
|
desktopClientId,
|
|
ownerUserId: owner.id,
|
|
ownerUserName: owner.userName,
|
|
env: {
|
|
ZITADEL_AUDIENCE: [webClientId, desktopClientId, projectId].filter(Boolean).join(","),
|
|
// 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`,
|
|
},
|
|
};
|
|
console.log(JSON.stringify(result, null, 2));
|