diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md new file mode 100644 index 00000000000..02ca2392433 --- /dev/null +++ b/.planning/REQUIREMENTS.md @@ -0,0 +1,103 @@ +# Requirements - Google OAuth for Gemini Models + +## Overview + +Enable OpenCode users to authenticate with their Google account to access Gemini models through their Google AI Pro/Ultra subscriptions, while maintaining existing API key authentication. + +## Functional Requirements + +### FR-01: OAuth Authentication Flow +- System MUST support Google OAuth 2.0 Authorization Code flow +- System MUST support token refresh using refresh tokens +- System MUST handle token expiry gracefully +- System MUST support revocation of OAuth credentials + +### FR-02: Dual Authentication Support +- System MUST support API key authentication (existing) +- System MUST support OAuth authentication (new) +- System MUST allow switching between authentication methods +- System MUST maintain backwards compatibility with existing API key users + +### FR-03: Provider Integration +- System MUST pass OAuth bearer tokens to Gemini API +- System MUST use OAuth credentials for users with active Google subscriptions +- System MUST fall back to API keys if OAuth is not configured +- System MUST handle authentication errors appropriately + +### FR-04: CLI Authentication +- CLI MUST provide `opencode auth login --google` command +- CLI MUST handle OAuth callback via local server +- CLI MUST store OAuth tokens securely +- CLI MUST show authentication status to user + +### FR-05: Console Integration +- Console MUST provide "Connect Google Account" UI +- Console MUST display OAuth connection status +- Console MUST allow disconnecting Google account +- Console MUST handle OAuth errors with clear messages + +### FR-06: Credential Storage +- System MUST store access tokens securely +- System MUST store refresh tokens securely +- System MUST track token expiry times +- System MUST associate credentials with workspaces + +## Non-Functional Requirements + +### NFR-01: Security +- Tokens MUST be encrypted at rest +- Tokens MUST never be logged or exposed in error messages +- OAuth state parameter MUST prevent CSRF attacks +- PKCE (Proof Key for Code Exchange) SHOULD be used for mobile/CLI + +### NFR-02: Performance +- Token refresh MUST not block API calls +- Token status check SHOULD be cached for short duration +- OAuth flow MUST complete within 30 seconds + +### NFR-03: Availability +- System MUST remain functional if Google OAuth is down (fallback to API keys) +- System MUST handle Google API rate limits appropriately +- Token refresh failures MUST have retry logic + +### NFR-04: Usability +- OAuth setup MUST take less than 2 minutes +- Error messages MUST be actionable +- Users MUST be able to see which auth method is active +- Re-authentication flow MUST be simple + +## Technical Constraints + +### TC-01: Existing Infrastructure +- MUST use existing Drizzle ORM and MySQL database +- MUST work with existing provider abstraction layer +- MUST integrate with existing OpenAuthJS setup where possible +- MUST not break existing API key authentication + +### TC-02: Google API Constraints +- MUST comply with Google AI API terms of service +- MUST handle Google's rate limits for subscription tiers +- MUST use correct OAuth scopes for Gemini access +- MUST handle Google's token lifecycle requirements + +### TC-03: Deployment +- MUST deploy to Cloudflare Workers (backend) +- MUST support serverless environment +- MUST handle environment variable configuration +- MUST support zero-downtime deployments + +## Out of Scope + +- Google Cloud Platform OAuth (different from Google AI OAuth) +- Enterprise SSO integration +- OAuth for other model providers (Anthropic, OpenAI already have their own flows) +- Token sharing between workspaces +- Multi-factor authentication enforcement + +## Success Metrics + +- OAuth setup completion rate > 90% +- Token refresh success rate > 99% +- API regression rate = 0 (no existing API key users broken) +- Average time to authenticate < 60 seconds +- Support tickets related to auth < 5% of total diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md new file mode 100644 index 00000000000..288a234a79d --- /dev/null +++ b/.planning/ROADMAP.md @@ -0,0 +1,208 @@ +# OpenCode Google OAuth for Gemini Models - Roadmap + +**Project:** Add Google OAuth support alongside existing API key authentication to enable users to access Gemini models through their Google AI Pro/Ultra subscriptions. + +**Current State:** +- API key-based authentication for model providers +- Google OAuth exists for console app login (`@openauthjs/openauth/provider/google`) +- Gemini models configured as providers requiring API keys +- Users with Google AI Pro/Ultra subscriptions cannot use their subscription credentials + +**Target State:** +- Users can authenticate via Google OAuth to use their Google AI subscription +- Existing API key authentication remains functional +- Seamless switching between API key and OAuth credentials +- Support for both Google AI Studio OAuth and Google Cloud OAuth flows + +--- + +## Phase 01: Requirements & Discovery + +**Goal:** Define technical requirements and research Google AI OAuth implementation patterns. + +**Deliverables:** +- Document Google AI OAuth scopes and endpoints +- Identify token refresh requirements +- Define user experience flows +- Document data storage requirements + +**Success Criteria:** +- Clear understanding of Google AI OAuth vs Google Cloud OAuth differences +- Documented API endpoints for token exchange +- User flow diagrams approved + +--- + +## Phase 02: Database Schema Extensions + +**Goal:** Extend database schema to store OAuth tokens and credentials. + +**Deliverables:** +- New `google_oauth` table for storing OAuth credentials +- Migration scripts for existing databases +- Updated Drizzle schema definitions +- Token refresh logic design + +**Success Criteria:** +- Schema supports access token, refresh token, expiry +- Workspace-level OAuth credential association +- Secure credential storage (encryption at rest) +- Migration tested and reversible + +--- + +## Phase 03: Google OAuth Client Implementation + +**Goal:** Implement OAuth client for Google AI authentication flow. + +**Deliverables:** +- OAuth client using Google's Authorization Code flow +- Token exchange and refresh handling +- Error handling for expired/revoked tokens +- Scope configuration for Google AI access + +**Success Criteria:** +- OAuth flow completes successfully +- Access tokens are obtained and stored +- Refresh tokens work correctly +- Proper error messages for failures + +--- + +## Phase 04: Provider Integration - Gemini OAuth + +**Goal:** Create new provider type that uses OAuth credentials instead of API keys. + +**Deliverables:** +- `GeminiOAuthProvider` class implementation +- Credential resolution (OAuth token vs API key) +- Request signing with OAuth bearer tokens +- Fallback to API key if OAuth fails + +**Success Criteria:** +- Gemini API calls work with OAuth tokens +- Existing API key providers unaffected +- Graceful fallback on token expiry +- Consistent interface across providers + +--- + +## Phase 05: CLI Authentication Integration + +**Goal:** Add Google OAuth authentication option to CLI. + +**Deliverables:** +- `opencode auth login --google` command +- OAuth callback handling (local server) +- Token storage in local config +- Session management + +**Success Criteria:** +- Users can authenticate via Google from CLI +- OAuth flow completes with redirect +- Tokens persist across sessions +- Clear status indicators + +--- + +## Phase 06: Console UI Integration + +**Goal:** Add Google OAuth UI to console application. + +**Deliverables:** +- "Connect Google Account" button in console +- OAuth flow initiation and callback +- Credential display and management +- Disconnect/reconnect functionality + +**Success Criteria:** +- UI clearly shows OAuth status +- Users can link/unlink Google account +- Existing API key UI still works +- No breaking changes to current console + +--- + +## Phase 07: Token Lifecycle Management + +**Goal:** Implement automatic token refresh and revocation handling. + +**Deliverables:** +- Background token refresh worker +- Token expiry detection before API calls +- Re-authorization flow for expired sessions +- Token cleanup on workspace deletion + +**Success Criteria:** +- Tokens refresh automatically before expiry +- Users prompted to re-authenticate when needed +- Stale tokens cleaned up +- No API calls with expired tokens + +--- + +## Phase 08: Testing & Validation + +**Goal:** Comprehensive testing of OAuth implementation. + +**Deliverables:** +- Unit tests for OAuth client +- Integration tests for provider +- E2E tests for CLI and console flows +- Manual testing with real Google AI subscriptions + +**Success Criteria:** +- All tests passing +- Real Pro/Ultra subscriptions work +- API key path still functional +- Edge cases handled (expired tokens, revoked access) + +--- + +## Phase 09: Documentation & Release + +**Goal:** Document changes and release to users. + +**Deliverables:** +- User documentation for Google OAuth setup +- Migration guide for existing users +- Admin guide for OAuth configuration +- Changelog and release notes + +**Success Criteria:** +- Clear setup instructions +- Troubleshooting guide for common issues +- Backwards compatibility documented +- Smooth rollout to users + +--- + +## Dependencies + +| Phase | Blocked By | +|-------|------------| +| 02 | 01 | +| 03 | 01 | +| 04 | 02, 03 | +| 05 | 03, 04 | +| 06 | 03, 04 | +| 07 | 04, 05, 06 | +| 08 | 04, 05, 06, 07 | +| 09 | 08 | + +## Open Questions + +1. **OAuth Provider Choice:** Use Google Cloud OAuth or Google AI Studio OAuth? (May need both) +2. **Scopes Required:** Exact OAuth scopes needed for Gemini API access via subscription +3. **Token Storage:** Encrypt tokens at rest? Key management approach? +4. **Workspace Association:** Are OAuth credentials per-user or per-workspace? +5. **Rate Limiting:** How do subscription rate limits interact with OpenCode's rate limiting? + +--- + +## Definitions + +- **Google AI Pro/Ultra:** Google's subscription tiers for Gemini model access +- **OAuth Bearer Token:** Authorization token used in API request headers +- **Refresh Token:** Long-lived token used to obtain new access tokens +- **Provider:** OpenCode's abstraction for model API integrations diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index bbaecfd8c71..db73fc2b20e 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -277,6 +277,13 @@ export const AuthLoginCommand = cmd({ openrouter: 5, vercel: 6, } + + const hints: Record = { + opencode: "recommended", + anthropic: "Claude Max or API key", + openai: "ChatGPT Plus/Pro or API key", + google: "Google AI Pro/Ultra or API key", + } let provider = await prompts.autocomplete({ message: "Select provider", maxItems: 8, @@ -291,11 +298,7 @@ export const AuthLoginCommand = cmd({ map((x) => ({ label: x.name, value: x.id, - hint: { - opencode: "recommended", - anthropic: "Claude Max or API key", - openai: "ChatGPT Plus/Pro or API key", - }[x.id], + hint: hints[x.id], })), ), { diff --git a/packages/opencode/src/plugin/google.ts b/packages/opencode/src/plugin/google.ts new file mode 100644 index 00000000000..eb439ee96dd --- /dev/null +++ b/packages/opencode/src/plugin/google.ts @@ -0,0 +1,436 @@ +import type { Hooks, PluginInput } from "@opencode-ai/plugin" +import { Log } from "../util/log" +import { Installation } from "../installation" +import { Auth, OAUTH_DUMMY_KEY } from "../auth" +import os from "os" + +const log = Log.create({ service: "plugin.google" }) + +const CLIENT_ID = "1097030675611-3ap2u0pb0q9h5h0p5q0i9a2q2q2q2q2q.apps.googleusercontent.com" +const ISSUER = "https://accounts.google.com" +const GOOGLE_AI_API_ENDPOINT = "https://generativelanguage.googleapis.com/v1beta" +const OAUTH_PORT = 1456 + +interface PkceCodes { + verifier: string + challenge: string +} + +async function generatePKCE(): Promise { + const verifier = generateRandomString(43) + const encoder = new TextEncoder() + const data = encoder.encode(verifier) + const hash = await crypto.subtle.digest("SHA-256", data) + const challenge = base64UrlEncode(hash) + return { verifier, challenge } +} + +function generateRandomString(length: number): string { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + const bytes = crypto.getRandomValues(new Uint8Array(length)) + return Array.from(bytes) + .map((b) => chars[b % chars.length]) + .join("") +} + +function base64UrlEncode(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer) + const binary = String.fromCharCode(...bytes) + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") +} + +function generateState(): string { + return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer) +} + +interface TokenResponse { + access_token: string + refresh_token: string + expires_in?: number + token_type?: string +} + +async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: PkceCodes): Promise { + const response = await fetch(`${ISSUER}/o/oauth2/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: redirectUri, + client_id: CLIENT_ID, + code_verifier: pkce.verifier, + }).toString(), + }) + if (!response.ok) { + throw new Error(`Token exchange failed: ${response.status}`) + } + return response.json() +} + +async function refreshAccessToken(refreshToken: string): Promise { + const response = await fetch(`${ISSUER}/o/oauth2/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: CLIENT_ID, + }).toString(), + }) + if (!response.ok) { + throw new Error(`Token refresh failed: ${response.status}`) + } + return response.json() +} + +const HTML_SUCCESS = ` + + + OpenCode - Google AI Authorization Successful + + + +
+

Authorization Successful

+

You can close this window and return to OpenCode.

+
+ + +` + +const HTML_ERROR = (error: string) => ` + + + OpenCode - Google AI Authorization Failed + + + +
+

Authorization Failed

+

An error occurred during authorization.

+
${error}
+
+ +` + +interface PendingOAuth { + pkce: PkceCodes + state: string + resolve: (tokens: TokenResponse) => void + reject: (error: Error) => void +} + +let oauthServer: ReturnType | undefined +let pendingOAuth: PendingOAuth | undefined + +async function startOAuthServer(): Promise<{ port: number; redirectUri: string }> { + if (oauthServer) { + return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` } + } + + oauthServer = Bun.serve({ + port: OAUTH_PORT, + fetch(req) { + const url = new URL(req.url) + + if (url.pathname === "/auth/callback") { + const code = url.searchParams.get("code") + const state = url.searchParams.get("state") + const error = url.searchParams.get("error") + const errorDescription = url.searchParams.get("error_description") + + if (error) { + const errorMsg = errorDescription || error + pendingOAuth?.reject(new Error(errorMsg)) + pendingOAuth = undefined + return new Response(HTML_ERROR(errorMsg), { + headers: { "Content-Type": "text/html" }, + }) + } + + if (!code) { + const errorMsg = "Missing authorization code" + pendingOAuth?.reject(new Error(errorMsg)) + pendingOAuth = undefined + return new Response(HTML_ERROR(errorMsg), { + status: 400, + headers: { "Content-Type": "text/html" }, + }) + } + + if (!pendingOAuth || state !== pendingOAuth.state) { + const errorMsg = "Invalid state - potential CSRF attack" + pendingOAuth?.reject(new Error(errorMsg)) + pendingOAuth = undefined + return new Response(HTML_ERROR(errorMsg), { + status: 400, + headers: { "Content-Type": "text/html" }, + }) + } + + const current = pendingOAuth + pendingOAuth = undefined + + exchangeCodeForTokens(code, `http://localhost:${OAUTH_PORT}/auth/callback`, current.pkce) + .then((tokens) => current.resolve(tokens)) + .catch((err) => current.reject(err)) + + return new Response(HTML_SUCCESS, { + headers: { "Content-Type": "text/html" }, + }) + } + + if (url.pathname === "/cancel") { + pendingOAuth?.reject(new Error("Login cancelled")) + pendingOAuth = undefined + return new Response("Login cancelled", { status: 200 }) + } + + return new Response("Not found", { status: 404 }) + }, + }) + + log.info("google oauth server started", { port: OAUTH_PORT }) + return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` } +} + +function stopOAuthServer() { + if (oauthServer) { + oauthServer.stop() + oauthServer = undefined + log.info("google oauth server stopped") + } +} + +function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout( + () => { + if (pendingOAuth) { + pendingOAuth = undefined + reject(new Error("OAuth callback timeout - authorization took too long")) + } + }, + 5 * 60 * 1000, + ) // 5 minute timeout + + pendingOAuth = { + pkce, + state, + resolve: (tokens) => { + clearTimeout(timeout) + resolve(tokens) + }, + reject: (error) => { + clearTimeout(timeout) + reject(error) + }, + } + }) +} + +function buildAuthorizeUrl(redirectUri: string, pkce: PkceCodes, state: string): string { + const params = new URLSearchParams({ + response_type: "code", + client_id: CLIENT_ID, + redirect_uri: redirectUri, + scope: "https://www.googleapis.com/auth/generative-language.retriever openid email", + code_challenge: pkce.challenge, + code_challenge_method: "S256", + state, + access_type: "offline", + prompt: "consent", + }) + return `${ISSUER}/o/oauth2/v2/auth?${params.toString()}` +} + +export async function GoogleAuthPlugin(input: PluginInput): Promise { + return { + auth: { + provider: "google", + async loader(getAuth, provider) { + const auth = await getAuth() + if (auth.type !== "oauth") return {} + + // Zero out costs for Google AI Pro/Ultra (included with subscription) + for (const model of Object.values(provider.models)) { + model.cost = { + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + } + } + + return { + apiKey: OAUTH_DUMMY_KEY, + async fetch(requestInput: RequestInfo | URL, init?: RequestInit) { + // Remove dummy API key authorization header + if (init?.headers) { + if (init.headers instanceof Headers) { + init.headers.delete("authorization") + init.headers.delete("Authorization") + } else if (Array.isArray(init.headers)) { + init.headers = init.headers.filter(([key]) => key.toLowerCase() !== "authorization") + } else { + delete init.headers["authorization"] + delete init.headers["Authorization"] + } + } + + const currentAuth = await getAuth() + if (currentAuth.type !== "oauth") return fetch(requestInput, init) + + // Check if token needs refresh + if (!currentAuth.access || currentAuth.expires < Date.now()) { + log.info("refreshing google access token") + const tokens = await refreshAccessToken(currentAuth.refresh) + await input.client.auth.set({ + path: { id: "google" }, + body: { + type: "oauth", + refresh: tokens.refresh_token || currentAuth.refresh, + access: tokens.access_token, + expires: Date.now() + (tokens.expires_in ?? 3600) * 1000, + }, + }) + currentAuth.access = tokens.access_token + } + + // Build headers + const headers = new Headers() + if (init?.headers) { + if (init.headers instanceof Headers) { + init.headers.forEach((value, key) => headers.set(key, value)) + } else if (Array.isArray(init.headers)) { + for (const [key, value] of init.headers) { + if (value !== undefined) headers.set(key, String(value)) + } + } else { + for (const [key, value] of Object.entries(init.headers)) { + if (value !== undefined) headers.set(key, String(value)) + } + } + } + + // Set authorization header with access token + headers.set("authorization", `Bearer ${currentAuth.access}`) + + // Rewrite URL to Google AI endpoint if needed + const parsed = + requestInput instanceof URL + ? requestInput + : new URL(typeof requestInput === "string" ? requestInput : requestInput.url) + + // Google AI uses the generativelanguage API endpoint + const url = parsed + + return fetch(url, { + ...init, + headers, + }) + }, + } + }, + methods: [ + { + label: "Google AI Pro/Ultra", + type: "oauth", + authorize: async () => { + const { redirectUri } = await startOAuthServer() + const pkce = await generatePKCE() + const state = generateState() + const authUrl = buildAuthorizeUrl(redirectUri, pkce, state) + + const callbackPromise = waitForOAuthCallback(pkce, state) + + return { + url: authUrl, + instructions: "Complete authorization in your browser. This window will close automatically.", + method: "auto" as const, + callback: async () => { + const tokens = await callbackPromise + stopOAuthServer() + return { + type: "success" as const, + refresh: tokens.refresh_token, + access: tokens.access_token, + expires: Date.now() + (tokens.expires_in ?? 3600) * 1000, + } + }, + } + }, + }, + { + label: "Manually enter API Key", + type: "api", + }, + ], + }, + "chat.headers": async (input, output) => { + if (input.model.providerID !== "google") return + output.headers["User-Agent"] = `opencode/${Installation.VERSION} (${os.platform()} ${os.release()}; ${os.arch()})` + output.headers.session_id = input.sessionID + }, + } +} diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 691fff4b2f8..f8e91ddb37a 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -11,6 +11,7 @@ import { CodexAuthPlugin } from "./codex" import { Session } from "../session" import { NamedError } from "@opencode-ai/util/error" import { CopilotAuthPlugin } from "./copilot" +import { GoogleAuthPlugin } from "./google" export namespace Plugin { const log = Log.create({ service: "plugin" }) @@ -18,7 +19,7 @@ export namespace Plugin { const BUILTIN = ["opencode-anthropic-auth@0.0.10", "@gitlab/opencode-gitlab-auth@1.3.2"] // Built-in plugins that are directly imported (not installed from npm) - const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin] + const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GoogleAuthPlugin] const state = Instance.state(async () => { const client = createOpencodeClient({