94 lines
3.7 KiB
TypeScript
94 lines
3.7 KiB
TypeScript
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 { 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
|
|
|
|
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', 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 });
|
|
}
|
|
}
|