-
-
Notifications
You must be signed in to change notification settings - Fork 10.1k
fix(oauth): antigravity login no longer hangs — fire-and-forget onboarding + bounded post-exchange #5193
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix(oauth): antigravity login no longer hangs — fire-and-forget onboarding + bounded post-exchange #5193
Changes from all commits
6c7bf74
ab1813f
ea204bd
22dd424
7a890ab
9158ac8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -6,11 +6,21 @@ import { | |||||||||||||
| } from "@omniroute/open-sse/services/antigravityHeaders.ts"; | ||||||||||||||
| import { extractCodeAssistOnboardTierId } from "@omniroute/open-sse/services/codeAssistSubscription.ts"; | ||||||||||||||
|
|
||||||||||||||
| async function fetchFirstOk(endpoints: string[], init: RequestInit) { | ||||||||||||||
| // Bound every Antigravity post-exchange call. Without this an unreachable/slow | ||||||||||||||
| // upstream made the `/exchange` request (and therefore the whole OAuth login) | ||||||||||||||
| // hang forever — the dashboard "just spins". Mirrors the AbortSignal.timeout | ||||||||||||||
| // pattern already used by antigravityProjectBootstrap.ts. | ||||||||||||||
| const POSTEXCHANGE_TIMEOUT_MS = 8_000; | ||||||||||||||
|
|
||||||||||||||
| async function fetchFirstOk(endpoints: string[], init: RequestInit, timeoutMs?: number) { | ||||||||||||||
| let lastError: unknown = null; | ||||||||||||||
| // One shared deadline for the WHOLE fallback list — a stalled set of endpoints | ||||||||||||||
| // must bound to a single timeout, not timeoutMs × endpoints (that re-introduced | ||||||||||||||
| // a ~40s login wait). A reachable endpoint still returns immediately. | ||||||||||||||
| const signal = timeoutMs ? AbortSignal.timeout(timeoutMs) : init.signal; | ||||||||||||||
| for (const endpoint of endpoints) { | ||||||||||||||
| try { | ||||||||||||||
| const response = await fetch(endpoint, init); | ||||||||||||||
| const response = await fetch(endpoint, { ...init, signal }); | ||||||||||||||
| if (response.ok) return response; | ||||||||||||||
| lastError = new Error(`${response.status} ${await response.text()}`); | ||||||||||||||
| } catch (error) { | ||||||||||||||
|
|
@@ -22,7 +32,13 @@ async function fetchFirstOk(endpoints: string[], init: RequestInit) { | |||||||||||||
|
|
||||||||||||||
| export const antigravity = { | ||||||||||||||
| config: ANTIGRAVITY_CONFIG, | ||||||||||||||
| flowType: "authorization_code_pkce", | ||||||||||||||
| // NO PKCE. The embedded Antigravity client is a Google "Desktop/native" OAuth client; | ||||||||||||||
| // sending a PKCE code_challenge (combined with the openid scope) pushed Google into the | ||||||||||||||
| // `signin/oauth/firstparty/nativeapp` consent flow that hangs and never redirects back | ||||||||||||||
| // (operator report 2026-06-27). The working 9router flow uses a plain authorization_code | ||||||||||||||
| // grant with client_secret and no code_challenge — match it exactly. The token exchange | ||||||||||||||
| // already sends client_secret (ANTIGRAVITY_OAUTH_CLIENT_SECRET) and omits code_verifier. | ||||||||||||||
| flowType: "authorization_code", | ||||||||||||||
| buildAuthUrl: (config, redirectUri, state, codeChallenge) => { | ||||||||||||||
| const params = new URLSearchParams({ | ||||||||||||||
| client_id: config.clientId, | ||||||||||||||
|
|
@@ -39,7 +55,13 @@ export const antigravity = { | |||||||||||||
| } | ||||||||||||||
| return `${config.authorizeUrl}?${params.toString()}`; | ||||||||||||||
| }, | ||||||||||||||
| exchangeToken: async (config, code, redirectUri, codeVerifier) => { | ||||||||||||||
| // NOTE: no PKCE. Antigravity is a plain authorization_code grant now (see flowType | ||||||||||||||
| // above). The shared generateAuthData() still mints a codeVerifier for every flow, but | ||||||||||||||
| // we MUST NOT forward it here — the authorize URL carries no code_challenge, so sending | ||||||||||||||
| // a code_verifier makes Google reject the exchange with invalid_grant ("code_verifier | ||||||||||||||
| // provided but code_challenge was not"), surfacing as a 500 on /exchange. Ignore it and | ||||||||||||||
| // authenticate with client_secret only, exactly like the working 9router flow. | ||||||||||||||
| exchangeToken: async (config, code, redirectUri) => { | ||||||||||||||
| const bodyParams: Record<string, string> = { | ||||||||||||||
| grant_type: "authorization_code", | ||||||||||||||
| client_id: config.clientId, | ||||||||||||||
|
|
@@ -51,10 +73,6 @@ export const antigravity = { | |||||||||||||
| bodyParams.client_secret = config.clientSecret; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| if (codeVerifier) { | ||||||||||||||
| bodyParams.code_verifier = codeVerifier; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| const response = await fetch(config.tokenUrl, { | ||||||||||||||
| method: "POST", | ||||||||||||||
| headers: { | ||||||||||||||
|
|
@@ -76,48 +94,52 @@ export const antigravity = { | |||||||||||||
| const headers = getAntigravityHeaders("loadCodeAssist", tokens.access_token); | ||||||||||||||
| const metadata = getAntigravityLoadCodeAssistMetadata(); | ||||||||||||||
|
|
||||||||||||||
| // Best-effort + bounded: a slow userinfo endpoint must never hang the login. | ||||||||||||||
| const userInfoRes = await fetch(`${ANTIGRAVITY_CONFIG.userInfoUrl}?alt=json`, { | ||||||||||||||
| headers: { Authorization: `Bearer ${tokens.access_token}` }, | ||||||||||||||
| }); | ||||||||||||||
| const userInfo = userInfoRes.ok ? await userInfoRes.json() : {}; | ||||||||||||||
| signal: AbortSignal.timeout(POSTEXCHANGE_TIMEOUT_MS), | ||||||||||||||
| }).catch(() => null); | ||||||||||||||
| const userInfo = userInfoRes?.ok ? await userInfoRes.json() : {}; | ||||||||||||||
|
|
||||||||||||||
| let projectId = ""; | ||||||||||||||
| let tierId = "legacy-tier"; | ||||||||||||||
| try { | ||||||||||||||
| const loadRes = await fetchFirstOk(ANTIGRAVITY_CONFIG.loadCodeAssistEndpoints, { | ||||||||||||||
| method: "POST", | ||||||||||||||
| headers, | ||||||||||||||
| body: JSON.stringify({ metadata }), | ||||||||||||||
| }); | ||||||||||||||
| const loadRes = await fetchFirstOk( | ||||||||||||||
| ANTIGRAVITY_CONFIG.loadCodeAssistEndpoints, | ||||||||||||||
| { method: "POST", headers, body: JSON.stringify({ metadata }) }, | ||||||||||||||
| POSTEXCHANGE_TIMEOUT_MS | ||||||||||||||
| ); | ||||||||||||||
| const data = await loadRes.json(); | ||||||||||||||
| projectId = data.cloudaicompanionProject?.id || data.cloudaicompanionProject || ""; | ||||||||||||||
| tierId = extractCodeAssistOnboardTierId(data); | ||||||||||||||
| } catch (e) { | ||||||||||||||
| console.log("Failed to load code assist:", e); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // Fire-and-forget onboarding — it must NOT block the OAuth login response. | ||||||||||||||
| // The previous inline `await` loop (up to 10×5s, each fetch un-timed) made the | ||||||||||||||
| // `/exchange` request hang forever when an upstream was slow/unreachable, so the | ||||||||||||||
| // dashboard "just spun". Onboarding is also performed lazily at request time by | ||||||||||||||
| // antigravityProjectBootstrap.ts, so backgrounding it here is safe. Matches the | ||||||||||||||
| // 9router web flow. (#5180-followup / antigravity login hang) | ||||||||||||||
| if (projectId) { | ||||||||||||||
| try { | ||||||||||||||
| const onboardInBackground = async () => { | ||||||||||||||
| for (let i = 0; i < 10; i++) { | ||||||||||||||
| const onboardRes = await fetchFirstOk(ANTIGRAVITY_CONFIG.onboardUserEndpoints, { | ||||||||||||||
| method: "POST", | ||||||||||||||
| headers, | ||||||||||||||
| body: JSON.stringify({ tier_id: tierId, metadata }), | ||||||||||||||
| }); | ||||||||||||||
| const result = await onboardRes.json(); | ||||||||||||||
| if (result.done === true) { | ||||||||||||||
| if (result.response?.cloudaicompanionProject) { | ||||||||||||||
| const respProject = result.response.cloudaicompanionProject; | ||||||||||||||
| projectId = | ||||||||||||||
| typeof respProject === "string" ? respProject.trim() : respProject.id || projectId; | ||||||||||||||
| } | ||||||||||||||
| try { | ||||||||||||||
| const onboardRes = await fetchFirstOk( | ||||||||||||||
| ANTIGRAVITY_CONFIG.onboardUserEndpoints, | ||||||||||||||
| { method: "POST", headers, body: JSON.stringify({ tier_id: tierId, metadata }) }, | ||||||||||||||
| POSTEXCHANGE_TIMEOUT_MS | ||||||||||||||
| ); | ||||||||||||||
| const result = await onboardRes.json(); | ||||||||||||||
| if (result.done === true) break; | ||||||||||||||
| } catch { | ||||||||||||||
| break; | ||||||||||||||
| } | ||||||||||||||
|
Comment on lines
+136
to
138
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Breaking the loop on any error (
Suggested change
|
||||||||||||||
| await new Promise((resolve) => setTimeout(resolve, 5000)); | ||||||||||||||
| } | ||||||||||||||
| } catch (e) { | ||||||||||||||
| console.log("Failed to onboard user:", e); | ||||||||||||||
| } | ||||||||||||||
| }; | ||||||||||||||
| void onboardInBackground().catch(() => {}); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| return { userInfo, projectId, tierId }; | ||||||||||||||
|
|
||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| // Regression guard for the Antigravity OAuth login hang on Google's consent page. | ||
| // | ||
| // The embedded Antigravity client is a Google "Desktop/native" OAuth client. | ||
| // Sending a PKCE code_challenge AND the `openid` scope pushed Google into the | ||
| // `signin/oauth/firstparty/nativeapp` consent flow, which hung and never redirected | ||
| // back (operator report 2026-06-27). The working 9router flow uses a plain | ||
| // authorization_code grant (client_secret, no code_challenge) and does NOT request | ||
| // `openid`. This test pins our antigravity (and the `agy` alias) to that shape. | ||
| // | ||
| // Flip-proof: set flowType back to "authorization_code_pkce" → generateAuthData emits | ||
| // code_challenge → first assertion fails. Re-add "openid" → scope assertion fails. | ||
|
|
||
| import test from "node:test"; | ||
| import assert from "node:assert/strict"; | ||
| import { generateAuthData } from "../../src/lib/oauth/providers.ts"; | ||
| import PROVIDERS from "../../src/lib/oauth/providers/index.ts"; | ||
|
|
||
| const REDIRECT = "http://127.0.0.1:20128/callback"; | ||
|
|
||
| for (const providerId of ["antigravity", "agy"]) { | ||
| test(`${providerId}: no PKCE + no openid in the auth URL (matches working 9router flow)`, () => { | ||
| assert.equal( | ||
| PROVIDERS[providerId].flowType, | ||
| "authorization_code", | ||
| `${providerId} must use a plain authorization_code grant (no PKCE) for the Google native client` | ||
| ); | ||
|
|
||
| const authData = generateAuthData(providerId, REDIRECT); | ||
| assert.ok(authData.authUrl, `${providerId} must produce an auth URL`); | ||
|
|
||
| const url = new URL(authData.authUrl); | ||
| assert.equal(url.origin, "https://accounts.google.com"); | ||
|
|
||
| // No PKCE challenge — its presence triggers the hanging nativeapp consent. | ||
| assert.equal( | ||
| url.searchParams.get("code_challenge"), | ||
| null, | ||
| `${providerId} auth URL must NOT carry a PKCE code_challenge` | ||
| ); | ||
| assert.equal(url.searchParams.get("code_challenge_method"), null); | ||
|
|
||
| // No openid scope — only the Cloud Code / userinfo scopes 9router requests. | ||
| const scopes = (url.searchParams.get("scope") || "").split(" "); | ||
| assert.ok(!scopes.includes("openid"), `${providerId} must not request the openid scope`); | ||
| assert.ok( | ||
| scopes.includes("https://www.googleapis.com/auth/cloud-platform"), | ||
| `${providerId} must still request the cloud-platform scope` | ||
| ); | ||
| }); | ||
| } | ||
|
|
||
| test("antigravity.exchangeToken never forwards code_verifier (no PKCE → no invalid_grant 500)", async () => { | ||
| const origFetch = globalThis.fetch; | ||
| let sentBody = ""; | ||
| globalThis.fetch = (async (_url: unknown, init: { body?: unknown } = {}) => { | ||
| sentBody = String(init.body ?? ""); | ||
| return new Response( | ||
| JSON.stringify({ access_token: "t", refresh_token: "r", expires_in: 3600 }), | ||
| { status: 200, headers: { "Content-Type": "application/json" } } | ||
| ); | ||
| }) as typeof fetch; | ||
| try { | ||
| // Pass a codeVerifier (as the modal does — generateAuthData always mints one). | ||
| // It MUST be ignored: the authorize URL had no code_challenge, so forwarding a | ||
| // code_verifier makes Google reject the exchange (invalid_grant → 500). | ||
| await PROVIDERS.antigravity.exchangeToken( | ||
| { | ||
| clientId: "cid", | ||
| clientSecret: "sec", | ||
| tokenUrl: "https://oauth2.googleapis.com/token", | ||
| }, | ||
| "the-code", | ||
| "http://127.0.0.1:20128/callback", | ||
| "should-be-ignored-verifier" | ||
| ); | ||
| } finally { | ||
| globalThis.fetch = origFetch; | ||
| } | ||
| const params = new URLSearchParams(sentBody); | ||
| assert.equal(params.get("code_verifier"), null, "must NOT forward code_verifier (no PKCE)"); | ||
| assert.equal(params.get("client_secret"), "sec", "must authenticate via client_secret"); | ||
| assert.equal(params.get("grant_type"), "authorization_code"); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the
userInforequest succeeds but the response body is invalid JSON or empty, callinguserInfoRes.json()will throw an unhandled exception and crash the entirepostExchange(and thus the OAuth login) flow. Wrapping both the fetch and the JSON parsing in a singletry-catchblock ensures that any network, timeout, or parsing errors are safely caught and handled gracefully.