9router/src/app/api/oauth/[provider]/[action]/route.js

234 lines
8.3 KiB
JavaScript
Raw Normal View History

2026-01-04 21:58:59 -05:00
import { NextResponse } from "next/server";
import {
getProvider,
generateAuthData,
exchangeTokens,
requestDeviceCode,
pollForToken
} from "@/lib/oauth/providers";
import { createProviderConnection } from "@/models";
import {
startCodexProxy,
stopCodexProxy,
registerCodexSession,
getCodexSessionStatus,
clearCodexSession,
} from "@/lib/oauth/utils/server";
2026-01-04 21:58:59 -05:00
/**
* Dynamic OAuth API Route
* Handles: authorize, exchange, device-code, poll
*/
// GET /api/oauth/[provider]/authorize - Generate auth URL
// GET /api/oauth/[provider]/device-code - Request device code (for device_code flow)
export async function GET(request, { params }) {
try {
const { provider, action } = await params;
const { searchParams } = new URL(request.url);
if (action === "authorize") {
const redirectUri = searchParams.get("redirect_uri") || "http://localhost:8080/callback";
// Collect provider-specific meta params (e.g. gitlab passes baseUrl, clientId, clientSecret)
const reservedParams = new Set(["redirect_uri"]);
const meta = {};
searchParams.forEach((value, key) => { if (!reservedParams.has(key)) meta[key] = value; });
const authData = generateAuthData(provider, redirectUri, Object.keys(meta).length ? meta : undefined);
2026-01-04 21:58:59 -05:00
return NextResponse.json(authData);
}
if (action === "start-proxy") {
if (provider !== "codex") {
return NextResponse.json({ error: "Proxy only supported for codex" }, { status: 400 });
}
const appPort = searchParams.get("app_port");
if (!appPort) {
return NextResponse.json({ error: "Missing app_port" }, { status: 400 });
}
// Optional server-side mode params: register session for auto-exchange
const state = searchParams.get("state");
const codeVerifier = searchParams.get("code_verifier");
const redirectUri = searchParams.get("redirect_uri");
const result = await startCodexProxy(Number(appPort));
let serverSide = false;
if (result.success && state && codeVerifier && redirectUri) {
serverSide = registerCodexSession({ state, codeVerifier, redirectUri });
}
return NextResponse.json({ ...result, serverSide });
}
if (action === "poll-status") {
if (provider !== "codex") {
return NextResponse.json({ error: "Poll only supported for codex" }, { status: 400 });
}
const state = searchParams.get("state");
if (!state) {
return NextResponse.json({ error: "Missing state" }, { status: 400 });
}
const session = getCodexSessionStatus(state);
if (!session) return NextResponse.json({ status: "unknown" });
if (session.status === "done" || session.status === "error") {
const payload = { ...session };
clearCodexSession(state);
return NextResponse.json(payload);
}
return NextResponse.json({ status: session.status });
}
if (action === "stop-proxy") {
if (provider !== "codex") {
return NextResponse.json({ error: "Proxy only supported for codex" }, { status: 400 });
}
stopCodexProxy();
return NextResponse.json({ success: true });
}
2026-01-04 21:58:59 -05:00
if (action === "device-code") {
const providerData = getProvider(provider);
if (providerData.flowType !== "device_code") {
return NextResponse.json({ error: "Provider does not support device code flow" }, { status: 400 });
}
const authData = generateAuthData(provider, null);
const startUrl = searchParams.get("start_url");
const region = searchParams.get("region");
const authMethod = searchParams.get("auth_method");
const deviceOptions = provider === "kiro"
? {
...(startUrl ? { startUrl } : {}),
...(region ? { region } : {}),
...(authMethod ? { authMethod } : {}),
}
: undefined;
2026-01-04 21:58:59 -05:00
2026-02-20 05:05:46 -05:00
// Providers that don't use PKCE for device code
const noPkceDeviceProviders = ["github", "kiro", "kimi-coding", "kilocode", "codebuddy"];
2026-01-04 21:58:59 -05:00
let deviceData;
2026-02-20 05:05:46 -05:00
if (noPkceDeviceProviders.includes(provider)) {
deviceData = await requestDeviceCode(provider, undefined, deviceOptions);
2026-01-04 21:58:59 -05:00
} else {
2026-02-20 05:05:46 -05:00
// Qwen and other PKCE providers
deviceData = await requestDeviceCode(provider, authData.codeChallenge, deviceOptions);
2026-01-04 21:58:59 -05:00
}
return NextResponse.json({
...deviceData,
codeVerifier: authData.codeVerifier,
});
}
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
} catch (error) {
console.log("OAuth GET error:", error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
// POST /api/oauth/[provider]/exchange - Exchange code for tokens and save
// POST /api/oauth/[provider]/poll - Poll for token (device_code flow)
export async function POST(request, { params }) {
try {
const { provider, action } = await params;
2026-02-22 09:44:11 -05:00
let body;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid or empty request body" }, { status: 400 });
}
2026-01-04 21:58:59 -05:00
if (action === "exchange") {
const { code, redirectUri, codeVerifier, state, meta } = body;
2026-01-04 21:58:59 -05:00
2026-02-20 05:05:46 -05:00
// Cline uses authorization_code without PKCE
const noPkceExchangeProviders = ["cline"];
if (!code || !redirectUri || (!codeVerifier && !noPkceExchangeProviders.includes(provider))) {
2026-01-04 21:58:59 -05:00
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
// Exchange code for tokens (meta carries provider-specific params, e.g. gitlab clientId/baseUrl)
const tokenData = await exchangeTokens(provider, code, redirectUri, codeVerifier, state, meta);
2026-01-04 21:58:59 -05:00
// Save to database
const connection = await createProviderConnection({
provider,
authType: "oauth",
...tokenData,
expiresAt: tokenData.expiresIn
? new Date(Date.now() + tokenData.expiresIn * 1000).toISOString()
: null,
testStatus: "active",
});
return NextResponse.json({
success: true,
connection: {
id: connection.id,
provider: connection.provider,
email: connection.email,
displayName: connection.displayName,
}
});
}
if (action === "poll") {
2026-01-15 06:29:47 -05:00
const { deviceCode, codeVerifier, extraData } = body;
2026-01-04 21:58:59 -05:00
if (!deviceCode) {
return NextResponse.json({ error: "Missing device code" }, { status: 400 });
}
2026-02-20 05:05:46 -05:00
// Providers that don't use PKCE for device code
const noPkceProviders = ["github", "kimi-coding", "kilocode", "codebuddy"];
2026-01-04 21:58:59 -05:00
let result;
2026-02-20 05:05:46 -05:00
if (noPkceProviders.includes(provider)) {
2026-01-04 21:58:59 -05:00
result = await pollForToken(provider, deviceCode);
2026-01-15 06:29:47 -05:00
} else if (provider === "kiro") {
// Kiro needs extraData (clientId, clientSecret) from device code response
result = await pollForToken(provider, deviceCode, null, extraData);
2026-01-04 21:58:59 -05:00
} else {
2026-02-20 05:05:46 -05:00
// Qwen and other PKCE providers
2026-01-04 21:58:59 -05:00
if (!codeVerifier) {
return NextResponse.json({ error: "Missing code verifier" }, { status: 400 });
}
result = await pollForToken(provider, deviceCode, codeVerifier);
}
if (result.success) {
// Save to database
const connection = await createProviderConnection({
provider,
authType: "oauth",
...result.tokens,
expiresAt: result.tokens.expiresIn
? new Date(Date.now() + result.tokens.expiresIn * 1000).toISOString()
: null,
testStatus: "active",
});
return NextResponse.json({
success: true,
connection: {
id: connection.id,
provider: connection.provider,
}
});
}
2026-01-15 06:29:47 -05:00
// Still pending or error - don't create connection for pending states
const isPending = result.pending || result.error === "authorization_pending" || result.error === "slow_down";
2026-01-04 21:58:59 -05:00
return NextResponse.json({
success: false,
error: result.error,
errorDescription: result.errorDescription,
2026-01-15 06:29:47 -05:00
pending: isPending,
2026-01-04 21:58:59 -05:00
});
}
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
} catch (error) {
console.log("OAuth POST error:", error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}