mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 14:22:09 +03:00
feat(oauth): complete Windsurf / Devin CLI OAuth + API-token flows (#2168)
Integrated into release/v3.8.0 — complete Windsurf/Devin CLI OAuth + API-token executor flows with unit tests.
This commit is contained in:
@@ -21,6 +21,7 @@ import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
|
||||
import {
|
||||
jsonObjectSchema,
|
||||
oauthExchangeSchema,
|
||||
oauthImportTokenSchema,
|
||||
oauthPollSchema,
|
||||
} from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
@@ -30,6 +31,16 @@ import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
if (!globalThis.__codexCallbackState) {
|
||||
globalThis.__codexCallbackState = null;
|
||||
}
|
||||
// Windsurf / Devin CLI PKCE callback server state (separate from Codex)
|
||||
if (!globalThis.__windsurfCallbackState) {
|
||||
globalThis.__windsurfCallbackState = null;
|
||||
}
|
||||
|
||||
/** Providers that use the PKCE browser callback flow (like Codex). */
|
||||
const PKCE_CALLBACK_PROVIDERS = new Set(["codex", "windsurf", "devin-cli"]);
|
||||
|
||||
/** Providers that allow direct import of a raw API token (no OAuth exchange). */
|
||||
const IMPORT_TOKEN_PROVIDERS = new Set(["windsurf", "devin-cli"]);
|
||||
|
||||
/**
|
||||
* Constant-time string comparison to prevent timing-oracle attacks (CWE-208).
|
||||
@@ -150,40 +161,44 @@ export async function GET(
|
||||
}
|
||||
|
||||
/**
|
||||
* Start Codex callback server on port 1455
|
||||
* Returns the auth URL and stores codeVerifier for later exchange
|
||||
* Start PKCE callback server for Codex, Windsurf, or Devin CLI.
|
||||
* Codex uses fixed port 1455; Windsurf/Devin CLI use a random free port (port 0).
|
||||
* Returns the auth URL and stores codeVerifier for later exchange.
|
||||
*/
|
||||
async function handleStartCallbackServer(provider: string, searchParams: URLSearchParams) {
|
||||
if (provider !== "codex") {
|
||||
if (!PKCE_CALLBACK_PROVIDERS.has(provider)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Callback server only supported for codex" },
|
||||
{ error: `Callback server not supported for provider: ${provider}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const isWindsurf = provider === "windsurf" || provider === "devin-cli";
|
||||
const stateKey = isWindsurf ? "__windsurfCallbackState" : "__codexCallbackState";
|
||||
|
||||
// Clean up existing server if any
|
||||
if (globalThis.__codexCallbackState?.close) {
|
||||
if (globalThis[stateKey]?.close) {
|
||||
try {
|
||||
globalThis.__codexCallbackState.close();
|
||||
globalThis[stateKey].close();
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
globalThis.__codexCallbackState = null;
|
||||
globalThis[stateKey] = null;
|
||||
|
||||
try {
|
||||
// Start temp server on port 1455
|
||||
// Codex: fixed port 1455. Windsurf/Devin CLI: OS-assigned random port (0)
|
||||
const serverPort = isWindsurf ? 0 : 1455;
|
||||
const { port, close } = await startLocalServer((params) => {
|
||||
// Write directly to globalThis so it survives module reloads
|
||||
if (globalThis.__codexCallbackState) {
|
||||
globalThis.__codexCallbackState.callbackParams = params;
|
||||
if (globalThis[stateKey]) {
|
||||
globalThis[stateKey].callbackParams = params;
|
||||
}
|
||||
}, 1455);
|
||||
}, serverPort);
|
||||
|
||||
const redirectUri = `http://localhost:${port}/auth/callback`;
|
||||
const authData = generateAuthData(provider, redirectUri);
|
||||
|
||||
globalThis.__codexCallbackState = {
|
||||
globalThis[stateKey] = {
|
||||
callbackParams: null,
|
||||
close,
|
||||
port,
|
||||
@@ -195,13 +210,13 @@ async function handleStartCallbackServer(provider: string, searchParams: URLSear
|
||||
// Auto-cleanup after 5 minutes
|
||||
const startedAt = Date.now();
|
||||
setTimeout(() => {
|
||||
if (globalThis.__codexCallbackState?.startedAt === startedAt) {
|
||||
if (globalThis[stateKey]?.startedAt === startedAt) {
|
||||
try {
|
||||
close();
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
globalThis.__codexCallbackState = null;
|
||||
globalThis[stateKey] = null;
|
||||
}
|
||||
}, 300000);
|
||||
|
||||
@@ -263,6 +278,12 @@ export async function POST(
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
body = validation.data;
|
||||
} else if (action === "import-token") {
|
||||
const validation = validateBody(oauthImportTokenSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
body = validation.data;
|
||||
}
|
||||
|
||||
if (action === "exchange") {
|
||||
@@ -454,15 +475,20 @@ export async function POST(
|
||||
if (action === "poll-callback") {
|
||||
const { connectionId } = body;
|
||||
|
||||
// Poll for Codex callback server result
|
||||
if (provider !== "codex") {
|
||||
// poll-callback is supported for all PKCE callback providers
|
||||
if (!PKCE_CALLBACK_PROVIDERS.has(provider)) {
|
||||
return NextResponse.json(
|
||||
{ error: "poll-callback only supported for codex" },
|
||||
{
|
||||
error: `poll-callback only supported for PKCE callback providers: ${[...PKCE_CALLBACK_PROVIDERS].join(", ")}`,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!globalThis.__codexCallbackState) {
|
||||
// Windsurf and Devin CLI share __windsurfCallbackState; Codex uses its own slot
|
||||
const stateKey = provider === "codex" ? "__codexCallbackState" : "__windsurfCallbackState";
|
||||
|
||||
if (!globalThis[stateKey]) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: "no_server",
|
||||
@@ -470,13 +496,13 @@ export async function POST(
|
||||
});
|
||||
}
|
||||
|
||||
if (!globalThis.__codexCallbackState.callbackParams) {
|
||||
if (!globalThis[stateKey].callbackParams) {
|
||||
return NextResponse.json({ success: false, pending: true });
|
||||
}
|
||||
|
||||
// Callback received! Extract code and exchange for tokens
|
||||
const params = globalThis.__codexCallbackState.callbackParams;
|
||||
const { redirectUri, codeVerifier, close } = globalThis.__codexCallbackState;
|
||||
const params = globalThis[stateKey].callbackParams;
|
||||
const { redirectUri, codeVerifier, close } = globalThis[stateKey];
|
||||
|
||||
// Clean up server
|
||||
try {
|
||||
@@ -484,7 +510,7 @@ export async function POST(
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
globalThis.__codexCallbackState = null;
|
||||
globalThis[stateKey] = null;
|
||||
|
||||
if (params.error) {
|
||||
return NextResponse.json({
|
||||
@@ -571,6 +597,76 @@ export async function POST(
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "import-token") {
|
||||
const { token, connectionId } = body;
|
||||
|
||||
if (!IMPORT_TOKEN_PROVIDERS.has(provider)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `import-token not supported for provider: ${provider}. Supported: ${[...IMPORT_TOKEN_PROVIDERS].join(", ")}`,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Map the raw token via the provider's mapTokens() — skips the HTTP exchange entirely.
|
||||
const providerData = getProvider(provider);
|
||||
const tokenData = providerData.mapTokens({ accessToken: token });
|
||||
|
||||
// Normalize: if name is missing, use email as fallback display label
|
||||
if (!tokenData.name && (tokenData.email || tokenData.displayName)) {
|
||||
tokenData.name = tokenData.email || tokenData.displayName;
|
||||
}
|
||||
|
||||
const expiresAt = tokenData.expiresIn
|
||||
? new Date(Date.now() + tokenData.expiresIn * 1000).toISOString()
|
||||
: null;
|
||||
|
||||
let connection: any;
|
||||
if (tokenData.email) {
|
||||
const existing = await getProviderConnections({ provider });
|
||||
const match = existing.find((c: any) => {
|
||||
if (c.id && safeEqual(connectionId, c.id)) return true;
|
||||
if (!safeEqual(c.email, tokenData.email) || c.authType !== "oauth") return false;
|
||||
return true;
|
||||
});
|
||||
const matchId = typeof match?.id === "string" ? match.id : null;
|
||||
if (matchId) {
|
||||
connection = await updateProviderConnection(matchId, {
|
||||
...tokenData,
|
||||
expiresAt,
|
||||
testStatus: "active",
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!connection) {
|
||||
connection = await createProviderConnection({
|
||||
provider,
|
||||
authType: "oauth",
|
||||
...tokenData,
|
||||
expiresAt,
|
||||
testStatus: "active",
|
||||
});
|
||||
}
|
||||
|
||||
await syncToCloudIfEnabled();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
connection: {
|
||||
id: connection.id,
|
||||
provider: connection.provider,
|
||||
email: connection.email,
|
||||
displayName: connection.displayName,
|
||||
},
|
||||
});
|
||||
} catch (importErr: any) {
|
||||
return NextResponse.json({ success: false, error: importErr.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
|
||||
} catch (error) {
|
||||
console.log("OAuth POST error:", error);
|
||||
|
||||
13
src/app/auth/callback/page.tsx
Normal file
13
src/app/auth/callback/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* /auth/callback — OAuth callback endpoint for providers that use the
|
||||
* `/auth/callback` path (Windsurf, Devin CLI PKCE flow).
|
||||
*
|
||||
* Reuses the same logic as /callback:
|
||||
* - postMessage to opener (popup mode)
|
||||
* - BroadcastChannel (same-origin tabs)
|
||||
* - localStorage fallback
|
||||
*
|
||||
* On true localhost the random-port callback server intercepts this path first,
|
||||
* so this page is only reached in the LAN / popup-without-callback-server case.
|
||||
*/
|
||||
export { default } from "@/app/callback/page";
|
||||
@@ -264,6 +264,44 @@ export const CURSOR_CONFIG = {
|
||||
},
|
||||
};
|
||||
|
||||
// Windsurf / Devin CLI Configuration
|
||||
//
|
||||
// Authentication uses PKCE Authorization Code Flow — same pattern as Codex CLI.
|
||||
// Extracted from Devin CLI binary (model_configs_v2.bin + devin.exe strings):
|
||||
//
|
||||
// Authorize URL: https://app.devin.ai/editor/signin
|
||||
// Params: response_type=code, redirect_uri, code_challenge, code_challenge_method=S256
|
||||
// Callback path: /auth/callback (local server on random port 127.0.0.1:0)
|
||||
// Exchange: POST https://server.codeium.com/<ExchangePKCEAuthorizationCode>
|
||||
// via Connect JSON protocol (Content-Type: application/json)
|
||||
// Response field: windsurfApiKey → stored as accessToken / WINDSURF_API_KEY
|
||||
//
|
||||
// Fallback: user can also paste a token from windsurf.com/show-auth-token
|
||||
export const WINDSURF_CONFIG = {
|
||||
// Browser-based PKCE authorize endpoint (extracted from devin.exe binary)
|
||||
authorizeUrl: "https://app.devin.ai/editor/signin",
|
||||
codeChallengeMethod: "S256" as const,
|
||||
// Local callback server — 0 = OS assigns a free port
|
||||
callbackPort: 0,
|
||||
callbackPath: "/auth/callback",
|
||||
// Token exchange via Windsurf Connect JSON (gRPC-web + JSON)
|
||||
apiServerUrl: "https://server.codeium.com",
|
||||
exchangePath: "/exa.seat_management_pb.SeatManagementService/ExchangePKCEAuthorizationCode",
|
||||
// Inference server URL (gRPC-web requests go here)
|
||||
inferenceUrl: "https://server.self-serve.windsurf.com",
|
||||
// Fallback: user visits this page, copies token, pastes it
|
||||
showAuthTokenUrl: "https://windsurf.com/show-auth-token",
|
||||
// Token refresh via Firebase Secure Token Service (for short-lived browser-flow tokens).
|
||||
// Value comes from WINDSURF_FIREBASE_API_KEY env var (set in .env.example).
|
||||
// Long-lived import tokens never need this — refresh is skipped when key is absent.
|
||||
firebaseApiKey: process.env.WINDSURF_FIREBASE_API_KEY || "",
|
||||
firebaseTokenUrl: "https://securetoken.googleapis.com/v1/token",
|
||||
// IDE identity sent with every gRPC request
|
||||
ideName: "windsurf",
|
||||
ideVersion: "3.14.0",
|
||||
extensionVersion: "3.14.0",
|
||||
};
|
||||
|
||||
// OAuth timeout (5 minutes)
|
||||
export const OAUTH_TIMEOUT = 300000;
|
||||
|
||||
@@ -284,4 +322,6 @@ export const PROVIDERS = {
|
||||
CURSOR: "cursor",
|
||||
KILOCODE: "kilocode",
|
||||
CLINE: "cline",
|
||||
WINDSURF: "windsurf",
|
||||
DEVIN_CLI: "devin-cli",
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ import { kiro } from "./kiro";
|
||||
import { cursor } from "./cursor";
|
||||
import { kilocode } from "./kilocode";
|
||||
import { cline } from "./cline";
|
||||
import { windsurf } from "./windsurf";
|
||||
|
||||
export const PROVIDERS = {
|
||||
claude,
|
||||
@@ -39,6 +40,9 @@ export const PROVIDERS = {
|
||||
cursor,
|
||||
kilocode,
|
||||
cline,
|
||||
windsurf,
|
||||
// devin-cli shares the same token format as windsurf (WINDSURF_API_KEY / devin auth login)
|
||||
"devin-cli": windsurf,
|
||||
};
|
||||
|
||||
export default PROVIDERS;
|
||||
|
||||
121
src/lib/oauth/providers/windsurf.ts
Normal file
121
src/lib/oauth/providers/windsurf.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { WINDSURF_CONFIG } from "../constants/oauth";
|
||||
|
||||
/**
|
||||
* Windsurf / Devin CLI OAuth Provider
|
||||
*
|
||||
* Uses PKCE Authorization Code Flow — same pattern as Codex CLI.
|
||||
* Extracted from Devin CLI binary (devin.exe string analysis):
|
||||
*
|
||||
* 1. OmniRoute starts a local callback server (random port, 127.0.0.1)
|
||||
* 2. Browser opens:
|
||||
* https://app.devin.ai/editor/signin
|
||||
* ?response_type=code
|
||||
* &redirect_uri=http://127.0.0.1:PORT/auth/callback
|
||||
* &code_challenge=<S256_CHALLENGE>
|
||||
* &code_challenge_method=S256
|
||||
* 3. User logs in (Google / GitHub / Windsurf Enterprise)
|
||||
* 4. Browser redirects back to callback server with `code`
|
||||
* 5. Exchange code via Windsurf Connect JSON:
|
||||
* POST https://server.codeium.com/exa.seat_management_pb.SeatManagementService/ExchangePKCEAuthorizationCode
|
||||
* { "code": "...", "codeVerifier": "...", "redirectUri": "..." }
|
||||
* 6. Response: { "windsurfApiKey": "...", "apiServerUrl": "...", ... }
|
||||
* 7. `windsurfApiKey` stored as `accessToken` (= WINDSURF_API_KEY)
|
||||
*
|
||||
* Fallback (import_token): user visits windsurf.com/show-auth-token,
|
||||
* copies their API key, and pastes it into the connection form.
|
||||
*/
|
||||
export const windsurf = {
|
||||
config: WINDSURF_CONFIG,
|
||||
flowType: "authorization_code_pkce",
|
||||
// Fixed callback path expected by Devin CLI auth flow
|
||||
callbackPath: WINDSURF_CONFIG.callbackPath,
|
||||
// Port 0 = OS assigns a free port (we use the globalThis devin callback state)
|
||||
callbackPort: WINDSURF_CONFIG.callbackPort,
|
||||
|
||||
buildAuthUrl: (
|
||||
config: typeof WINDSURF_CONFIG,
|
||||
redirectUri: string,
|
||||
state: string,
|
||||
codeChallenge: string
|
||||
) => {
|
||||
const params = new URLSearchParams({
|
||||
response_type: "code",
|
||||
redirect_uri: redirectUri,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: config.codeChallengeMethod,
|
||||
state,
|
||||
});
|
||||
return `${config.authorizeUrl}?${params.toString()}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Exchange authorization code for Windsurf API key.
|
||||
* Uses the Windsurf Connect JSON protocol (not standard OAuth token endpoint).
|
||||
*/
|
||||
exchangeToken: async (
|
||||
config: typeof WINDSURF_CONFIG,
|
||||
code: string,
|
||||
redirectUri: string,
|
||||
codeVerifier: string
|
||||
) => {
|
||||
const url = `${config.apiServerUrl}${config.exchangePath}`;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
// Connect protocol version header
|
||||
"Connect-Protocol-Version": "1",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
code,
|
||||
codeVerifier,
|
||||
redirectUri,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Windsurf token exchange failed (${response.status}): ${error}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Map exchange response to OmniRoute connection fields.
|
||||
* The Windsurf Connect response uses camelCase JSON:
|
||||
* windsurfApiKey, apiServerUrl, devinWebappHost, devinApiUrl
|
||||
*/
|
||||
mapTokens: (tokens: {
|
||||
windsurfApiKey?: string;
|
||||
apiServerUrl?: string;
|
||||
devinWebappHost?: string;
|
||||
devinApiUrl?: string;
|
||||
// Fallback import-token fields
|
||||
accessToken?: string;
|
||||
apiKey?: string;
|
||||
refreshToken?: string;
|
||||
expiresIn?: number;
|
||||
email?: string;
|
||||
authMethod?: string;
|
||||
}) => {
|
||||
// PKCE flow: token is in windsurfApiKey
|
||||
const token = tokens.windsurfApiKey || tokens.accessToken || tokens.apiKey || "";
|
||||
|
||||
return {
|
||||
accessToken: token,
|
||||
// Windsurf API keys are long-lived — no refresh token needed
|
||||
refreshToken: tokens.refreshToken || null,
|
||||
expiresIn: tokens.expiresIn || 0,
|
||||
email: tokens.email || null,
|
||||
providerSpecificData: {
|
||||
authMethod: tokens.authMethod || (tokens.windsurfApiKey ? "browser" : "import"),
|
||||
apiServerUrl: tokens.apiServerUrl || null,
|
||||
devinWebappHost: tokens.devinWebappHost || null,
|
||||
devinApiUrl: tokens.devinApiUrl || null,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -9,6 +9,9 @@ import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
|
||||
const GOOGLE_OAUTH_PROVIDERS = new Set(["antigravity", "gemini-cli"]);
|
||||
|
||||
/** Providers that use a local callback server on a random port (PKCE browser flow). */
|
||||
const PKCE_CALLBACK_SERVER_PROVIDERS = new Set(["codex", "windsurf", "devin-cli"]);
|
||||
|
||||
type OAuthModalProps = {
|
||||
isOpen: boolean;
|
||||
provider?: string;
|
||||
@@ -41,6 +44,12 @@ export default function OAuthModal({
|
||||
const [isDeviceCode, setIsDeviceCode] = useState(false);
|
||||
const [deviceData, setDeviceData] = useState(null);
|
||||
const [polling, setPolling] = useState(false);
|
||||
// API-key paste mode: for providers that accept a token directly (windsurf, devin-cli)
|
||||
const [showPasteToken, setShowPasteToken] = useState(false);
|
||||
const [pasteToken, setPasteToken] = useState("");
|
||||
const [savingToken, setSavingToken] = useState(false);
|
||||
|
||||
const supportsTokenPaste = provider === "windsurf" || provider === "devin-cli";
|
||||
const popupRef = useRef(null);
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
const deviceVerificationUrl =
|
||||
@@ -149,6 +158,42 @@ export default function OAuthModal({
|
||||
[authData, provider, onSuccess, reauthConnection]
|
||||
);
|
||||
|
||||
// Save a raw API token directly (windsurf / devin-cli import-token path)
|
||||
const handleSaveToken = useCallback(async () => {
|
||||
const token = pasteToken.trim();
|
||||
if (!token || !provider) return;
|
||||
setSavingToken(true);
|
||||
setError(null);
|
||||
try {
|
||||
// POST to /exchange with a synthetic "import_token" payload.
|
||||
// The windsurf provider's mapTokens() handles a bare accessToken/apiKey field.
|
||||
const res = await fetch(`/api/oauth/${provider}/import-token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
connectionId: reauthConnection?.id,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
const errMsg =
|
||||
typeof data.error === "object" && data.error !== null
|
||||
? ((data.error as Record<string, unknown>).message as string) ||
|
||||
JSON.stringify(data.error)
|
||||
: data.error || "Save failed";
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
setStep("success");
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
// Show error inline inside the paste-token form (don't flip to error step)
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSavingToken(false);
|
||||
}
|
||||
}, [pasteToken, provider, onSuccess, reauthConnection]);
|
||||
|
||||
// Poll for device code token
|
||||
const startPolling = useCallback(
|
||||
async (deviceCode, codeVerifier, interval, extraData) => {
|
||||
@@ -273,13 +318,14 @@ export default function OAuthModal({
|
||||
forceManual = true;
|
||||
}
|
||||
|
||||
// Codex: on localhost use callback server on port 1455,
|
||||
// on remote use standard auth code flow (callback server is unreachable)
|
||||
if (provider === "codex") {
|
||||
if (isLocalhost) {
|
||||
// Localhost: use callback server on port 1455 + polling
|
||||
// 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/codex/start-callback-server`);
|
||||
const serverRes = await fetch(`/api/oauth/${provider}/start-callback-server`);
|
||||
const serverData = await serverRes.json();
|
||||
if (!serverRes.ok) throw new Error(serverData.error);
|
||||
|
||||
@@ -297,7 +343,7 @@ export default function OAuthModal({
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
|
||||
const pollRes = await fetch(`/api/oauth/codex/poll-callback`, {
|
||||
const pollRes = await fetch(`/api/oauth/${provider}/poll-callback`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ connectionId: reauthConnection?.id }),
|
||||
@@ -318,10 +364,10 @@ export default function OAuthModal({
|
||||
|
||||
setPolling(false);
|
||||
throw new Error("Authorization timeout");
|
||||
} catch (codexErr) {
|
||||
} catch (pkceErr) {
|
||||
console.warn(
|
||||
"Codex callback server failed, falling back to standard manual flow",
|
||||
codexErr
|
||||
`${provider} callback server failed, falling back to manual flow`,
|
||||
pkceErr
|
||||
);
|
||||
setPolling(false);
|
||||
forceManual = true;
|
||||
@@ -333,6 +379,8 @@ export default function OAuthModal({
|
||||
// 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, gemini-cli): always localhost, regardless of
|
||||
// where OmniRoute is hosted — Google only accepts pre-registered localhost URIs with
|
||||
// the built-in credentials. Remote users must configure their own credentials.
|
||||
@@ -341,6 +389,11 @@ export default function OAuthModal({
|
||||
let redirectUri: string;
|
||||
if (provider === "codex" || provider === "openai") {
|
||||
redirectUri = "http://localhost:1455/auth/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 localhost redirect URIs.
|
||||
// Even in remote deployments we use localhost — user copies the callback URL manually.
|
||||
@@ -618,148 +671,210 @@ export default function OAuthModal({
|
||||
size="lg"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Waiting Step (Localhost - popup mode) */}
|
||||
{step === "waiting" && !isDeviceCode && (
|
||||
<div className="text-center py-6">
|
||||
<div className="size-16 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<span className="material-symbols-outlined text-3xl text-primary animate-spin">
|
||||
progress_activity
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">{t("waiting")}</h3>
|
||||
<p className="text-sm text-text-muted mb-2">{t("completeAuthInPopup")}</p>
|
||||
<p className="text-xs text-text-muted mb-4 opacity-70">{t("popupClosedHint")}</p>
|
||||
<Button variant="ghost" onClick={() => setStep("input")}>
|
||||
{t("popupBlocked")}
|
||||
</Button>
|
||||
{/* Paste-token tab toggle (Windsurf / Devin CLI only) */}
|
||||
{supportsTokenPaste && step !== "success" && (
|
||||
<div className="flex gap-2 border-b border-border pb-3">
|
||||
<button
|
||||
className={`text-sm px-3 py-1 rounded-t ${!showPasteToken ? "font-semibold border-b-2 border-primary text-primary" : "text-text-muted"}`}
|
||||
onClick={() => setShowPasteToken(false)}
|
||||
>
|
||||
Browser Login
|
||||
</button>
|
||||
<button
|
||||
className={`text-sm px-3 py-1 rounded-t ${showPasteToken ? "font-semibold border-b-2 border-primary text-primary" : "text-text-muted"}`}
|
||||
onClick={() => setShowPasteToken(true)}
|
||||
>
|
||||
Paste API Key
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Device Code Flow - Waiting */}
|
||||
{step === "waiting" && isDeviceCode && deviceData && (
|
||||
<>
|
||||
<div className="text-center py-4">
|
||||
<p className="text-sm text-text-muted mb-4">{t("deviceCodeVisitUrl")}</p>
|
||||
<div className="bg-sidebar p-4 rounded-lg mb-4">
|
||||
<p className="text-xs text-text-muted mb-1">{t("deviceCodeVerificationUrl")}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 text-sm break-all">{deviceVerificationUrl}</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon={copied === "verify_url" ? "check" : "content_copy"}
|
||||
onClick={() => copy(deviceVerificationUrl, "verify_url")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-primary/10 p-4 rounded-lg">
|
||||
<p className="text-xs text-text-muted mb-1">{t("deviceCodeYourCode")}</p>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<p className="text-2xl font-mono font-bold text-primary">
|
||||
{deviceData.user_code}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon={copied === "user_code" ? "check" : "content_copy"}
|
||||
onClick={() => copy(deviceData.user_code, "user_code")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Paste-token form (Windsurf / Devin CLI) */}
|
||||
{supportsTokenPaste && showPasteToken && step !== "success" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm text-text-muted">
|
||||
{provider === "windsurf"
|
||||
? "Visit windsurf.com/show-auth-token, copy your Windsurf API key, and paste it below."
|
||||
: "Provide your WINDSURF_API_KEY (obtained via `devin auth login` or windsurf.com/show-auth-token)."}
|
||||
</p>
|
||||
<Input
|
||||
value={pasteToken}
|
||||
onChange={(e) => setPasteToken(e.target.value)}
|
||||
placeholder="ws-..."
|
||||
type="password"
|
||||
label="API Key / Token"
|
||||
/>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleSaveToken}
|
||||
fullWidth
|
||||
disabled={!pasteToken.trim() || savingToken}
|
||||
>
|
||||
{savingToken ? "Saving…" : "Save Connection"}
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
{polling && (
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-text-muted">
|
||||
<span className="material-symbols-outlined animate-spin">progress_activity</span>
|
||||
{t("deviceCodeWaiting")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OAuth flow steps — hidden when paste-token mode is active */}
|
||||
{(!supportsTokenPaste || !showPasteToken) && (
|
||||
<>
|
||||
{/* Waiting Step (Localhost - popup mode) */}
|
||||
{step === "waiting" && !isDeviceCode && (
|
||||
<div className="text-center py-6">
|
||||
<div className="size-16 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<span className="material-symbols-outlined text-3xl text-primary animate-spin">
|
||||
progress_activity
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">{t("waiting")}</h3>
|
||||
<p className="text-sm text-text-muted mb-2">{t("completeAuthInPopup")}</p>
|
||||
<p className="text-xs text-text-muted mb-4 opacity-70">{t("popupClosedHint")}</p>
|
||||
<Button variant="ghost" onClick={() => setStep("input")}>
|
||||
{t("popupBlocked")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Device Code Flow - Waiting */}
|
||||
{step === "waiting" && isDeviceCode && deviceData && (
|
||||
<>
|
||||
<div className="text-center py-4">
|
||||
<p className="text-sm text-text-muted mb-4">{t("deviceCodeVisitUrl")}</p>
|
||||
<div className="bg-sidebar p-4 rounded-lg mb-4">
|
||||
<p className="text-xs text-text-muted mb-1">{t("deviceCodeVerificationUrl")}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 text-sm break-all">{deviceVerificationUrl}</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon={copied === "verify_url" ? "check" : "content_copy"}
|
||||
onClick={() => copy(deviceVerificationUrl, "verify_url")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-primary/10 p-4 rounded-lg">
|
||||
<p className="text-xs text-text-muted mb-1">{t("deviceCodeYourCode")}</p>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<p className="text-2xl font-mono font-bold text-primary">
|
||||
{deviceData.user_code}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon={copied === "user_code" ? "check" : "content_copy"}
|
||||
onClick={() => copy(deviceData.user_code, "user_code")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{polling && (
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-text-muted">
|
||||
<span className="material-symbols-outlined animate-spin">
|
||||
progress_activity
|
||||
</span>
|
||||
{t("deviceCodeWaiting")}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Manual Input Step */}
|
||||
{step === "input" && !isDeviceCode && (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
{/* Remote/LAN server info for Google OAuth providers */}
|
||||
{!isTrueLocalhost && GOOGLE_OAUTH_PROVIDERS.has(provider) && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-200">
|
||||
<span className="material-symbols-outlined text-sm align-middle mr-1">
|
||||
warning
|
||||
</span>
|
||||
<strong>
|
||||
{t.rich("googleOAuthWarning", {
|
||||
code: (c) => <code className="font-mono">{c}</code>,
|
||||
a: (c) => (
|
||||
<a
|
||||
href="https://github.com/diegosouzapw/OmniRoute#oauth-on-a-remote-server"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{c}
|
||||
</a>
|
||||
),
|
||||
})}
|
||||
</strong>
|
||||
</div>
|
||||
)}
|
||||
{/* Generic remote info for other providers */}
|
||||
{!isTrueLocalhost && !GOOGLE_OAUTH_PROVIDERS.has(provider) && (
|
||||
<div className="rounded-lg border border-blue-500/30 bg-blue-500/10 p-3 text-xs text-blue-200">
|
||||
<span className="material-symbols-outlined text-sm align-middle mr-1">
|
||||
info
|
||||
</span>
|
||||
{t("remoteAccessInfo")}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">{t("step1OpenUrl")}</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={authData?.authUrl || ""}
|
||||
readOnly
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={copied === "auth_url" ? "check" : "content_copy"}
|
||||
onClick={() => copy(authData?.authUrl, "auth_url")}
|
||||
>
|
||||
{t("copy")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">{t("step2PasteCallback")}</p>
|
||||
<p className="text-xs text-text-muted mb-2">
|
||||
{t.rich("step2Hint", {
|
||||
code: (c) => <code className="font-mono">{c}</code>,
|
||||
})}
|
||||
</p>
|
||||
<Input
|
||||
value={callbackUrl}
|
||||
onChange={(e) => setCallbackUrl(e.target.value)}
|
||||
placeholder={
|
||||
provider === "claude" || provider === "cline"
|
||||
? "code#state or /callback?code=..."
|
||||
: placeholderUrl
|
||||
}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleManualSubmit}
|
||||
fullWidth
|
||||
disabled={!callbackUrl || !authData}
|
||||
>
|
||||
{t("connect")}
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Manual Input Step */}
|
||||
{step === "input" && !isDeviceCode && (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
{/* Remote/LAN server info for Google OAuth providers */}
|
||||
{!isTrueLocalhost && GOOGLE_OAUTH_PROVIDERS.has(provider) && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-200">
|
||||
<span className="material-symbols-outlined text-sm align-middle mr-1">
|
||||
warning
|
||||
</span>
|
||||
<strong>
|
||||
{t.rich("googleOAuthWarning", {
|
||||
code: (c) => <code className="font-mono">{c}</code>,
|
||||
a: (c) => (
|
||||
<a
|
||||
href="https://github.com/diegosouzapw/OmniRoute#oauth-on-a-remote-server"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{c}
|
||||
</a>
|
||||
),
|
||||
})}
|
||||
</strong>
|
||||
</div>
|
||||
)}
|
||||
{/* Generic remote info for other providers */}
|
||||
{!isTrueLocalhost && !GOOGLE_OAUTH_PROVIDERS.has(provider) && (
|
||||
<div className="rounded-lg border border-blue-500/30 bg-blue-500/10 p-3 text-xs text-blue-200">
|
||||
<span className="material-symbols-outlined text-sm align-middle mr-1">info</span>
|
||||
{t("remoteAccessInfo")}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">{t("step1OpenUrl")}</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={authData?.authUrl || ""}
|
||||
readOnly
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={copied === "auth_url" ? "check" : "content_copy"}
|
||||
onClick={() => copy(authData?.authUrl, "auth_url")}
|
||||
>
|
||||
{t("copy")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">{t("step2PasteCallback")}</p>
|
||||
<p className="text-xs text-text-muted mb-2">
|
||||
{t.rich("step2Hint", {
|
||||
code: (c) => <code className="font-mono">{c}</code>,
|
||||
})}
|
||||
</p>
|
||||
<Input
|
||||
value={callbackUrl}
|
||||
onChange={(e) => setCallbackUrl(e.target.value)}
|
||||
placeholder={
|
||||
provider === "claude" || provider === "cline"
|
||||
? "code#state or /callback?code=..."
|
||||
: placeholderUrl
|
||||
}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleManualSubmit} fullWidth disabled={!callbackUrl || !authData}>
|
||||
{t("connect")}
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Success Step */}
|
||||
{/* Success Step — shown for both OAuth and paste-token flows */}
|
||||
{step === "success" && (
|
||||
<div className="text-center py-6">
|
||||
<div className="size-16 mx-auto mb-4 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||
@@ -777,8 +892,8 @@ export default function OAuthModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Step */}
|
||||
{step === "error" && (
|
||||
{/* Error Step — OAuth errors only; paste-token errors shown inline */}
|
||||
{step === "error" && !showPasteToken && (
|
||||
<div className="text-center py-6">
|
||||
<div className="size-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
|
||||
<span className="material-symbols-outlined text-3xl text-red-600">error</span>
|
||||
|
||||
@@ -90,6 +90,28 @@ export const OAUTH_PROVIDERS = {
|
||||
color: "#5B9BD5",
|
||||
textIcon: "CL",
|
||||
},
|
||||
windsurf: {
|
||||
id: "windsurf",
|
||||
alias: "ws",
|
||||
name: "Windsurf (Devin CLI)",
|
||||
icon: "air",
|
||||
color: "#00C5A0",
|
||||
textIcon: "WS",
|
||||
authHint:
|
||||
"Sign in at windsurf.com to get your token. Visit windsurf.com/show-auth-token after logging in and paste it here, or use the device-code login flow.",
|
||||
website: "https://windsurf.com",
|
||||
},
|
||||
"devin-cli": {
|
||||
id: "devin-cli",
|
||||
alias: "dv",
|
||||
name: "Devin CLI (Official)",
|
||||
icon: "terminal",
|
||||
color: "#6366F1",
|
||||
textIcon: "DV",
|
||||
authHint:
|
||||
"Requires the Devin CLI binary. Run `devin auth login` to authenticate, or provide your WINDSURF_API_KEY. Install: https://cli.devin.ai",
|
||||
website: "https://cli.devin.ai",
|
||||
},
|
||||
};
|
||||
|
||||
// Web / Cookie Providers
|
||||
|
||||
@@ -67,6 +67,24 @@ const CLI_TOOLS: Record<string, any> = {
|
||||
healthcheckTimeoutMs: 4000,
|
||||
paths: {},
|
||||
},
|
||||
devin: {
|
||||
defaultCommand: "devin",
|
||||
envBinKey: "CLI_DEVIN_BIN",
|
||||
requiresBinary: true,
|
||||
// devin acp cold-start can take a few seconds on first run
|
||||
healthcheckTimeoutMs: 12000,
|
||||
paths: {
|
||||
// %APPDATA%\devin\config.json (Windows)
|
||||
// ~/.config/devin/config.json (Linux/macOS)
|
||||
config: isWindows()
|
||||
? path.join(
|
||||
process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"),
|
||||
"devin",
|
||||
"config.json"
|
||||
)
|
||||
: path.join(os.homedir(), ".config", "devin", "config.json"),
|
||||
},
|
||||
},
|
||||
cline: {
|
||||
defaultCommand: "cline",
|
||||
envBinKey: "CLI_CLINE_BIN",
|
||||
@@ -439,6 +457,10 @@ const getKnownToolPaths = (toolId: string): string[] => {
|
||||
["qodercli.cmd", "qodercli"],
|
||||
["qodercli.exe", "qodercli"],
|
||||
],
|
||||
devin: [
|
||||
["devin.exe", "devin"],
|
||||
["devin.cmd", "devin"],
|
||||
],
|
||||
};
|
||||
|
||||
const bins = toolBins[toolId] || [];
|
||||
@@ -464,6 +486,11 @@ const getKnownToolPaths = (toolId: string): string[] => {
|
||||
paths.push(path.join(home, "bin", "droid.exe"));
|
||||
}
|
||||
|
||||
// Devin CLI installs to %LOCALAPPDATA%\devin\cli\bin\devin.exe
|
||||
if (toolId === "devin" && localAppData) {
|
||||
paths.push(path.join(localAppData, "devin", "cli", "bin", "devin.exe"));
|
||||
}
|
||||
|
||||
for (const [winName] of bins) {
|
||||
if (npmPrefix) paths.push(path.join(npmPrefix, winName));
|
||||
if (appData) {
|
||||
@@ -501,6 +528,12 @@ const getKnownToolPaths = (toolId: string): string[] => {
|
||||
if (toolId === "claude") {
|
||||
paths.push(path.join(home, ".claude", "bin", posixName));
|
||||
}
|
||||
// Devin CLI installs to ~/.local/share/devin/bin/devin (Linux)
|
||||
// or via shell installer to ~/.devin/bin/devin
|
||||
if (toolId === "devin") {
|
||||
paths.push(path.join(home, ".local", "share", "devin", "bin", "devin"));
|
||||
paths.push(path.join(home, ".devin", "bin", "devin"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1269,6 +1269,12 @@ export const oauthPollSchema = z.object({
|
||||
extraData: z.unknown().optional(),
|
||||
});
|
||||
|
||||
/** Import a raw API token (e.g. WINDSURF_API_KEY) without going through the browser OAuth flow. */
|
||||
export const oauthImportTokenSchema = z.object({
|
||||
token: z.string().trim().min(1, "Token is required"),
|
||||
connectionId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const cursorImportSchema = z.object({
|
||||
accessToken: z.string().trim().min(1, "Access token is required"),
|
||||
machineId: z.string().trim().optional(),
|
||||
|
||||
Reference in New Issue
Block a user