Files
SRCmail/app/api/auth/token/route.ts
T
Matthieu MALVACHEandMatthieu MALVACHE ec06b0c494 feat: add OAuth2/OIDC with PKCE for SSO login
Add opt-in SSO authentication alongside Basic Auth. OAuth endpoints are
auto-discovered via .well-known, with support for external IdPs
(Keycloak, Authentik) via configurable OAUTH_ISSUER_URL. Sessions
persist through httpOnly refresh token cookies with automatic renewal.
2026-02-25 23:41:37 +01:00

194 lines
6.5 KiB
TypeScript

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<string> {
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<string | null> {
const { discoveryUrl } = getRequiredConfig();
const metadata = await discoverOAuth(discoveryUrl);
return metadata?.revocation_endpoint || null;
}
function buildOAuthParams(base: Record<string, string>): 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 });
}
}