fix(oauth): windsurf import-token mapTokens signature mismatch

The route at `src/app/api/oauth/[provider]/[action]/route.ts` invokes
`providerData.mapTokens({ accessToken: token })` (object), matching the
cursor/kiro signature. The windsurf provider was declared with
`mapTokens(token: string)` instead, so the entire object was stored as
`accessToken`. When the connection record reached the SQLite layer it
crashed with:

  SQLite3 can only bind numbers, strings, bigints, buffers, and null

Fix by aligning windsurf's `mapTokens` signature with the route caller
and the cursor/kiro convention. Also dedupe a copy-pasted second
`if (action === "import-token")` block in the route handler — the
second block was unreachable but identical to the first.

Adds two regression tests asserting that
`provider.mapTokens({ accessToken })` returns a string `accessToken` for
both windsurf and devin-cli, so a future signature drift trips the gate
instead of the SQLite bind error in production.
This commit is contained in:
yuna amelia
2026-05-29 13:47:45 +08:00
parent a928d9f56c
commit e3d5bc2d61
3 changed files with 31 additions and 76 deletions

View File

@@ -755,79 +755,6 @@ 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: sanitizeErrorMessage(importErr.message) || "Import failed" },
{ status: 500 }
);
}
}
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
} catch (error) {
console.error("OAuth POST error:", error);

View File

@@ -39,12 +39,20 @@ export const windsurf = {
/**
* Map a pasted import token onto the connection record. The token IS the
* Windsurf API key; there is no exchange step.
*
* NOTE: callers in `src/app/api/oauth/[provider]/[action]/route.ts` invoke
* this with `{ accessToken: token }` (object), matching the cursor/kiro
* signature. The earlier signature was `mapTokens(token: string)`, which
* caused the route to spread `{ accessToken: { accessToken: "sk-..." } }`
* into the DB layer and crashed the SQLite bind step:
* `SQLite3 can only bind numbers, strings, bigints, buffers, and null`.
* Keep the object signature.
*/
mapTokens(token: string) {
mapTokens(tokens: { accessToken: string }) {
return {
accessToken: token,
accessToken: tokens.accessToken,
refreshToken: null,
expiresAt: null,
expiresIn: null as number | null,
};
},
};

View File

@@ -316,3 +316,23 @@ test("OAuth route: GET codex/authorize is NOT retired (regression check)", async
} as never);
assert.notEqual(response.status, 410);
});
// ─── Regression: mapTokens accepts {accessToken} object, returns string accessToken ─
// Earlier signature was `mapTokens(token: string)` which crashed the SQLite
// bind layer when the route called `mapTokens({ accessToken })`: the object
// got stored as accessToken and SQLite rejected it with
// "SQLite3 can only bind numbers, strings, bigints, buffers, and null".
test("windsurf mapTokens: accepts object {accessToken} and returns string accessToken", () => {
const provider = getProvider("windsurf");
const mapped = provider.mapTokens({ accessToken: "sk-ws-test-token-1234567890" });
assert.equal(typeof mapped.accessToken, "string");
assert.equal(mapped.accessToken, "sk-ws-test-token-1234567890");
assert.equal(mapped.refreshToken, null);
});
test("devin-cli mapTokens: accepts object {accessToken} and returns string accessToken", () => {
const provider = getProvider("devin-cli");
const mapped = provider.mapTokens({ accessToken: "sk-devin-test-token-1234567890" });
assert.equal(typeof mapped.accessToken, "string");
assert.equal(mapped.accessToken, "sk-devin-test-token-1234567890");
});