- P2.9: VNCtalk video meeting — create/update meeting from event modal, 'Join Meeting' link in event detail. Admin config vnctalkServerUrl. - P2.10: Collabora online editing — 'Edit with Collabora' for office files, WOPI discovery + edit URL. Admin config collaboraServerUrl. - P2.11: Calendar enhancements — clickable links in descriptions, participant contact popover, Reply/Reply All from event, timezone picker, map links for locations. - P2.13: VNCdirectory IDP admin panel — Connection, SAML/IDP, LDAP, Authentication, Federated Apps configuration. Secret masking on display.
152 lines
6.7 KiB
TypeScript
152 lines
6.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, 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';
|
|
import { configManager } from '@/lib/admin/config-manager';
|
|
|
|
// TODO(P2.13): Wire SAML IDP integration once VNCdirectory is configured.
|
|
// When VNCdirectory is enabled and SAML is configured (see
|
|
// lib/admin/vncdirectory-config.ts), the SSO start flow should:
|
|
// 1. Check isVncDirectoryEnabled() — if false, fall through to existing OAuth flow.
|
|
// 2. Read getVncDirectoryConfig() for samlIdpUrl, samlIssuer, samlSpCert.
|
|
// 3. Build a SAML AuthnRequest and redirect to the IdP instead of OAuth.
|
|
// 4. The /sso/complete handler should process the SAML Response assertion,
|
|
// validate the signature against the SP certificate, extract the subject,
|
|
// and create a session.
|
|
// Reference: docs/admin/VNCDIRECTORY.md in the VNCmail+ plan (P2.13).
|
|
|
|
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,
|
|
purpose: rawPurpose,
|
|
} = 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 && !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 } : {}),
|
|
...(isReauth ? { purpose: 'reauth' } : {}),
|
|
};
|
|
|
|
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. 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', 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);
|
|
}
|
|
|
|
// Force a fresh credential entry for step-up re-auth. prompt=login and
|
|
// max_age=0 both ask the IdP to re-authenticate even if it has an active
|
|
// session; honoring them depends on the IdP supporting these OIDC params.
|
|
if (isReauth) {
|
|
authUrl.searchParams.set('prompt', 'login');
|
|
authUrl.searchParams.set('max_age', '0');
|
|
}
|
|
|
|
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 });
|
|
}
|
|
}
|