feat: add mobile handoff page and JMAP authentication verification
This commit is contained in:
@@ -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<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string>('jmapServerUrl', '') ||
|
||||||
|
process.env.JMAP_SERVER_URL ||
|
||||||
|
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
|
||||||
|
'';
|
||||||
|
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
|
||||||
|
const serverList = parseJmapServers(configManager.get<unknown>('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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user