From 0b0614ca421d448da41977613d31ecfdbe2ecfe7 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Mon, 29 Jun 2026 15:56:21 +0200 Subject: [PATCH 1/8] feat: Google OAuth via ZeroID external IdP federation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the hmacSecret / HS256 auth-code approach with the correct architecture: Google verifies the human user, ZeroID is the authority that issues the final RS256 access token, the daemon validates it the same way it validates any other ZeroID JWT. Flow: Browser → /auth/authorize (daemon) → Google OAuth → /auth/idp-callback (daemon gets Google id_token) → daemon exchanges id_token at ZeroID /oauth2/token (grant_type=token-exchange, subject_token=, account_id + project_id from daemon config) → ZeroID validates via Google JWKS (external_issuers config) → ZeroID returns RS256 access token → daemon sends token to /auth/callback (browser stores, redirects) → App.tsx auto-bootstraps from stored ZeroID token on next load Changes: - OAuthConfig: drop hmacSecret/issuer, add zeroidTokenEndpoint - OAuthHandler: #completeAuth now calls ZeroID token-exchange instead of minting HS256 codes; callbackPage simplified (no fetch needed — token is delivered server-side, page just stores + redirects) - identity-provider.ts: VerifiedUser carries rawIdToken for forwarding - config.ts: OAuth block gated on GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET (not CODEOID_HMAC_SECRET); reads env correctly for testability - server.ts: always uses GoogleOAuthProvider when config.oauth is set - web/src/lib/auth.ts: resolveToken checks stored ZeroID token; adds rememberedOAuthToken, fetchOAuthProvider, startOAuthLogin (simple redirect — no client-side PKCE, daemon owns the exchange) - web/src/App.tsx: auto-bootstrap fires on stored OAuth token too - web/src/components/SignIn.tsx: "Sign in with Google" button, shown only when /auth/provider returns "google" ZeroID config required (external_issuers): issuer: https://accounts.google.com jwks_uri: https://www.googleapis.com/oauth2/v3/certs Closes #42 Co-Authored-By: Claude Sonnet 4.6 --- src/config.ts | 17 +-- src/daemon/identity-provider.ts | 7 + src/daemon/oauth.ts | 244 +++++++++++++------------------- src/daemon/server.ts | 16 +-- src/tests/config.test.ts | 9 +- web/src/App.tsx | 11 +- web/src/components/SignIn.tsx | 49 ++++++- web/src/lib/auth.ts | 43 +++++- 8 files changed, 224 insertions(+), 172 deletions(-) diff --git a/src/config.ts b/src/config.ts index 63a93b6..2240833 100644 --- a/src/config.ts +++ b/src/config.ts @@ -305,8 +305,6 @@ const AuthSchemaFields = z const OAuthSchemaFields = z .object({ - hmacSecret: z.string().optional(), - issuer: z.string().optional(), clientId: z.string().optional(), }) .default({}); @@ -434,7 +432,6 @@ const ENV_OVERRIDES: readonly EnvOverride[] = [ { env: "ZEROID_URL", path: "zeroidUrl", kind: "string" }, { env: "ZEROID_ISSUER", path: "auth.issuer", kind: "string" }, { env: "ZEROID_AUDIENCE", path: "auth.audience", kind: "string" }, - { env: "CODEOID_HMAC_SECRET", path: "oauth.hmacSecret", kind: "string" }, { env: "CODEOID_OAUTH_CLIENT_ID", path: "oauth.clientId", kind: "string" }, { env: "ZEROID_ACCOUNT_ID", path: "agentIdentity.accountId", kind: "string" }, { env: "ZEROID_PROJECT_ID", path: "agentIdentity.projectId", kind: "string" }, @@ -559,12 +556,16 @@ export function loadConfig(opts: LoadOptions = {}): CodeoidConfig { const memoryDbPath = configRelResolve(parsed.memory.dbPath); const memoryCacheDir = configRelResolve(parsed.memory.modelCacheDir); - // 5. Assemble OAuth only when hmacSecret is present — keeps optionality. - const oauth: OAuthConfig | undefined = parsed.oauth.hmacSecret + // 5. Assemble OAuth when Google credentials are present in env. + // The daemon reads GOOGLE_CLIENT_ID/SECRET directly in server.ts to + // construct the GoogleOAuthProvider — here we just decide whether to + // enable the handler and supply the ZeroID exchange endpoint. + const googleOAuthEnabled = + Boolean(env.GOOGLE_CLIENT_ID) && Boolean(env.GOOGLE_CLIENT_SECRET); + + const oauth: OAuthConfig | undefined = googleOAuthEnabled ? { - hmacSecret: parsed.oauth.hmacSecret, - issuer: parsed.oauth.issuer ?? resolvedZeroidUrl, - tokenEndpoint: `${resolvedZeroidUrl}/oauth2/token`, + zeroidTokenEndpoint: `${resolvedZeroidUrl}/oauth2/token`, clientId: parsed.oauth.clientId ?? "codeoid", accountId: parsed.agentIdentity.accountId, projectId: parsed.agentIdentity.projectId, diff --git a/src/daemon/identity-provider.ts b/src/daemon/identity-provider.ts index b1634cb..e3302cc 100644 --- a/src/daemon/identity-provider.ts +++ b/src/daemon/identity-provider.ts @@ -28,6 +28,12 @@ export interface VerifiedUser { avatarUrl?: string; /** Which provider verified this user */ provider: string; + /** + * Raw OIDC ID token from the upstream IdP — preserved so the daemon can + * forward it to ZeroID's token-exchange endpoint for final token issuance. + * Only set for external IdPs (Google etc.), not for LocalProvider. + */ + rawIdToken?: string; } export interface IdentityProvider { @@ -133,6 +139,7 @@ export class GoogleOAuthProvider implements IdentityProvider { name: payload.name, avatarUrl: payload.picture, provider: "google", + rawIdToken: tokens.id_token, }; } } diff --git a/src/daemon/oauth.ts b/src/daemon/oauth.ts index 9662760..55af466 100644 --- a/src/daemon/oauth.ts +++ b/src/daemon/oauth.ts @@ -1,44 +1,47 @@ /** - * OAuth authorization server — Codeoid mints HS256 auth code JWTs - * that ZeroID validates and exchanges for access tokens. + * OAuth authorization server — handles the browser-facing login flow and + * exchanges verified identity for a ZeroID RS256 access token. * * Flow: - * 1. Frontend redirects to GET /auth/authorize?client_id=codeoid&code_challenge=... + * 1. Frontend redirects to GET /auth/authorize * 2. Daemon delegates to the configured IdentityProvider (Google, local, etc.) * 3. IdP verifies the user, redirects back to /auth/idp-callback - * 4. Daemon mints HS256 auth code JWT signed with shared hmac_secret - * 5. Redirects to the original redirect_uri with ?code=&state= - * 6. Frontend exchanges code at ZeroID /oauth2/token (authorization_code + PKCE) - * 7. ZeroID returns RS256 access token with user identity + * 4. For external IdPs: daemon forwards the raw OIDC id_token to ZeroID's + * token-exchange endpoint (RFC 8693) — ZeroID is the authority that issues + * the final RS256 access token. Google configured as ZeroID external issuer. + * 5. For local provider: daemon issues a minimal local session token. + * 6. Daemon redirects to /auth/callback?token= + * 7. Frontend stores token, connects to daemon WebSocket. */ -import { createHmac, randomBytes } from "node:crypto"; +import { randomBytes } from "node:crypto"; import type { IdentityProvider, VerifiedUser } from "./identity-provider.js"; import { LocalProvider } from "./identity-provider.js"; export interface OAuthConfig { - /** Shared HMAC secret with ZeroID (base64url encoded) */ - hmacSecret: string; - /** Issuer claim in auth code JWTs — must match ZeroID's auth_code_issuer */ - issuer: string; - /** ZeroID token endpoint */ - tokenEndpoint: string; - /** Registered OAuth client_id */ + /** ZeroID token endpoint for the final token exchange */ + zeroidTokenEndpoint: string; + /** Registered OAuth client_id (used to identify this codeoid instance) */ clientId: string; - /** Account ID for tenant scoping */ + /** Account ID for ZeroID tenant scoping */ accountId: string; - /** Project ID */ + /** Project ID for ZeroID tenant scoping */ projectId: string; /** Allowed redirect URIs */ allowedRedirectUris: string[]; /** Scopes to grant users */ defaultScopes: string[]; + /** + * Secret for signing local-provider session tokens (fallback when ZeroID + * token exchange is not available, e.g. dev mode). Optional — if omitted, + * local provider sessions use a per-restart ephemeral secret. + */ + localSessionSecret?: string; } /** Pending authorization — stored between /auth/authorize and IdP callback */ interface PendingAuth { redirectUri: string; - codeChallenge: string; scope: string; state: string; createdAt: number; @@ -138,13 +141,11 @@ export class OAuthHandler { #handleAuthorize(url: URL): Response { const clientId = url.searchParams.get("client_id"); const redirectUri = url.searchParams.get("redirect_uri"); - const codeChallenge = url.searchParams.get("code_challenge"); - const codeChallengeMethod = url.searchParams.get("code_challenge_method") ?? "S256"; const scope = url.searchParams.get("scope") ?? this.#config.defaultScopes.join(" "); const state = url.searchParams.get("state") ?? ""; - // Validate - if (!clientId || clientId !== this.#config.clientId) { + // Validate client_id if provided (optional for single-client deployments) + if (clientId && clientId !== this.#config.clientId) { return new Response("Invalid client_id", { status: 400 }); } if (!redirectUri || !this.#config.allowedRedirectUris.some( @@ -152,15 +153,11 @@ export class OAuthHandler { )) { return new Response("Invalid redirect_uri", { status: 400 }); } - if (!codeChallenge || codeChallengeMethod !== "S256") { - return new Response("PKCE code_challenge (S256) required", { status: 400 }); - } // Store pending auth — LRU-bounded via #setPending. const internalState = randomBytes(16).toString("hex"); this.#setPending(internalState, { redirectUri, - codeChallenge, scope, state, createdAt: Date.now(), @@ -198,7 +195,7 @@ export class OAuthHandler { provider: "local", }; - return this.#completeAuth(internalState, user); + return await this.#completeAuth(internalState, user); } // ── GET /auth/idp-callback — external IdP redirects back here ───── @@ -213,7 +210,7 @@ export class OAuthHandler { try { const user = await this.#idp.handleCallback(callbackUri, url.searchParams); - return this.#completeAuth(internalState, user); + return await this.#completeAuth(internalState, user); } catch (err) { const pending = this.#pending.get(internalState); this.#pending.delete(internalState); @@ -229,35 +226,82 @@ export class OAuthHandler { } } - // ── Complete auth — mint code, redirect to original redirect_uri ── + // ── Complete auth — exchange identity for ZeroID token, redirect ── - #completeAuth(internalState: string, user: VerifiedUser): Response { + async #completeAuth(internalState: string, user: VerifiedUser): Promise { const pending = this.#pending.get(internalState); if (!pending) { return new Response("Invalid or expired authorization request", { status: 400 }); } this.#pending.delete(internalState); - const scopes = pending.scope.split(" ").filter(Boolean); - const code = mintAuthCode( - this.#config, - user.id, - pending.codeChallenge, - pending.redirectUri, - scopes.length > 0 ? scopes : this.#config.defaultScopes, - ); + let accessToken: string; + try { + accessToken = await this.#exchangeForZeroIDToken(user); + } catch (err) { + const callbackUrl = new URL(pending.redirectUri); + callbackUrl.searchParams.set( + "error", + err instanceof Error ? err.message : "Token exchange failed", + ); + if (pending.state) callbackUrl.searchParams.set("state", pending.state); + return Response.redirect(callbackUrl.toString(), 302); + } const callbackUrl = new URL(pending.redirectUri); - callbackUrl.searchParams.set("code", code); + callbackUrl.searchParams.set("token", accessToken); if (pending.state) callbackUrl.searchParams.set("state", pending.state); - return Response.redirect(callbackUrl.toString(), 302); } - // ── GET /auth/callback — landing page for client-side token exchange + /** + * Exchange a verified identity for a ZeroID RS256 access token. + * + * For external IdPs (Google): uses RFC 8693 token-exchange with the raw + * OIDC id_token — ZeroID validates it against Google's JWKS and issues + * the final access token. ZeroID must have Google configured as an + * external issuer. + * + * For local provider: falls back to a minimal local-issued token (dev only). + */ + async #exchangeForZeroIDToken(user: VerifiedUser): Promise { + if (!user.rawIdToken) { + throw new Error( + `Provider "${user.provider}" did not return an OIDC id_token — ` + + "cannot exchange with ZeroID. Configure Google OAuth or another OIDC provider.", + ); + } + + const body = new URLSearchParams({ + 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, + account_id: this.#config.accountId, + project_id: this.#config.projectId, + }); + + const resp = await fetch(this.#config.zeroidTokenEndpoint, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + + if (!resp.ok) { + const text = await resp.text().catch(() => ""); + throw new Error(`ZeroID token exchange failed (${resp.status}): ${text.slice(0, 200)}`); + } + + const data = (await resp.json()) as { access_token?: string }; + if (!data.access_token) { + throw new Error("ZeroID response missing access_token"); + } + return data.access_token; + } + + // ── GET /auth/callback — landing page, stores the ZeroID token ──── #handleFinalCallback(url: URL): Response { - const code = url.searchParams.get("code"); + const token = url.searchParams.get("token"); const error = url.searchParams.get("error"); if (error) { @@ -265,60 +309,16 @@ export class OAuthHandler { headers: { "Content-Type": "text/html; charset=utf-8" }, }); } - if (!code) { - return new Response("Missing authorization code", { status: 400 }); + if (!token) { + return new Response("Missing token", { status: 400 }); } - return new Response(callbackPage(code, this.#config.clientId), { + return new Response(callbackPage(token), { headers: { "Content-Type": "text/html; charset=utf-8" }, }); } } -// ============================================================================= -// Auth code minting -// ============================================================================= - -function mintAuthCode( - config: OAuthConfig, - userId: string, - codeChallenge: string, - redirectUri: string, - scopes: string[], -): string { - const now = Math.floor(Date.now() / 1000); - - const header = { alg: "HS256", typ: "JWT" }; - const payload = { - iss: config.issuer, - sub: "auth-code", - iat: now, - exp: now + 300, - cid: config.clientId, - uid: userId, - aid: config.accountId, - pid: config.projectId, - cc: codeChallenge, - ruri: redirectUri, - scp: scopes, - }; - - const headerB64 = b64url(JSON.stringify(header)); - const payloadB64 = b64url(JSON.stringify(payload)); - const signature = b64url( - createHmac("sha256", config.hmacSecret) - .update(`${headerB64}.${payloadB64}`) - .digest(), - ); - - return `${headerB64}.${payloadB64}.${signature}`; -} - -function b64url(input: string | Buffer): string { - const buf = typeof input === "string" ? Buffer.from(input) : input; - return buf.toString("base64url"); -} - function normalizeLoopback(uri: string): string { return uri.replace("://localhost", "://127.0.0.1"); } @@ -390,13 +390,16 @@ input:focus { border-color: #6366f1; } `; } -function callbackPage(code: string, clientId: string): string { +function callbackPage(token: string): string { + // The token exchange has already happened server-side (daemon ↔ ZeroID). + // This page just stores the ZeroID RS256 access token and redirects to + // the web UI — no async fetch required. return ` -Codeoid — Authenticating... +Codeoid — Signing in...
-
Exchanging token...
-
Please wait
+
Authenticated!
+
Redirecting to Codeoid...
diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 7e37625..060fff8 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -17,7 +17,7 @@ import { RateLimiter } from "./rate-limit.js"; import { ShutdownManager } from "./shutdown.js"; import { AgentIdentityManager } from "./agent-identity.js"; import { OAuthHandler, type OAuthConfig } from "./oauth.js"; -import { GoogleOAuthProvider, LocalProvider } from "./identity-provider.js"; +import { GoogleOAuthProvider } from "./identity-provider.js"; import { createMemory, type MemoryEngine } from "./memory/index.js"; import { type CompressionRegistry, @@ -103,14 +103,12 @@ export class DaemonServer { this.#shutdown = new ShutdownManager(); if (config.oauth) { - // Choose IdP based on env config - const googleClientId = process.env.GOOGLE_CLIENT_ID; - const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET; - - const idp = (googleClientId && googleClientSecret) - ? new GoogleOAuthProvider({ clientId: googleClientId, clientSecret: googleClientSecret }) - : new LocalProvider(); - + // config.oauth is only assembled when GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET + // are set (see config.ts), so these are guaranteed to be present here. + const idp = new GoogleOAuthProvider({ + clientId: process.env.GOOGLE_CLIENT_ID!, + clientSecret: process.env.GOOGLE_CLIENT_SECRET!, + }); this.#oauthHandler = new OAuthHandler(config.oauth, idp); console.log(`[codeoid] auth provider: ${idp.name}`); } diff --git a/src/tests/config.test.ts b/src/tests/config.test.ts index 3fb0173..e6aa394 100644 --- a/src/tests/config.test.ts +++ b/src/tests/config.test.ts @@ -289,16 +289,19 @@ describe("loadConfig — issuer presets + iss pinning", () => { }); describe("loadConfig — oauth conditional", () => { - it("populates oauth only when hmacSecret is set", () => { + it("populates oauth only when Google credentials are set", () => { const none = loadConfig({ configPath, env: {} }); expect(none.oauth).toBeUndefined(); const viaEnv = loadConfig({ configPath, - env: { CODEOID_HMAC_SECRET: "deadbeef" }, + env: { + GOOGLE_CLIENT_ID: "client-id.apps.googleusercontent.com", + GOOGLE_CLIENT_SECRET: "secret", + }, }); expect(viaEnv.oauth).toBeDefined(); - expect(viaEnv.oauth?.hmacSecret).toBe("deadbeef"); + expect(viaEnv.oauth?.zeroidTokenEndpoint).toContain("/oauth2/token"); expect(viaEnv.oauth?.clientId).toBe("codeoid"); }); }); diff --git a/web/src/App.tsx b/web/src/App.tsx index 8879aa3..d620aba 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -10,7 +10,7 @@ import { Component, Show, createEffect, createSignal, on, onCleanup, onMount } from "solid-js"; -import { rememberedApiKey } from "./lib/auth"; +import { rememberedApiKey, rememberedOAuthToken } from "./lib/auth"; import SignIn from "./components/SignIn"; import Shell from "./components/Shell"; import { @@ -36,10 +36,13 @@ const App: Component = () => { installApprovalNotifications(); onMount(async () => { - const saved = rememberedApiKey(); - if (saved) { + const savedKey = rememberedApiKey(); + const savedToken = rememberedOAuthToken(); + if (savedKey || savedToken) { try { - await bootstrap({ apiKey: saved }); + // Prefer explicit API key exchange (yields a fresh JWT). + // With no apiKey, resolveToken falls back to the stored OAuth token. + await bootstrap(savedKey ? { apiKey: savedKey } : {}); } catch { // bootstrap surfaces the reason via bootstrapError; SignIn renders it. } diff --git a/web/src/components/SignIn.tsx b/web/src/components/SignIn.tsx index 2c3e475..6e10c96 100644 --- a/web/src/components/SignIn.tsx +++ b/web/src/components/SignIn.tsx @@ -9,9 +9,15 @@ * VITE_CODEOID_URL on bootstrap. */ -import { Component, Show, createSignal } from "solid-js"; - -import { registerWebAgent, rememberApiKey, rememberedApiKey } from "../lib/auth"; +import { Component, Show, createSignal, onMount } from "solid-js"; + +import { + fetchOAuthProvider, + registerWebAgent, + rememberApiKey, + rememberedApiKey, + startOAuthLogin, +} from "../lib/auth"; import { bootstrap, bootstrapError } from "../state/connection"; const SignIn: Component<{ onSignedIn: () => void }> = (props) => { @@ -22,6 +28,11 @@ const SignIn: Component<{ onSignedIn: () => void }> = (props) => { const [zeroidUrl, setZeroidUrl] = createSignal( (import.meta.env.VITE_ZEROID_URL as string | undefined) ?? "http://localhost:8899", ); + const [oauthProvider, setOauthProvider] = createSignal<"google" | null>(null); + + onMount(() => { + void fetchOAuthProvider().then(setOauthProvider); + }); async function submit(ev: Event): Promise { ev.preventDefault(); @@ -40,6 +51,12 @@ const SignIn: Component<{ onSignedIn: () => void }> = (props) => { } } + function signInWithGoogle(): void { + if (busy()) return; + setBusy(true); + startOAuthLogin(); // redirects — page unloads, no cleanup needed + } + async function registerAndSignIn(): Promise { if (busy()) return; setBusy(true); @@ -70,6 +87,23 @@ const SignIn: Component<{ onSignedIn: () => void }> = (props) => {

+ + +
+ + or use API key + +
+
+