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, getDiscoveryValidator } from '@/lib/oauth/token-exchange'; import { discoverOAuth } from '@/lib/oauth/discovery'; import { getOauthScopes } 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 // The mobile app's deep-link scheme. Only redirect targets starting with // this prefix may flow through the mobile handoff path; without the guard // the SSO complete route would be coerced into returning tokens to whatever // caller-controlled URL the attacker chose. const MOBILE_REDIRECT_SCHEME = 'bulwarkmobile://'; 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, mobile_redirect_uri: rawMobileRedirectUri, mobile_state: rawMobileState, } = await request.json(); if (!redirect_uri || typeof redirect_uri !== 'string') { return NextResponse.json({ error: 'Missing redirect_uri' }, { status: 400 }); } const mobileRedirectUri = typeof rawMobileRedirectUri === 'string' && rawMobileRedirectUri ? rawMobileRedirectUri : null; const mobileState = typeof rawMobileState === 'string' && rawMobileState ? rawMobileState : null; if (mobileRedirectUri && !mobileRedirectUri.startsWith(MOBILE_REDIRECT_SCHEME)) { return NextResponse.json({ error: 'Invalid mobile_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, { validateEndpoint: getDiscoveryValidator() }); 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. // Mobile params are captured here so /complete knows to return tokens to // the caller (in the JSON response) instead of writing the usual server // cookies - and so the callback page can redirect back to the app. const pendingData = { state, code_verifier: codeVerifier, redirect_uri, created_at: Date.now(), ...(serverId ? { server_id: serverId } : {}), ...(mobileRedirectUri ? { mobile_redirect_uri: mobileRedirectUri } : {}), ...(mobileState ? { mobile_state: mobileState } : {}), }; 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', getOauthScopes()); 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 }); } }