diff --git a/src/app/api/oauth/[provider]/[action]/route.ts b/src/app/api/oauth/[provider]/[action]/route.ts index 56c74dd71b..22885a43d2 100755 --- a/src/app/api/oauth/[provider]/[action]/route.ts +++ b/src/app/api/oauth/[provider]/[action]/route.ts @@ -39,7 +39,17 @@ if (!globalThis.__windsurfCallbackState) { } /** Providers that use the PKCE browser callback flow (like Codex). */ -const PKCE_CALLBACK_PROVIDERS = new Set(["codex", "windsurf", "devin-cli"]); +const PKCE_CALLBACK_PROVIDERS = new Set(["codex"]); + +/** + * Providers whose PKCE flow has been retired but whose import-token path is + * still active. Returning 410 Gone on `authorize` / `start-callback-server` / + * `poll-callback` (instead of 400) tells callers the action is permanently + * gone and points them at /import-token. windsurf/devin-cli were retired + * 2026-05-29 because app.devin.ai/editor/signin returned 404 post-rebrand. + * Phase 2 will reintroduce browser login via Firebase OAuth + RegisterUser. + */ +const RETIRED_PKCE_PROVIDERS = new Set(["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"]); @@ -73,6 +83,33 @@ export async function GET( request: Request, { params }: { params: Promise<{ provider: string; action: string }> } ) { + // Phase 1 hotfix (2026-05-29): retired PKCE flows return 410 Gone BEFORE auth. + // The action permanently does not exist for these providers regardless of who + // is asking — answering 401 first would mislead callers into thinking the + // route is gated rather than gone. See spec + // docs/superpowers/specs/2026-05-29-windsurf-login-fix-design.md. + try { + const earlyParams = await params; + if ( + RETIRED_PKCE_PROVIDERS.has(earlyParams.provider) && + (earlyParams.action === "authorize" || + earlyParams.action === "start-callback-server" || + earlyParams.action === "poll-callback") + ) { + return NextResponse.json( + { + error: + `Browser OAuth disabled for ${earlyParams.provider} — use import-token via ` + + `/api/oauth/${earlyParams.provider}/import-token. ` + + `Visit https://windsurf.com/show-auth-token to obtain a token.`, + }, + { status: 410 } + ); + } + } catch { + /* fall through to normal handling */ + } + const authResponse = await requireOAuthRouteAuth(request); if (authResponse) return authResponse; @@ -242,11 +279,50 @@ export async function POST( request: Request, { params }: { params: Promise<{ provider: string; action: string }> } ) { + // Phase 1 hotfix (2026-05-29): retired PKCE flows return 410 Gone BEFORE auth. + // See GET handler comment. + try { + const earlyParams = await params; + if ( + RETIRED_PKCE_PROVIDERS.has(earlyParams.provider) && + earlyParams.action === "poll-callback" + ) { + return NextResponse.json( + { + error: + `Browser OAuth disabled for ${earlyParams.provider} — use import-token via ` + + `/api/oauth/${earlyParams.provider}/import-token. ` + + `Visit https://windsurf.com/show-auth-token to obtain a token.`, + }, + { status: 410 } + ); + } + } catch { + /* fall through to normal handling */ + } + const authResponse = await requireOAuthRouteAuth(request); if (authResponse) return authResponse; try { const { provider, action } = await params; + + // Phase 1 hotfix (2026-05-29): retired PKCE flows return 410 Gone before + // body parsing. windsurf/devin-cli `poll-callback` is permanently retired + // because the upstream PKCE endpoint returns 404. Use /import-token + // (handled later in this same handler) for those providers instead. + if (RETIRED_PKCE_PROVIDERS.has(provider) && action === "poll-callback") { + return NextResponse.json( + { + error: + `Browser OAuth disabled for ${provider} — use import-token via ` + + `/api/oauth/${provider}/import-token. ` + + `Visit https://windsurf.com/show-auth-token to obtain a token.`, + }, + { status: 410 } + ); + } + let rawBody: any = {}; try { rawBody = await request.json(); diff --git a/tests/unit/windsurf-devin-executors.test.ts b/tests/unit/windsurf-devin-executors.test.ts index c75087f902..339a5bac9b 100644 --- a/tests/unit/windsurf-devin-executors.test.ts +++ b/tests/unit/windsurf-devin-executors.test.ts @@ -260,3 +260,59 @@ test("devin-cli provider: generateAuthData returns no authUrl", () => { assert.equal(data.authUrl, undefined); assert.equal(data.supported, false); }); + +// ─── Phase 1 hotfix: retired PKCE actions return 410 Gone ──────────────────── +import { GET as oauthGet, POST as oauthPost } from "@/app/api/oauth/[provider]/[action]/route"; + +test("OAuth route: GET windsurf/start-callback-server returns 410 Gone", async () => { + const url = "http://localhost:20128/api/oauth/windsurf/start-callback-server"; + const request = new Request(url, { method: "GET" }); + const response = await oauthGet(request, { + params: Promise.resolve({ provider: "windsurf", action: "start-callback-server" }), + } as never); + assert.equal(response.status, 410); + const body = await response.json(); + assert.match(body.error, /import-token|disabled|410|show-auth-token/i); +}); + +test("OAuth route: GET devin-cli/authorize returns 410 Gone", async () => { + const url = "http://localhost:20128/api/oauth/devin-cli/authorize"; + const request = new Request(url, { method: "GET" }); + const response = await oauthGet(request, { + params: Promise.resolve({ provider: "devin-cli", action: "authorize" }), + } as never); + assert.equal(response.status, 410); + const body = await response.json(); + assert.match(body.error, /import-token|disabled|410|show-auth-token/i); +}); + +test("OAuth route: GET windsurf/poll-callback returns 410 Gone", async () => { + const url = "http://localhost:20128/api/oauth/windsurf/poll-callback"; + const request = new Request(url, { method: "GET" }); + const response = await oauthGet(request, { + params: Promise.resolve({ provider: "windsurf", action: "poll-callback" }), + } as never); + assert.equal(response.status, 410); +}); + +test("OAuth route: POST windsurf/poll-callback returns 410 Gone", async () => { + const url = "http://localhost:20128/api/oauth/windsurf/poll-callback"; + const request = new Request(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + const response = await oauthPost(request, { + params: Promise.resolve({ provider: "windsurf", action: "poll-callback" }), + } as never); + assert.equal(response.status, 410); +}); + +test("OAuth route: GET codex/authorize is NOT retired (regression check)", async () => { + const url = "http://localhost:20128/api/oauth/codex/authorize"; + const request = new Request(url, { method: "GET" }); + const response = await oauthGet(request, { + params: Promise.resolve({ provider: "codex", action: "authorize" }), + } as never); + assert.notEqual(response.status, 410); +});