feat: zitadel provisioning script + project-audience scope

This commit is contained in:
2026-06-10 08:07:20 +00:00
parent f83bb25316
commit 56186a1fea
4 changed files with 178 additions and 0 deletions

View File

@@ -0,0 +1,167 @@
// 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}) — set ALLOWED_USER_IDS manually`);
}
const result = {
projectId,
webClientId,
desktopClientId,
ownerUserId: owner.id,
ownerUserName: owner.userName,
env: {
ZITADEL_AUDIENCE: [webClientId, desktopClientId, projectId].filter(Boolean).join(","),
ALLOWED_USER_IDS: owner.id,
NUXT_PUBLIC_ZITADEL_CLIENT_ID: webClientId,
PROJECT_AUDIENCE_SCOPE: `urn:zitadel:iam:org:project:id:${projectId}:aud`,
},
};
console.log(JSON.stringify(result, null, 2));