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:
@@ -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
|
### Phase 2: OAuth Core
|
||||||
|
|
||||||
- [ ] **4. Implement OAuth flow**
|
- [x] **4. Implement OAuth flow**
|
||||||
|
|
||||||
**What to do**:
|
**What to do**:
|
||||||
- Create `src/auth/antigravity/oauth.ts`
|
- Create `src/auth/antigravity/oauth.ts`
|
||||||
@@ -554,17 +554,17 @@ Phase 4 (Plugin Assembly)
|
|||||||
- cliproxyapi line: `exchangeAntigravityCode` function
|
- cliproxyapi line: `exchangeAntigravityCode` function
|
||||||
|
|
||||||
**Acceptance Criteria**:
|
**Acceptance Criteria**:
|
||||||
- [ ] PKCE verifier/challenge generated correctly
|
- [x] PKCE verifier/challenge generated correctly
|
||||||
- [ ] Auth URL includes all required parameters
|
- [x] Auth URL includes all required parameters
|
||||||
- [ ] Token exchange returns access_token and refresh_token
|
- [x] Token exchange returns access_token and refresh_token
|
||||||
- [ ] User info fetch returns email
|
- [x] User info fetch returns email
|
||||||
- [x] `bun run typecheck` passes
|
- [x] `bun run typecheck` passes
|
||||||
|
|
||||||
**Commit Checkpoint**: NO (groups with Task 6)
|
**Commit Checkpoint**: NO (groups with Task 6)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
- [ ] **5. Implement token management**
|
- [x] **5. Implement token management**
|
||||||
|
|
||||||
**What to do**:
|
**What to do**:
|
||||||
- Create `src/auth/antigravity/token.ts`
|
- Create `src/auth/antigravity/token.ts`
|
||||||
@@ -584,16 +584,16 @@ Phase 4 (Plugin Assembly)
|
|||||||
- `~/tools/cliproxyapi/sdk/auth/antigravity.go` - token refresh logic
|
- `~/tools/cliproxyapi/sdk/auth/antigravity.go` - token refresh logic
|
||||||
|
|
||||||
**Acceptance Criteria**:
|
**Acceptance Criteria**:
|
||||||
- [ ] Token expiration check includes 60s buffer
|
- [x] Token expiration check includes 60s buffer
|
||||||
- [ ] Refresh token exchange works with Google endpoint
|
- [x] Refresh token exchange works with Google endpoint
|
||||||
- [ ] Token parsing handles `|` separated format
|
- [x] Token parsing handles `|` separated format
|
||||||
- [x] `bun run typecheck` passes
|
- [x] `bun run typecheck` passes
|
||||||
|
|
||||||
**Commit Checkpoint**: NO (groups with Task 6)
|
**Commit Checkpoint**: NO (groups with Task 6)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
- [ ] **6. Implement project context**
|
- [x] **6. Implement project context**
|
||||||
|
|
||||||
**What to do**:
|
**What to do**:
|
||||||
- Create `src/auth/antigravity/project.ts`
|
- Create `src/auth/antigravity/project.ts`
|
||||||
@@ -616,9 +616,9 @@ Phase 4 (Plugin Assembly)
|
|||||||
- Response field: `cloudaicompanionProject`
|
- Response field: `cloudaicompanionProject`
|
||||||
|
|
||||||
**Acceptance Criteria**:
|
**Acceptance Criteria**:
|
||||||
- [ ] loadCodeAssist API called with correct headers
|
- [x] loadCodeAssist API called with correct headers
|
||||||
- [ ] Project ID extracted from response
|
- [x] Project ID extracted from response
|
||||||
- [ ] Fallback to default project ID works
|
- [x] Fallback to default project ID works
|
||||||
- [x] `bun run typecheck` passes
|
- [x] `bun run typecheck` passes
|
||||||
|
|
||||||
**Commit Checkpoint**: YES
|
**Commit Checkpoint**: YES
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
// Antigravity auth module barrel export
|
// Antigravity auth module barrel export
|
||||||
// Types and constants will be populated by Task 1 and Task 2
|
|
||||||
|
|
||||||
export * from "./types"
|
export * from "./types"
|
||||||
export * from "./constants"
|
export * from "./constants"
|
||||||
|
export * from "./oauth"
|
||||||
|
export * from "./token"
|
||||||
|
export * from "./project"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 || ""}`
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user