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>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-22 00:43:10 -03:00
committed by GitHub
parent 91f4c35e9d
commit b861dd045a
15 changed files with 940 additions and 444 deletions

View File

@@ -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))

View File

@@ -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, {

View File

@@ -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

View File

@@ -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

View File

@@ -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<Record<string, unknown>> {
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: <JWT string or auth.json blob> }`).
*/
export function isGrokBuildBrowserTokens(tokens: unknown): tokens is Record<string, unknown> {
return (
!!tokens &&
typeof tokens === "object" &&
typeof (tokens as Record<string, unknown>).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<string, unknown>): {
accessToken: string;
refreshToken: string | null;
expiresIn: number;
email: string | null;
name: string | null;
providerSpecificData: Record<string, unknown>;
} {
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",
},
};
}

View File

@@ -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:
* <JWT string or auth.json blob> }`, 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),
};

View File

@@ -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,

View File

@@ -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<string, unknown>;
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:<port> 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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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:<port> 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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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"
>
<div className="flex flex-col gap-4">
{/* 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" && (
<div className="flex gap-2 border-b border-border pb-3">
{provider === "grok-cli" && (
<button
className={`text-sm px-3 py-1 rounded-t ${!showPasteToken && !grokBrowserMode ? "font-semibold border-b-2 border-primary text-primary" : "text-text-muted"}`}
onClick={handleDeviceCodeMode}
>
Device Code
</button>
)}
<button
className={`text-sm px-3 py-1 rounded-t ${!showPasteToken ? "font-semibold border-b-2 border-primary text-primary" : "text-text-muted"}`}
className={`text-sm px-3 py-1 rounded-t ${!showPasteToken && (provider !== "grok-cli" || grokBrowserMode) ? "font-semibold border-b-2 border-primary text-primary" : "text-text-muted"}`}
onClick={handleBrowserMode}
>
Browser Login

View File

@@ -36,7 +36,7 @@ export const OAUTH_PROVIDERS = {
subscriptionRisk: true,
riskNoticeVariant: "oauth",
authHint:
"Paste your ~/.grok/auth.json (or the JWT access token) from the Grok Build CLI; refresh_token is rotated automatically.",
"Sign in with your browser, or paste your ~/.grok/auth.json (or the JWT access token) from the Grok Build CLI; refresh_token is rotated automatically either way.",
},
qoder: {
id: "qoder",

View File

@@ -6,9 +6,6 @@ const { GrokCliExecutor } = await import("@omniroute/open-sse/executors/grok-cli
const { getGrokBuildClientVersion } = await import("@omniroute/open-sse/config/grokBuild.ts");
const { resolvePublicCred } = await import("@omniroute/open-sse/utils/publicCreds");
const GROK_CLI_SCOPE =
"openid profile email offline_access grok-cli:access api:access conversations:read conversations:write workspaces:read workspaces:write";
test("Grok Build OAuth Provider - config", () => {
assert.ok(grokCli.config.clientId, "clientId should be defined");
// The public client_id must come from the embedded default (Hard Rule #11),
@@ -27,181 +24,36 @@ test("publicCreds: grok_id embedded default is present and decodes", () => {
assert.ok(decoded.length > 0, "grok_id must decode to a non-empty client id");
});
test("Grok Build OAuth Provider - flowType is device_code", () => {
// #7013 (reworked): grok-cli now ships a browser PKCE flow ALONGSIDE the
// pre-existing device_code flow (#7358) and paste-token import — all three
// coexist under one registry entry instead of the browser flow replacing
// device_code. flowType stays "device_code" so it remains the DEFAULT/primary
// method in OAuthModal.tsx and route.ts's device-code/poll action family;
// supportsBrowserPkce is the capability marker providers.ts::generateAuthData
// and route.ts's exchange codeVerifier guard check to also build/require PKCE
// for the browser method. requestDeviceCode/pollToken are restored (see
// coexistence assertions below); mapTokens still auto-detects and handles
// pasted-token input (see tests below) and the browser-flow OAuth-token shape
// (`access_token`+`id_token`) is covered in tests/unit/oauth-grok-cli-browser.test.ts,
// which asserts that exact shape dispatches through mapGrokBuildBrowserTokens
// (grok-cli-oauth.ts).
test("Grok Build OAuth Provider - flowType stays device_code (primary, #7358) with browser PKCE alongside (#7013)", () => {
assert.equal(grokCli.flowType, "device_code");
assert.equal(grokCli.config.deviceCodeUrl, "https://auth.x.ai/oauth2/device/code");
assert.equal(grokCli.config.scope, GROK_CLI_SCOPE);
assert.equal(grokCli.supportsBrowserPkce, true);
assert.equal(grokCli.config.scope, "openid profile email offline_access grok-cli:access");
});
test("Grok Build OAuth Provider - requests and normalizes a device code", async (t) => {
const originalFetch = globalThis.fetch;
t.after(() => {
globalThis.fetch = originalFetch;
});
let requestUrl = "";
let requestInit: RequestInit | undefined;
globalThis.fetch = (async (input, init) => {
requestUrl = String(input);
requestInit = init;
return new Response(
JSON.stringify({
device_code: "opaque-device-code",
user_code: "ABCD-EFGH",
verification_uri: "https://accounts.x.ai/oauth2/device",
verification_uri_complete: "https://accounts.x.ai/oauth2/device?user_code=ABCD-EFGH",
expires_in: 1800,
interval: 5,
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}) as typeof fetch;
const result = await grokCli.requestDeviceCode(grokCli.config);
const body = new URLSearchParams(String(requestInit?.body));
assert.equal(requestUrl, grokCli.config.deviceCodeUrl);
assert.equal(requestInit?.method, "POST");
assert.equal(body.get("client_id"), grokCli.config.clientId);
assert.equal(body.get("scope"), GROK_CLI_SCOPE);
assert.equal(body.get("referrer"), "grok-build");
const headers = new Headers(requestInit?.headers);
assert.equal(headers.get("x-grok-client-version"), getGrokBuildClientVersion());
assert.equal(headers.get("x-grok-client-surface"), "ui");
assert.equal(result.device_code, "opaque-device-code");
assert.equal(result.user_code, "ABCD-EFGH");
assert.equal(result.expires_in, 1800);
assert.equal(result.interval, 5);
});
test("Grok Build OAuth Provider - rejects unsafe device authorization responses", async (t) => {
const originalFetch = globalThis.fetch;
t.after(() => {
globalThis.fetch = originalFetch;
});
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
device_code: "opaque-device-code",
user_code: "ABCD\nEFGH",
verification_uri: "javascript:alert(1)",
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
)) as typeof fetch;
await assert.rejects(() => grokCli.requestDeviceCode(grokCli.config), /invalid device code/);
});
test("Grok Build OAuth Provider - rejects unsupported verification URL schemes", async (t) => {
const originalFetch = globalThis.fetch;
t.after(() => {
globalThis.fetch = originalFetch;
});
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
device_code: "opaque-device-code",
user_code: "ABCD-EFGH",
verification_uri: "javascript:alert(1)",
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
)) as typeof fetch;
await assert.rejects(
() => grokCli.requestDeviceCode(grokCli.config),
/unsupported verification URL/
);
});
test("Grok Build OAuth Provider - polls with the standard device grant", async (t) => {
const originalFetch = globalThis.fetch;
t.after(() => {
globalThis.fetch = originalFetch;
});
let requestInit: RequestInit | undefined;
globalThis.fetch = (async (_input, init) => {
requestInit = init;
return new Response(
JSON.stringify({
error: "authorization_pending",
error_description: "User has not yet authorized",
}),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}) as typeof fetch;
const result = await grokCli.pollToken(grokCli.config, "opaque-device-code");
const body = new URLSearchParams(String(requestInit?.body));
assert.equal(body.get("client_id"), grokCli.config.clientId);
assert.equal(body.get("device_code"), "opaque-device-code");
assert.equal(body.get("grant_type"), "urn:ietf:params:oauth:grant-type:device_code");
const headers = new Headers(requestInit?.headers);
assert.equal(headers.get("x-grok-client-version"), getGrokBuildClientVersion());
assert.equal(headers.get("x-grok-client-surface"), "ui");
assert.equal(result.ok, false);
assert.equal(result.data.error, "authorization_pending");
});
test("Grok Build OAuth Provider - maps a standard OAuth token response", () => {
const accessPayload = {
sub: "user-123",
email: "device@example.com",
team_id: "team-456",
tier: 2,
principal_type: "Team",
principal_id: "team-456",
exp: Math.floor(Date.now() / 1000) + 3600,
};
const accessToken = `eyJhbGciOiJFUzI1NiJ9.${Buffer.from(JSON.stringify(accessPayload)).toString("base64url")}.signature`;
const idToken = `eyJhbGciOiJFUzI1NiJ9.${Buffer.from(
JSON.stringify({ email: "device@example.com" })
).toString("base64url")}.signature`;
const result = grokCli.mapTokens({
access_token: accessToken,
refresh_token: "refresh-device-token",
id_token: idToken,
expires_in: 3600,
token_type: "Bearer",
scope: GROK_CLI_SCOPE,
});
assert.equal(result.accessToken, accessToken);
assert.equal(result.refreshToken, "refresh-device-token");
assert.equal(result.idToken, idToken);
assert.equal(result.expiresIn, 3600);
assert.equal(result.tokenType, "Bearer");
assert.equal(result.scope, GROK_CLI_SCOPE);
assert.equal(result.email, "device@example.com");
assert.equal(result.providerSpecificData?.teamId, "team-456");
assert.equal(result.providerSpecificData?.userId, "team-456");
assert.equal(result.providerSpecificData?.email, "device@example.com");
assert.equal(result.providerSpecificData?.principalType, "Team");
assert.equal(result.providerSpecificData?.principalId, "team-456");
});
test("Grok Build OAuth Provider - maps organization principals to their principal id", () => {
const accessPayload = {
sub: "user-123",
principal_type: "Organization",
principal_id: "org-456",
exp: Math.floor(Date.now() / 1000) + 3600,
};
const idPayload = { sub: "user-123", email: "org-user@example.com" };
const accessToken = `eyJhbGciOiJFUzI1NiJ9.${Buffer.from(JSON.stringify(accessPayload)).toString("base64url")}.signature`;
const idToken = `eyJhbGciOiJFUzI1NiJ9.${Buffer.from(JSON.stringify(idPayload)).toString("base64url")}.signature`;
const result = grokCli.mapTokens({ access_token: accessToken, id_token: idToken });
assert.equal(result.email, "org-user@example.com");
assert.equal(result.providerSpecificData?.userId, "org-456");
assert.equal(result.providerSpecificData?.organizationId, "org-456");
assert.equal(result.providerSpecificData?.principalType, "Organization");
assert.equal(result.providerSpecificData?.principalId, "org-456");
// #7013 rework coexistence guard: BOTH flows' handlers must be present on the
// single grok-cli registry entry — losing either one silently breaks either
// the device-code panel (OAuthModal.tsx DEVICE_CODE_PROVIDERS) or the browser
// PKCE login (PKCE_CALLBACK_SERVER_PROVIDERS / providers.ts::generateAuthData).
test("Grok Build OAuth Provider - device_code AND browser PKCE handlers coexist (#7013)", () => {
assert.equal(typeof grokCli.requestDeviceCode, "function", "requestDeviceCode must be present");
assert.equal(typeof grokCli.pollToken, "function", "pollToken must be present");
assert.equal(typeof grokCli.buildAuthUrl, "function", "buildAuthUrl must be present");
assert.equal(typeof grokCli.exchangeToken, "function", "exchangeToken must be present");
assert.equal(typeof grokCli.mapTokens, "function", "mapTokens must be present");
assert.equal(grokCli.pkceVerifierBytes, 96);
});
test("Grok Build OAuth Provider - mapTokens from raw JWT", () => {

View File

@@ -0,0 +1,214 @@
// #7013: Grok Build (grok-cli) browser login (PKCE) — unit coverage for
// everything testable without a real auth.x.ai round-trip (that half is
// validated live on the VPS per Hard Rule #18, see the PR description).
//
// DB handles released in test.after (CLAUDE.md learning: unreleased SQLite
// handles hang node:test).
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-oauth-grok-cli-7013-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const settingsDb = await import("../../src/lib/db/settings.ts");
const route = await import("../../src/app/api/oauth/[provider]/[action]/route.ts");
const { generateAuthData } = await import("../../src/lib/oauth/providers.ts");
const { grokCli } = await import("../../src/lib/oauth/providers/grok-cli.ts");
const { GROK_BUILD_OAUTH_CONFIG, XAI_OAUTH_CONFIG } = await import(
"../../src/lib/oauth/constants/oauth.ts"
);
const originalFetch = globalThis.fetch;
test.before(async () => {
await settingsDb.updateSettings({ requireLogin: false });
});
test.after(async () => {
globalThis.fetch = originalFetch;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test.afterEach(() => {
globalThis.fetch = originalFetch;
});
function getRoute(provider: string, action: string, search = "") {
const request = new Request(`http://localhost:20128/api/oauth/${provider}/${action}${search}`);
return route.GET(request, { params: Promise.resolve({ provider, action }) });
}
function postRoute(provider: string, action: string, body: unknown) {
const request = new Request(`http://localhost:20128/api/oauth/${provider}/${action}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return route.POST(request, { params: Promise.resolve({ provider, action }) });
}
// ── buildAuthUrl ─────────────────────────────────────────────────────────
test("grok-cli buildAuthUrl targets GROK_BUILD_OAUTH_CONFIG.authorizeUrl with the Grok Build scope", () => {
const authData = generateAuthData("grok-cli", "http://127.0.0.1:56122/callback");
const url = new URL(authData.authUrl);
assert.equal(url.origin, "https://auth.x.ai");
assert.equal(url.pathname, "/oauth2/authorize");
assert.equal(url.searchParams.get("response_type"), "code");
assert.equal(url.searchParams.get("client_id"), GROK_BUILD_OAUTH_CONFIG.clientId);
assert.equal(url.searchParams.get("code_challenge_method"), "S256");
assert.equal(url.searchParams.get("scope"), GROK_BUILD_OAUTH_CONFIG.scope);
assert.equal(authData.fixedPort, 56122);
assert.equal(authData.callbackPath, "/callback");
assert.equal(authData.callbackHost, "127.0.0.1");
});
test("grok-cli and xai-oauth reuse the same public client id but scope Grok Build separately", () => {
assert.equal(GROK_BUILD_OAUTH_CONFIG.clientId, XAI_OAUTH_CONFIG.clientId);
assert.notEqual(GROK_BUILD_OAUTH_CONFIG.scope, XAI_OAUTH_CONFIG.scope);
assert.ok(GROK_BUILD_OAUTH_CONFIG.scope.includes("grok-cli:access"));
});
// ── loopback port collision guard ───────────────────────────────────────
test("grok-cli's loopback port does not collide with xai-oauth or codex", () => {
assert.equal(GROK_BUILD_OAUTH_CONFIG.loopbackPort, 56122);
assert.equal(XAI_OAUTH_CONFIG.loopbackPort, 56121);
assert.notEqual(GROK_BUILD_OAUTH_CONFIG.loopbackPort, XAI_OAUTH_CONFIG.loopbackPort);
assert.notEqual(GROK_BUILD_OAUTH_CONFIG.loopbackPort, 1455); // codex's fixedPort
});
// ── exchangeToken ────────────────────────────────────────────────────────
test("grok-cli exchangeToken POSTs grant_type=authorization_code with the PKCE verifier", async () => {
globalThis.fetch = async (input, init) => {
assert.equal(String(input), GROK_BUILD_OAUTH_CONFIG.tokenUrl);
assert.equal(init?.method, "POST");
assert.equal(init?.headers?.["Content-Type"], "application/x-www-form-urlencoded");
const body = init?.body as URLSearchParams;
assert.equal(body.get("grant_type"), "authorization_code");
assert.equal(body.get("client_id"), GROK_BUILD_OAUTH_CONFIG.clientId);
assert.equal(body.get("code"), "auth-code");
assert.equal(body.get("redirect_uri"), "http://127.0.0.1:56122/callback");
assert.equal(body.get("code_verifier"), "verifier");
return Response.json({ access_token: "gb-access", refresh_token: "gb-refresh", expires_in: 3600 });
};
const tokens = await grokCli.exchangeToken(
GROK_BUILD_OAUTH_CONFIG,
"auth-code",
"http://127.0.0.1:56122/callback",
"verifier"
);
assert.equal(tokens.access_token, "gb-access");
});
test("grok-cli exchangeToken throws (not a raw stack leak) on a non-OK upstream response", async () => {
globalThis.fetch = async () => new Response("upstream said no", { status: 400 });
await assert.rejects(
() =>
grokCli.exchangeToken(
GROK_BUILD_OAUTH_CONFIG,
"bad-code",
"http://127.0.0.1:56122/callback",
"verifier"
),
(err: Error) => {
assert.ok(err instanceof Error);
assert.doesNotMatch(err.message, /at \//, "must not carry a stack-trace-shaped fragment");
return true;
}
);
});
// ── mapTokens: unified dispatch (browser vs paste-token) ────────────────
test("grok-cli mapTokens maps a browser PKCE exchange response (access_token shape)", () => {
const mapped = grokCli.mapTokens({
access_token: "browser-access",
refresh_token: "browser-refresh",
expires_in: 3600,
});
assert.equal(mapped.accessToken, "browser-access");
assert.equal(mapped.refreshToken, "browser-refresh");
assert.equal(mapped.expiresIn, 3600);
assert.ok(mapped.providerSpecificData);
});
test("grok-cli mapTokens still maps a pasted JWT (accessToken shape, paste-token path)", () => {
const payload = { sub: "12345", email: "paste@example.com", team_id: "team-1", tier: 2 };
const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString("base64url");
const mockJwt = `eyJhbGciOiJFUzI1NiJ9.${payloadBase64}.signature`;
const mapped = grokCli.mapTokens(mockJwt);
assert.equal(mapped.accessToken, mockJwt);
assert.equal(mapped.email, "paste@example.com");
assert.equal(mapped.providerSpecificData?.userId, "12345");
});
test("grok-cli mapTokens browser output clamps expiresIn to a positive TTL (#5775 pattern, duplicated)", () => {
const mapped = grokCli.mapTokens({
access_token: "browser-access",
refresh_token: "browser-refresh",
expires_in: -100,
});
assert.ok(mapped.expiresIn >= 1, `expected expiresIn >= 1, got ${mapped.expiresIn}`);
});
// ── route dispatch: PKCE path is reachable, import-token still works ────
test("GET /api/oauth/grok-cli/authorize dispatches through the PKCE path (not the disabled-import_token branch)", async () => {
const res = await getRoute("grok-cli", "authorize");
assert.equal(res.status, 200);
const body = await res.json();
// #7013 rework: flowType stays "device_code" (the primary/default method,
// #7358) — the PKCE authUrl is built off the supportsBrowserPkce capability
// marker (providers.ts::generateAuthData), not off flowType equality.
assert.equal(body.flowType, "device_code");
assert.ok(body.authUrl, "authUrl must be present — PKCE is enabled, not disabled");
assert.ok(!("supported" in body) || body.supported !== false);
});
test("POST /api/oauth/grok-cli/exchange requires a codeVerifier (PKCE branch reached)", async () => {
const res = await postRoute("grok-cli", "exchange", {
code: "auth-code",
redirectUri: "http://127.0.0.1:56122/callback",
});
assert.equal(res.status, 400);
const body = await res.json();
assert.match(body.error.details[0].message, /Code verifier is required for grok-cli/);
});
test("POST /api/oauth/grok-cli/exchange failure returns a sanitized 500 (Hard Rule #12)", async () => {
globalThis.fetch = async () => new Response("upstream secret leak: token=abc123", { status: 500 });
const res = await postRoute("grok-cli", "exchange", {
code: "auth-code",
redirectUri: "http://127.0.0.1:56122/callback",
codeVerifier: "verifier",
});
assert.equal(res.status, 500);
const body = await res.json();
assert.doesNotMatch(String(body.error), /token=abc123/, "must not leak the upstream error body");
assert.doesNotMatch(String(body.error), /at \//, "must not leak a stack trace");
});
test("POST /api/oauth/grok-cli/import-token still works — no regression to the paste-token path", async () => {
const payload = { sub: "import-1", email: "import-regress@example.com", team_id: "t1", tier: 1 };
const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString("base64url");
const mockJwt = `eyJhbGciOiJFUzI1NiJ9.${payloadBase64}.signature`;
const res = await postRoute("grok-cli", "import-token", { token: mockJwt });
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.success, true);
assert.equal(body.connection.email, "import-regress@example.com");
});

View File

@@ -0,0 +1,41 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
// #7013: grok-cli now ships its own browser PKCE login alongside the
// pre-existing paste-token import. Regression guard for the two
// set-membership flips in OAuthModal.tsx that gate the "Browser Login" tab.
// Source-level guard (like oauth-device-code-error-transparency.test.ts):
// OAuthModal is a "use client" component with heavy runtime deps (next-intl,
// popup/fetch orchestration); pinning the exact provider-set membership by
// source inspection is the lightweight, reliable check for this regression.
const here = dirname(fileURLToPath(import.meta.url));
const modal = readFileSync(
resolve(here, "../../src/shared/components/OAuthModal.tsx"),
"utf8"
);
function extractSet(constName: string): string[] {
const match = modal.match(new RegExp(`const ${constName} = new Set\\(\\[([^\\]]*)\\]\\)`));
assert.ok(match, `expected to find ${constName} in OAuthModal.tsx`);
return match![1].split(",").map((s) => s.trim().replace(/^"|"$/g, "")).filter(Boolean);
}
test("grok-cli is NOT import-token-only — the Browser Login tab renders", () => {
assert.ok(!extractSet("IMPORT_TOKEN_ONLY_PROVIDERS").includes("grok-cli"));
});
test("windsurf/devin-cli stay import-token-only (no regression to the Phase-1 hotfix)", () => {
const set = extractSet("IMPORT_TOKEN_ONLY_PROVIDERS");
assert.ok(set.includes("windsurf"));
assert.ok(set.includes("devin-cli"));
});
test("grok-cli uses the local PKCE callback server, alongside codex/xai-oauth", () => {
const set = extractSet("PKCE_CALLBACK_SERVER_PROVIDERS");
assert.ok(set.includes("grok-cli"));
assert.ok(set.includes("codex"));
assert.ok(set.includes("xai-oauth"));
});

View File

@@ -33,7 +33,7 @@ const {
GHE_COPILOT_CONFIG,
GITHUB_CONFIG,
GITLAB_DUO_CONFIG,
GROK_CLI_CONFIG,
GROK_BUILD_OAUTH_CONFIG,
KILOCODE_CONFIG,
KIMI_CODING_CONFIG,
KIRO_CONFIG,
@@ -101,7 +101,7 @@ const EXPECTED_CONFIG_BY_PROVIDER = {
windsurf: WINDSURF_CONFIG,
"devin-cli": WINDSURF_CONFIG,
trae: TRAE_CONFIG,
"grok-cli": GROK_CLI_CONFIG,
"grok-cli": GROK_BUILD_OAUTH_CONFIG,
"xai-oauth": XAI_OAUTH_CONFIG,
"codebuddy-cn": CODEBUDDY_CN_CONFIG,
zed: ZED_CONFIG,
@@ -151,6 +151,8 @@ const REQUIRED_FIELDS_BY_PROVIDER = {
// prettier-ignore
"xai-oauth": ["authorizeUrl", "tokenUrl", "scope", "codeChallengeMethod", "clientId", "loopbackPort", "callbackPath", "callbackHost"],
// prettier-ignore
"grok-cli": ["authorizeUrl", "tokenUrl", "scope", "codeChallengeMethod", "clientId", "loopbackPort", "callbackPath", "callbackHost"],
// prettier-ignore
"zed-hosted": ["webBaseUrl", "cloudBaseUrl", "llmBaseUrl", "userInfoUrl", "llmTokenUrl", "modelsUrl"],
};

View File

@@ -51,6 +51,14 @@ test("resolvePublicCred('windsurf_fb') returns an AIza-style Google API key", ()
assert.match(v, /^A[I]za[A-Za-z0-9_-]{20,}$/);
});
// Gap-fix (#7013): grok_id already backs GROK_CLI_CONFIG/GROK_BUILD_OAUTH_CONFIG/
// XAI_OAUTH_CONFIG's clientId in production, but had no shape assertion here.
test("resolvePublicCred('grok_id') returns a UUID-shaped xAI OAuth client id", () => {
const v = resolvePublicCred("grok_id");
assert.match(v, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
assert.equal(v.length, 36);
});
test("encode/decode roundtrip is stable across arbitrary plaintexts", () => {
for (const sample of [
"hello world",

View File

@@ -151,4 +151,99 @@ describe("OAuthModal Grok Device Code", () => {
expect(pollCalls).toHaveLength(0);
expect(element.textContent).toBe("");
});
// #7013 rework coexistence guard: device_code (#7358) and the browser PKCE
// login (#7013) must BOTH be reachable from the same modal instance via the
// "Device Code" / "Browser Login" tabs, instead of one flow replacing the
// other.
it("lets the user switch to Browser Login, then back to Device Code", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) => {
const url = String(input);
if (url.includes("/device-code")) {
return new Response(
JSON.stringify({
device_code: "opaque-device-code",
user_code: "ABCD-EFGH",
verification_uri: "https://accounts.x.ai/oauth2/device",
verification_uri_complete: "https://accounts.x.ai/oauth2/device?user_code=ABCD-EFGH",
expires_in: 1800,
interval: 5,
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}
if (url.includes("/start-callback-server")) {
return new Response(
JSON.stringify({
authUrl: "https://auth.x.ai/oauth2/authorize?client_id=test&code_challenge=abc",
codeVerifier: "verifier-123",
redirectUri: "http://127.0.0.1:56122/callback",
serverPort: 56122,
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}
if (url.includes("/poll-callback")) {
return new Response(JSON.stringify({ success: false, pending: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ success: false, pending: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
});
vi.stubGlobal("fetch", fetchMock);
const openMock = vi.spyOn(window, "open").mockImplementation(() => null);
const { element } = renderModal(true);
await flushEffects();
// Default: device_code flow started first (matches the #7358 test above).
expect(fetchMock.mock.calls[0]?.[0].toString()).toContain("/api/oauth/grok-cli/device-code");
expect(element.textContent).toContain("Device Code");
expect(element.textContent).toContain("Browser Login");
expect(element.textContent).toContain("JWT Token");
const findButton = (label: string) =>
Array.from(element.querySelectorAll("button")).find((b) => b.textContent === label);
const browserLoginButton = findButton("Browser Login");
expect(browserLoginButton).toBeTruthy();
await act(async () => {
browserLoginButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushEffects();
// Clicking "Browser Login" must dispatch the PKCE callback-server path,
// not another device-code request.
const callbackServerCall = fetchMock.mock.calls.find(([url]) =>
String(url).includes("/start-callback-server")
);
expect(callbackServerCall).toBeTruthy();
expect(openMock).toHaveBeenCalledWith(
expect.stringContaining("https://auth.x.ai/oauth2/authorize"),
"oauth_auth"
);
// Switching back to "Device Code" must re-issue a device-code request.
const deviceCodeCallsBefore = fetchMock.mock.calls.filter(([url]) =>
String(url).includes("/device-code")
).length;
const deviceCodeButton = findButton("Device Code");
expect(deviceCodeButton).toBeTruthy();
await act(async () => {
deviceCodeButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushEffects();
const deviceCodeCallsAfter = fetchMock.mock.calls.filter(([url]) =>
String(url).includes("/device-code")
).length;
expect(deviceCodeCallsAfter).toBeGreaterThan(deviceCodeCallsBefore);
});
});