2025-12-12 21:59:40 +09:00
|
|
|
/**
|
2026-01-09 02:24:43 +09:00
|
|
|
* Antigravity OAuth 2.0 flow implementation.
|
2025-12-12 21:59:40 +09:00
|
|
|
* Handles Google OAuth for Antigravity authentication.
|
|
|
|
|
*/
|
|
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Result from building an OAuth authorization URL.
|
|
|
|
|
*/
|
|
|
|
|
export interface AuthorizationResult {
|
|
|
|
|
/** Full OAuth URL to open in browser */
|
|
|
|
|
url: string
|
2026-01-09 02:24:43 +09:00
|
|
|
/** State for CSRF protection */
|
|
|
|
|
state: string
|
2025-12-12 21:59:40 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function buildAuthURL(
|
2025-12-12 23:24:20 +09:00
|
|
|
projectId?: string,
|
2025-12-13 00:30:17 +09:00
|
|
|
clientId: string = ANTIGRAVITY_CLIENT_ID,
|
|
|
|
|
port: number = ANTIGRAVITY_CALLBACK_PORT
|
2025-12-12 21:59:40 +09:00
|
|
|
): Promise<AuthorizationResult> {
|
2026-01-09 02:24:43 +09:00
|
|
|
const state = crypto.randomUUID().replace(/-/g, "")
|
2025-12-12 21:59:40 +09:00
|
|
|
|
2025-12-13 00:30:17 +09:00
|
|
|
const redirectUri = `http://localhost:${port}/oauth-callback`
|
|
|
|
|
|
2025-12-12 21:59:40 +09:00
|
|
|
const url = new URL(GOOGLE_AUTH_URL)
|
2025-12-12 23:24:20 +09:00
|
|
|
url.searchParams.set("client_id", clientId)
|
2025-12-13 00:30:17 +09:00
|
|
|
url.searchParams.set("redirect_uri", redirectUri)
|
2025-12-12 21:59:40 +09:00
|
|
|
url.searchParams.set("response_type", "code")
|
|
|
|
|
url.searchParams.set("scope", ANTIGRAVITY_SCOPES.join(" "))
|
2026-01-09 02:24:43 +09:00
|
|
|
url.searchParams.set("state", state)
|
2025-12-12 21:59:40 +09:00
|
|
|
url.searchParams.set("access_type", "offline")
|
|
|
|
|
url.searchParams.set("prompt", "consent")
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
url: url.toString(),
|
2026-01-09 02:24:43 +09:00
|
|
|
state,
|
2025-12-12 21:59:40 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Exchange authorization code for tokens.
|
|
|
|
|
*
|
|
|
|
|
* @param code - Authorization code from OAuth callback
|
2026-01-09 02:24:43 +09:00
|
|
|
* @param redirectUri - OAuth redirect URI
|
2025-12-12 23:24:20 +09:00
|
|
|
* @param clientId - Optional custom client ID (defaults to ANTIGRAVITY_CLIENT_ID)
|
|
|
|
|
* @param clientSecret - Optional custom client secret (defaults to ANTIGRAVITY_CLIENT_SECRET)
|
2025-12-12 21:59:40 +09:00
|
|
|
* @returns Token exchange result with access and refresh tokens
|
|
|
|
|
*/
|
|
|
|
|
export async function exchangeCode(
|
|
|
|
|
code: string,
|
2026-01-09 02:24:43 +09:00
|
|
|
redirectUri: string,
|
2025-12-12 23:24:20 +09:00
|
|
|
clientId: string = ANTIGRAVITY_CLIENT_ID,
|
2026-01-09 02:24:43 +09:00
|
|
|
clientSecret: string = ANTIGRAVITY_CLIENT_SECRET
|
2025-12-12 21:59:40 +09:00
|
|
|
): Promise<AntigravityTokenExchangeResult> {
|
|
|
|
|
const params = new URLSearchParams({
|
2025-12-12 23:24:20 +09:00
|
|
|
client_id: clientId,
|
|
|
|
|
client_secret: clientSecret,
|
2025-12-12 21:59:40 +09:00
|
|
|
code,
|
|
|
|
|
grant_type: "authorization_code",
|
2025-12-13 00:30:17 +09:00
|
|
|
redirect_uri: redirectUri,
|
2025-12-12 21:59:40 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-13 00:30:17 +09:00
|
|
|
export interface CallbackServerHandle {
|
|
|
|
|
port: number
|
2026-01-09 02:24:43 +09:00
|
|
|
redirectUri: string
|
2025-12-13 00:30:17 +09:00
|
|
|
waitForCallback: () => Promise<CallbackResult>
|
|
|
|
|
close: () => void
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-12 21:59:40 +09:00
|
|
|
export function startCallbackServer(
|
|
|
|
|
timeoutMs: number = 5 * 60 * 1000
|
2025-12-13 00:30:17 +09:00
|
|
|
): CallbackServerHandle {
|
|
|
|
|
let server: ReturnType<typeof Bun.serve> | null = null
|
|
|
|
|
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
|
|
|
|
let resolveCallback: ((result: CallbackResult) => void) | null = null
|
|
|
|
|
let rejectCallback: ((error: Error) => void) | null = null
|
|
|
|
|
|
|
|
|
|
const cleanup = () => {
|
|
|
|
|
if (timeoutId) {
|
|
|
|
|
clearTimeout(timeoutId)
|
|
|
|
|
timeoutId = null
|
2025-12-12 21:59:40 +09:00
|
|
|
}
|
2025-12-13 00:30:17 +09:00
|
|
|
if (server) {
|
|
|
|
|
server.stop()
|
|
|
|
|
server = null
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-12 21:59:40 +09:00
|
|
|
|
2026-01-09 02:24:43 +09:00
|
|
|
const fetchHandler = (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
|
|
|
|
|
|
|
|
|
|
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>"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
cleanup()
|
|
|
|
|
if (resolveCallback) {
|
|
|
|
|
resolveCallback({ code, state, error })
|
2025-12-13 00:30:17 +09:00
|
|
|
}
|
2026-01-09 02:24:43 +09:00
|
|
|
}, 100)
|
2025-12-13 00:30:17 +09:00
|
|
|
|
2026-01-09 02:24:43 +09:00
|
|
|
return new Response(responseBody, {
|
|
|
|
|
status: 200,
|
|
|
|
|
headers: { "Content-Type": "text/html" },
|
|
|
|
|
})
|
|
|
|
|
}
|
2025-12-13 00:30:17 +09:00
|
|
|
|
2026-01-09 02:24:43 +09:00
|
|
|
return new Response("Not Found", { status: 404 })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
server = Bun.serve({
|
|
|
|
|
port: ANTIGRAVITY_CALLBACK_PORT,
|
|
|
|
|
fetch: fetchHandler,
|
|
|
|
|
})
|
|
|
|
|
} catch (error) {
|
|
|
|
|
server = Bun.serve({
|
|
|
|
|
port: 0,
|
|
|
|
|
fetch: fetchHandler,
|
|
|
|
|
})
|
|
|
|
|
}
|
2025-12-13 00:30:17 +09:00
|
|
|
|
|
|
|
|
const actualPort = server.port as number
|
2026-01-09 02:24:43 +09:00
|
|
|
const redirectUri = `http://localhost:${actualPort}/oauth-callback`
|
2025-12-13 00:30:17 +09:00
|
|
|
|
|
|
|
|
const waitForCallback = (): Promise<CallbackResult> => {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
resolveCallback = resolve
|
|
|
|
|
rejectCallback = reject
|
|
|
|
|
|
|
|
|
|
timeoutId = setTimeout(() => {
|
|
|
|
|
cleanup()
|
|
|
|
|
reject(new Error("OAuth callback timeout"))
|
|
|
|
|
}, timeoutMs)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
port: actualPort,
|
2026-01-09 02:24:43 +09:00
|
|
|
redirectUri,
|
2025-12-13 00:30:17 +09:00
|
|
|
waitForCallback,
|
|
|
|
|
close: cleanup,
|
|
|
|
|
}
|
2025-12-12 21:59:40 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function performOAuthFlow(
|
|
|
|
|
projectId?: string,
|
2025-12-12 23:24:20 +09:00
|
|
|
openBrowser?: (url: string) => Promise<void>,
|
|
|
|
|
clientId: string = ANTIGRAVITY_CLIENT_ID,
|
|
|
|
|
clientSecret: string = ANTIGRAVITY_CLIENT_SECRET
|
2025-12-12 21:59:40 +09:00
|
|
|
): Promise<{
|
|
|
|
|
tokens: AntigravityTokenExchangeResult
|
|
|
|
|
userInfo: AntigravityUserInfo
|
2026-01-09 02:24:43 +09:00
|
|
|
state: string
|
2025-12-12 21:59:40 +09:00
|
|
|
}> {
|
2025-12-13 00:30:17 +09:00
|
|
|
const serverHandle = startCallbackServer()
|
2025-12-12 21:59:40 +09:00
|
|
|
|
2025-12-13 00:30:17 +09:00
|
|
|
try {
|
|
|
|
|
const auth = await buildAuthURL(projectId, clientId, serverHandle.port)
|
2025-12-12 21:59:40 +09:00
|
|
|
|
2025-12-13 00:30:17 +09:00
|
|
|
if (openBrowser) {
|
|
|
|
|
await openBrowser(auth.url)
|
|
|
|
|
}
|
2025-12-12 21:59:40 +09:00
|
|
|
|
2025-12-13 00:30:17 +09:00
|
|
|
const callback = await serverHandle.waitForCallback()
|
2025-12-12 21:59:40 +09:00
|
|
|
|
2025-12-13 00:30:17 +09:00
|
|
|
if (callback.error) {
|
|
|
|
|
throw new Error(`OAuth error: ${callback.error}`)
|
|
|
|
|
}
|
2025-12-12 21:59:40 +09:00
|
|
|
|
2025-12-13 00:30:17 +09:00
|
|
|
if (!callback.code) {
|
|
|
|
|
throw new Error("No authorization code received")
|
|
|
|
|
}
|
2025-12-12 21:59:40 +09:00
|
|
|
|
2026-01-09 02:24:43 +09:00
|
|
|
if (callback.state !== auth.state) {
|
|
|
|
|
throw new Error("State mismatch - possible CSRF attack")
|
2025-12-13 00:30:17 +09:00
|
|
|
}
|
2025-12-12 21:59:40 +09:00
|
|
|
|
2026-01-09 02:24:43 +09:00
|
|
|
const redirectUri = `http://localhost:${serverHandle.port}/oauth-callback`
|
|
|
|
|
const tokens = await exchangeCode(callback.code, redirectUri, clientId, clientSecret)
|
2025-12-13 00:30:17 +09:00
|
|
|
const userInfo = await fetchUserInfo(tokens.access_token)
|
2025-12-12 21:59:40 +09:00
|
|
|
|
2026-01-09 02:24:43 +09:00
|
|
|
return { tokens, userInfo, state: auth.state }
|
2025-12-13 00:30:17 +09:00
|
|
|
} catch (err) {
|
|
|
|
|
serverHandle.close()
|
|
|
|
|
throw err
|
2025-12-12 21:59:40 +09:00
|
|
|
}
|
|
|
|
|
}
|