fix(oauth): return 410 Gone for retired windsurf/devin-cli PKCE actions

start-callback-server, authorize, and poll-callback (GET + POST) now
return 410 Gone with a pointer to /import-token. The 410 short-circuit
runs before auth so the response is honest about the action being
permanently gone, not gated. Codex PKCE flow unchanged.

Tests: 5 new assertions cover GET + POST 410 paths and a Codex
regression check.
This commit is contained in:
yuna amelia
2026-05-29 12:59:17 +08:00
parent 6d157e481f
commit 82999c021f
2 changed files with 133 additions and 1 deletions

View File

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

View File

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