From 973ce1e5bd0c816d24ed0990e217701a3010ed54 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 19 May 2026 00:05:40 +0200 Subject: [PATCH] feat: add mobile handoff page and JMAP authentication verification --- app/[locale]/mobile-handoff/page.tsx | 162 +++++++++++++++++++++++++++ app/api/auth/mobile-verify/route.ts | 56 +++++++++ 2 files changed, 218 insertions(+) create mode 100644 app/[locale]/mobile-handoff/page.tsx create mode 100644 app/api/auth/mobile-verify/route.ts diff --git a/app/[locale]/mobile-handoff/page.tsx b/app/[locale]/mobile-handoff/page.tsx new file mode 100644 index 00000000..fd470509 --- /dev/null +++ b/app/[locale]/mobile-handoff/page.tsx @@ -0,0 +1,162 @@ +"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 new file mode 100644 index 00000000..53ffabc7 --- /dev/null +++ b/app/api/auth/mobile-verify/route.ts @@ -0,0 +1,56 @@ +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 }); + } +}