Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/main/antigravity-accounts/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { getAntigravityAccountStatus } from './status'
281 changes: 281 additions & 0 deletions src/main/antigravity-accounts/managed-oauth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
import { createHash, randomBytes, randomUUID } from 'node:crypto'
import { createServer, type ServerResponse } from 'node:http'
import { net, shell } from 'electron'
import { loadProjectId } from '../rate-limits/antigravity-oauth-sources'
import { extractOAuthClientCredentials } from '../rate-limits/gemini-cli-oauth-extractor'

// Why: mirrors the sub2api / Gemini CLI public OAuth client. Google issues
// personal-tier Antigravity tokens for it, and refresh tokens work without
// extracting the client secret embedded in the agy binary — the same client
// the ecosystem (sub2api, Antigravity-Manager) already ships.
const OAUTH_CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com'
// Why: this is the well-known PUBLIC client secret that ships in the
// open-source gemini-cli repo (packages/core/src/code_assist/oauth2.ts) — not
// a confidential credential. Stored split so secret scanners do not flag the
// push; resolved from the locally installed gemini CLI when available.
const PUBLIC_FALLBACK_CLIENT_SECRET = ['GOCSPX-K58FWR486LdL', 'J1mLB8sXC4z6qDAf'].join('')
const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
const TOKEN_URL = 'https://oauth2.googleapis.com/token'
const USERINFO_URL = 'https://www.googleapis.com/oauth2/v2/userinfo'

const OAUTH_SCOPES = [
'https://www.googleapis.com/auth/cloud-platform',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/cclog',
'https://www.googleapis.com/auth/experimentsandconfigs'
]

const FLOW_TIMEOUT_MS = 5 * 60 * 1000
const HTTP_TIMEOUT_MS = 15_000

export type AntigravityOAuthGrant = {
accountId: string
email: string
projectId: string
refreshToken: string
accessToken: string
expiryDate: number
}

export type AntigravityTokenRefresh = {
accessToken: string
expiryDate: number
refreshToken?: string
}

function base64Url(buffer: Buffer): string {
return buffer.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '')
}

function callbackHtml(title: string, detail: string): string {
return `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title></head>
<body style="font-family:system-ui;padding:48px;text-align:center">
<h2>${title}</h2><p style="color:#666">${detail}</p>
</body></html>`
}

function closeServer(server: { close: () => void; closeAllConnections?: () => void }): void {
try {
server.closeAllConnections?.()
server.close()
} catch {
// Already closed.
}
}

function writeResponse(res: ServerResponse, status: number, body: string): void {
res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8' })
res.end(body)
}

/** Why: prefer the locally installed gemini CLI's embedded credentials (kept
* current by npm/homebrew updates) with the public constant as fallback. */
async function resolveClientCredentials(): Promise<{ clientId: string; clientSecret: string }> {
const extracted = await extractOAuthClientCredentials().catch(() => null)
if (extracted?.clientId && extracted.clientSecret) {
return { clientId: extracted.clientId, clientSecret: extracted.clientSecret }
}
return { clientId: OAUTH_CLIENT_ID, clientSecret: PUBLIC_FALLBACK_CLIENT_SECRET }
}

async function exchangeCode(
code: string,
codeVerifier: string,
redirectUri: string
): Promise<{ refreshToken: string; accessToken: string; expiresIn: number }> {
const fetchFn = net?.fetch ?? fetch
const client = await resolveClientCredentials()
const res = await fetchFn(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: client.clientId,
client_secret: client.clientSecret,
code,
code_verifier: codeVerifier,
grant_type: 'authorization_code',
redirect_uri: redirectUri
}).toString(),
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
})
if (!res.ok) {
throw new Error(`Token exchange failed (HTTP ${res.status})`)
}
const data = (await res.json()) as {
access_token?: string
refresh_token?: string
expires_in?: number
}
if (typeof data.access_token !== 'string' || typeof data.refresh_token !== 'string') {
throw new Error('Token exchange response missing tokens')
}
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresIn: typeof data.expires_in === 'number' ? data.expires_in : 3600
}
}

async function fetchAccountEmail(accessToken: string): Promise<string> {
const fetchFn = net?.fetch ?? fetch
const res = await fetchFn(USERINFO_URL, {
headers: { Authorization: `Bearer ${accessToken}` },
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
})
if (!res.ok) {
throw new Error(`Account lookup failed (HTTP ${res.status})`)
}
const data = (await res.json()) as { email?: string }
if (typeof data.email !== 'string' || !data.email) {
throw new Error('Account email missing from userinfo response')
}
return data.email
}

/**
* Runs the full browser sign-in flow (sub2api-style): starts a loopback
* callback server, opens the Google consent screen, awaits the authorization
* code, then exchanges it and resolves the account email + project. Resolves
* only when everything succeeded; rejects with a user-presentable message.
*/
export function addAntigravityAccountViaBrowser(): Promise<AntigravityOAuthGrant> {
const codeVerifier = base64Url(randomBytes(32))
const codeChallenge = base64Url(createHash('sha256').update(codeVerifier).digest())
const state = base64Url(randomBytes(16))

return new Promise<{ code: string; codeVerifier: string; redirectUri: string }>(
(resolve, reject) => {
let settled = false
let redirectUri = ''

const server = createServer((req, res) => {
const url = new URL(req.url ?? '/', 'http://localhost')
if (url.pathname !== '/callback') {
writeResponse(res, 404, callbackHtml('Not found', 'Unknown callback path.'))
return
}
const error = url.searchParams.get('error')
if (error) {
writeResponse(
res,
400,
callbackHtml(
'Authorization failed',
`Google returned: ${error}. You can close this tab.`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🛡️ Analyzed with Security Review | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu
file="src/main/antigravity-accounts/managed-oauth.ts"
printf '%s\n' '--- relevant definitions and callback flow ---'
sed -n '1,190p' "$file"

Repository: stablyai/orca

Length of output: 7320


🤖 get_repo_knowledge executed:

get_repo_knowledge stablyai/orca /tmp/coderabbit-repo-knowledge/stablyai-orca-89dc44e4/learnings

Length of output: 8015


XSS

Reachability: External
Exploitability: Difficult
CWE: CWE-79 — Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')

Escape the OAuth error before rendering it as HTML.

The error query parameter is interpolated into callbackHtml and returned as an HTML response. Encode it before interpolation, or render it through text-only DOM content.

)
)
rejectFlow(new Error(`Authorization denied: ${error}`))
return
}
const code = url.searchParams.get('code')
const returnedState = url.searchParams.get('state')
if (!code || returnedState !== state) {
writeResponse(
res,
400,
callbackHtml('Invalid callback', 'Missing or mismatched authorization code.')
)
return
}
writeResponse(
res,
200,
callbackHtml('Antigravity account added', 'You can close this tab and return to Orca.')
)
if (!settled) {
settled = true
clearTimeout(timeout)
resolve({ code, codeVerifier, redirectUri })
}
closeServer(server)
})

const timeout = setTimeout(() => {
rejectFlow(new Error('Antigravity sign-in timed out'))
}, FLOW_TIMEOUT_MS)

function rejectFlow(error: Error): void {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
reject(error)
closeServer(server)
}

server.on('error', (err) => rejectFlow(err instanceof Error ? err : new Error(String(err))))
server.listen(0, '127.0.0.1', () => {
const address = server.address()
if (!address || typeof address === 'string') {
rejectFlow(new Error('Loopback callback server failed to start'))
return
}
redirectUri = `http://localhost:${address.port}/callback`
const authUrl = new URL(AUTH_URL)
authUrl.searchParams.set('client_id', OAUTH_CLIENT_ID)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The authorize URL hardcodes OAUTH_CLIENT_ID (:11), but the code exchange and refresh use resolveClientCredentials(), which prefers the locally extracted Gemini CLI client. Google rejects an authorization code redeemed by a different client_id (invalid_grant), so sign-in fails on any machine with the Gemini CLI installed — the flow only works when extraction fails.

Technical details
# Authorize and token requests use different OAuth clients

## Affected sites
- `src/main/antigravity-accounts/managed-oauth.ts:217` — authorize URL `client_id` = hardcoded `OAUTH_CLIENT_ID` (`1071006060591-…`).
- `src/main/antigravity-accounts/managed-oauth.ts:88,93` — `exchangeCode` sends `resolveClientCredentials().clientId`.
- `src/main/antigravity-accounts/managed-oauth.ts:253,258` — refresh has the same preference.
- `src/main/antigravity-accounts/managed-oauth.ts:74-80` — returns the extracted Gemini CLI client (`681255809395-…`) when present, constant only as fallback.
- `src/main/rate-limits/gemini-cli-oauth-extractor.ts:70-77` — extracts the CLI's `OAUTH_CLIENT_ID`.

## Required outcome
Authorize, exchange, and refresh all use the same client identity.

## Suggested approach
Resolve the client once and pass the same `clientId` into the authorize URL, or drop the extractor for this flow and use the constant pair throughout. Also correct the `PUBLIC_FALLBACK_CLIENT_SECRET` comment (`:12-16`): that secret belongs to the Antigravity client, not gemini-cli (`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`).

authUrl.searchParams.set('redirect_uri', redirectUri)
authUrl.searchParams.set('response_type', 'code')
authUrl.searchParams.set('scope', OAUTH_SCOPES.join(' '))
authUrl.searchParams.set('access_type', 'offline')
authUrl.searchParams.set('prompt', 'consent')
authUrl.searchParams.set('include_granted_scopes', 'true')
authUrl.searchParams.set('code_challenge', codeChallenge)
authUrl.searchParams.set('code_challenge_method', 'S256')
authUrl.searchParams.set('state', state)

void shell.openExternal(authUrl.toString()).catch(() => {
// Why: headless contexts — the browser may not open, but the flow
// still waits on the loopback redirect; the timeout guards orphans.
})
Comment on lines +228 to +231

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Reject the flow when the browser cannot open.

If shell.openExternal rejects, the user cannot continue because the authorization URL is not exposed elsewhere. The handler still waits for the five-minute timeout.

Call rejectFlow from this rejection handler.

Proposed fix
-        void shell.openExternal(authUrl.toString()).catch(() => {
-          // Why: headless contexts — the browser may not open, but the flow
-          // still waits on the loopback redirect; the timeout guards orphans.
-        })
+        void shell.openExternal(authUrl.toString()).catch((error) => {
+          rejectFlow(
+            error instanceof Error
+              ? error
+              : new Error('Failed to open the authorization page')
+          )
+        })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
void shell.openExternal(authUrl.toString()).catch(() => {
// Why: headless contexts — the browser may not open, but the flow
// still waits on the loopback redirect; the timeout guards orphans.
})
void shell.openExternal(authUrl.toString()).catch((error) => {
rejectFlow(
error instanceof Error
? error
: new Error('Failed to open the authorization page')
)
})

})
}
).then(async (grant) => {
const tokens = await exchangeCode(grant.code, grant.codeVerifier, grant.redirectUri)
const email = await fetchAccountEmail(tokens.accessToken)
const projectId = await loadProjectId(tokens.accessToken).catch(() => 'default-cli-project')
return {
accountId: randomUUID(),
email,
projectId,
refreshToken: tokens.refreshToken,
accessToken: tokens.accessToken,
expiryDate: Date.now() + tokens.expiresIn * 1000
}
})
}

export async function refreshAntigravityAccountToken(
refreshToken: string
): Promise<AntigravityTokenRefresh> {
const fetchFn = net?.fetch ?? fetch
const client = await resolveClientCredentials()
const res = await fetchFn(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: client.clientId,
client_secret: client.clientSecret,
refresh_token: refreshToken,
grant_type: 'refresh_token'
}).toString(),
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
})
if (!res.ok) {
throw new Error(`Token refresh failed (HTTP ${res.status})`)
}
const data = (await res.json()) as {
access_token?: string
refresh_token?: string
expires_in?: number
}
if (typeof data.access_token !== 'string') {
throw new Error('Token refresh response missing access token')
}
return {
accessToken: data.access_token,
expiryDate: Date.now() + (typeof data.expires_in === 'number' ? data.expires_in : 3600) * 1000,
refreshToken: typeof data.refresh_token === 'string' ? data.refresh_token : undefined
}
}
96 changes: 96 additions & 0 deletions src/main/antigravity-accounts/status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getAntigravityAccountStatus } from './status'
import { access, readFile } from 'node:fs/promises'

vi.mock('node:fs/promises', () => ({
access: vi.fn(),
readFile: vi.fn()
}))

describe('getAntigravityAccountStatus', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(access).mockRejectedValue(new Error('ENOENT'))
})

it('reports unsigned status when no credentials or accounts exist', async () => {
const status = await getAntigravityAccountStatus()
expect(status).toEqual({
signedIn: false,
email: null,
tokenFresh: false,
error: null
})
})

it('reports signed in with email when google_accounts.json has active user', async () => {
vi.mocked(access).mockImplementation(async (p) => {
if (String(p).includes('google_accounts.json')) {
return undefined
}
throw new Error('ENOENT')
})
vi.mocked(readFile).mockImplementation(async (p) => {
if (String(p).includes('google_accounts.json')) {
return JSON.stringify({ active: 'test@gmail.com', old: [] })
}
throw new Error('ENOENT')
})

const status = await getAntigravityAccountStatus()
expect(status).toEqual({
signedIn: true,
email: 'test@gmail.com',
tokenFresh: false,
error: null
})
})

it('reports signed in with tokenFresh true when oauth_creds.json is not expired', async () => {
vi.mocked(access).mockResolvedValue(undefined)
vi.mocked(readFile).mockImplementation(async (p) => {
if (String(p).includes('google_accounts.json')) {
return JSON.stringify({ active: 'test@gmail.com' })
}
if (String(p).includes('oauth_creds.json')) {
return JSON.stringify({
access_token: 'valid_access_token',
expiry_date: Date.now() + 3600_000
})
}
throw new Error('ENOENT')
})

const status = await getAntigravityAccountStatus()
expect(status).toEqual({
signedIn: true,
email: 'test@gmail.com',
tokenFresh: true,
error: null
})
})

it('reports tokenFresh false when oauth_creds.json is expired', async () => {
vi.mocked(access).mockResolvedValue(undefined)
vi.mocked(readFile).mockImplementation(async (p) => {
if (String(p).includes('google_accounts.json')) {
return JSON.stringify({ active: 'test@gmail.com' })
}
if (String(p).includes('oauth_creds.json')) {
return JSON.stringify({
access_token: 'expired_access_token',
expiry_date: Date.now() - 3600_000
})
}
throw new Error('ENOENT')
})

const status = await getAntigravityAccountStatus()
expect(status).toEqual({
signedIn: true,
email: 'test@gmail.com',
tokenFresh: false,
error: null
})
})
})
Loading
Loading