diff --git a/packages/server/src/server/routes/antigravity.ts b/packages/server/src/server/routes/antigravity.ts index b2ac70b..1fc7087 100644 --- a/packages/server/src/server/routes/antigravity.ts +++ b/packages/server/src/server/routes/antigravity.ts @@ -11,6 +11,30 @@ interface AntigravityRouteDeps { // Maximum number of tool execution loops const MAX_TOOL_LOOPS = 10 +// Google OAuth Device Flow configuration +// Using the same client ID as gcloud CLI / Cloud SDK +const GOOGLE_OAUTH_CONFIG = { + clientId: "764086051850-6qr4p6gpi6hn506pt8ejuq83di341hur.apps.googleusercontent.com", + clientSecret: "d-FL95Q19q7MQmFpd7hHD0Ty", // Public client secret for device flow + deviceAuthEndpoint: "https://oauth2.googleapis.com/device/code", + tokenEndpoint: "https://oauth2.googleapis.com/token", + scopes: [ + "openid", + "email", + "profile", + "https://www.googleapis.com/auth/cloud-platform" + ] +} + +// Active device auth sessions (in-memory, per-server instance) +const deviceAuthSessions = new Map() + export async function registerAntigravityRoutes( app: FastifyInstance, deps: AntigravityRouteDeps @@ -65,6 +89,193 @@ export async function registerAntigravityRoutes( } }) + // ========================================== + // Google Device Authorization Flow Endpoints + // ========================================== + + // Step 1: Start device authorization - returns user_code and verification URL + app.post('/api/antigravity/device-auth/start', async (request, reply) => { + try { + logger.info("Starting Google Device Authorization flow for Antigravity") + + const response = await fetch(GOOGLE_OAUTH_CONFIG.deviceAuthEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + client_id: GOOGLE_OAUTH_CONFIG.clientId, + scope: GOOGLE_OAUTH_CONFIG.scopes.join(' ') + }) + }) + + if (!response.ok) { + const error = await response.text() + logger.error({ error, status: response.status }, "Device auth request failed") + return reply.status(500).send({ error: "Failed to start device authorization" }) + } + + const data = await response.json() as { + device_code: string + user_code: string + verification_url: string + expires_in: number + interval: number + } + + // Generate a session ID for tracking this auth flow + const sessionId = crypto.randomUUID() + + // Store the session + deviceAuthSessions.set(sessionId, { + deviceCode: data.device_code, + userCode: data.user_code, + verificationUrl: data.verification_url, + expiresAt: Date.now() + (data.expires_in * 1000), + interval: data.interval + }) + + // Clean up expired sessions + for (const [id, session] of deviceAuthSessions) { + if (session.expiresAt < Date.now()) { + deviceAuthSessions.delete(id) + } + } + + logger.info({ sessionId, userCode: data.user_code }, "Device auth session created") + + return { + sessionId, + userCode: data.user_code, + verificationUrl: data.verification_url, + expiresIn: data.expires_in, + interval: data.interval + } + } catch (error) { + logger.error({ error }, "Failed to start device authorization") + return reply.status(500).send({ error: "Failed to start device authorization" }) + } + }) + + // Step 2: Poll for token (called by client after user enters code) + app.post('/api/antigravity/device-auth/poll', async (request, reply) => { + try { + const { sessionId } = request.body as { sessionId: string } + + if (!sessionId) { + return reply.status(400).send({ error: "Missing sessionId" }) + } + + const session = deviceAuthSessions.get(sessionId) + if (!session) { + return reply.status(404).send({ error: "Session not found or expired" }) + } + + if (session.expiresAt < Date.now()) { + deviceAuthSessions.delete(sessionId) + return reply.status(410).send({ error: "Session expired" }) + } + + // Poll Google's token endpoint + const response = await fetch(GOOGLE_OAUTH_CONFIG.tokenEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + client_id: GOOGLE_OAUTH_CONFIG.clientId, + client_secret: GOOGLE_OAUTH_CONFIG.clientSecret, + device_code: session.deviceCode, + grant_type: 'urn:ietf:params:oauth:grant-type:device_code' + }) + }) + + const data = await response.json() as any + + if (data.error) { + // Still waiting for user + if (data.error === 'authorization_pending') { + return { status: 'pending', interval: session.interval } + } + // Slow down polling + if (data.error === 'slow_down') { + session.interval = Math.min(session.interval + 5, 60) + return { status: 'pending', interval: session.interval } + } + // User denied or other error + if (data.error === 'access_denied') { + deviceAuthSessions.delete(sessionId) + return { status: 'denied' } + } + if (data.error === 'expired_token') { + deviceAuthSessions.delete(sessionId) + return { status: 'expired' } + } + + logger.error({ error: data.error }, "Token poll error") + return { status: 'error', error: data.error } + } + + // Success! We have tokens + deviceAuthSessions.delete(sessionId) + + logger.info("Device authorization successful") + + return { + status: 'success', + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresIn: data.expires_in, + tokenType: data.token_type, + scope: data.scope + } + } catch (error) { + logger.error({ error }, "Failed to poll for token") + return reply.status(500).send({ error: "Failed to poll for token" }) + } + }) + + // Refresh an expired token + app.post('/api/antigravity/device-auth/refresh', async (request, reply) => { + try { + const { refreshToken } = request.body as { refreshToken: string } + + if (!refreshToken) { + return reply.status(400).send({ error: "Missing refreshToken" }) + } + + const response = await fetch(GOOGLE_OAUTH_CONFIG.tokenEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + client_id: GOOGLE_OAUTH_CONFIG.clientId, + client_secret: GOOGLE_OAUTH_CONFIG.clientSecret, + refresh_token: refreshToken, + grant_type: 'refresh_token' + }) + }) + + if (!response.ok) { + const error = await response.text() + logger.error({ error }, "Token refresh failed") + return reply.status(401).send({ error: "Token refresh failed" }) + } + + const data = await response.json() as any + + return { + accessToken: data.access_token, + expiresIn: data.expires_in, + tokenType: data.token_type + } + } catch (error) { + logger.error({ error }, "Failed to refresh token") + return reply.status(500).send({ error: "Failed to refresh token" }) + } + }) + // Chat completion endpoint WITH MCP TOOL SUPPORT app.post('/api/antigravity/chat', async (request, reply) => { try { @@ -124,7 +335,7 @@ export async function registerAntigravityRoutes( } }) - logger.info("Antigravity routes registered with MCP tool support - Google OAuth required!") + logger.info("Antigravity routes registered with Google Device Auth flow!") } /** diff --git a/packages/ui/src/components/settings/AntigravitySettings.tsx b/packages/ui/src/components/settings/AntigravitySettings.tsx index 22c2af5..18ba39c 100644 --- a/packages/ui/src/components/settings/AntigravitySettings.tsx +++ b/packages/ui/src/components/settings/AntigravitySettings.tsx @@ -1,5 +1,5 @@ -import { Component, createSignal, onMount, For, Show } from 'solid-js' -import { Rocket, CheckCircle, XCircle, Loader, Sparkles, Key, LogOut, Shield, Terminal, Copy, ExternalLink } from 'lucide-solid' +import { Component, createSignal, onMount, onCleanup, For, Show } from 'solid-js' +import { Rocket, CheckCircle, XCircle, Loader, Sparkles, LogIn, LogOut, Shield, ExternalLink, Copy } from 'lucide-solid' import { getUserScopedKey } from '../../lib/user-storage' interface AntigravityModel { @@ -29,10 +29,18 @@ const AntigravitySettings: Component = () => { const [connectionStatus, setConnectionStatus] = createSignal<'idle' | 'testing' | 'connected' | 'failed'>('idle') const [authStatus, setAuthStatus] = createSignal<'unknown' | 'authenticated' | 'unauthenticated'>('unknown') const [error, setError] = createSignal(null) - const [showTokenInput, setShowTokenInput] = createSignal(false) - const [tokenInput, setTokenInput] = createSignal('') + + // Device auth state + const [isAuthenticating, setIsAuthenticating] = createSignal(false) + const [deviceAuthSession, setDeviceAuthSession] = createSignal<{ + sessionId: string + userCode: string + verificationUrl: string + } | null>(null) const [copied, setCopied] = createSignal(false) + let pollInterval: number | undefined + // Check stored token on mount onMount(async () => { checkAuthStatus() @@ -40,6 +48,12 @@ const AntigravitySettings: Component = () => { await testConnection() }) + onCleanup(() => { + if (pollInterval) { + clearInterval(pollInterval) + } + }) + const getStoredToken = (): AntigravityToken | null => { if (typeof window === "undefined") return null try { @@ -101,45 +115,148 @@ const AntigravitySettings: Component = () => { } } - const handleTokenSubmit = () => { - const token = tokenInput().trim() - if (!token) { - setError('Please enter a valid token') - return - } + // Start device authorization flow + const startDeviceAuth = async () => { + setIsAuthenticating(true) + setError(null) try { - const tokenData: AntigravityToken = { - access_token: token, - expires_in: 3600, // 1 hour default - created_at: Date.now() + const response = await fetch('/api/antigravity/device-auth/start', { + method: 'POST' + }) + + if (!response.ok) { + throw new Error('Failed to start authentication') } - window.localStorage.setItem( - getUserScopedKey(ANTIGRAVITY_TOKEN_KEY), - JSON.stringify(tokenData) - ) + const data = await response.json() as { + sessionId: string + userCode: string + verificationUrl: string + expiresIn: number + interval: number + } - setAuthStatus('authenticated') - setShowTokenInput(false) - setTokenInput('') - setError(null) - loadModels() - } catch (err) { - setError('Failed to save token') + setDeviceAuthSession({ + sessionId: data.sessionId, + userCode: data.userCode, + verificationUrl: data.verificationUrl + }) + + // Start polling for token + const pollIntervalMs = (data.interval || 5) * 1000 + pollInterval = window.setInterval(() => { + pollForToken(data.sessionId) + }, pollIntervalMs) + + // Open verification URL in new tab + window.open(data.verificationUrl, '_blank') + + } catch (err: any) { + console.error('Device auth error:', err) + setError(err.message || 'Authentication failed') + setIsAuthenticating(false) } } + // Poll for token completion + const pollForToken = async (sessionId: string) => { + try { + const response = await fetch('/api/antigravity/device-auth/poll', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId }) + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + if (response.status === 410 || response.status === 404) { + // Session expired + stopPolling() + setError('Session expired. Please try again.') + return + } + throw new Error(errorData.error || 'Poll failed') + } + + const data = await response.json() as any + + if (data.status === 'pending') { + // Still waiting, continue polling + return + } + + if (data.status === 'success') { + // Got tokens! Save them + const token: AntigravityToken = { + access_token: data.accessToken, + refresh_token: data.refreshToken, + expires_in: data.expiresIn, + created_at: Date.now() + } + + window.localStorage.setItem( + getUserScopedKey(ANTIGRAVITY_TOKEN_KEY), + JSON.stringify(token) + ) + + stopPolling() + setAuthStatus('authenticated') + setError(null) + loadModels() + return + } + + if (data.status === 'denied') { + stopPolling() + setError('Access was denied. Please try again.') + return + } + + if (data.status === 'expired') { + stopPolling() + setError('Session expired. Please try again.') + return + } + + if (data.status === 'error') { + stopPolling() + setError(data.error || 'Authentication failed') + return + } + + } catch (err: any) { + console.error('Poll error:', err) + // Don't stop polling on network errors, just log them + } + } + + const stopPolling = () => { + if (pollInterval) { + clearInterval(pollInterval) + pollInterval = undefined + } + setIsAuthenticating(false) + setDeviceAuthSession(null) + } + + const cancelAuth = () => { + stopPolling() + setError(null) + } + const signOut = () => { window.localStorage.removeItem(getUserScopedKey(ANTIGRAVITY_TOKEN_KEY)) setAuthStatus('unauthenticated') } - const copyCommand = async () => { - const command = 'npx opencode-antigravity-auth login' - await navigator.clipboard.writeText(command) - setCopied(true) - setTimeout(() => setCopied(false), 2000) + const copyCode = async () => { + const session = deviceAuthSession() + if (session) { + await navigator.clipboard.writeText(session.userCode) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } } const formatNumber = (num: number): string => { @@ -199,7 +316,7 @@ const AntigravitySettings: Component = () => {

Premium AI Models

Antigravity provides access to Gemini 3 Pro/Flash, Claude Sonnet 4.5, Claude Opus 4.5, - and GPT-OSS 120B through Google's infrastructure. + and GPT-OSS 120B through Google's infrastructure. Sign in with your Google account to get started.

@@ -211,11 +328,11 @@ const AntigravitySettings: Component = () => {
-

Authentication

+

Google Authentication

{authStatus() === 'authenticated' - ? 'Token configured - you can use Antigravity models' - : 'Configure authentication to access premium models'} + ? 'You are signed in and can use Antigravity models' + : 'Sign in with Google to access premium models'}

@@ -224,89 +341,86 @@ const AntigravitySettings: Component = () => {
- Configured + Authenticated
+ {/* Not authenticated - show login button or device auth flow */} - {/* SDK Mode Instructions */} -
-
- - Recommended: Use OpenCode SDK Mode -
-

- For the best experience, switch to OpenCode SDK mode in General settings. - The Antigravity plugin handles authentication automatically via Google OAuth. -

-
- - npx opencode-antigravity-auth login - - -
-
+ + + - {/* Manual Token Entry */} -
- - - - - -
- -
- setTokenInput(e.currentTarget.value)} - placeholder="Paste your Google OAuth access token..." - class="flex-1 px-3 py-2 bg-zinc-900 border border-zinc-700 rounded-lg text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-purple-500" - /> + {/* Device auth in progress - show code */} + +
+
+

+ Enter this code on the Google sign-in page: +

+
+ + {deviceAuthSession()?.userCode} + -
-

- You can get an access token by running the Antigravity auth plugin in a terminal, - or by extracting it from an authenticated Google Cloud session. -

- -
+ +
+ + Waiting for you to complete sign-in... +
+ + +
+
@@ -387,7 +501,7 @@ const AntigravitySettings: Component = () => {
-

Models will be available after configuration.

+

Models will be available after signing in.

@@ -396,26 +510,13 @@ const AntigravitySettings: Component = () => {

How to Use

    -
  • SDK Mode (Recommended): Switch to OpenCode binary mode and install the antigravity auth plugin
  • -
  • Native Mode: Enter your access token manually above
  • -
  • • Select any Antigravity model from the model picker in chat
  • +
  • • Click "Sign in with Google" and enter the code on the Google page
  • +
  • • Once authenticated, select any Antigravity model from the chat model picker
  • • Models include Gemini 3, Claude Sonnet/Opus 4.5, and GPT-OSS
  • • Thinking-enabled models show step-by-step reasoning
  • +
  • • Full tool use and MCP support included
- - {/* Plugin Link */} - ) }