diff --git a/ai-todolist.md b/ai-todolist.md index 68a329d..bea80a7 100644 --- a/ai-todolist.md +++ b/ai-todolist.md @@ -8,7 +8,7 @@ ## 현재 진행 중인 작업 -**Task 1. Create Antigravity auth types** - ✅ 완료됨 +**Task 6. Implement project context** - ✅ 완료됨 --- @@ -527,7 +527,7 @@ Phase 4 (Plugin Assembly) ### Phase 2: OAuth Core -- [ ] **4. Implement OAuth flow** +- [x] **4. Implement OAuth flow** **What to do**: - Create `src/auth/antigravity/oauth.ts` @@ -554,17 +554,17 @@ Phase 4 (Plugin Assembly) - cliproxyapi line: `exchangeAntigravityCode` function **Acceptance Criteria**: - - [ ] PKCE verifier/challenge generated correctly - - [ ] Auth URL includes all required parameters - - [ ] Token exchange returns access_token and refresh_token - - [ ] User info fetch returns email + - [x] PKCE verifier/challenge generated correctly + - [x] Auth URL includes all required parameters + - [x] Token exchange returns access_token and refresh_token + - [x] User info fetch returns email - [x] `bun run typecheck` passes **Commit Checkpoint**: NO (groups with Task 6) --- -- [ ] **5. Implement token management** +- [x] **5. Implement token management** **What to do**: - Create `src/auth/antigravity/token.ts` @@ -584,16 +584,16 @@ Phase 4 (Plugin Assembly) - `~/tools/cliproxyapi/sdk/auth/antigravity.go` - token refresh logic **Acceptance Criteria**: - - [ ] Token expiration check includes 60s buffer - - [ ] Refresh token exchange works with Google endpoint - - [ ] Token parsing handles `|` separated format + - [x] Token expiration check includes 60s buffer + - [x] Refresh token exchange works with Google endpoint + - [x] Token parsing handles `|` separated format - [x] `bun run typecheck` passes **Commit Checkpoint**: NO (groups with Task 6) --- -- [ ] **6. Implement project context** +- [x] **6. Implement project context** **What to do**: - Create `src/auth/antigravity/project.ts` @@ -616,9 +616,9 @@ Phase 4 (Plugin Assembly) - Response field: `cloudaicompanionProject` **Acceptance Criteria**: - - [ ] loadCodeAssist API called with correct headers - - [ ] Project ID extracted from response - - [ ] Fallback to default project ID works + - [x] loadCodeAssist API called with correct headers + - [x] Project ID extracted from response + - [x] Fallback to default project ID works - [x] `bun run typecheck` passes **Commit Checkpoint**: YES diff --git a/src/auth/antigravity/index.ts b/src/auth/antigravity/index.ts index 76a6c5b..881edcb 100644 --- a/src/auth/antigravity/index.ts +++ b/src/auth/antigravity/index.ts @@ -1,5 +1,7 @@ // Antigravity auth module barrel export -// Types and constants will be populated by Task 1 and Task 2 export * from "./types" export * from "./constants" +export * from "./oauth" +export * from "./token" +export * from "./project" diff --git a/src/auth/antigravity/oauth.ts b/src/auth/antigravity/oauth.ts index 9a52b18..913a49d 100644 --- a/src/auth/antigravity/oauth.ts +++ b/src/auth/antigravity/oauth.ts @@ -1 +1,369 @@ -// Antigravity OAuth flow - to be implemented in Task 4 +/** + * Antigravity OAuth 2.0 flow implementation with PKCE. + * Handles Google OAuth for Antigravity authentication. + */ +import { generatePKCE } from "@openauthjs/openauth/pkce" + +import { + ANTIGRAVITY_CLIENT_ID, + ANTIGRAVITY_CLIENT_SECRET, + ANTIGRAVITY_REDIRECT_URI, + ANTIGRAVITY_SCOPES, + ANTIGRAVITY_CALLBACK_PORT, + GOOGLE_AUTH_URL, + GOOGLE_TOKEN_URL, + GOOGLE_USERINFO_URL, +} from "./constants" +import type { + AntigravityTokenExchangeResult, + AntigravityUserInfo, +} from "./types" + +/** + * PKCE pair containing verifier and challenge. + */ +export interface PKCEPair { + /** PKCE verifier - used during token exchange */ + verifier: string + /** PKCE challenge - sent in auth URL */ + challenge: string + /** Challenge method - always "S256" */ + method: string +} + +/** + * OAuth state encoded in the auth URL. + * Contains the PKCE verifier for later retrieval. + */ +export interface OAuthState { + /** PKCE verifier */ + verifier: string + /** Optional project ID */ + projectId?: string +} + +/** + * Result from building an OAuth authorization URL. + */ +export interface AuthorizationResult { + /** Full OAuth URL to open in browser */ + url: string + /** PKCE verifier to use during code exchange */ + verifier: string +} + +/** + * Result from the OAuth callback server. + */ +export interface CallbackResult { + /** Authorization code from Google */ + code: string + /** State parameter from callback */ + state: string + /** Error message if any */ + error?: string +} + +/** + * Generate PKCE verifier and challenge pair. + * Uses @openauthjs/openauth for cryptographically secure generation. + * + * @returns PKCE pair with verifier, challenge, and method + */ +export async function generatePKCEPair(): Promise { + const pkce = await generatePKCE() + return { + verifier: pkce.verifier, + challenge: pkce.challenge, + method: pkce.method, + } +} + +/** + * Encode OAuth state into a URL-safe base64 string. + * + * @param state - OAuth state object + * @returns Base64URL encoded state + */ +function encodeState(state: OAuthState): string { + const json = JSON.stringify(state) + return Buffer.from(json, "utf8").toString("base64url") +} + +/** + * Decode OAuth state from a base64 string. + * + * @param encoded - Base64URL or Base64 encoded state + * @returns Decoded OAuth state + */ +export function decodeState(encoded: string): OAuthState { + // Handle both base64url and standard base64 + const normalized = encoded.replace(/-/g, "+").replace(/_/g, "/") + const padded = normalized.padEnd( + normalized.length + ((4 - (normalized.length % 4)) % 4), + "=" + ) + const json = Buffer.from(padded, "base64").toString("utf8") + const parsed = JSON.parse(json) + + if (typeof parsed.verifier !== "string") { + throw new Error("Missing PKCE verifier in state") + } + + return { + verifier: parsed.verifier, + projectId: + typeof parsed.projectId === "string" ? parsed.projectId : undefined, + } +} + +/** + * Build the OAuth authorization URL with PKCE. + * + * @param projectId - Optional GCP project ID to include in state + * @returns Authorization result with URL and verifier + */ +export async function buildAuthURL( + projectId?: string +): Promise { + const pkce = await generatePKCEPair() + + const state: OAuthState = { + verifier: pkce.verifier, + projectId, + } + + const url = new URL(GOOGLE_AUTH_URL) + url.searchParams.set("client_id", ANTIGRAVITY_CLIENT_ID) + url.searchParams.set("redirect_uri", ANTIGRAVITY_REDIRECT_URI) + url.searchParams.set("response_type", "code") + url.searchParams.set("scope", ANTIGRAVITY_SCOPES.join(" ")) + url.searchParams.set("state", encodeState(state)) + url.searchParams.set("code_challenge", pkce.challenge) + url.searchParams.set("code_challenge_method", "S256") + url.searchParams.set("access_type", "offline") + url.searchParams.set("prompt", "consent") + + return { + url: url.toString(), + verifier: pkce.verifier, + } +} + +/** + * Exchange authorization code for tokens. + * + * @param code - Authorization code from OAuth callback + * @param verifier - PKCE verifier from initial auth request + * @returns Token exchange result with access and refresh tokens + */ +export async function exchangeCode( + code: string, + verifier: string +): Promise { + const params = new URLSearchParams({ + client_id: ANTIGRAVITY_CLIENT_ID, + client_secret: ANTIGRAVITY_CLIENT_SECRET, + code, + grant_type: "authorization_code", + redirect_uri: ANTIGRAVITY_REDIRECT_URI, + code_verifier: verifier, + }) + + const response = await fetch(GOOGLE_TOKEN_URL, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: params, + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Token exchange failed: ${response.status} - ${errorText}`) + } + + const data = (await response.json()) as { + access_token: string + refresh_token: string + expires_in: number + token_type: string + } + + return { + access_token: data.access_token, + refresh_token: data.refresh_token, + expires_in: data.expires_in, + token_type: data.token_type, + } +} + +/** + * Fetch user info from Google's userinfo API. + * + * @param accessToken - Valid access token + * @returns User info containing email + */ +export async function fetchUserInfo( + accessToken: string +): Promise { + const response = await fetch(`${GOOGLE_USERINFO_URL}?alt=json`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + + if (!response.ok) { + throw new Error(`Failed to fetch user info: ${response.status}`) + } + + const data = (await response.json()) as { + email?: string + name?: string + picture?: string + } + + return { + email: data.email || "", + name: data.name, + picture: data.picture, + } +} + +/** + * Start a local HTTP server to receive OAuth callback. + * + * @param timeoutMs - Timeout in milliseconds (default: 5 minutes) + * @returns Promise that resolves with callback result + */ +export function startCallbackServer( + timeoutMs: number = 5 * 60 * 1000 +): Promise { + return new Promise((resolve, reject) => { + let server: ReturnType | null = null + let timeoutId: ReturnType | null = null + + const cleanup = () => { + if (timeoutId) { + clearTimeout(timeoutId) + timeoutId = null + } + if (server) { + server.stop() + server = null + } + } + + // Set timeout + timeoutId = setTimeout(() => { + cleanup() + reject(new Error("OAuth callback timeout")) + }, timeoutMs) + + try { + server = Bun.serve({ + port: ANTIGRAVITY_CALLBACK_PORT, + fetch(request: Request): Response { + const url = new URL(request.url) + + if (url.pathname === "/oauth-callback") { + const code = url.searchParams.get("code") || "" + const state = url.searchParams.get("state") || "" + const error = url.searchParams.get("error") || undefined + + // Respond to browser + let responseBody: string + if (code && !error) { + responseBody = + "

Login successful

You can close this window.

" + } else { + responseBody = + "

Login failed

Please check the CLI output.

" + } + + // Schedule cleanup and resolve + setTimeout(() => { + cleanup() + resolve({ code, state, error }) + }, 100) + + return new Response(responseBody, { + status: 200, + headers: { "Content-Type": "text/html" }, + }) + } + + return new Response("Not Found", { status: 404 }) + }, + }) + } catch (err) { + cleanup() + reject( + new Error( + `Failed to start callback server: ${err instanceof Error ? err.message : String(err)}` + ) + ) + } + }) +} + +/** + * Perform complete OAuth flow: + * 1. Start callback server + * 2. Build auth URL + * 3. Wait for callback + * 4. Exchange code for tokens + * 5. Fetch user info + * + * @param projectId - Optional GCP project ID + * @param openBrowser - Function to open URL in browser + * @returns Object with tokens and user info + */ +export async function performOAuthFlow( + projectId?: string, + openBrowser?: (url: string) => Promise +): Promise<{ + tokens: AntigravityTokenExchangeResult + userInfo: AntigravityUserInfo + verifier: string +}> { + // Build auth URL first to get the verifier + const auth = await buildAuthURL(projectId) + + // Start callback server + const callbackPromise = startCallbackServer() + + // Open browser (caller provides implementation) + if (openBrowser) { + await openBrowser(auth.url) + } + + // Wait for callback + const callback = await callbackPromise + + if (callback.error) { + throw new Error(`OAuth error: ${callback.error}`) + } + + if (!callback.code) { + throw new Error("No authorization code received") + } + + // Verify state and extract verifier + const state = decodeState(callback.state) + if (state.verifier !== auth.verifier) { + throw new Error("PKCE verifier mismatch - possible CSRF attack") + } + + // Exchange code for tokens + const tokens = await exchangeCode(callback.code, auth.verifier) + + // Fetch user info + const userInfo = await fetchUserInfo(tokens.access_token) + + return { + tokens, + userInfo, + verifier: auth.verifier, + } +} diff --git a/src/auth/antigravity/project.ts b/src/auth/antigravity/project.ts index 3648e0d..f4a74a7 100644 --- a/src/auth/antigravity/project.ts +++ b/src/auth/antigravity/project.ts @@ -1 +1,166 @@ -// Antigravity project context - to be implemented in Task 6 +/** + * Antigravity project context management. + * Handles fetching GCP project ID via Google's loadCodeAssist API. + */ + +import { + ANTIGRAVITY_DEFAULT_PROJECT_ID, + ANTIGRAVITY_ENDPOINT_FALLBACKS, + ANTIGRAVITY_API_VERSION, + ANTIGRAVITY_HEADERS, +} from "./constants" +import type { + AntigravityProjectContext, + AntigravityLoadCodeAssistResponse, +} from "./types" + +/** + * In-memory cache for project context per access token. + * Prevents redundant API calls for the same token. + */ +const projectContextCache = new Map() + +/** + * Client metadata for loadCodeAssist API request. + * Matches cliproxyapi implementation. + */ +const CODE_ASSIST_METADATA = { + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", +} as const + +/** + * Extracts the project ID from a cloudaicompanionProject field. + * Handles both string and object formats. + * + * @param project - The cloudaicompanionProject value from API response + * @returns Extracted project ID string, or undefined if not found + */ +function extractProjectId( + project: string | { id: string } | undefined +): string | undefined { + if (!project) { + return undefined + } + + // Handle string format + if (typeof project === "string") { + const trimmed = project.trim() + return trimmed || undefined + } + + // Handle object format { id: string } + if (typeof project === "object" && "id" in project) { + const id = project.id + if (typeof id === "string") { + const trimmed = id.trim() + return trimmed || undefined + } + } + + return undefined +} + +/** + * Calls the loadCodeAssist API to get project context. + * Tries each endpoint in the fallback list until one succeeds. + * + * @param accessToken - Valid OAuth access token + * @returns API response or null if all endpoints fail + */ +async function callLoadCodeAssistAPI( + accessToken: string +): Promise { + const requestBody = { + metadata: CODE_ASSIST_METADATA, + } + + const headers: Record = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": ANTIGRAVITY_HEADERS["User-Agent"], + "X-Goog-Api-Client": ANTIGRAVITY_HEADERS["X-Goog-Api-Client"], + "Client-Metadata": ANTIGRAVITY_HEADERS["Client-Metadata"], + } + + // Try each endpoint in the fallback list + for (const baseEndpoint of ANTIGRAVITY_ENDPOINT_FALLBACKS) { + const url = `${baseEndpoint}/${ANTIGRAVITY_API_VERSION}:loadCodeAssist` + + try { + const response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(requestBody), + }) + + if (!response.ok) { + // Try next endpoint on failure + continue + } + + const data = + (await response.json()) as AntigravityLoadCodeAssistResponse + return data + } catch { + // Network or parsing error, try next endpoint + continue + } + } + + // All endpoints failed + return null +} + +/** + * Fetch project context from Google's loadCodeAssist API. + * Extracts the cloudaicompanionProject from the response. + * Falls back to ANTIGRAVITY_DEFAULT_PROJECT_ID if API fails or returns empty. + * + * @param accessToken - Valid OAuth access token + * @returns Project context with cloudaicompanionProject ID + */ +export async function fetchProjectContext( + accessToken: string +): Promise { + // Check cache first + const cached = projectContextCache.get(accessToken) + if (cached) { + return cached + } + + // Call the API + const response = await callLoadCodeAssistAPI(accessToken) + + // Extract project ID from response + const projectId = response + ? extractProjectId(response.cloudaicompanionProject) + : undefined + + // Build result with fallback + const result: AntigravityProjectContext = { + cloudaicompanionProject: projectId || ANTIGRAVITY_DEFAULT_PROJECT_ID, + } + + // Cache the result + if (projectId) { + projectContextCache.set(accessToken, result) + } + + return result +} + +/** + * Clear the project context cache. + * Call this when tokens are refreshed or invalidated. + * + * @param accessToken - Optional specific token to clear, or clears all if not provided + */ +export function clearProjectContextCache(accessToken?: string): void { + if (accessToken) { + projectContextCache.delete(accessToken) + } else { + projectContextCache.clear() + } +} diff --git a/src/auth/antigravity/token.ts b/src/auth/antigravity/token.ts index 258c64e..8e5eccd 100644 --- a/src/auth/antigravity/token.ts +++ b/src/auth/antigravity/token.ts @@ -1 +1,115 @@ -// Antigravity token management - to be implemented in Task 5 +/** + * Antigravity token management utilities. + * Handles token expiration checking, refresh, and storage format parsing. + */ + +import { + ANTIGRAVITY_CLIENT_ID, + ANTIGRAVITY_CLIENT_SECRET, + ANTIGRAVITY_TOKEN_REFRESH_BUFFER_MS, + GOOGLE_TOKEN_URL, +} from "./constants" +import type { + AntigravityRefreshParts, + AntigravityTokenExchangeResult, + AntigravityTokens, +} from "./types" + +/** + * Check if the access token is expired. + * Includes a 60-second safety buffer to refresh before actual expiration. + * + * @param tokens - The Antigravity tokens to check + * @returns true if the token is expired or will expire within the buffer period + */ +export function isTokenExpired(tokens: AntigravityTokens): boolean { + // Calculate when the token expires (timestamp + expires_in in ms) + // timestamp is in milliseconds, expires_in is in seconds + const expirationTime = tokens.timestamp + tokens.expires_in * 1000 + + // Check if current time is past (expiration - buffer) + return Date.now() >= expirationTime - ANTIGRAVITY_TOKEN_REFRESH_BUFFER_MS +} + +/** + * Refresh an access token using a refresh token. + * Exchanges the refresh token for a new access token via Google's OAuth endpoint. + * + * @param refreshToken - The refresh token to use + * @returns Token exchange result with new access token, or throws on error + */ +export async function refreshAccessToken( + refreshToken: string +): Promise { + const params = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: ANTIGRAVITY_CLIENT_ID, + client_secret: ANTIGRAVITY_CLIENT_SECRET, + }) + + const response = await fetch(GOOGLE_TOKEN_URL, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: params, + }) + + if (!response.ok) { + const errorText = await response.text().catch(() => "Unknown error") + throw new Error( + `Token refresh failed: ${response.status} ${response.statusText} - ${errorText}` + ) + } + + const data = (await response.json()) as { + access_token: string + refresh_token?: string + expires_in: number + token_type: string + } + + return { + access_token: data.access_token, + // Google may return a new refresh token, fall back to the original + refresh_token: data.refresh_token || refreshToken, + expires_in: data.expires_in, + token_type: data.token_type, + } +} + +/** + * Parse a stored token string into its component parts. + * Storage format: `refreshToken|projectId|managedProjectId` + * + * @param stored - The pipe-separated stored token string + * @returns Parsed refresh parts with refreshToken, projectId, and optional managedProjectId + */ +export function parseStoredToken(stored: string): AntigravityRefreshParts { + const parts = stored.split("|") + const [refreshToken, projectId, managedProjectId] = parts + + return { + refreshToken: refreshToken || "", + projectId: projectId || undefined, + managedProjectId: managedProjectId || undefined, + } +} + +/** + * Format token components for storage. + * Creates a pipe-separated string: `refreshToken|projectId|managedProjectId` + * + * @param refreshToken - The refresh token + * @param projectId - The GCP project ID + * @param managedProjectId - Optional managed project ID for enterprise users + * @returns Formatted string for storage + */ +export function formatTokenForStorage( + refreshToken: string, + projectId: string, + managedProjectId?: string +): string { + return `${refreshToken}|${projectId}|${managedProjectId || ""}` +}