From 97ddf935a8412336daf89e9126f243c748f63690 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 19 May 2026 00:45:35 +0200 Subject: [PATCH] fix: mobile handoff flow for OAuth authentication --- app/[locale]/auth/callback/page.tsx | 64 ++++++++++- app/[locale]/login/page.tsx | 77 ++++++++++++- app/[locale]/mobile-handoff/page.tsx | 162 --------------------------- app/api/auth/mobile-verify/route.ts | 56 --------- app/api/auth/sso/complete/route.ts | 55 +++++++-- app/api/auth/sso/start/route.ts | 29 ++++- 6 files changed, 210 insertions(+), 233 deletions(-) delete mode 100644 app/[locale]/mobile-handoff/page.tsx delete mode 100644 app/api/auth/mobile-verify/route.ts diff --git a/app/[locale]/auth/callback/page.tsx b/app/[locale]/auth/callback/page.tsx index b03e2313..efe701cb 100644 --- a/app/[locale]/auth/callback/page.tsx +++ b/app/[locale]/auth/callback/page.tsx @@ -78,7 +78,69 @@ function OAuthCallbackInner() { setError("token_exchange_failed"); }); } else if (state) { - // Server-side SSO flow - state was stored in encrypted httpOnly cookie + // Server-side SSO flow - state was stored in encrypted httpOnly cookie. + // Branch on mobile handoff first: the login page left a marker in + // sessionStorage if it kicked this OAuth dance off for the mobile app. + let mobileRedirectUri: string | null = null; + let mobileState: string | null = null; + try { + mobileRedirectUri = sessionStorage.getItem("mobile_redirect_uri"); + mobileState = sessionStorage.getItem("mobile_state"); + } catch { /* sessionStorage may be unavailable */ } + + if (mobileRedirectUri && mobileRedirectUri.startsWith("bulwarkmobile://")) { + // Drive /api/auth/sso/complete directly so we can read the tokens + // out of the response — loginWithServerSso would consume them and + // wire up the webmail auth store, which isn't useful here. The + // server's mobile-flow branch (keyed on the pending cookie) skips + // the refresh-token cookie write for the same reason. + (async () => { + try { + const res = await fetch("/api/auth/sso/complete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ code, state }), + }); + if (!res.ok) { + setError("token_exchange_failed"); + return; + } + const data = await res.json(); + const serverUrl = data.server_url as string | undefined; + const accessToken = data.access_token as string | undefined; + const tokenEndpoint = data.token_endpoint as string | undefined; + const clientId = data.client_id as string | undefined; + if (!serverUrl || !accessToken || !tokenEndpoint || !clientId) { + setError("token_exchange_failed"); + return; + } + const fragment = new URLSearchParams({ + flow: "oauth", + server_url: serverUrl, + access_token: accessToken, + token_endpoint: tokenEndpoint, + client_id: clientId, + state: mobileState ?? "", + }); + if (typeof data.refresh_token === "string") { + fragment.set("refresh_token", data.refresh_token); + } + if (typeof data.expires_in === "number") { + fragment.set("expires_in", String(data.expires_in)); + } + try { + sessionStorage.removeItem("mobile_redirect_uri"); + sessionStorage.removeItem("mobile_state"); + } catch { /* ignore */ } + window.location.replace(`${mobileRedirectUri}#${fragment.toString()}`); + } catch { + setError("token_exchange_failed"); + } + })(); + return; + } + const ssoPrefix = getPathPrefix(params.locale as string); loginWithServerSso(code, state) .then((success) => { diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index 458a4fc3..a998af52 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -108,12 +108,29 @@ function VersionBadge() { ); } +// Only redirect targets matching this scheme are honored by the mobile +// handoff path. Without the check the login page becomes an open redirector +// that funnels password and token material to any caller-supplied URL. +const MOBILE_REDIRECT_SCHEME = "bulwarkmobile://"; + export default function LoginPage() { const router = useRouter(); const t = useTranslations("login"); const params = useParams(); const searchParams = useSearchParams(); const isAddAccountMode = searchParams.get("mode") === "add-account"; + + // When the mobile app launches the webmail in a browser tab it tacks on + // these params. We grab them once at mount and stash them in a ref so any + // login path that completes (password or OAuth) can hand control back to + // the app instead of routing into /mail. + const rawMobileRedirectUri = searchParams.get("mobile_redirect_uri") ?? ""; + const rawMobileState = searchParams.get("mobile_state") ?? ""; + const mobileRedirectUri = rawMobileRedirectUri.startsWith(MOBILE_REDIRECT_SCHEME) + ? rawMobileRedirectUri + : ""; + const mobileState = mobileRedirectUri ? rawMobileState : ""; + const isMobileHandoff = Boolean(mobileRedirectUri); 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: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig(); @@ -159,6 +176,9 @@ export default function LoginPage() { const totpInputRef = useRef(null); const prevError = useRef(null); const themeMenuRef = useRef(null); + // Captured by handleSubmit when in mobile handoff mode; consumed by the + // isAuthenticated effect to build the deep-link fragment. + const mobileHandoffPayloadRef = useRef<{ server_url: string; username: string; password: string } | null>(null); useEffect(() => { initializeTheme(); @@ -238,6 +258,19 @@ export default function LoginPage() { useEffect(() => { if (isAuthenticated && !isAddAccountMode) { + // Mobile handoff: the password path completes here once the auth store + // flips isAuthenticated. Hand the verified credentials back to the + // mobile app instead of pushing to /mail. handleSubmit captured the + // values needed for the fragment. + if (isMobileHandoff && mobileHandoffPayloadRef.current) { + const fragment = new URLSearchParams({ + flow: "password", + ...mobileHandoffPayloadRef.current, + state: mobileState, + }); + window.location.replace(`${mobileRedirectUri}#${fragment.toString()}`); + return; + } let redirectTo = '/'; try { const saved = sessionStorage.getItem('redirect_after_login'); @@ -248,7 +281,7 @@ export default function LoginPage() { } catch { /* ignore */ } router.push(redirectTo); } - }, [isAuthenticated, router, isAddAccountMode]); + }, [isAuthenticated, router, isAddAccountMode, isMobileHandoff, mobileRedirectUri, mobileState]); useEffect(() => { clearError(); @@ -316,6 +349,16 @@ export default function LoginPage() { try { const prefix = getPathPrefix(params.locale as string); const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`; + // In mobile-handoff mode the callback page needs to know it should + // redirect into the app rather than into /mail. Stash the params in + // sessionStorage so the same-tab callback can read them — the SSO + // pending cookie carries the authoritative copy server-side too. + if (isMobileHandoff) { + try { + sessionStorage.setItem("mobile_redirect_uri", mobileRedirectUri); + sessionStorage.setItem("mobile_state", mobileState); + } catch { /* sessionStorage unavailable */ } + } const res = await apiFetch('/api/auth/sso/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -324,6 +367,9 @@ export default function LoginPage() { redirect_uri: redirectUri, locale: params.locale, server_id: selectedServer?.id, + ...(isMobileHandoff + ? { mobile_redirect_uri: mobileRedirectUri, mobile_state: mobileState } + : {}), }), }); @@ -350,7 +396,7 @@ export default function LoginPage() { } catch { setOauthLoading(false); } - }, [params.locale, selectedServer?.id]); + }, [params.locale, selectedServer?.id, isMobileHandoff, mobileRedirectUri, mobileState]); useEffect(() => { if (!autoSsoEnabled || !oauthOnly || !oauthDiscoveryDone || !oauthMetadata) return; @@ -492,6 +538,15 @@ export default function LoginPage() { const handleOAuthLogin = async () => { if (!oauthMetadata || !effectiveOauthClientId) return; + // In mobile-handoff mode the client-side PKCE flow doesn't help us: + // tokens would land in sessionStorage on the webmail origin and the + // mobile app couldn't read them. Route through the server-side SSO + // path instead, which has the mobile-aware /api/auth/sso/complete + // branch. + if (isMobileHandoff) { + await startServerSideSso(); + return; + } setOauthLoading(true); const verifier = generateCodeVerifier(); @@ -546,6 +601,16 @@ export default function LoginPage() { // when the admin hasn't configured a server list. const effectiveServerUrl = selectedServer?.url || (allowCustomJmapEndpoint ? jmapEndpoint : serverUrl); + // Capture before login() so the isAuthenticated effect can build the + // deep-link fragment with values the user actually typed (formData may + // be cleared by the auth store on success). + if (isMobileHandoff) { + mobileHandoffPayloadRef.current = { + server_url: effectiveServerUrl, + username: formData.username, + password: formData.password, + }; + } const success = await login( effectiveServerUrl, formData.username, @@ -556,7 +621,15 @@ export default function LoginPage() { if (success) { saveUsername(formData.username); + if (isMobileHandoff) { + // The isAuthenticated effect handles the redirect; nothing else to + // do here. Don't push to / — that would race the deep link. + return; + } router.push('/'); + } else if (isMobileHandoff) { + // Stale payload should never feed into a later retry's redirect. + mobileHandoffPayloadRef.current = null; } }; diff --git a/app/[locale]/mobile-handoff/page.tsx b/app/[locale]/mobile-handoff/page.tsx deleted file mode 100644 index fd470509..00000000 --- a/app/[locale]/mobile-handoff/page.tsx +++ /dev/null @@ -1,162 +0,0 @@ -"use client"; - -import { useState, useMemo, type FormEvent } from "react"; -import { useSearchParams } from "next/navigation"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Smartphone, AlertCircle, Loader2 } from "lucide-react"; - -// Only redirect back to the official mobile-app scheme. Without this guard -// the page becomes an open redirector that funnels arbitrary credentials to -// any URL an attacker chooses. -const ALLOWED_REDIRECT_PREFIX = "bulwarkmobile://"; - -export default function MobileHandoffPage() { - const searchParams = useSearchParams(); - const redirectUri = searchParams.get("redirect_uri") ?? ""; - const state = searchParams.get("state") ?? ""; - - const redirectOk = useMemo( - () => redirectUri.startsWith(ALLOWED_REDIRECT_PREFIX), - [redirectUri], - ); - - const [serverUrl, setServerUrl] = useState(""); - const [username, setUsername] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState(null); - const [busy, setBusy] = useState(false); - - const canSubmit = serverUrl.trim() && username.trim() && password; - - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - if (!canSubmit) return; - setError(null); - setBusy(true); - try { - const trimmedServerUrl = serverUrl.trim().replace(/\/+$/, ""); - const verifyRes = await fetch("/api/auth/mobile-verify", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - serverUrl: trimmedServerUrl, - username: username.trim(), - password, - }), - }); - const verifyJson = await verifyRes.json().catch(() => ({})); - if (!verifyRes.ok) { - setError(verifyJson.error || "Sign-in failed"); - setBusy(false); - return; - } - - // Build the callback URL with credentials in the fragment so they - // don't end up in HTTP referrer logs along the way. - const verifiedUrl = (verifyJson.serverUrl as string) || trimmedServerUrl; - const fragment = new URLSearchParams({ - server_url: verifiedUrl, - username: username.trim(), - password, - state, - }); - window.location.href = `${redirectUri}#${fragment.toString()}`; - } catch (err) { - setError(err instanceof Error ? err.message : "Sign-in failed"); - setBusy(false); - } - }; - - if (!redirectOk) { - return ( -
-
- -

Invalid request

-

- The mobile app sent an unrecognized callback URL. Update the app and try again. -

-
-
- ); - } - - return ( -
-
-
- -

- Sign in to Bulwark Mobile -

-

- Enter your credentials. They'll be handed off to the app and you'll be returned automatically. -

-
- - - - - - - - {error ? ( -
- - {error} -
- ) : null} - - -
-
- ); -} diff --git a/app/api/auth/mobile-verify/route.ts b/app/api/auth/mobile-verify/route.ts deleted file mode 100644 index 53ffabc7..00000000 --- a/app/api/auth/mobile-verify/route.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { logger } from '@/lib/logger'; -import { - JmapAuthVerificationError, - verifyJmapAuth, -} from '@/lib/auth/verify-jmap-auth'; -import { configManager } from '@/lib/admin/config-manager'; -import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers'; - -// Verifies a JMAP credential pair against the user-supplied server URL on -// behalf of the mobile handoff page. We deliberately do NOT set any session -// cookies here — the credentials are about to be handed back to the mobile -// app, which manages its own per-account credential storage. -export async function POST(request: NextRequest) { - try { - const { serverUrl, username, password } = await request.json(); - if (!serverUrl || !username || !password) { - return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); - } - - await configManager.ensureLoaded(); - const configuredServerUrl = - configManager.get('jmapServerUrl', '') || - process.env.JMAP_SERVER_URL || - process.env.NEXT_PUBLIC_JMAP_SERVER_URL || - ''; - const allowCustomEndpoint = configManager.get('allowCustomJmapEndpoint', false); - const serverList = parseJmapServers(configManager.get('jmapServers', [])); - const trustedUrl = resolveTrustedJmapUrl(serverUrl, configuredServerUrl, serverList); - - let upstreamUrl: string; - let upstreamTrusted: boolean; - if (trustedUrl) { - upstreamUrl = trustedUrl; - upstreamTrusted = true; - } else if (allowCustomEndpoint) { - upstreamUrl = serverUrl; - upstreamTrusted = false; - } else { - return NextResponse.json({ error: 'JMAP server not configured' }, { status: 500 }); - } - - const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; - const normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { - trusted: upstreamTrusted, - }); - - return NextResponse.json({ ok: true, serverUrl: normalizedServerUrl }); - } catch (error) { - if (error instanceof JmapAuthVerificationError) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - logger.error('Mobile verify error', { error: error instanceof Error ? error.message : 'Unknown error' }); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} diff --git a/app/api/auth/sso/complete/route.ts b/app/api/auth/sso/complete/route.ts index b6808774..4ca79151 100644 --- a/app/api/auth/sso/complete/route.ts +++ b/app/api/auth/sso/complete/route.ts @@ -2,7 +2,11 @@ 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 { + exchangeCodeForTokens, + getRequiredConfig, + getTokenEndpoint, +} from '@/lib/oauth/token-exchange'; import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens'; import { getCookieOptions } from '@/lib/oauth/cookie-config'; @@ -56,6 +60,10 @@ export async function POST(request: NextRequest) { const codeVerifier = pending.code_verifier as string; const redirectUri = pending.redirect_uri as string; const pendingServerId = typeof pending.server_id === 'string' ? pending.server_id : null; + const mobileRedirectUri = + typeof pending.mobile_redirect_uri === 'string' ? pending.mobile_redirect_uri : null; + const mobileState = typeof pending.mobile_state === 'string' ? pending.mobile_state : null; + const isMobileFlow = Boolean(mobileRedirectUri); if (!codeVerifier || !redirectUri) { cookieStore.delete(SSO_PENDING_COOKIE); @@ -65,21 +73,46 @@ export async function POST(request: NextRequest) { // Exchange code for tokens const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId); - // Store refresh token in the per-account cookie slot. - if (tokens.refresh_token) { - const cookieName = refreshTokenCookieName(slot); - cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions()); - } - const serverCookieName = refreshTokenServerCookieName(slot); - if (pendingServerId) { - cookieStore.set(serverCookieName, pendingServerId, getCookieOptions()); - } else { - cookieStore.delete(serverCookieName); + // For the mobile handoff flow the tokens are handed back to the app + // verbatim — we deliberately don't write any cookies on the webmail + // origin (the mobile browser tab disposes of the session after the + // redirect anyway, but the cookie would still get committed to the + // user's main webmail session if they happened to be logged in there). + if (!isMobileFlow) { + if (tokens.refresh_token) { + const cookieName = refreshTokenCookieName(slot); + cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions()); + } + const serverCookieName = refreshTokenServerCookieName(slot); + if (pendingServerId) { + cookieStore.set(serverCookieName, pendingServerId, getCookieOptions()); + } else { + cookieStore.delete(serverCookieName); + } } // Delete pending cookie cookieStore.delete(SSO_PENDING_COOKIE); + if (isMobileFlow) { + // The mobile client needs the bits it can't re-derive: the refresh + // token, the token endpoint it should hit to refresh later, and the + // client_id the IdP expects on that refresh call. The server URL is + // returned so the app knows which JMAP host to connect to. + const { clientId, serverUrl } = getRequiredConfig(pendingServerId); + const tokenEndpoint = await getTokenEndpoint(pendingServerId); + return NextResponse.json({ + access_token: tokens.access_token, + expires_in: tokens.expires_in, + refresh_token: tokens.refresh_token, + token_endpoint: tokenEndpoint, + client_id: clientId, + server_url: serverUrl, + mobile_redirect_uri: mobileRedirectUri, + mobile_state: mobileState, + }); + } + return NextResponse.json({ access_token: tokens.access_token, expires_in: tokens.expires_in, diff --git a/app/api/auth/sso/start/route.ts b/app/api/auth/sso/start/route.ts index 8c3cdbde..59326c96 100644 --- a/app/api/auth/sso/start/route.ts +++ b/app/api/auth/sso/start/route.ts @@ -13,18 +13,40 @@ 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 } = await request.json(); + 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 @@ -53,12 +75,17 @@ export async function POST(request: NextRequest) { // 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);