import { NextRequest, NextResponse } from 'next/server'; import { cookies } from 'next/headers'; import { logger } from '@/lib/logger'; import { discoverOAuth } from '@/lib/oauth/discovery'; import { REFRESH_TOKEN_COOKIE } from '@/lib/oauth/tokens'; const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || ''; const COOKIE_OPTIONS = { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax' as const, path: '/', maxAge: 30 * 24 * 60 * 60, }; function getRequiredConfig() { const clientId = process.env.OAUTH_CLIENT_ID; const serverUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL; const issuerUrl = process.env.OAUTH_ISSUER_URL; if (!clientId || !serverUrl) { throw new Error(`OAuth misconfigured: ${[!clientId && 'OAUTH_CLIENT_ID', !serverUrl && 'JMAP_SERVER_URL'].filter(Boolean).join(', ')} not set`); } const discoveryUrl = issuerUrl?.trim() || serverUrl; if (issuerUrl !== undefined && !issuerUrl.trim()) { logger.warn('OAUTH_ISSUER_URL is set but empty, falling back to JMAP_SERVER_URL for discovery'); } return { clientId, serverUrl, discoveryUrl }; } async function getTokenEndpoint(): Promise { const { discoveryUrl } = getRequiredConfig(); const metadata = await discoverOAuth(discoveryUrl); if (!metadata?.token_endpoint) { throw new Error('OAuth token endpoint not found'); } return metadata.token_endpoint; } async function getRevocationEndpoint(): Promise { const { discoveryUrl } = getRequiredConfig(); const metadata = await discoverOAuth(discoveryUrl); return metadata?.revocation_endpoint || null; } function buildOAuthParams(base: Record): URLSearchParams { const { clientId } = getRequiredConfig(); const params = new URLSearchParams({ ...base, client_id: clientId }); if (CLIENT_SECRET) { params.set('client_secret', CLIENT_SECRET); } return params; } export async function POST(request: NextRequest) { try { const { code, code_verifier, redirect_uri } = await request.json(); if (!code || !code_verifier || !redirect_uri) { return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 }); } const tokenEndpoint = await getTokenEndpoint(); const params = buildOAuthParams({ grant_type: 'authorization_code', code, redirect_uri, code_verifier, }); 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 exchange failed', { status: tokenResponse.status, error: errorText }); return NextResponse.json({ error: 'Token exchange failed' }, { status: 401 }); } const tokens = await tokenResponse.json(); if (!tokens.access_token) { logger.error('Token response missing access_token', { response: JSON.stringify(tokens).substring(0, 500) }); return NextResponse.json({ error: 'Invalid token response' }, { status: 502 }); } const response = NextResponse.json({ access_token: tokens.access_token, expires_in: tokens.expires_in || 3600, }); if (tokens.refresh_token) { const cookieStore = await cookies(); cookieStore.set(REFRESH_TOKEN_COOKIE, tokens.refresh_token, COOKIE_OPTIONS); } 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() { try { const cookieStore = await cookies(); const refreshToken = cookieStore.get(REFRESH_TOKEN_COOKIE)?.value; if (!refreshToken) { return NextResponse.json({ error: 'No refresh token' }, { status: 401 }); } const tokenEndpoint = await getTokenEndpoint(); const params = buildOAuthParams({ grant_type: 'refresh_token', refresh_token: refreshToken, }); 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(REFRESH_TOKEN_COOKIE); 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(REFRESH_TOKEN_COOKIE, tokens.refresh_token, COOKIE_OPTIONS); } 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() { try { const cookieStore = await cookies(); const refreshToken = cookieStore.get(REFRESH_TOKEN_COOKIE)?.value; if (refreshToken) { const revocationEndpoint = await getRevocationEndpoint(); if (revocationEndpoint) { const params = buildOAuthParams({ token: refreshToken, token_type_hint: 'refresh_token', }); try { const revocationResponse = await fetch(revocationEndpoint, { 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(REFRESH_TOKEN_COOKIE); } return NextResponse.json({ ok: true }); } catch (error) { logger.error('Token revocation error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } }