mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 08:12:43 +00:00
173 lines
8.0 KiB
TypeScript
173 lines
8.0 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, getDiscoveryValidator } from '@/lib/oauth/token-exchange';
|
|
import { discoverOAuth } from '@/lib/oauth/discovery';
|
|
import { getOauthScopes, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
|
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
|
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
|
import { configManager } from '@/lib/admin/config-manager';
|
|
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
|
|
import { mobileHandoffTarget } from '@/lib/auth/mobile-handoff';
|
|
|
|
const SSO_PENDING_COOKIE = 'sso_pending';
|
|
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
|
|
|
// Only the mobile app's own callback link may flow through the mobile
|
|
// handoff path (lib/auth/mobile-handoff.ts); without the guard the SSO
|
|
// complete route would be coerced into returning tokens to whatever
|
|
// caller-controlled URL the attacker chose.
|
|
|
|
/**
|
|
* A re-auth mints the grant a paired phone keeps using. Providers such as
|
|
* Keycloak tie ordinary refresh tokens to the browser's login session, so
|
|
* signing out on the desktop (RP-initiated logout) would sign the phone out
|
|
* too; an `offline_access` token outlives that session. Only asked for when
|
|
* the provider advertises it, as an unknown scope can fail the request.
|
|
*/
|
|
function reauthScopes(scopes: string, isReauth: boolean, supported: string[] | undefined): string {
|
|
if (!isReauth || !supported?.includes('offline_access')) return scopes;
|
|
const list = scopes.split(/\s+/).filter(Boolean);
|
|
return list.includes('offline_access') ? scopes : [...list, 'offline_access'].join(' ');
|
|
}
|
|
|
|
export async function POST(request: NextRequest) {
|
|
// CSRF gate (GHSA-qvr9-m8cq-7wvg): cookies written here are SameSite=Lax,
|
|
// so a cross-site top-level POST would otherwise reach this handler.
|
|
const crossOrigin = rejectCrossOriginRequest(request);
|
|
if (crossOrigin) return crossOrigin;
|
|
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,
|
|
purpose: rawPurpose,
|
|
slot: rawSlot,
|
|
} = await request.json();
|
|
|
|
// `reauth` drives the step-up flow for device pairing: it forces a fresh
|
|
// IdP login (prompt=login) and the /reauth/sso/complete handler sets the
|
|
// short-lived pairing re-auth proof instead of logging the user in again.
|
|
const isReauth = rawPurpose === 'reauth';
|
|
|
|
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 && !mobileHandoffTarget(mobileRedirectUri)) {
|
|
return NextResponse.json({ error: 'Invalid mobile_redirect_uri' }, { status: 400 });
|
|
}
|
|
|
|
const cookieStore = await cookies();
|
|
|
|
// A re-auth must hit the IdP of the account being paired. The client
|
|
// doesn't know that account's server id, so read it from the slot's
|
|
// server cookie like pair/create does. Without it, deployments that only
|
|
// configure OAuth per server (no global client/server) fail discovery.
|
|
const reauthSlot =
|
|
typeof rawSlot === 'number' && Number.isInteger(rawSlot) && rawSlot >= 0 && rawSlot < MAX_ACCOUNT_SLOTS ? rawSlot : 0;
|
|
let serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
|
|
if (!serverId && isReauth) {
|
|
serverId = cookieStore.get(refreshTokenServerCookieName(reauthSlot))?.value || 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(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.
|
|
// 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 } : {}),
|
|
// The re-auth completes into a grant for the phone paired to this slot.
|
|
...(isReauth ? { purpose: 'reauth', slot: reauthSlot } : {}),
|
|
};
|
|
|
|
const encrypted = encryptPayload(pendingData, 'sso-pending');
|
|
const baseCookieOpts = getCookieOptions();
|
|
cookieStore.set(SSO_PENDING_COOKIE, encrypted, {
|
|
...baseCookieOpts,
|
|
maxAge: SSO_PENDING_MAX_AGE,
|
|
});
|
|
|
|
// Build authorize URL. OAUTH_AUTHORIZE_URL, when set, overrides only the
|
|
// user-facing authorize endpoint (e.g. a per-brand login host). Discovery,
|
|
// token exchange and refresh keep using the canonical discovered endpoints.
|
|
const authorizeOverride =
|
|
configManager.get<string>('oauthAuthorizeUrl', '') || process.env.OAUTH_AUTHORIZE_URL;
|
|
const authUrl = new URL(authorizeOverride?.trim() || 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', reauthScopes(getOauthScopes(), isReauth, metadata.scopes_supported));
|
|
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);
|
|
}
|
|
|
|
// Force a fresh credential entry for step-up re-auth. prompt=login asks
|
|
// the IdP to re-authenticate even if it has an active session; honoring it
|
|
// depends on the IdP supporting this OIDC param. Deliberately no max_age=0:
|
|
// some IdPs (Authelia) re-evaluate max_age at the consent step and loop
|
|
// back to login forever, and nothing on our side reads auth_time.
|
|
if (isReauth) {
|
|
authUrl.searchParams.set('prompt', 'login');
|
|
}
|
|
|
|
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 });
|
|
}
|
|
}
|