import { NextRequest, NextResponse } from 'next/server'; import { cookies } from 'next/headers'; import { logger } from '@/lib/logger'; import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens'; import { exchangeCodeForTokens, buildOAuthParams, getMetadata, getTokenEndpoint } from '@/lib/oauth/token-exchange'; import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; function getSlot(request: NextRequest): number { const raw = request.nextUrl.searchParams.get('slot'); if (raw === null) return 0; const slot = parseInt(raw, 10); if (isNaN(slot) || slot < 0 || slot >= MAX_ACCOUNT_SLOTS) return 0; return slot; } export async function POST(request: NextRequest) { try { const { code, code_verifier, redirect_uri, slot: bodySlot, server_id: bodyServerId } = await request.json(); if (!code || !code_verifier || !redirect_uri) { return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 }); } const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : getSlot(request); const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null; const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri, serverId); const response = NextResponse.json({ access_token: tokens.access_token, expires_in: tokens.expires_in, }); const cookieStore = await cookies(); if (tokens.refresh_token) { const cookieName = refreshTokenCookieName(slot); cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions()); } // Persist which server entry minted this refresh token so the PUT/DELETE // handlers can route the refresh/revocation calls to the right token // endpoint without the client having to track it across page loads. const serverCookieName = refreshTokenServerCookieName(slot); if (serverId) { cookieStore.set(serverCookieName, serverId, getCookieOptions()); } else { cookieStore.delete(serverCookieName); } return response; } catch (error) { logger.error('Token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } } export async function PUT(request: NextRequest) { try { const slot = getSlot(request); const cookieName = refreshTokenCookieName(slot); const cookieStore = await cookies(); const refreshToken = cookieStore.get(cookieName)?.value; const serverId = cookieStore.get(refreshTokenServerCookieName(slot))?.value || null; if (!refreshToken) { return NextResponse.json({ error: 'No refresh token' }, { status: 401 }); } const tokenEndpoint = await getTokenEndpoint(serverId); const params = buildOAuthParams({ grant_type: 'refresh_token', refresh_token: refreshToken, }, serverId); const tokenResponse = await fetch(tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString(), }); if (!tokenResponse.ok) { const errorText = await tokenResponse.text(); logger.error('Token refresh failed', { status: tokenResponse.status, error: errorText }); cookieStore.delete(cookieName); cookieStore.delete(refreshTokenServerCookieName(slot)); return NextResponse.json({ error: 'Refresh failed' }, { status: 401 }); } const tokens = await tokenResponse.json(); if (!tokens.access_token) { logger.error('Refresh response missing access_token', { response: JSON.stringify(tokens).substring(0, 500) }); return NextResponse.json({ error: 'Invalid token response' }, { status: 502 }); } if (tokens.refresh_token) { cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions()); } return NextResponse.json({ access_token: tokens.access_token, expires_in: tokens.expires_in || 3600, }); } catch (error) { logger.error('Token refresh error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } } export async function DELETE(request: NextRequest) { try { const all = request.nextUrl.searchParams.get('all') === 'true'; if (all) { // Revoke and delete all refresh token cookies across every slot. const cookieStore = await cookies(); for (let i = 0; i < MAX_ACCOUNT_SLOTS; i++) { const name = refreshTokenCookieName(i); const serverCookieName = refreshTokenServerCookieName(i); const token = cookieStore.get(name)?.value; const slotServerId = cookieStore.get(serverCookieName)?.value || null; if (token) { // Best-effort revocation try { const metadata = await getMetadata(slotServerId).catch(() => null); if (metadata?.revocation_endpoint) { const params = buildOAuthParams({ token, token_type_hint: 'refresh_token' }, slotServerId); await fetch(metadata.revocation_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString(), }).catch(() => {}); } } catch { /* best effort */ } cookieStore.delete(name); } cookieStore.delete(serverCookieName); } return NextResponse.json({ ok: true }); } const slot = getSlot(request); const cookieName = refreshTokenCookieName(slot); const cookieStore = await cookies(); const refreshToken = cookieStore.get(cookieName)?.value; const slotServerId = cookieStore.get(refreshTokenServerCookieName(slot))?.value || null; const metadata = await getMetadata(slotServerId).catch((err) => { logger.warn('Failed to discover OAuth metadata during logout', { error: err instanceof Error ? err.message : 'Unknown error', }); return null; }); if (refreshToken) { if (metadata?.revocation_endpoint) { const params = buildOAuthParams({ token: refreshToken, token_type_hint: 'refresh_token', }, slotServerId); try { const revocationResponse = await fetch(metadata.revocation_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString(), }); if (!revocationResponse.ok) { logger.warn('Token revocation returned error', { status: revocationResponse.status }); } } catch (err) { logger.error('Token revocation network error', { error: err instanceof Error ? err.message : 'Unknown error' }); } } cookieStore.delete(cookieName); } cookieStore.delete(refreshTokenServerCookieName(slot)); let end_session_url: string | undefined; if (metadata?.end_session_endpoint) { try { const parsed = new URL(metadata.end_session_endpoint); if (parsed.protocol === 'https:') { end_session_url = metadata.end_session_endpoint; } else { logger.warn('Ignoring non-HTTPS end_session_endpoint', { url: metadata.end_session_endpoint }); } } catch { logger.warn('Invalid end_session_endpoint URL', { url: metadata.end_session_endpoint }); } } return NextResponse.json({ ok: true, ...(end_session_url && { end_session_url }) }); } catch (error) { logger.error('Token revocation error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } }