fix: mobile handoff flow for OAuth authentication

This commit is contained in:
Linus Rath
2026-05-19 00:45:35 +02:00
parent 973ce1e5bd
commit 97ddf935a8
6 changed files with 210 additions and 233 deletions
+63 -1
View File
@@ -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) => {
+75 -2
View File
@@ -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<HTMLInputElement>(null);
const prevError = useRef<string | null>(null);
const themeMenuRef = useRef<HTMLDivElement>(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;
}
};
-162
View File
@@ -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<string | null>(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 (
<main className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="max-w-md rounded-lg border border-border bg-card p-6 text-center">
<AlertCircle className="mx-auto h-8 w-8 text-destructive" />
<h1 className="mt-3 text-lg font-semibold text-foreground">Invalid request</h1>
<p className="mt-2 text-sm text-muted-foreground">
The mobile app sent an unrecognized callback URL. Update the app and try again.
</p>
</div>
</main>
);
}
return (
<main className="flex min-h-screen items-center justify-center bg-background p-4">
<form
onSubmit={handleSubmit}
className="w-full max-w-sm space-y-4 rounded-lg border border-border bg-card p-6 shadow-sm"
>
<div className="flex flex-col items-center text-center">
<Smartphone className="h-8 w-8 text-primary" />
<h1 className="mt-3 text-lg font-semibold text-foreground">
Sign in to Bulwark Mobile
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Enter your credentials. They'll be handed off to the app and you'll be returned automatically.
</p>
</div>
<label className="block space-y-1.5">
<span className="text-sm font-medium text-foreground">JMAP server URL</span>
<Input
type="url"
placeholder="https://mail.example.com"
autoComplete="url"
value={serverUrl}
onChange={(e) => setServerUrl(e.target.value)}
required
disabled={busy}
/>
</label>
<label className="block space-y-1.5">
<span className="text-sm font-medium text-foreground">Email or username</span>
<Input
type="email"
placeholder="you@example.com"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
disabled={busy}
/>
</label>
<label className="block space-y-1.5">
<span className="text-sm font-medium text-foreground">Password</span>
<Input
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={busy}
/>
</label>
{error ? (
<div
role="alert"
className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive"
>
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
) : null}
<Button type="submit" size="lg" className="w-full" disabled={!canSubmit || busy}>
{busy ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Signing in
</>
) : (
"Sign in and return to app"
)}
</Button>
</form>
</main>
);
}