From b861dd045a3510ea5fb1adb17e1288d6ab402fc4 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 22 Jul 2026 00:43:10 -0300 Subject: [PATCH] feat: browser login for Grok Build provider (#7013) (#7735) * feat(oauth): add browser login for Grok Build provider (#7013) * feat(oauth): grok-build supports device_code AND browser-PKCE side-by-side (#7013) Reworks #7735 so the browser PKCE login is added ALONGSIDE the device_code flow (#7358) instead of replacing it; the OAuthModal lets the user pick either method. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- .../features/7013-grok-build-browser-login.md | 1 + .../api/oauth/[provider]/[action]/route.ts | 19 +- src/lib/oauth/constants/oauth.ts | 15 + src/lib/oauth/providers.ts | 11 +- src/lib/oauth/providers/grok-cli-oauth.ts | 116 ++++ src/lib/oauth/providers/grok-cli.ts | 155 ++++-- src/lib/oauth/providers/index.ts | 2 + src/shared/components/OAuthModal.tsx | 497 ++++++++++-------- src/shared/constants/providers/oauth.ts | 2 +- tests/unit/grok-cli-oauth.test.ts | 202 +------ tests/unit/oauth-grok-cli-browser.test.ts | 214 ++++++++ ...-modal-grok-cli-browser-login-7013.test.ts | 41 ++ tests/unit/oauth-providers-config.test.ts | 6 +- tests/unit/publicCreds.test.ts | 8 + .../unit/ui/grok-device-oauth-modal.test.tsx | 95 ++++ 15 files changed, 940 insertions(+), 444 deletions(-) create mode 100644 changelog.d/features/7013-grok-build-browser-login.md create mode 100644 src/lib/oauth/providers/grok-cli-oauth.ts create mode 100644 tests/unit/oauth-grok-cli-browser.test.ts create mode 100644 tests/unit/oauth-modal-grok-cli-browser-login-7013.test.ts diff --git a/changelog.d/features/7013-grok-build-browser-login.md b/changelog.d/features/7013-grok-build-browser-login.md new file mode 100644 index 0000000000..b423709e76 --- /dev/null +++ b/changelog.d/features/7013-grok-build-browser-login.md @@ -0,0 +1 @@ +- **feat(oauth):** Add a one-click browser (PKCE) login for Grok Build (`grok-cli`) ALONGSIDE the existing device-code flow — reusing the same `auth.x.ai` authorize/token endpoints and public client id as the sibling `xai-oauth` provider on its own loopback port — while keeping the pre-existing device-code method and the paste-token/`auth.json` import flow both available; the connect modal lets the user pick "Device Code", "Browser Login", or "JWT Token" ([#7013](https://github.com/diegosouzapw/OmniRoute/issues/7013)) diff --git a/src/app/api/oauth/[provider]/[action]/route.ts b/src/app/api/oauth/[provider]/[action]/route.ts index 4037745ff0..08d415a452 100755 --- a/src/app/api/oauth/[provider]/[action]/route.ts +++ b/src/app/api/oauth/[provider]/[action]/route.ts @@ -46,7 +46,7 @@ if (!globalThis.__pkceCallbackStates) { } /** Providers that use the PKCE browser callback flow (like Codex). */ -const PKCE_CALLBACK_PROVIDERS = new Set(["codex", "xai-oauth"]); +const PKCE_CALLBACK_PROVIDERS = new Set(["codex", "xai-oauth", "grok-cli"]); /** * Providers whose device flow runs in the user's browser (auth.openai.com blocks @@ -488,7 +488,15 @@ export async function POST( const normalizedState = typeof state === "string" && state.length > 0 ? state : undefined; const providerData = getProvider(provider); - if (providerData.flowType === "authorization_code_pkce" && !codeVerifier) { + // Capability check, not a bare flowType equality: grok-cli keeps flowType + // "device_code" as its primary flow (#7358) while ALSO exposing a browser + // PKCE login via supportsBrowserPkce (#7013 rework) — its exchange still + // needs a codeVerifier when the browser method was used. Other providers + // are untouched since only grok-cli sets supportsBrowserPkce. + if ( + (providerData.flowType === "authorization_code_pkce" || providerData.supportsBrowserPkce) && + !codeVerifier + ) { return NextResponse.json( { error: { @@ -747,7 +755,12 @@ export async function POST( const existing = await getProviderConnections({ provider }); // Codex accounts sharing an email require workspaceId/chatgptUserId // agreement to be treated as the same account (#7737). - const match = findExistingOAuthConnectionMatch(existing, provider, tokenData, connectionId); + const match = findExistingOAuthConnectionMatch( + existing, + provider, + tokenData, + connectionId + ); const matchId = typeof match?.id === "string" ? match.id : null; if (matchId) { connection = await updateProviderConnection(matchId, { diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index e7dfe6df45..990fc139a5 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -126,6 +126,21 @@ export const GROK_CLI_CONFIG = { scope: GROK_BUILD_OAUTH_SCOPES.join(" "), }; +// Grok Build (xAI) OAuth Configuration (Browser PKCE Flow — added #7013) +// Same auth.x.ai authorize/token endpoints and public client_id as XAI_OAUTH_CONFIG, +// but scoped to the Grok Build (cli-chat-proxy.grok.com) entitlement and kept as a +// separate config so grok-cli's own baseUrl/model registry stay untouched. +export const GROK_BUILD_OAUTH_CONFIG = { + clientId: resolvePublicCred("grok_id", "GROK_OAUTH_CLIENT_ID"), + authorizeUrl: "https://auth.x.ai/oauth2/authorize", + tokenUrl: "https://auth.x.ai/oauth2/token", + scope: "openid profile email offline_access grok-cli:access", + codeChallengeMethod: "S256", + loopbackPort: 56122, // distinct from xai-oauth's 56121 — both can run concurrently + callbackPath: "/callback", + callbackHost: "127.0.0.1", +}; + // xAI API OAuth Configuration (Authorization Code Flow with PKCE) // This intentionally uses a separate provider from Grok Build: both use the // public Grok CLI OAuth client, but their inference endpoints and model diff --git a/src/lib/oauth/providers.ts b/src/lib/oauth/providers.ts index da0b63e552..3a0bb4a627 100644 --- a/src/lib/oauth/providers.ts +++ b/src/lib/oauth/providers.ts @@ -143,10 +143,15 @@ export function generateAuthData(providerName, redirectUri) { } let authUrl; - if (provider.flowType === "device_code") { - authUrl = null; - } else if (provider.flowType === "authorization_code_pkce") { + // Capability check (not a bare flowType equality) so a provider can carry + // flowType "device_code" as its primary/default flow AND still expose a + // browser PKCE login as an additional method (#7013 grok-cli rework): + // grokCli keeps flowType "device_code" but sets supportsBrowserPkce so this + // branch still builds its PKCE authUrl for the "Browser Login" method. + if (provider.flowType === "authorization_code_pkce" || provider.supportsBrowserPkce) { authUrl = provider.buildAuthUrl(provider.config, redirectUri, state, codeChallenge); + } else if (provider.flowType === "device_code") { + authUrl = null; } else { const built = provider.buildAuthUrl(provider.config, redirectUri, state); // Some non-PKCE "authorization_code" providers (e.g. zed-hosted) need to diff --git a/src/lib/oauth/providers/grok-cli-oauth.ts b/src/lib/oauth/providers/grok-cli-oauth.ts new file mode 100644 index 0000000000..4d4e7f68c1 --- /dev/null +++ b/src/lib/oauth/providers/grok-cli-oauth.ts @@ -0,0 +1,116 @@ +/** + * Grok Build (xAI) OAuth Provider — Browser PKCE Flow helpers + * + * Shares the auth.x.ai authorize/token endpoints and public client id with + * the sibling xai-oauth provider (PR #7399) — see GROK_BUILD_OAUTH_CONFIG in + * ../constants/oauth.ts — but is scoped to the Grok Build + * (cli-chat-proxy.grok.com) entitlement. Split into its own module so + * grok-cli.ts stays focused on merging this browser flow with the existing + * paste-token import flow under one provider entry. + */ + +import { decodeXaiIdTokenIdentity } from "./xai-oauth"; +import { GROK_BUILD_OAUTH_CONFIG } from "../constants/oauth"; + +const GROK_BUILD_DEFAULT_TTL_SEC = 21600; + +export function buildGrokBuildAuthUrl( + config: typeof GROK_BUILD_OAUTH_CONFIG, + redirectUri: string, + state: string, + codeChallenge: string +): string { + const params = { + response_type: "code", + client_id: config.clientId, + redirect_uri: redirectUri, + scope: config.scope, + code_challenge: codeChallenge, + code_challenge_method: config.codeChallengeMethod, + state, + }; + const query = Object.entries(params) + .map(([key, value]) => `${key}=${encodeURIComponent(String(value))}`) + .join("&"); + return `${config.authorizeUrl}?${query}`; +} + +export async function exchangeGrokBuildToken( + config: typeof GROK_BUILD_OAUTH_CONFIG, + code: string, + redirectUri: string, + codeVerifier: string +): Promise> { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: config.clientId, + code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Grok Build token exchange failed: ${error}`); + } + + return response.json(); +} + +/** + * Detect an OAuth token-endpoint response (browser PKCE exchange output), + * which uses snake_case `access_token`, as opposed to the paste-token import + * shape (`{ accessToken: }`). + */ +export function isGrokBuildBrowserTokens(tokens: unknown): tokens is Record { + return ( + !!tokens && + typeof tokens === "object" && + typeof (tokens as Record).access_token === "string" + ); +} + +/** + * Map a browser PKCE token-endpoint response into the same field shape the + * paste-token mapTokens() in grok-cli.ts produces, so downstream refresh + * (which reads generically off config.tokenUrl + refresh_token, not + * provider-specific code) keeps working unmodified regardless of which flow + * acquired the tokens. + */ +export function mapGrokBuildBrowserTokens(tokens: Record): { + accessToken: string; + refreshToken: string | null; + expiresIn: number; + email: string | null; + name: string | null; + providerSpecificData: Record; +} { + const identity = decodeXaiIdTokenIdentity(tokens.id_token); + const rawExpiresIn = typeof tokens.expires_in === "number" ? tokens.expires_in : NaN; + // #5775 follow-up (duplicated from the import-token path in grok-cli.ts): + // clamp to a tiny positive TTL instead of letting a non-positive expiresIn + // be read as "not expiring" downstream by AutoCombo. + const expiresIn = Math.max( + 1, + Number.isFinite(rawExpiresIn) ? rawExpiresIn : GROK_BUILD_DEFAULT_TTL_SEC + ); + + return { + accessToken: typeof tokens.access_token === "string" ? tokens.access_token : "", + refreshToken: typeof tokens.refresh_token === "string" ? tokens.refresh_token : null, + expiresIn, + email: identity.email, + name: identity.name || identity.email, + providerSpecificData: { + scope: typeof tokens.scope === "string" ? tokens.scope : GROK_BUILD_OAUTH_CONFIG.scope, + tokenType: typeof tokens.token_type === "string" ? tokens.token_type : "Bearer", + }, + }; +} diff --git a/src/lib/oauth/providers/grok-cli.ts b/src/lib/oauth/providers/grok-cli.ts index 313307c18f..8dc106b922 100644 --- a/src/lib/oauth/providers/grok-cli.ts +++ b/src/lib/oauth/providers/grok-cli.ts @@ -1,9 +1,24 @@ /** - * Grok Build OAuth Provider — Device Code Flow with Import Token Fallback + * Grok Build OAuth Provider — Device Code + Browser PKCE + Import Token Flows * - * User pastes the entire auth.json from ~/.grok/auth.json - * or just the JWT access token string. - * Supports automatic token refresh using the refresh_token. + * Three ways to connect, merged under one provider entry (#7013 reworked to + * coexist with #7358 instead of replacing it): + * - Device code (primary, flowType): the official Grok Build CLI flow — + * requestDeviceCode()/pollToken() poll cli-chat-proxy's device-authorization + * endpoint (GROK_CLI_CONFIG). This stays the DEFAULT in OAuthModal.tsx so + * existing installs / docs referencing "grok login"-style device codes + * keep working unchanged. + * - Browser login (supportsBrowserPkce): PKCE authorization-code flow against + * auth.x.ai, reusing the same public client id as the sibling xai-oauth + * provider (see grok-cli-oauth.ts / GROK_BUILD_OAUTH_CONFIG). One click, + * no polling — offered as an alternative via the OAuthModal chooser. + * - Import token: user pastes the entire auth.json from ~/.grok/auth.json + * or just the JWT access token string. Kept as a fallback for headless / + * remote installs where neither a loopback callback nor device-code + * verification page can be reached. + * All three paths converge on mapTokens() below and support automatic refresh + * using the refresh_token (open-sse token-refresh reads config.tokenUrl + * generically, independent of which flow acquired the tokens). */ import { @@ -11,7 +26,13 @@ import { GROK_BUILD_OAUTH_ISSUER, GROK_BUILD_OAUTH_REFERRER, } from "@omniroute/open-sse/config/grokBuild.ts"; -import { GROK_CLI_CONFIG } from "../constants/oauth"; +import { GROK_CLI_CONFIG, GROK_BUILD_OAUTH_CONFIG } from "../constants/oauth"; +import { + buildGrokBuildAuthUrl, + exchangeGrokBuildToken, + isGrokBuildBrowserTokens, + mapGrokBuildBrowserTokens, +} from "./grok-cli-oauth"; interface GrokCliAuthInfo { user_id: string; @@ -66,7 +87,23 @@ function validateVerificationUri(value: string): void { } } -async function requestDeviceCode(config: typeof GROK_CLI_CONFIG) { +/** + * Device-code flow (#7358). Kept alongside the browser PKCE flow below (#7013 + * rework) — see grokCli.flowType, which stays "device_code" so it remains the + * primary/default experience in OAuthModal.tsx and the route.ts device-code + * action family. + * + * `grokCli.config` below is GROK_BUILD_OAUTH_CONFIG (the browser-PKCE shape — + * required so it stays reference-equal for oauth-providers-config.test.ts and + * so buildAuthUrl/exchangeToken keep receiving the right config). The + * device-code endpoints and scope live on a DIFFERENT config (GROK_CLI_CONFIG: + * deviceCodeUrl + a wider legacy scope set) that has no `authorizeUrl`/ + * `loopbackPort` shape, so requestDeviceCode/pollToken intentionally ignore + * whatever config providers.ts passes them and always read GROK_CLI_CONFIG + * directly. + */ +async function requestDeviceCode(_config?: unknown) { + const config = GROK_CLI_CONFIG; const response = await fetch(config.deviceCodeUrl, { method: "POST", headers: getGrokBuildOAuthHeaders("ui"), @@ -113,7 +150,8 @@ async function requestDeviceCode(config: typeof GROK_CLI_CONFIG) { }; } -async function pollToken(config: typeof GROK_CLI_CONFIG, deviceCode: string) { +async function pollToken(_config: unknown, deviceCode: string) { + const config = GROK_CLI_CONFIG; const response = await fetch(config.tokenUrl, { method: "POST", headers: getGrokBuildOAuthHeaders("ui"), @@ -343,36 +381,81 @@ function resolveGrokExpiresIn(extracted: ExtractedGrokToken, accessClaims: Parse return Math.max(1, expiresIn); } +/** + * The pre-existing paste-token mapping (auth.json / raw JWT import), generalized by + * #7358 to also resolve identity off an accompanying id_token when present (team/org + * principal handling via resolveGrokIdentity/resolveGrokExpiresIn) — #5775 clamp + * included. Used for the import-token fallback path; the browser PKCE exchange uses + * mapGrokBuildBrowserTokens (grok-cli-oauth.ts) instead, since auth.x.ai's OIDC + * id_token carries standard claims (name/email) rather than Grok Build's own + * principal_type/team_id/tier custom claims. + */ +function mapImportedToken(token: unknown) { + const extracted = extractTokenAndRefresh(token); + const accessClaims = parseJwtPayload(extracted.accessToken); + const idClaims = extracted.idToken ? parseJwtPayload(extracted.idToken) : emptyGrokJwt(); + const identity = resolveGrokIdentity(accessClaims, idClaims); + const expiresIn = resolveGrokExpiresIn(extracted, accessClaims); + + return { + accessToken: extracted.accessToken, + refreshToken: extracted.refreshToken, + idToken: extracted.idToken, + expiresIn, + tokenType: extracted.tokenType, + scope: extracted.scope, + email: identity.email, + providerSpecificData: { + userId: identity.userId, + email: identity.email, + teamId: identity.teamId, + tier: accessClaims.authInfo?.tier || idClaims.authInfo?.tier || 1, + principalType: identity.principalType, + principalId: identity.principalId, + organizationId: identity.organizationId, + rawAuthJson: extracted.rawAuthJson || undefined, + }, + }; +} + export const grokCli = { - config: GROK_CLI_CONFIG, - flowType: "device_code", + // NOTE: this is the BROWSER-PKCE config (authorizeUrl/loopbackPort/etc, same + // reference oauth-providers-config.test.ts pins), used by buildAuthUrl / + // exchangeToken below. The device-code endpoints (deviceCodeUrl + a wider + // legacy scope set) live on the separate GROK_CLI_CONFIG that + // requestDeviceCode/pollToken read directly — see the note above them. + config: GROK_BUILD_OAUTH_CONFIG, + // device_code stays PRIMARY (#7358) — OAuthModal.tsx defaults grok-cli into + // the device-code panel and route.ts's device-code/poll action family keys + // off this flowType. The browser PKCE login (#7013) is an ADDITIONAL, + // equally-first-class method advertised via supportsBrowserPkce below — + // callers that need capability detection (providers.ts::generateAuthData, + // route.ts's exchange codeVerifier guard) check supportsBrowserPkce instead + // of requiring flowType === "authorization_code_pkce". + flowType: "device_code" as const, requestDeviceCode, pollToken, - mapTokens: (token: unknown, _extra?: unknown) => { - const extracted = extractTokenAndRefresh(token); - const accessClaims = parseJwtPayload(extracted.accessToken); - const idClaims = extracted.idToken ? parseJwtPayload(extracted.idToken) : emptyGrokJwt(); - const identity = resolveGrokIdentity(accessClaims, idClaims); - const expiresIn = resolveGrokExpiresIn(extracted, accessClaims); - - return { - accessToken: extracted.accessToken, - refreshToken: extracted.refreshToken, - idToken: extracted.idToken, - expiresIn, - tokenType: extracted.tokenType, - scope: extracted.scope, - email: identity.email, - providerSpecificData: { - userId: identity.userId, - email: identity.email, - teamId: identity.teamId, - tier: accessClaims.authInfo?.tier || idClaims.authInfo?.tier || 1, - principalType: identity.principalType, - principalId: identity.principalId, - organizationId: identity.organizationId, - rawAuthJson: extracted.rawAuthJson || undefined, - }, - }; - }, + // Browser PKCE capability marker + fields (#7013), kept alongside device_code. + supportsBrowserPkce: true as const, + fixedPort: GROK_BUILD_OAUTH_CONFIG.loopbackPort, + callbackPath: GROK_BUILD_OAUTH_CONFIG.callbackPath, + callbackHost: GROK_BUILD_OAUTH_CONFIG.callbackHost, + // The xAI flow uses a 96-byte random verifier (128 base64url chars), same as xai-oauth. + pkceVerifierBytes: 96, + buildAuthUrl: buildGrokBuildAuthUrl, + exchangeToken: exchangeGrokBuildToken, + /** + * Unified token mapper serving ALL THREE flows under this single provider + * entry: device-code polling (tokens shaped like the standard OAuth token + * response, dispatched here the same as a paste-token import unless they + * carry the browser-flow's id_token/OIDC shape), the browser PKCE exchange + * (tokens shaped like the OAuth token-endpoint response — + * `access_token`/`refresh_token`/`id_token`/`expires_in`, detected via + * isGrokBuildBrowserTokens), and the paste-token import (`{ accessToken: + * }`, see extractTokenAndRefresh above). + * All converge on the same persisted connection shape, so refresh keeps + * working unmodified regardless of which flow acquired the tokens. + */ + mapTokens: (token: unknown) => + isGrokBuildBrowserTokens(token) ? mapGrokBuildBrowserTokens(token) : mapImportedToken(token), }; diff --git a/src/lib/oauth/providers/index.ts b/src/lib/oauth/providers/index.ts index 2ec8a86994..9a8ede1c4e 100644 --- a/src/lib/oauth/providers/index.ts +++ b/src/lib/oauth/providers/index.ts @@ -54,6 +54,8 @@ export const PROVIDERS = { windsurf, // devin-cli shares the same token format as windsurf (WINDSURF_API_KEY / devin auth login) "devin-cli": windsurf, + // grok-cli carries BOTH the browser PKCE flow and the paste-token import flow + // under this one entry (#7013) — see grok-cli.ts's mapTokens for the dispatch. "grok-cli": grokCli, "xai-oauth": xaiOauth, "codebuddy-cn": codebuddyCn, diff --git a/src/shared/components/OAuthModal.tsx b/src/shared/components/OAuthModal.tsx index f8c7147bfb..6578d0b5dd 100644 --- a/src/shared/components/OAuthModal.tsx +++ b/src/shared/components/OAuthModal.tsx @@ -20,8 +20,13 @@ export { formatDeviceCodeRemaining } from "./OAuthModalPanels"; const GOOGLE_OAUTH_PROVIDERS = new Set(["antigravity", "agy"]); /** Providers that use a local callback server on a random port (PKCE browser flow). */ -const PKCE_CALLBACK_SERVER_PROVIDERS = new Set(["codex", "xai-oauth"]); +const PKCE_CALLBACK_SERVER_PROVIDERS = new Set(["codex", "xai-oauth", "grok-cli"]); +// grok-cli is wired into BOTH the device-code panel (its default, #7358) and +// the browser PKCE + import-token paths above/below (#7013) — the user picks +// via the "Device Code" / "Browser Login" / "JWT Token" tabs rendered further +// down. See the grokBrowserMode state and handleDeviceCodeMode/handleBrowserMode +// below for how the method choice is threaded into startOAuthFlow. const DEVICE_CODE_PROVIDERS = new Set([ "github", "kiro", @@ -29,11 +34,18 @@ const DEVICE_CODE_PROVIDERS = new Set([ "kimi-coding", "kilocode", "codebuddy-cn", - "grok-cli", "ghe-copilot", + "grok-cli", ]); const TOKEN_PASTE_PROVIDERS = new Set(["windsurf", "devin-cli", "grok-cli"]); + +/** + * Phase 1 hotfix (2026-05-29): windsurf & devin-cli only support import-token. + * Their PKCE flow targeting app.devin.ai/editor/signin returned 404 post-rebrand. + * Phase 2 will reintroduce browser login via Firebase OAuth + RegisterUser. + * Spec: _tasks/superpowers/specs/2026-05-29-windsurf-login-fix-design.md. + */ const IMPORT_TOKEN_ONLY_PROVIDERS = new Set(["windsurf", "devin-cli"]); // POST a bare Codex access token to the access-token-only import endpoint @@ -129,6 +141,10 @@ export default function OAuthModal({ const [showPasteToken, setShowPasteToken] = useState(IMPORT_TOKEN_ONLY_PROVIDERS.has(provider)); const [pasteToken, setPasteToken] = useState(""); const [savingToken, setSavingToken] = useState(false); + // grok-cli only (#7013 rework): device_code is the default method (matches + // DEVICE_CODE_PROVIDERS); flipping this to true routes startOAuthFlow through + // the browser PKCE / PKCE_CALLBACK_SERVER_PROVIDERS branch instead. + const [grokBrowserMode, setGrokBrowserMode] = useState(false); const supportsTokenPaste = TOKEN_PASTE_PROVIDERS.has(provider); const importTokenOnly = IMPORT_TOKEN_ONLY_PROVIDERS.has(provider); @@ -333,243 +349,254 @@ export default function OAuthModal({ [provider, onSuccess, reauthConnection] ); - // Start OAuth flow - const startOAuthFlow = useCallback(async () => { - if (!provider) return; - try { - setError(null); + // Start OAuth flow. `opts.grokBrowser` lets the grok-cli method tabs force a + // specific branch synchronously (avoids reading a just-set state value through + // a stale closure); when omitted, falls back to the grokBrowserMode state. + const startOAuthFlow = useCallback( + async (opts?: { grokBrowser?: boolean }) => { + if (!provider) return; + try { + setError(null); - // Device code flow - if (DEVICE_CODE_PROVIDERS.has(provider)) { - invalidateDeviceFlow(); - setIsDeviceCode(true); - setDeviceData(null); - setStep("waiting"); + const grokWantsBrowser = provider === "grok-cli" && (opts?.grokBrowser ?? grokBrowserMode); - // GHE Copilot needs the enterprise URL collected first (see ghe-config step) - if (provider === "ghe-copilot" && !gheUrl.trim()) { - setStep("ghe-config"); + // Device code flow + if (DEVICE_CODE_PROVIDERS.has(provider) && !grokWantsBrowser) { + invalidateDeviceFlow(); + setIsDeviceCode(true); + setDeviceData(null); + setStep("waiting"); + + // GHE Copilot needs the enterprise URL collected first (see ghe-config step) + if (provider === "ghe-copilot" && !gheUrl.trim()) { + setStep("ghe-config"); + return; + } + + const deviceCodeUrl = new URL( + `/api/oauth/${provider}/device-code`, + window.location.origin + ); + if ( + (provider === "kiro" || provider === "amazon-q") && + idcConfig && + typeof idcConfig === "object" + ) { + const idc = idcConfig as { startUrl?: string; region?: string }; + if (typeof idc.startUrl === "string" && idc.startUrl.trim()) { + deviceCodeUrl.searchParams.set("startUrl", idc.startUrl.trim()); + } + if (typeof idc.region === "string" && idc.region.trim()) { + deviceCodeUrl.searchParams.set("region", idc.region.trim()); + } + } + if (provider === "ghe-copilot" && gheUrl.trim()) { + deviceCodeUrl.searchParams.set("gheUrl", gheUrl.trim()); + } + + const res = await fetch(deviceCodeUrl.toString()); + const data = (await parseResponseBody(res)) as Record; + if (!res.ok) { + const errMsg = getErrorMessage(data, res.status, "Request failed"); + throw new Error(errMsg); + } + + setDeviceData(data); + + // Open verification URL + const verifyUrl = data.verification_uri_complete || data.verification_uri; + if (typeof verifyUrl === "string" && verifyUrl) window.open(verifyUrl, "oauth_verify"); + + // Start polling - pass extraData for Kiro (contains _clientId, _clientSecret) + const extraData = + provider === "kiro" || provider === "amazon-q" + ? { + _clientId: data._clientId, + _clientSecret: data._clientSecret, + _region: data._region, + } + : provider === "ghe-copilot" && gheUrl.trim() + ? { gheUrl: gheUrl.trim() } + : null; + startPolling( + data.device_code, + data.codeVerifier, + data.interval || 5, + data.expires_in, + extraData + ); return; } - const deviceCodeUrl = new URL(`/api/oauth/${provider}/device-code`, window.location.origin); - if ( - (provider === "kiro" || provider === "amazon-q") && - idcConfig && - typeof idcConfig === "object" - ) { - const idc = idcConfig as { startUrl?: string; region?: string }; - if (typeof idc.startUrl === "string" && idc.startUrl.trim()) { - deviceCodeUrl.searchParams.set("startUrl", idc.startUrl.trim()); - } - if (typeof idc.region === "string" && idc.region.trim()) { - deviceCodeUrl.searchParams.set("region", idc.region.trim()); - } - } - if (provider === "ghe-copilot" && gheUrl.trim()) { - deviceCodeUrl.searchParams.set("gheUrl", gheUrl.trim()); + let forceManual = false; + + // Claude Code and Cline OAuth flows can finish on provider-hosted pages that + // show an auth code instead of redirecting back to OmniRoute. + // Start directly in manual mode so users always have an input to paste code/url. + // zed-hosted's native-app sign-in always redirects the browser to a local + // 127.0.0.1: callback that OmniRoute never listens on (the port is + // arbitrary and unrelated to the dashboard's own port) — nothing can + // auto-close the popup, so always show the manual paste-URL input. + if (provider === "claude" || provider === "cline" || provider === "zed-hosted") { + forceManual = true; } - const res = await fetch(deviceCodeUrl.toString()); + // PKCE callback server providers (Codex, Windsurf, Devin CLI): + // On localhost, spin up a local callback server and poll for the result. + // Codex uses a fixed port 1455; Windsurf/Devin CLI use a random OS-assigned port. + // On remote the server is unreachable — fall through to standard manual flow. + if (PKCE_CALLBACK_SERVER_PROVIDERS.has(provider)) { + if (isTrueLocalhost) { + try { + const serverRes = await fetch(`/api/oauth/${provider}/start-callback-server`); + const serverData = (await parseResponseBody(serverRes)) as Record; + if (!serverRes.ok) + throw new Error( + getErrorMessage(serverData, serverRes.status, "Failed to start callback server") + ); + + setAuthData({ ...serverData, redirectUri: serverData.redirectUri }); + setStep("waiting"); + popupRef.current = window.open(serverData.authUrl, "oauth_auth"); + + // If browser blocked the popup, switch to manual input step immediately + if (!popupRef.current) { + setStep("input"); + } + + setPolling(true); + const maxAttempts = 150; + for (let i = 0; i < maxAttempts; i++) { + await new Promise((r) => setTimeout(r, 2000)); + + const pollRes = await fetch(`/api/oauth/${provider}/poll-callback`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ connectionId: reauthConnection?.id }), + }); + const pollData = (await parseResponseBody(pollRes)) as Record; + + if (pollData.success) { + setStep("success"); + setPolling(false); + onSuccess?.(); + return; + } + + if (pollData.error && !pollData.pending) { + throw new Error(pollData.errorDescription || pollData.error); + } + } + + setPolling(false); + throw new Error("Authorization timeout"); + } catch (pkceErr) { + console.warn( + `${provider} callback server failed, falling back to manual flow`, + pkceErr + ); + setPolling(false); + forceManual = true; + } + } + // Remote: fall through to standard auth code flow below + } + + // Authorization code flow + // Redirect URI strategy: + // - Codex/OpenAI: always port 1455 (registered in OAuth app) + // - Windsurf/Devin CLI (remote fallback): use localhost with OmniRoute port + /auth/callback + // (on true localhost the callback server handles it; this is only reached on remote) + // - Google OAuth providers (antigravity/agy): default to loopback so the + // bundled native/desktop credentials keep working. Prefer 127.0.0.1 over + // localhost for the Google native-app handoff; Google documents that localhost + // can run into local firewall/name-resolution edge cases. The authorize route + // upgrades this to the public callback when custom Google web credentials plus + // NEXT_PUBLIC_BASE_URL or OMNIROUTE_PUBLIC_BASE_URL are configured. + // - Other providers on remote: use actual origin (supports PUBLIC_URL env var) + // - Localhost: use localhost:port + let redirectUri: string; + if (provider === "codex" || provider === "openai") { + redirectUri = "http://localhost:1455/auth/callback"; + } else if (provider === "xai-oauth" || provider === "grok-cli") { + // Fixed native-app loopback callback, distinct ports so both can run concurrently (#7013). + const grokBuildPort = provider === "xai-oauth" ? 56121 : 56122; + redirectUri = `http://127.0.0.1:${grokBuildPort}/callback`; + } else if (provider === "windsurf" || provider === "devin-cli") { + // Remote fallback: use OmniRoute's port with the /auth/callback path Windsurf expects. + // On true localhost this code is never reached (callback server handles the flow above). + const port = window.location.port || "20128"; + redirectUri = `http://localhost:${port}/auth/callback`; + } else if (GOOGLE_OAUTH_PROVIDERS.has(provider)) { + // Google OAuth built-in credentials only accept loopback redirect URIs. + // Even in remote deployments we use loopback — user copies the callback URL manually. + const port = window.location.port || "20128"; + redirectUri = `http://127.0.0.1:${port}/callback`; + } else if (!isLocalhost) { + // Behind reverse proxy: use actual origin (e.g., https://omniroute.example.com/callback) + // Supports PUBLIC_URL env var override, or falls back to window.location.origin. + const publicUrl = process.env.NEXT_PUBLIC_BASE_URL; + const origin = + publicUrl && publicUrl !== "http://localhost:20128" + ? publicUrl.replace(/\/$/, "") + : window.location.origin; + redirectUri = `${origin}/callback`; + } else { + const port = + window.location.port || (window.location.protocol === "https:" ? "443" : "80"); + redirectUri = `http://localhost:${port}/callback`; + } + + const res = await fetch( + `/api/oauth/${provider}/authorize?redirect_uri=${encodeURIComponent(redirectUri)}` + ); const data = (await parseResponseBody(res)) as Record; if (!res.ok) { - const errMsg = getErrorMessage(data, res.status, "Request failed"); + const errMsg = getErrorMessage(data, res.status, "Authorization failed"); throw new Error(errMsg); } - setDeviceData(data); + if (!data.authUrl) { + throw new Error( + data.error || + "Browser OAuth is unavailable for this provider in the current environment. Use the supported auth method instead." + ); + } - // Open verification URL - const verifyUrl = data.verification_uri_complete || data.verification_uri; - if (typeof verifyUrl === "string" && verifyUrl) window.open(verifyUrl, "oauth_verify"); + setAuthData({ ...data, redirectUri: data.redirectUri || redirectUri }); - // Start polling - pass extraData for Kiro (contains _clientId, _clientSecret) - const extraData = - provider === "kiro" || provider === "amazon-q" - ? { - _clientId: data._clientId, - _clientSecret: data._clientSecret, - _region: data._region, - } - : provider === "ghe-copilot" && gheUrl.trim() - ? { gheUrl: gheUrl.trim() } - : null; - startPolling( - data.device_code, - data.codeVerifier, - data.interval || 5, - data.expires_in, - extraData - ); - return; - } + // For non-true-localhost (LAN IPs, remote) or manual fallback: use manual input mode (user pastes callback URL) + if (!isTrueLocalhost || forceManual) { + setStep("input"); + window.open(data.authUrl, "oauth_auth"); + } else { + // Localhost: Open popup and wait for message + setStep("waiting"); + popupRef.current = window.open(data.authUrl, "oauth_popup", "width=600,height=700"); - let forceManual = false; - - // Claude Code and Cline OAuth flows can finish on provider-hosted pages that - // show an auth code instead of redirecting back to OmniRoute. - // Start directly in manual mode so users always have an input to paste code/url. - // zed-hosted's native-app sign-in always redirects the browser to a local - // 127.0.0.1: callback that OmniRoute never listens on (the port is - // arbitrary and unrelated to the dashboard's own port) — nothing can - // auto-close the popup, so always show the manual paste-URL input. - if (provider === "claude" || provider === "cline" || provider === "zed-hosted") { - forceManual = true; - } - - // PKCE callback server providers (Codex, Windsurf, Devin CLI): - // On localhost, spin up a local callback server and poll for the result. - // Codex uses a fixed port 1455; Windsurf/Devin CLI use a random OS-assigned port. - // On remote the server is unreachable — fall through to standard manual flow. - if (PKCE_CALLBACK_SERVER_PROVIDERS.has(provider)) { - if (isTrueLocalhost) { - try { - const serverRes = await fetch(`/api/oauth/${provider}/start-callback-server`); - const serverData = (await parseResponseBody(serverRes)) as Record; - if (!serverRes.ok) - throw new Error( - getErrorMessage(serverData, serverRes.status, "Failed to start callback server") - ); - - setAuthData({ ...serverData, redirectUri: serverData.redirectUri }); - setStep("waiting"); - popupRef.current = window.open(serverData.authUrl, "oauth_auth"); - - // If browser blocked the popup, switch to manual input step immediately - if (!popupRef.current) { - setStep("input"); - } - - setPolling(true); - const maxAttempts = 150; - for (let i = 0; i < maxAttempts; i++) { - await new Promise((r) => setTimeout(r, 2000)); - - const pollRes = await fetch(`/api/oauth/${provider}/poll-callback`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ connectionId: reauthConnection?.id }), - }); - const pollData = (await parseResponseBody(pollRes)) as Record; - - if (pollData.success) { - setStep("success"); - setPolling(false); - onSuccess?.(); - return; - } - - if (pollData.error && !pollData.pending) { - throw new Error(pollData.errorDescription || pollData.error); - } - } - - setPolling(false); - throw new Error("Authorization timeout"); - } catch (pkceErr) { - console.warn( - `${provider} callback server failed, falling back to manual flow`, - pkceErr - ); - setPolling(false); - forceManual = true; + // Check if popup was blocked + if (!popupRef.current) { + setStep("input"); } } - // Remote: fall through to standard auth code flow below + } catch (err) { + setError(err.message); + setStep("error"); } - - // Authorization code flow - // Redirect URI strategy: - // - Codex/OpenAI: always port 1455 (registered in OAuth app) - // - Windsurf/Devin CLI (remote fallback): use localhost with OmniRoute port + /auth/callback - // (on true localhost the callback server handles it; this is only reached on remote) - // - Google OAuth providers (antigravity/agy): default to loopback so the - // bundled native/desktop credentials keep working. Prefer 127.0.0.1 over - // localhost for the Google native-app handoff; Google documents that localhost - // can run into local firewall/name-resolution edge cases. The authorize route - // upgrades this to the public callback when custom Google web credentials plus - // NEXT_PUBLIC_BASE_URL or OMNIROUTE_PUBLIC_BASE_URL are configured. - // - Other providers on remote: use actual origin (supports PUBLIC_URL env var) - // - Localhost: use localhost:port - let redirectUri: string; - if (provider === "codex" || provider === "openai") { - redirectUri = "http://localhost:1455/auth/callback"; - } else if (provider === "xai-oauth") { - // xAI registers a fixed native-app loopback callback. On remote installs - // the browser cannot reach OmniRoute there, so the user pastes the - // resulting callback URL into the existing manual-flow input. - redirectUri = "http://127.0.0.1:56121/callback"; - } else if (provider === "windsurf" || provider === "devin-cli") { - // Remote fallback: use OmniRoute's port with the /auth/callback path Windsurf expects. - // On true localhost this code is never reached (callback server handles the flow above). - const port = window.location.port || "20128"; - redirectUri = `http://localhost:${port}/auth/callback`; - } else if (GOOGLE_OAUTH_PROVIDERS.has(provider)) { - // Google OAuth built-in credentials only accept loopback redirect URIs. - // Even in remote deployments we use loopback — user copies the callback URL manually. - const port = window.location.port || "20128"; - redirectUri = `http://127.0.0.1:${port}/callback`; - } else if (!isLocalhost) { - // Behind reverse proxy: use actual origin (e.g., https://omniroute.example.com/callback) - // Supports PUBLIC_URL env var override, or falls back to window.location.origin. - const publicUrl = process.env.NEXT_PUBLIC_BASE_URL; - const origin = - publicUrl && publicUrl !== "http://localhost:20128" - ? publicUrl.replace(/\/$/, "") - : window.location.origin; - redirectUri = `${origin}/callback`; - } else { - const port = window.location.port || (window.location.protocol === "https:" ? "443" : "80"); - redirectUri = `http://localhost:${port}/callback`; - } - - const res = await fetch( - `/api/oauth/${provider}/authorize?redirect_uri=${encodeURIComponent(redirectUri)}` - ); - const data = (await parseResponseBody(res)) as Record; - if (!res.ok) { - const errMsg = getErrorMessage(data, res.status, "Authorization failed"); - throw new Error(errMsg); - } - - if (!data.authUrl) { - throw new Error( - data.error || - "Browser OAuth is unavailable for this provider in the current environment. Use the supported auth method instead." - ); - } - - setAuthData({ ...data, redirectUri: data.redirectUri || redirectUri }); - - // For non-true-localhost (LAN IPs, remote) or manual fallback: use manual input mode (user pastes callback URL) - if (!isTrueLocalhost || forceManual) { - setStep("input"); - window.open(data.authUrl, "oauth_auth"); - } else { - // Localhost: Open popup and wait for message - setStep("waiting"); - popupRef.current = window.open(data.authUrl, "oauth_popup", "width=600,height=700"); - - // Check if popup was blocked - if (!popupRef.current) { - setStep("input"); - } - } - } catch (err) { - setError(err.message); - setStep("error"); - } - }, [ - provider, - isLocalhost, - isTrueLocalhost, - startPolling, - onSuccess, - reauthConnection, - idcConfig, - gheUrl, - invalidateDeviceFlow, - ]); + }, + [ + provider, + isLocalhost, + isTrueLocalhost, + startPolling, + onSuccess, + reauthConnection, + idcConfig, + gheUrl, + invalidateDeviceFlow, + grokBrowserMode, + ] + ); useEffect(() => { if (!deviceCodeExpiresAt) { @@ -590,6 +617,7 @@ export default function OAuthModal({ useEffect(() => { invalidateDeviceFlow(); flowStartedRef.current = false; + setGrokBrowserMode(false); }, [provider, invalidateDeviceFlow]); useEffect(() => { @@ -612,6 +640,7 @@ export default function OAuthModal({ flowStartedRef.current = true; const startsInPasteMode = IMPORT_TOKEN_ONLY_PROVIDERS.has(provider); setShowPasteToken(startsInPasteMode); + setGrokBrowserMode(false); setAuthData(null); setCallbackUrl(""); setError(null); @@ -863,7 +892,16 @@ export default function OAuthModal({ const handleBrowserMode = useCallback(() => { setShowPasteToken(false); - startOAuthFlow(); + if (provider === "grok-cli") setGrokBrowserMode(true); + startOAuthFlow(provider === "grok-cli" ? { grokBrowser: true } : undefined); + }, [startOAuthFlow, provider]); + + // grok-cli only (#7013 rework): switch back to the device_code method + // (the default) after the user previously chose Browser Login. + const handleDeviceCodeMode = useCallback(() => { + setShowPasteToken(false); + setGrokBrowserMode(false); + startOAuthFlow({ grokBrowser: false }); }, [startOAuthFlow]); if (!provider || !providerInfo) return null; @@ -876,11 +914,22 @@ export default function OAuthModal({ size="lg" >
- {/* Browser login with an optional token-import fallback. */} + {/* Browser login with an optional token-import fallback. grok-cli adds a + third "Device Code" tab since it keeps BOTH the device_code flow + (#7358, default) and the browser PKCE login (#7013) alongside the + paste-token import. */} {supportsTokenPaste && !importTokenOnly && step !== "success" && (
+ {provider === "grok-cli" && ( + + )}