fix: mobile handoff flow for OAuth authentication
This commit is contained in:
@@ -78,7 +78,69 @@ function OAuthCallbackInner() {
|
|||||||
setError("token_exchange_failed");
|
setError("token_exchange_failed");
|
||||||
});
|
});
|
||||||
} else if (state) {
|
} 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);
|
const ssoPrefix = getPathPrefix(params.locale as string);
|
||||||
loginWithServerSso(code, state)
|
loginWithServerSso(code, state)
|
||||||
.then((success) => {
|
.then((success) => {
|
||||||
|
|||||||
@@ -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() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslations("login");
|
const t = useTranslations("login");
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const isAddAccountMode = searchParams.get("mode") === "add-account";
|
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 { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
||||||
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
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();
|
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 totpInputRef = useRef<HTMLInputElement>(null);
|
||||||
const prevError = useRef<string | null>(null);
|
const prevError = useRef<string | null>(null);
|
||||||
const themeMenuRef = useRef<HTMLDivElement>(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(() => {
|
useEffect(() => {
|
||||||
initializeTheme();
|
initializeTheme();
|
||||||
@@ -238,6 +258,19 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated && !isAddAccountMode) {
|
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 = '/';
|
let redirectTo = '/';
|
||||||
try {
|
try {
|
||||||
const saved = sessionStorage.getItem('redirect_after_login');
|
const saved = sessionStorage.getItem('redirect_after_login');
|
||||||
@@ -248,7 +281,7 @@ export default function LoginPage() {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
router.push(redirectTo);
|
router.push(redirectTo);
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, router, isAddAccountMode]);
|
}, [isAuthenticated, router, isAddAccountMode, isMobileHandoff, mobileRedirectUri, mobileState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
clearError();
|
clearError();
|
||||||
@@ -316,6 +349,16 @@ export default function LoginPage() {
|
|||||||
try {
|
try {
|
||||||
const prefix = getPathPrefix(params.locale as string);
|
const prefix = getPathPrefix(params.locale as string);
|
||||||
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
|
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', {
|
const res = await apiFetch('/api/auth/sso/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -324,6 +367,9 @@ export default function LoginPage() {
|
|||||||
redirect_uri: redirectUri,
|
redirect_uri: redirectUri,
|
||||||
locale: params.locale,
|
locale: params.locale,
|
||||||
server_id: selectedServer?.id,
|
server_id: selectedServer?.id,
|
||||||
|
...(isMobileHandoff
|
||||||
|
? { mobile_redirect_uri: mobileRedirectUri, mobile_state: mobileState }
|
||||||
|
: {}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -350,7 +396,7 @@ export default function LoginPage() {
|
|||||||
} catch {
|
} catch {
|
||||||
setOauthLoading(false);
|
setOauthLoading(false);
|
||||||
}
|
}
|
||||||
}, [params.locale, selectedServer?.id]);
|
}, [params.locale, selectedServer?.id, isMobileHandoff, mobileRedirectUri, mobileState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoSsoEnabled || !oauthOnly || !oauthDiscoveryDone || !oauthMetadata) return;
|
if (!autoSsoEnabled || !oauthOnly || !oauthDiscoveryDone || !oauthMetadata) return;
|
||||||
@@ -492,6 +538,15 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
const handleOAuthLogin = async () => {
|
const handleOAuthLogin = async () => {
|
||||||
if (!oauthMetadata || !effectiveOauthClientId) return;
|
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);
|
setOauthLoading(true);
|
||||||
|
|
||||||
const verifier = generateCodeVerifier();
|
const verifier = generateCodeVerifier();
|
||||||
@@ -546,6 +601,16 @@ export default function LoginPage() {
|
|||||||
// when the admin hasn't configured a server list.
|
// when the admin hasn't configured a server list.
|
||||||
const effectiveServerUrl = selectedServer?.url
|
const effectiveServerUrl = selectedServer?.url
|
||||||
|| (allowCustomJmapEndpoint ? jmapEndpoint : serverUrl);
|
|| (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(
|
const success = await login(
|
||||||
effectiveServerUrl,
|
effectiveServerUrl,
|
||||||
formData.username,
|
formData.username,
|
||||||
@@ -556,7 +621,15 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
saveUsername(formData.username);
|
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('/');
|
router.push('/');
|
||||||
|
} else if (isMobileHandoff) {
|
||||||
|
// Stale payload should never feed into a later retry's redirect.
|
||||||
|
mobileHandoffPayloadRef.current = 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<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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<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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,11 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { decryptPayload } from '@/lib/auth/crypto';
|
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 { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
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 codeVerifier = pending.code_verifier as string;
|
||||||
const redirectUri = pending.redirect_uri as string;
|
const redirectUri = pending.redirect_uri as string;
|
||||||
const pendingServerId = typeof pending.server_id === 'string' ? pending.server_id : null;
|
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) {
|
if (!codeVerifier || !redirectUri) {
|
||||||
cookieStore.delete(SSO_PENDING_COOKIE);
|
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||||
@@ -65,7 +73,12 @@ export async function POST(request: NextRequest) {
|
|||||||
// Exchange code for tokens
|
// Exchange code for tokens
|
||||||
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId);
|
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId);
|
||||||
|
|
||||||
// Store refresh token in the per-account cookie slot.
|
// 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) {
|
if (tokens.refresh_token) {
|
||||||
const cookieName = refreshTokenCookieName(slot);
|
const cookieName = refreshTokenCookieName(slot);
|
||||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||||
@@ -76,10 +89,30 @@ export async function POST(request: NextRequest) {
|
|||||||
} else {
|
} else {
|
||||||
cookieStore.delete(serverCookieName);
|
cookieStore.delete(serverCookieName);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Delete pending cookie
|
// Delete pending cookie
|
||||||
cookieStore.delete(SSO_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({
|
return NextResponse.json({
|
||||||
access_token: tokens.access_token,
|
access_token: tokens.access_token,
|
||||||
expires_in: tokens.expires_in,
|
expires_in: tokens.expires_in,
|
||||||
|
|||||||
@@ -13,18 +13,40 @@ import { hasSessionSecret } from '@/lib/auth/session-secret';
|
|||||||
const SSO_PENDING_COOKIE = 'sso_pending';
|
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||||
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
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) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
if (!hasSessionSecret()) {
|
if (!hasSessionSecret()) {
|
||||||
return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 });
|
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') {
|
if (!redirect_uri || typeof redirect_uri !== 'string') {
|
||||||
return NextResponse.json({ error: 'Missing redirect_uri' }, { status: 400 });
|
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;
|
const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
|
||||||
|
|
||||||
// Validate redirect_uri origin matches the request origin to prevent open redirects
|
// 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
|
// Encrypt and store in httpOnly cookie. server_id is captured here so the
|
||||||
// /complete handler reaches the same OAuth endpoint we used to authorize.
|
// /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 = {
|
const pendingData = {
|
||||||
state,
|
state,
|
||||||
code_verifier: codeVerifier,
|
code_verifier: codeVerifier,
|
||||||
redirect_uri,
|
redirect_uri,
|
||||||
created_at: Date.now(),
|
created_at: Date.now(),
|
||||||
...(serverId ? { server_id: serverId } : {}),
|
...(serverId ? { server_id: serverId } : {}),
|
||||||
|
...(mobileRedirectUri ? { mobile_redirect_uri: mobileRedirectUri } : {}),
|
||||||
|
...(mobileState ? { mobile_state: mobileState } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const encrypted = encryptPayload(pendingData);
|
const encrypted = encryptPayload(pendingData);
|
||||||
|
|||||||
Reference in New Issue
Block a user