feat: zitadel provisioning script + project-audience scope
This commit is contained in:
167
scripts/provision-zitadel.ts
Normal file
167
scripts/provision-zitadel.ts
Normal 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));
|
||||
Reference in New Issue
Block a user