import { NextRequest, NextResponse } from 'next/server'; import { cookies } from 'next/headers'; import { logger } from '@/lib/logger'; import { encryptPayload } from '@/lib/auth/crypto'; import { generateCodeVerifierServer, generateCodeChallengeServer, generateStateServer } from '@/lib/oauth/pkce-server'; import { getRequiredConfig } from '@/lib/oauth/token-exchange'; import { discoverOAuth } from '@/lib/oauth/discovery'; import { OAUTH_SCOPES } from '@/lib/oauth/tokens'; import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { hasSessionSecret } from '@/lib/auth/session-secret'; const SSO_PENDING_COOKIE = 'sso_pending'; const SSO_PENDING_MAX_AGE = 300; // 5 minutes export async function POST(request: NextRequest) { try { if (!hasSessionSecret()) { return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 }); } const { redirect_uri, locale, server_id: bodyServerId } = await request.json(); if (!redirect_uri || typeof redirect_uri !== 'string') { return NextResponse.json({ error: 'Missing redirect_uri' }, { status: 400 }); } const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null; // Validate redirect_uri origin matches the request origin to prevent open redirects const requestOrigin = request.headers.get('origin') || request.nextUrl.origin; try { const redirectOrigin = new URL(redirect_uri).origin; if (redirectOrigin !== requestOrigin) { logger.warn('SSO start: redirect_uri origin mismatch', { redirectOrigin, requestOrigin }); return NextResponse.json({ error: 'Invalid redirect_uri' }, { status: 400 }); } } catch { return NextResponse.json({ error: 'Invalid redirect_uri' }, { status: 400 }); } const { clientId, discoveryUrl } = getRequiredConfig(serverId); const metadata = await discoverOAuth(discoveryUrl); if (!metadata?.authorization_endpoint) { return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 }); } // Generate PKCE + state server-side const codeVerifier = generateCodeVerifierServer(); const codeChallenge = generateCodeChallengeServer(codeVerifier); const state = generateStateServer(); // Encrypt and store in httpOnly cookie. server_id is captured here so the // /complete handler reaches the same OAuth endpoint we used to authorize. const pendingData = { state, code_verifier: codeVerifier, redirect_uri, created_at: Date.now(), ...(serverId ? { server_id: serverId } : {}), }; const encrypted = encryptPayload(pendingData); const cookieStore = await cookies(); const baseCookieOpts = getCookieOptions(); cookieStore.set(SSO_PENDING_COOKIE, encrypted, { ...baseCookieOpts, maxAge: SSO_PENDING_MAX_AGE, }); // Build authorize URL const authUrl = new URL(metadata.authorization_endpoint); authUrl.searchParams.set('response_type', 'code'); authUrl.searchParams.set('client_id', clientId); authUrl.searchParams.set('redirect_uri', redirect_uri); authUrl.searchParams.set('scope', OAUTH_SCOPES); authUrl.searchParams.set('state', state); authUrl.searchParams.set('code_challenge', codeChallenge); authUrl.searchParams.set('code_challenge_method', 'S256'); if (locale) { authUrl.searchParams.set('ui_locales', locale); } return NextResponse.json({ authorize_url: authUrl.toString(), state, }); } catch (error) { logger.error('SSO start error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } }