From 56186a1feaf947a7f1e37448bc45601519861a43 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 08:07:20 +0000 Subject: [PATCH] feat: zitadel provisioning script + project-audience scope --- .env.example | 2 + app/plugins/auth.client.ts | 6 ++ nuxt.config.ts | 3 + scripts/provision-zitadel.ts | 167 +++++++++++++++++++++++++++++++++++ 4 files changed, 178 insertions(+) create mode 100644 scripts/provision-zitadel.ts diff --git a/.env.example b/.env.example index 6d59a89..a092c98 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,8 @@ WEB_ORIGIN=https://claudedo.kuns.dev # --- Web client (public, exposed to browser) --- NUXT_PUBLIC_ZITADEL_ISSUER=https://auth.kuns.dev NUXT_PUBLIC_ZITADEL_CLIENT_ID= +# Zitadel project id — adds the project-audience scope at login so the API can validate `aud` +NUXT_PUBLIC_ZITADEL_PROJECT_ID= # --- Provisioning script only (not needed at runtime) --- # Zitadel Management API PAT (from ~/.secrets/coolify-tokens.env: ZITADEL_SERVICE_TOKEN) diff --git a/app/plugins/auth.client.ts b/app/plugins/auth.client.ts index 7f6406d..e99da17 100644 --- a/app/plugins/auth.client.ts +++ b/app/plugins/auth.client.ts @@ -5,9 +5,15 @@ import { useZitadelAuth } from "@kuns/zitadel-auth/vue"; // router guard that redirects unauthenticated users to the Zitadel hosted login. export default defineNuxtPlugin(() => { const cfg = useRuntimeConfig().public; + const scopes = ["openid", "profile", "email"]; + if (cfg.zitadelProjectId) { + // Force the project id into the access token's `aud` for backend validation. + scopes.push(`urn:zitadel:iam:org:project:id:${cfg.zitadelProjectId}:aud`); + } const auth = useZitadelAuth(useRouter() as never, { clientId: cfg.zitadelClientId as string, issuer: cfg.zitadelIssuer as string, + scopes, }); return { provide: { auth } }; }); diff --git a/nuxt.config.ts b/nuxt.config.ts index 64445bb..8163365 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -13,6 +13,9 @@ export default defineNuxtConfig({ public: { zitadelIssuer: process.env.NUXT_PUBLIC_ZITADEL_ISSUER || "https://auth.kuns.dev", zitadelClientId: process.env.NUXT_PUBLIC_ZITADEL_CLIENT_ID || "", + // Optional: Zitadel project id. When set, the login requests the project-audience + // scope so the access token's `aud` includes the project id (robust API validation). + zitadelProjectId: process.env.NUXT_PUBLIC_ZITADEL_PROJECT_ID || "", }, }, app: { diff --git a/scripts/provision-zitadel.ts b/scripts/provision-zitadel.ts new file mode 100644 index 0000000..fefdb80 --- /dev/null +++ b/scripts/provision-zitadel.ts @@ -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 { + 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 { + 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 { + 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 { + 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));