mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +03:00
* 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:
committed by
GitHub
parent
91f4c35e9d
commit
b861dd045a
1
changelog.d/features/7013-grok-build-browser-login.md
Normal file
1
changelog.d/features/7013-grok-build-browser-login.md
Normal 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))
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
116
src/lib/oauth/providers/grok-cli-oauth.ts
Normal file
116
src/lib/oauth/providers/grok-cli-oauth.ts
Normal 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",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
214
tests/unit/oauth-grok-cli-browser.test.ts
Normal file
214
tests/unit/oauth-grok-cli-browser.test.ts
Normal 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");
|
||||
});
|
||||
41
tests/unit/oauth-modal-grok-cli-browser-login-7013.test.ts
Normal file
41
tests/unit/oauth-modal-grok-cli-browser-login-7013.test.ts
Normal 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"));
|
||||
});
|
||||
@@ -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"],
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user