diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 7519bb1a..06b82c48 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -146,6 +146,13 @@ ANTHROPIC_API_KEY=sk-ant-... # if set, clusters get LLM-quality label GOOGLE_CLIENT_ID=... # Google IdP client id GOOGLE_CLIENT_SECRET=... # Google IdP client secret CODEOID_OAUTH_CLIENT_ID=codeoid # optional: OAuth client id (default "codeoid") +# The Google ID token is exchanged at ZeroID for a ZeroID token. ZeroID v1.12+ +# only accepts that exchange from an authenticated client the ID token was +# issued to, so register a confidential client at ZeroID whose client_id is +# GOOGLE_CLIENT_ID (token_endpoint_auth_method client_secret_post, with the +# Google issuer configured for that audience) and set its secret here. +CODEOID_ZEROID_CLIENT_ID=... # optional: defaults to GOOGLE_CLIENT_ID +CODEOID_ZEROID_CLIENT_SECRET=... # env only; required by ZeroID v1.12+ # Telegram frontend TELEGRAM_BOT_TOKEN=... diff --git a/src/config.ts b/src/config.ts index 86702d66..8dd7c2c8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -564,6 +564,14 @@ const AuthSchemaFields = z const OAuthSchemaFields = z .object({ clientId: z.string().optional(), + /** + * client_id codeoid authenticates as at ZeroID for the id_token exchange. + * ZeroID only lets the relying party an ID token was issued to redeem it + * (aud/azp), so this must be the client the IdP issued the ID token to. + * Defaults to GOOGLE_CLIENT_ID. Its secret comes from + * CODEOID_ZEROID_CLIENT_SECRET (env only, never config.json). + */ + zeroidClientId: z.string().optional(), }) .default({}); @@ -1542,6 +1550,7 @@ const ENV_OVERRIDES: readonly EnvOverride[] = [ { env: "ZEROID_ISSUER", path: "auth.issuer", kind: "string" }, { env: "ZEROID_AUDIENCE", path: "auth.audience", kind: "string" }, { env: "CODEOID_OAUTH_CLIENT_ID", path: "oauth.clientId", kind: "string" }, + { env: "CODEOID_ZEROID_CLIENT_ID", path: "oauth.zeroidClientId", kind: "string" }, { env: "ZEROID_ACCOUNT_ID", path: "agentIdentity.accountId", kind: "string" }, { env: "ZEROID_PROJECT_ID", path: "agentIdentity.projectId", kind: "string" }, { env: "ZEROID_REGISTRAR_KEY", path: "agentIdentity.registrarKey", kind: "string" }, @@ -1731,6 +1740,8 @@ export function loadConfig(opts: LoadOptions = {}): CodeoidConfig { ? { zeroidTokenEndpoint: `${resolvedZeroidUrl}/oauth2/token`, clientId: parsed.oauth.clientId ?? "codeoid", + zeroidClientId: parsed.oauth.zeroidClientId ?? googleClientId, + zeroidClientSecret: env.CODEOID_ZEROID_CLIENT_SECRET || undefined, googleClientId, googleClientSecret, accountId: parsed.agentIdentity.accountId, diff --git a/src/daemon/oauth.ts b/src/daemon/oauth.ts index 5253c6a4..a8c17962 100644 --- a/src/daemon/oauth.ts +++ b/src/daemon/oauth.ts @@ -21,6 +21,15 @@ export interface OAuthConfig { zeroidTokenEndpoint: string; /** Registered OAuth client_id (used to identify this codeoid instance) */ clientId: string; + /** + * client_id codeoid authenticates as at ZeroID's id_token exchange. ZeroID + * (v1.12+) requires an authenticated client and only accepts an ID token + * issued to that client (aud/azp), so this is the IdP client the ID token + * names: the Google OAuth client id by default. + */ + zeroidClientId: string; + /** Secret for zeroidClientId (client_secret_post). Unset sends no secret. */ + zeroidClientSecret?: string; /** Google OAuth client ID — passed to GoogleOAuthProvider */ googleClientId: string; /** Google OAuth client secret — passed to GoogleOAuthProvider */ @@ -266,11 +275,14 @@ export class OAuthHandler { grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", subject_token_type: "urn:ietf:params:oauth:token-type:id_token", subject_token: user.rawIdToken, - client_id: this.#config.clientId, + client_id: this.#config.zeroidClientId, account_id: this.#config.accountId, project_id: this.#config.projectId, scope, }); + if (this.#config.zeroidClientSecret) { + body.set("client_secret", this.#config.zeroidClientSecret); + } const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10_000); @@ -288,7 +300,11 @@ export class OAuthHandler { if (!resp.ok) { const text = await resp.text().catch(() => ""); - throw new Error(`ZeroID token exchange failed (${resp.status}): ${text.slice(0, 200)}`); + const hint = + resp.status === 401 && !this.#config.zeroidClientSecret + ? " — ZeroID requires client authentication for the id_token exchange; set CODEOID_ZEROID_CLIENT_SECRET" + : ""; + throw new Error(`ZeroID token exchange failed (${resp.status}): ${text.slice(0, 200)}${hint}`); } const data = (await resp.json()) as { access_token?: string }; diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 3ca5148f..087aefa4 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -278,6 +278,14 @@ export class DaemonServer { }); this.#oauthHandler = new OAuthHandler(config.oauth, idp); console.log(`[codeoid] auth provider: ${idp.name}`); + if (!config.oauth.zeroidClientSecret) { + // ZeroID v1.12+ refuses an id_token exchange from an unauthenticated + // client. Warn rather than fail so codeoid still starts against an + // older ZeroID that does not require it. + console.warn( + `[codeoid] CODEOID_ZEROID_CLIENT_SECRET is not set: ZeroID v1.12+ will refuse Google sign-in (register a confidential client "${config.oauth.zeroidClientId}" at ZeroID and set its secret)`, + ); + } } else if (config.oauth) { console.warn("[codeoid] local mode: Google OAuth sign-in disabled (it needs ZeroID)"); } diff --git a/src/tests/config.test.ts b/src/tests/config.test.ts index 3ff11013..9bccc8a0 100644 --- a/src/tests/config.test.ts +++ b/src/tests/config.test.ts @@ -151,6 +151,29 @@ describe("loadConfig — registrar key + tenant", () => { }); }); +describe("loadConfig — ZeroID client for the id_token exchange", () => { + it("defaults to the Google client id, the client the ID token is issued to", () => { + const c = loadConfig({ configPath, env: { GOOGLE_CLIENT_ID: "gid", GOOGLE_CLIENT_SECRET: "gsec" } }); + expect(c.oauth?.zeroidClientId).toBe("gid"); + expect(c.oauth?.zeroidClientSecret).toBeUndefined(); + expect(c.oauth?.clientId).toBe("codeoid"); + }); + + it("takes an explicit client id and the secret from env", () => { + const c = loadConfig({ + configPath, + env: { + GOOGLE_CLIENT_ID: "gid", + GOOGLE_CLIENT_SECRET: "gsec", + CODEOID_ZEROID_CLIENT_ID: "zeroid-client", + CODEOID_ZEROID_CLIENT_SECRET: "zsec", + }, + }); + expect(c.oauth?.zeroidClientId).toBe("zeroid-client"); + expect(c.oauth?.zeroidClientSecret).toBe("zsec"); + }); +}); + describe("loadConfig — env precedence", () => { it("env overrides file on every field type", () => { writeConfig({ diff --git a/src/tests/oauth-handler.test.ts b/src/tests/oauth-handler.test.ts index 57d6630c..c2b22d03 100644 --- a/src/tests/oauth-handler.test.ts +++ b/src/tests/oauth-handler.test.ts @@ -24,6 +24,8 @@ import type { IdentityProvider, VerifiedUser } from "../daemon/identity-provider const BASE_CONFIG: OAuthConfig = { zeroidTokenEndpoint: "http://zeroid.test/oauth2/token", clientId: "codeoid", + zeroidClientId: "gid_xxx", + zeroidClientSecret: "zsecret_xxx", googleClientId: "gid_xxx", googleClientSecret: "gsecret_xxx", accountId: "acct_test", @@ -218,13 +220,44 @@ describe("GET /auth/idp-callback — Google OAuth (ZeroID token exchange)", () = const body = new URLSearchParams(captured.body); expect(body.get("grant_type")).toBe("urn:ietf:params:oauth:grant-type:token-exchange"); expect(body.get("subject_token_type")).toBe("urn:ietf:params:oauth:token-type:id_token"); - expect(body.get("client_id")).toBe("codeoid"); + // The exchange authenticates as the client the ID token was issued to + // (ZeroID D13), not as codeoid's own front-door client id. + expect(body.get("client_id")).toBe("gid_xxx"); + expect(body.get("client_secret")).toBe("zsecret_xxx"); expect(body.get("account_id")).toBe("acct_test"); expect(body.get("project_id")).toBe("proj_test"); expect(body.get("scope")).toBe("session:list"); // scope from /auth/authorize handler.stop(); }); + it("sends no client_secret when none is configured", async () => { + const handler = new OAuthHandler({ ...BASE_CONFIG, zeroidClientSecret: undefined }, new StubIdP()); + const captured: { body?: string } = {}; + (globalThis as { fetch: unknown }).fetch = async (_url: string, opts: RequestInit) => { + captured.body = opts.body as string; + return { ok: true, json: async () => ({ access_token: "tok" }), text: async () => "" }; + }; + + const internalState = await authorize(handler); + await get(handler, `/auth/idp-callback?state=${internalState}&id_token=gid-tok`); + + const body = new URLSearchParams(captured.body); + expect(body.get("client_id")).toBe("gid_xxx"); + expect(body.has("client_secret")).toBe(false); + handler.stop(); + }); + + it("names CODEOID_ZEROID_CLIENT_SECRET when ZeroID refuses an unauthenticated exchange", async () => { + const handler = new OAuthHandler({ ...BASE_CONFIG, zeroidClientSecret: undefined }, new StubIdP()); + mockZeroIDFail(401, '{"error":"invalid_client"}'); + + const internalState = await authorize(handler); + const resp = await get(handler, `/auth/idp-callback?state=${internalState}&id_token=gid-tok`); + const location = decodeURIComponent(resp!.headers.get("Location")!); + expect(location).toContain("CODEOID_ZEROID_CLIENT_SECRET"); + handler.stop(); + }); + it("redirects to callback with ?error= when ZeroID rejects", async () => { const handler = makeHandler(); mockZeroIDFail(401, "invalid id_token");