feat(antigravity-auth): add OAuth flow and token management

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
YeonGyu-Kim
2025-12-12 21:59:40 +09:00
parent 36b8576c78
commit 07e2e907c5
5 changed files with 667 additions and 18 deletions

View File

@@ -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

View File

@@ -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"

View File

@@ -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<PKCEPair> {
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<AuthorizationResult> {
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<AntigravityTokenExchangeResult> {
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<AntigravityUserInfo> {
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<CallbackResult> {
return new Promise((resolve, reject) => {
let server: ReturnType<typeof Bun.serve> | null = null
let timeoutId: ReturnType<typeof setTimeout> | 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 =
"<html><body><h1>Login successful</h1><p>You can close this window.</p></body></html>"
} else {
responseBody =
"<html><body><h1>Login failed</h1><p>Please check the CLI output.</p></body></html>"
}
// 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<void>
): 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,
}
}

View File

@@ -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<string, AntigravityProjectContext>()
/**
* 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<AntigravityLoadCodeAssistResponse | null> {
const requestBody = {
metadata: CODE_ASSIST_METADATA,
}
const headers: Record<string, string> = {
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<AntigravityProjectContext> {
// 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()
}
}

View File

@@ -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<AntigravityTokenExchangeResult> {
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 || ""}`
}