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.
194 lines
6.5 KiB
TypeScript
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 });
|
|
}
|
|
}
|