Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=...
Expand Down
11 changes: 11 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({});

Expand Down Expand Up @@ -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" },
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 18 additions & 2 deletions src/daemon/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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);
Expand All @@ -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 };
Expand Down
8 changes: 8 additions & 0 deletions src/daemon/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)");
}
Expand Down
23 changes: 23 additions & 0 deletions src/tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
35 changes: 34 additions & 1 deletion src/tests/oauth-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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");
Expand Down
Loading