diff --git a/app/[locale]/auth/callback/page.tsx b/app/[locale]/auth/callback/page.tsx index 3bc12379..bb8c2896 100644 --- a/app/[locale]/auth/callback/page.tsx +++ b/app/[locale]/auth/callback/page.tsx @@ -13,7 +13,7 @@ function OAuthCallbackInner() { const params = useParams(); const searchParams = useSearchParams(); const t = useTranslations("login"); - const { loginWithOAuth } = useAuthStore(); + const { loginWithOAuth, loginWithServerSso } = useAuthStore(); const [error, setError] = useState(null); useEffect(() => { @@ -32,44 +32,71 @@ function OAuthCallbackInner() { } const savedState = sessionStorage.getItem("oauth_state"); - if (!state || state !== savedState) { - setError("invalid_state"); - return; - } - const codeVerifier = sessionStorage.getItem("oauth_code_verifier"); - const serverUrl = sessionStorage.getItem("oauth_server_url"); + if (savedState) { + // Classic flow — sessionStorage has the PKCE state (same-tab OAuth) + if (!state || state !== savedState) { + setError("invalid_state"); + return; + } - if (!codeVerifier || !serverUrl) { - setError("missing_params"); - return; - } + const codeVerifier = sessionStorage.getItem("oauth_code_verifier"); + const serverUrl = sessionStorage.getItem("oauth_server_url"); - const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`; + if (!codeVerifier || !serverUrl) { + setError("missing_params"); + return; + } - loginWithOAuth(serverUrl, code, codeVerifier, redirectUri) - .then((success) => { - if (success) { - sessionStorage.removeItem("oauth_state"); - sessionStorage.removeItem("oauth_code_verifier"); - sessionStorage.removeItem("oauth_server_url"); - sessionStorage.removeItem("oauth_add_account_mode"); - let redirectTo = `/${params.locale}`; - try { - const saved = sessionStorage.getItem('redirect_after_login'); - if (saved) { - sessionStorage.removeItem('redirect_after_login'); - redirectTo = saved; - } - } catch { /* sessionStorage may be unavailable */ } - router.push(redirectTo); - } else { + const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`; + + loginWithOAuth(serverUrl, code, codeVerifier, redirectUri) + .then((success) => { + if (success) { + sessionStorage.removeItem("oauth_state"); + sessionStorage.removeItem("oauth_code_verifier"); + sessionStorage.removeItem("oauth_server_url"); + sessionStorage.removeItem("oauth_add_account_mode"); + let redirectTo = `/${params.locale}`; + try { + const saved = sessionStorage.getItem('redirect_after_login'); + if (saved) { + sessionStorage.removeItem('redirect_after_login'); + redirectTo = saved; + } + } catch { /* sessionStorage may be unavailable */ } + router.push(redirectTo); + } else { + setError("token_exchange_failed"); + } + }) + .catch(() => { setError("token_exchange_failed"); - } - }) - .catch(() => { - setError("token_exchange_failed"); - }); + }); + } else if (state) { + // Server-side SSO flow — state was stored in encrypted httpOnly cookie + loginWithServerSso(code, state) + .then((success) => { + if (success) { + let redirectTo = `/${params.locale}`; + try { + const saved = sessionStorage.getItem('redirect_after_login'); + if (saved) { + sessionStorage.removeItem('redirect_after_login'); + redirectTo = saved; + } + } catch { /* sessionStorage may be unavailable */ } + router.push(redirectTo); + } else { + setError("token_exchange_failed"); + } + }) + .catch(() => { + setError("token_exchange_failed"); + }); + } else { + setError("invalid_state"); + } }, []); // eslint-disable-line react-hooks/exhaustive-deps if (error) { diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx index b5578732..825ffdc2 100644 --- a/app/[locale]/layout.tsx +++ b/app/[locale]/layout.tsx @@ -2,6 +2,7 @@ import { notFound } from "next/navigation"; import { IntlProvider } from "@/components/providers/intl-provider"; import { ThemeProvider } from "@/components/providers/theme-provider"; import { CalendarAlertProvider } from "@/components/providers/calendar-alert-provider"; +import { EmbeddedBridgeProvider } from "@/components/providers/embedded-bridge-provider"; import { TourProvider } from "@/components/tour/tour-provider"; import { locales } from "@/i18n/routing"; @@ -27,9 +28,11 @@ export default async function LocaleLayout({ - - {children} - + + + {children} + + diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index f98a3331..ba076724 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -32,7 +32,7 @@ export default function LoginPage() { const isAddAccountMode = searchParams.get("mode") === "add-account"; const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore(); const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme }))); - const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError } = useConfig(); + const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode } = useConfig(); const resolvedTheme = useThemeStore((s) => s.resolvedTheme); const [formData, setFormData] = useState({ @@ -173,6 +173,63 @@ export default function LoginPage() { }); }, [oauthEnabled, serverUrl, oauthIssuerUrl]); + // Auto-SSO: when enabled with OAUTH_ONLY, skip the login page entirely + const ssoError = searchParams.get("sso_error"); + const autoSsoTriggered = useRef(false); + + const startServerSideSso = useCallback(async () => { + setOauthLoading(true); + try { + const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`; + const res = await fetch('/api/auth/sso/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ redirect_uri: redirectUri, locale: params.locale }), + }); + + if (!res.ok) { + setOauthLoading(false); + return; + } + + const { authorize_url } = await res.json(); + + // Navigate to the authorize URL + const isIframe = (() => { try { return window.self !== window.top; } catch { return true; } })(); + if (isIframe) { + // In an iframe, try top-level navigation + try { + window.top!.location.href = authorize_url; + } catch { + // Cross-origin restriction — fall back to current frame + window.location.href = authorize_url; + } + } else { + window.location.href = authorize_url; + } + } catch { + setOauthLoading(false); + } + }, [params.locale]); + + useEffect(() => { + if (!autoSsoEnabled || !oauthOnly || !oauthDiscoveryDone || !oauthMetadata) return; + if (ssoError || isAddAccountMode || isAuthenticated) return; + if (autoSsoTriggered.current) return; + + // Guard against redirect loops + try { + if (sessionStorage.getItem("sso_attempted")) return; + sessionStorage.setItem("sso_attempted", "1"); + // Clear the flag after 30 seconds so retries are possible + setTimeout(() => { try { sessionStorage.removeItem("sso_attempted"); } catch { /* ignore */ } }, 30000); + } catch { /* sessionStorage unavailable */ } + + autoSsoTriggered.current = true; + startServerSideSso(); + }, [autoSsoEnabled, oauthOnly, oauthDiscoveryDone, oauthMetadata, ssoError, isAddAccountMode, isAuthenticated, startServerSideSso]); + const handleThemeSelect = useCallback((newTheme: "light" | "dark" | "system") => { setTheme(newTheme); setShowThemeMenu(false); diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts index a205fa8f..f1733f50 100644 --- a/app/api/auth/session/route.ts +++ b/app/api/auth/session/route.ts @@ -3,12 +3,10 @@ import { cookies } from 'next/headers'; import { logger } from '@/lib/logger'; import { encryptSession, decryptSession } from '@/lib/auth/crypto'; import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie'; +import { getCookieOptions } from '@/lib/oauth/cookie-config'; const COOKIE_OPTIONS = { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax' as const, - path: '/', + ...getCookieOptions(), maxAge: SESSION_COOKIE_MAX_AGE, }; diff --git a/app/api/auth/sso/complete/route.ts b/app/api/auth/sso/complete/route.ts new file mode 100644 index 00000000..fcbf6d33 --- /dev/null +++ b/app/api/auth/sso/complete/route.ts @@ -0,0 +1,80 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { logger } from '@/lib/logger'; +import { decryptPayload } from '@/lib/auth/crypto'; +import { exchangeCodeForTokens } from '@/lib/oauth/token-exchange'; +import { refreshTokenCookieName } from '@/lib/oauth/tokens'; +import { getCookieOptions } from '@/lib/oauth/cookie-config'; + +const SSO_PENDING_COOKIE = 'sso_pending'; +const SSO_PENDING_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes + +export async function POST(request: NextRequest) { + const cookieStore = await cookies(); + + try { + const { code, state } = await request.json(); + + if (!code || !state) { + return NextResponse.json({ error: 'Missing code or state' }, { status: 400 }); + } + + // Read and decrypt the pending SSO cookie + const pendingCookie = cookieStore.get(SSO_PENDING_COOKIE)?.value; + if (!pendingCookie) { + logger.warn('SSO complete: no pending cookie found'); + return NextResponse.json({ error: 'No pending SSO session. Please start the login flow again.' }, { status: 400 }); + } + + const pending = decryptPayload(pendingCookie); + if (!pending) { + cookieStore.delete(SSO_PENDING_COOKIE); + return NextResponse.json({ error: 'Invalid SSO session' }, { status: 400 }); + } + + // Validate state + if (pending.state !== state) { + logger.warn('SSO complete: state mismatch'); + cookieStore.delete(SSO_PENDING_COOKIE); + return NextResponse.json({ error: 'State mismatch' }, { status: 400 }); + } + + // Validate TTL + const createdAt = pending.created_at as number; + if (!createdAt || Date.now() - createdAt > SSO_PENDING_MAX_AGE_MS) { + logger.warn('SSO complete: pending session expired'); + cookieStore.delete(SSO_PENDING_COOKIE); + return NextResponse.json({ error: 'SSO session expired. Please try again.' }, { status: 400 }); + } + + const codeVerifier = pending.code_verifier as string; + const redirectUri = pending.redirect_uri as string; + + if (!codeVerifier || !redirectUri) { + cookieStore.delete(SSO_PENDING_COOKIE); + return NextResponse.json({ error: 'Invalid SSO session data' }, { status: 400 }); + } + + // Exchange code for tokens + const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri); + + // Store refresh token + if (tokens.refresh_token) { + const cookieName = refreshTokenCookieName(0); + cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions()); + } + + // Delete pending cookie + cookieStore.delete(SSO_PENDING_COOKIE); + + return NextResponse.json({ + access_token: tokens.access_token, + expires_in: tokens.expires_in, + }); + } catch (error) { + // Clean up pending cookie on any error + cookieStore.delete(SSO_PENDING_COOKIE); + logger.error('SSO complete error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json({ error: 'Token exchange failed' }, { status: 401 }); + } +} diff --git a/app/api/auth/sso/start/route.ts b/app/api/auth/sso/start/route.ts new file mode 100644 index 00000000..07240ebe --- /dev/null +++ b/app/api/auth/sso/start/route.ts @@ -0,0 +1,88 @@ +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'; + +const SSO_PENDING_COOKIE = 'sso_pending'; +const SSO_PENDING_MAX_AGE = 300; // 5 minutes + +export async function POST(request: NextRequest) { + try { + if (!process.env.SESSION_SECRET) { + return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 }); + } + + const { redirect_uri, locale } = await request.json(); + + if (!redirect_uri || typeof redirect_uri !== 'string') { + return NextResponse.json({ error: 'Missing redirect_uri' }, { status: 400 }); + } + + // 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(); + 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 + const pendingData = { + state, + code_verifier: codeVerifier, + redirect_uri, + created_at: Date.now(), + }; + + 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 }); + } +} diff --git a/app/api/auth/token/route.ts b/app/api/auth/token/route.ts index b89ece03..9f2b152e 100644 --- a/app/api/auth/token/route.ts +++ b/app/api/auth/token/route.ts @@ -1,18 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; import { cookies } from 'next/headers'; import { logger } from '@/lib/logger'; -import { discoverOAuth } from '@/lib/oauth/discovery'; import { refreshTokenCookieName } from '@/lib/oauth/tokens'; - -const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || ''; - -const COOKIE_OPTIONS = { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax' as const, - path: '/', - maxAge: 30 * 24 * 60 * 60, -}; +import { exchangeCodeForTokens, buildOAuthParams, getMetadata, getTokenEndpoint } from '@/lib/oauth/token-exchange'; +import { getCookieOptions } from '@/lib/oauth/cookie-config'; function getSlot(request: NextRequest): number { const raw = request.nextUrl.searchParams.get('slot'); @@ -22,44 +13,6 @@ function getSlot(request: NextRequest): number { return slot; } - -function getRequiredConfig() { - const clientId = process.env.OAUTH_CLIENT_ID; - const serverUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL; - const issuerUrl = process.env.OAUTH_ISSUER_URL; - if (!clientId || !serverUrl) { - throw new Error(`OAuth misconfigured: ${[!clientId && 'OAUTH_CLIENT_ID', !serverUrl && 'JMAP_SERVER_URL'].filter(Boolean).join(', ')} not set`); - } - const discoveryUrl = issuerUrl?.trim() || serverUrl; - if (issuerUrl !== undefined && !issuerUrl.trim()) { - logger.warn('OAUTH_ISSUER_URL is set but empty, falling back to JMAP_SERVER_URL for discovery'); - } - return { clientId, serverUrl, discoveryUrl }; -} - -async function getTokenEndpoint(): Promise { - const { discoveryUrl } = getRequiredConfig(); - const metadata = await discoverOAuth(discoveryUrl); - if (!metadata?.token_endpoint) { - throw new Error('OAuth token endpoint not found'); - } - return metadata.token_endpoint; -} - -async function getMetadata(): Promise { - const { discoveryUrl } = getRequiredConfig(); - return discoverOAuth(discoveryUrl); -} - -function buildOAuthParams(base: Record): URLSearchParams { - const { clientId } = getRequiredConfig(); - const params = new URLSearchParams({ ...base, client_id: clientId }); - if (CLIENT_SECRET) { - params.set('client_secret', CLIENT_SECRET); - } - return params; -} - export async function POST(request: NextRequest) { try { const { code, code_verifier, redirect_uri, slot: bodySlot } = await request.json(); @@ -69,43 +22,18 @@ export async function POST(request: NextRequest) { } const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request); - const tokenEndpoint = await getTokenEndpoint(); - const params = buildOAuthParams({ - grant_type: 'authorization_code', - code, - redirect_uri, - code_verifier, - }); - - const tokenResponse = await fetch(tokenEndpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: params.toString(), - }); - - if (!tokenResponse.ok) { - const errorText = await tokenResponse.text(); - logger.error('Token exchange failed', { status: tokenResponse.status, error: errorText }); - return NextResponse.json({ error: 'Token exchange failed' }, { status: 401 }); - } - - const tokens = await tokenResponse.json(); - - if (!tokens.access_token) { - logger.error('Token response missing access_token', { response: JSON.stringify(tokens).substring(0, 500) }); - return NextResponse.json({ error: 'Invalid token response' }, { status: 502 }); - } + const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri); const response = NextResponse.json({ access_token: tokens.access_token, - expires_in: tokens.expires_in || 3600, + expires_in: tokens.expires_in, }); if (tokens.refresh_token) { const cookieName = refreshTokenCookieName(slot); const cookieStore = await cookies(); - cookieStore.set(cookieName, tokens.refresh_token, COOKIE_OPTIONS); + cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions()); } return response; @@ -154,7 +82,7 @@ export async function PUT(request: NextRequest) { } if (tokens.refresh_token) { - cookieStore.set(cookieName, tokens.refresh_token, COOKIE_OPTIONS); + cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions()); } return NextResponse.json({ diff --git a/app/api/config/route.ts b/app/api/config/route.ts index 5558f4ba..e27294b6 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -36,5 +36,8 @@ export async function GET() { loginPrivacyPolicyUrl: process.env.LOGIN_PRIVACY_POLICY_URL || '', loginWebsiteUrl: process.env.LOGIN_WEBSITE_URL || '', demoMode: process.env.DEMO_MODE === 'true', + autoSsoEnabled: process.env.AUTO_SSO_ENABLED === 'true', + embeddedMode: !!process.env.ALLOWED_FRAME_ANCESTORS && process.env.ALLOWED_FRAME_ANCESTORS !== "'none'", + parentOrigin: process.env.NEXT_PUBLIC_PARENT_ORIGIN || '', }); } diff --git a/app/layout.tsx b/app/layout.tsx index 5ab67392..2a1c0212 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -31,10 +31,14 @@ export default async function RootLayout({ }) { const locale = await getLocale(); const nonce = (await headers()).get("x-nonce") ?? ""; + const parentOrigin = process.env.NEXT_PUBLIC_PARENT_ORIGIN || ""; return ( + {parentOrigin && ( + + )}