182 lines
6.5 KiB
TypeScript
182 lines
6.5 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { cookies } from 'next/headers';
|
|
import { logger } from '@/lib/logger';
|
|
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
|
import { exchangeCodeForTokens, buildOAuthParams, getMetadata, getTokenEndpoint } from '@/lib/oauth/token-exchange';
|
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
|
|
|
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 > 4) return 0;
|
|
return slot;
|
|
}
|
|
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
const { code, code_verifier, redirect_uri, slot: bodySlot } = 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 <= 4 ? bodySlot : getSlot(request);
|
|
|
|
const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri);
|
|
|
|
const response = NextResponse.json({
|
|
access_token: tokens.access_token,
|
|
expires_in: tokens.expires_in,
|
|
});
|
|
|
|
if (tokens.refresh_token) {
|
|
const cookieName = refreshTokenCookieName(slot);
|
|
const cookieStore = await cookies();
|
|
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
|
}
|
|
|
|
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;
|
|
|
|
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(cookieName);
|
|
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 (slots 0-4)
|
|
const cookieStore = await cookies();
|
|
for (let i = 0; i <= 4; i++) {
|
|
const name = refreshTokenCookieName(i);
|
|
const token = cookieStore.get(name)?.value;
|
|
if (token) {
|
|
// Best-effort revocation
|
|
try {
|
|
const metadata = await getMetadata().catch(() => null);
|
|
if (metadata?.revocation_endpoint) {
|
|
const params = buildOAuthParams({ token, token_type_hint: 'refresh_token' });
|
|
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);
|
|
}
|
|
}
|
|
return NextResponse.json({ ok: true });
|
|
}
|
|
|
|
const slot = getSlot(request);
|
|
const cookieName = refreshTokenCookieName(slot);
|
|
const cookieStore = await cookies();
|
|
const refreshToken = cookieStore.get(cookieName)?.value;
|
|
const metadata = await getMetadata().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',
|
|
});
|
|
|
|
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);
|
|
}
|
|
|
|
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 });
|
|
}
|
|
}
|