feat: zitadel provisioning script + project-audience scope
This commit is contained in:
@@ -14,6 +14,8 @@ WEB_ORIGIN=https://claudedo.kuns.dev
|
|||||||
# --- Web client (public, exposed to browser) ---
|
# --- Web client (public, exposed to browser) ---
|
||||||
NUXT_PUBLIC_ZITADEL_ISSUER=https://auth.kuns.dev
|
NUXT_PUBLIC_ZITADEL_ISSUER=https://auth.kuns.dev
|
||||||
NUXT_PUBLIC_ZITADEL_CLIENT_ID=
|
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) ---
|
# --- Provisioning script only (not needed at runtime) ---
|
||||||
# Zitadel Management API PAT (from ~/.secrets/coolify-tokens.env: ZITADEL_SERVICE_TOKEN)
|
# Zitadel Management API PAT (from ~/.secrets/coolify-tokens.env: ZITADEL_SERVICE_TOKEN)
|
||||||
|
|||||||
@@ -5,9 +5,15 @@ import { useZitadelAuth } from "@kuns/zitadel-auth/vue";
|
|||||||
// router guard that redirects unauthenticated users to the Zitadel hosted login.
|
// router guard that redirects unauthenticated users to the Zitadel hosted login.
|
||||||
export default defineNuxtPlugin(() => {
|
export default defineNuxtPlugin(() => {
|
||||||
const cfg = useRuntimeConfig().public;
|
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, {
|
const auth = useZitadelAuth(useRouter() as never, {
|
||||||
clientId: cfg.zitadelClientId as string,
|
clientId: cfg.zitadelClientId as string,
|
||||||
issuer: cfg.zitadelIssuer as string,
|
issuer: cfg.zitadelIssuer as string,
|
||||||
|
scopes,
|
||||||
});
|
});
|
||||||
return { provide: { auth } };
|
return { provide: { auth } };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ export default defineNuxtConfig({
|
|||||||
public: {
|
public: {
|
||||||
zitadelIssuer: process.env.NUXT_PUBLIC_ZITADEL_ISSUER || "https://auth.kuns.dev",
|
zitadelIssuer: process.env.NUXT_PUBLIC_ZITADEL_ISSUER || "https://auth.kuns.dev",
|
||||||
zitadelClientId: process.env.NUXT_PUBLIC_ZITADEL_CLIENT_ID || "",
|
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: {
|
app: {
|
||||||
|
|||||||
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