feat: Add Google Device Authorization Flow for Antigravity native mode
Some checks failed
Release Binaries / release (push) Has been cancelled
Some checks failed
Release Binaries / release (push) Has been cancelled
- Implemented proper OAuth device flow using gcloud CLI client ID - Added /api/antigravity/device-auth/start endpoint - Added /api/antigravity/device-auth/poll endpoint with polling - Added /api/antigravity/device-auth/refresh for token renewal - Updated AntigravitySettings UI with user code display - Auto-opens Google sign-in page and polls for completion - Seamless authentication experience matching SDK mode
This commit is contained in:
@@ -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<string, {
|
||||
deviceCode: string
|
||||
userCode: string
|
||||
verificationUrl: string
|
||||
expiresAt: number
|
||||
interval: number
|
||||
}>()
|
||||
|
||||
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!")
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user