feat: hand the mobile app a one-time code instead of the sign-in in its link

This commit is contained in:
Linus Rath
2026-10-31 14:06:44 +01:00
parent 4887ed457c
commit fb3ba0275a
20 changed files with 1157 additions and 23 deletions
+32 -1
View File
@@ -11,6 +11,7 @@ import { Loader2, AlertCircle } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { useParams } from "next/navigation";
import { buildMobileHandoffUrl, mobileHandoffTarget } from "@/lib/auth/mobile-handoff";
import { confirmMobileHandoffCodeUrl } from "@/lib/auth/mobile-handoff-client";
import { MobileHandoffConsent } from "@/components/auth/mobile-handoff-consent";
function OAuthCallbackInner() {
@@ -22,7 +23,11 @@ function OAuthCallbackInner() {
const [error, setError] = useState<string | null>(null);
// A sign-in started for the mobile app, waiting for the user to confirm
// the handoff (lib/auth/mobile-handoff.ts).
const [pendingHandoff, setPendingHandoff] = useState<{ url: string; username: string } | null>(null);
const [pendingHandoff, setPendingHandoff] = useState<{
url: string | null;
resolveUrl?: () => Promise<string | null>;
username: string;
} | null>(null);
// The authorization code is single-use. React's development double effect
// ran this twice: the second pass no longer saw the pairing re-auth flag
// (the first had removed it) and spent the code on the login flow instead.
@@ -166,6 +171,27 @@ function OAuthCallbackInner() {
return;
}
const data = await res.json();
if (typeof data.mobile_handoff_code === "string" && data.mobile_handoff_code) {
// The app sent a PKCE challenge: the server kept the tokens and
// the link carries only a one-time code (docs/mobile-handoff.md).
// The code starts working when the user confirms.
const handoffCode: string = data.mobile_handoff_code;
const handoffState = typeof data.mobile_state === "string" ? data.mobile_state : mobileState ?? "";
try {
sessionStorage.removeItem("mobile_redirect_uri");
sessionStorage.removeItem("mobile_state");
} catch { /* ignore */ }
setPendingHandoff({
url: null,
resolveUrl: () => confirmMobileHandoffCodeUrl({
target: mobileRedirectUri,
code: handoffCode,
state: handoffState,
}),
username: typeof data.server_url === "string" ? data.server_url : "",
});
return;
}
const serverUrl = data.server_url as string | undefined;
const accessToken = data.access_token as string | undefined;
const tokenEndpoint = data.token_endpoint as string | undefined;
@@ -235,7 +261,12 @@ function OAuthCallbackInner() {
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
<MobileHandoffConsent
url={pendingHandoff.url}
resolveUrl={pendingHandoff.resolveUrl ?? null}
username={pendingHandoff.username}
onError={() => {
setPendingHandoff(null);
setError("token_exchange_failed");
}}
onCancel={() => {
setPendingHandoff(null);
router.push(toRouterPath(`${getPathPrefix(params.locale as string)}/${params.locale}/login`));
+58 -14
View File
@@ -33,7 +33,8 @@ import {
} from "@/lib/auth/lite-oauth";
import { toAsciiDomain } from "@/lib/idn";
import { usePrefetchMailView } from "@/hooks/use-prefetch-mail-view";
import { buildMobileHandoffUrl, mobileHandoffTarget, signedInLoginAction } from "@/lib/auth/mobile-handoff";
import { buildMobileHandoffUrl, mobileCodeChallenge, mobileHandoffTarget, signedInLoginAction } from "@/lib/auth/mobile-handoff";
import { requestMobileHandoffCodeUrl } from "@/lib/auth/mobile-handoff-client";
import { MobileHandoffConsent } from "@/components/auth/mobile-handoff-consent";
import { HelpLinks } from "@/components/layout/help-links";
import { consumeSessionEnd, type SessionEndReason } from "@/lib/session-policy/sign-out-reason";
@@ -193,10 +194,25 @@ function LoginPageContent() {
// Only the app's own callback link is a handoff target, and nothing is
// handed over before the user confirms it (lib/auth/mobile-handoff.ts).
const rawMobileState = searchParams.get("mobile_state") ?? "";
const mobileRedirectUri = mobileHandoffTarget(searchParams.get("mobile_redirect_uri"));
// App builds that send a PKCE challenge get a one-time code in the link
// instead of the sign-in (docs/mobile-handoff.md). A malformed challenge
// makes this no hand-off at all: falling back to the fragment would put
// the password in the link the app asked to keep it out of. Lite has no
// server to park the sign-in on and keeps handing over in the fragment.
const rawMobileCodeChallenge = IS_LITE ? "" : searchParams.get("mobile_code_challenge") ?? "";
const mobileChallenge = mobileCodeChallenge(rawMobileCodeChallenge);
const mobileRedirectUri = rawMobileCodeChallenge && !mobileChallenge
? ""
: mobileHandoffTarget(searchParams.get("mobile_redirect_uri"));
const mobileState = mobileRedirectUri ? rawMobileState : "";
const isMobileHandoff = Boolean(mobileRedirectUri);
const [pendingHandoff, setPendingHandoff] = useState<{ url: string; username: string } | null>(null);
const [pendingHandoff, setPendingHandoff] = useState<{
url: string | null;
resolveUrl?: () => Promise<string | null>;
username: string;
} | null>(null);
// Parking the sign-in for the app failed (code hand-off only).
const [handoffError, setHandoffError] = useState<string | null>(null);
usePrefetchMailView(!isMobileHandoff);
const { login, loginWithToken, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
@@ -266,6 +282,8 @@ function LoginPageContent() {
const [oauthLoading, setOauthLoading] = useState(false);
const [demoLoading, setDemoLoading] = useState(false);
// The auth store's error, else a failed hand-off to the app.
const shownError = error || handoffError;
// The error box and the recent-accounts list, tied to the fields they
// belong to so a screen reader reads them with the field.
const formId = useId();
@@ -451,12 +469,28 @@ function LoginPageContent() {
const payload = mobileHandoffPayloadRef.current;
if (!payload) return;
mobileHandoffPayloadRef.current = null;
setHandoffError(null);
if (mobileChallenge) {
// The app gets a one-time code: on confirmation the sign-in is parked
// on the server, and its 60 seconds start then.
setPendingHandoff({
url: null,
resolveUrl: () => requestMobileHandoffCodeUrl({
target: mobileRedirectUri,
challenge: mobileChallenge,
state: mobileState,
payload,
}),
username: payload.username,
});
return;
}
// Shown to the user first; the navigation happens on their confirmation.
setPendingHandoff({
url: buildMobileHandoffUrl(mobileRedirectUri, { flow: "password", ...payload, state: mobileState }),
username: payload.username,
});
}, [mobileRedirectUri, mobileState]);
}, [mobileRedirectUri, mobileState, mobileChallenge]);
useEffect(() => {
if (!isAuthenticated) return;
@@ -490,6 +524,7 @@ function LoginPageContent() {
useEffect(() => {
clearError();
setHandoffError(null);
}, [formData, clearError]);
useEffect(() => {
@@ -591,7 +626,11 @@ function LoginPageContent() {
server_id: selectedServer?.id,
remember: persistProviderSession,
...(isMobileHandoff
? { mobile_redirect_uri: mobileRedirectUri, mobile_state: mobileState }
? {
mobile_redirect_uri: mobileRedirectUri,
mobile_state: mobileState,
...(mobileChallenge ? { mobile_code_challenge: mobileChallenge } : {}),
}
: {}),
}),
});
@@ -619,7 +658,7 @@ function LoginPageContent() {
} catch {
setOauthLoading(false);
}
}, [params.locale, selectedServer?.id, persistProviderSession, isMobileHandoff, mobileRedirectUri, mobileState]);
}, [params.locale, selectedServer?.id, persistProviderSession, isMobileHandoff, mobileRedirectUri, mobileState, mobileChallenge]);
useEffect(() => {
// After signing out, show the login page rather than signing straight
@@ -1263,11 +1302,16 @@ function LoginPageContent() {
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-background via-muted/10 to-muted/30 relative px-4">
<MobileHandoffConsent
url={pendingHandoff?.url ?? null}
resolveUrl={pendingHandoff?.resolveUrl ?? null}
username={pendingHandoff?.username ?? ""}
onCancel={() => {
setPendingHandoff(null);
router.push('/');
}}
onError={() => {
setPendingHandoff(null);
setHandoffError("server_error");
}}
/>
{/* Theme toggle - top right, dropdown style; hidden while the theme policy locks the mode (#1280) */}
<div className="absolute top-5 end-5" ref={themeMenuRef} hidden={colorModeLocked} suppressHydrationWarning>
@@ -1386,27 +1430,27 @@ function LoginPageContent() {
{/* Error Message. Asking for the code is the next step of a
sign-in that worked so far, not a failure (#1422). */}
{error && (
{shownError && (
<div
className={cn(
"mb-5 p-3 rounded-xl border flex items-start gap-3",
error === 'totp_required' ? "border-info/20 bg-info/5" : "border-destructive/20 bg-destructive/5",
shownError === 'totp_required' ? "border-info/20 bg-info/5" : "border-destructive/20 bg-destructive/5",
shakeError && "animate-shake"
)}
data-testid="login-error"
role={error === 'totp_required' ? "status" : "alert"}
role={shownError === 'totp_required' ? "status" : "alert"}
>
<div className={cn(
"w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 shadow-sm",
error === 'totp_required' ? "bg-info/15 text-info" : "bg-destructive/15 text-destructive"
shownError === 'totp_required' ? "bg-info/15 text-info" : "bg-destructive/15 text-destructive"
)}>
{error === 'totp_required' ? <Info className="w-5 h-5" aria-hidden="true" /> : <AlertCircle className="w-5 h-5" aria-hidden="true" />}
{shownError === 'totp_required' ? <Info className="w-5 h-5" aria-hidden="true" /> : <AlertCircle className="w-5 h-5" aria-hidden="true" />}
</div>
<div className="flex-1 min-w-0 self-center">
<p id={errorId} className={cn("text-sm leading-relaxed", error === 'totp_required' ? "text-info" : "text-destructive")}>
{error === 'invalid_credentials' && showTotpField && totpCode
<p id={errorId} className={cn("text-sm leading-relaxed", shownError === 'totp_required' ? "text-info" : "text-destructive")}>
{shownError === 'invalid_credentials' && showTotpField && totpCode
? t('error.totp_invalid')
: t(`error.${error}`) || t("error.generic")}
: t(`error.${shownError}`) || t("error.generic")}
</p>
</div>
</div>
+29
View File
@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { confirmHandoff, isHandoffCode } from '@/lib/auth/handoff-store';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
// The SSO callback page calls this when the user confirms the hand-off to the
// mobile app, right before it opens the app's link. /api/auth/sso/complete
// parked the tokens before the confirmation was on screen; the code becomes
// redeemable from here, for 60 seconds (lib/auth/handoff-store.ts). Nothing
// secret comes back, and confirming a code only lets the holder of its
// verifier redeem it.
const NO_STORE = { 'Cache-Control': 'no-store' };
export async function POST(request: NextRequest) {
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
try {
const body = await request.json().catch(() => null);
const code = body?.code;
if (!isHandoffCode(code) || !confirmHandoff(code)) {
return NextResponse.json({ error: 'invalid_code' }, { status: 400, headers: NO_STORE });
}
return NextResponse.json({ ok: true }, { headers: NO_STORE });
} catch (error) {
logger.error('Mobile hand-off confirm error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500, headers: NO_STORE });
}
}
+59
View File
@@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { isHandoffCodeChallenge, reserveHandoffCreate, stashHandoff } from '@/lib/auth/handoff-store';
import { loginClientAddress } from '@/lib/auth/client-address';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
// Password sign-in hand-off to the mobile app, browser side. The login page
// was opened by the app with a `mobile_code_challenge`; after the user signed
// in and confirmed, it parks the sign-in here and opens the app's callback
// link with the returned code instead of the password
// (lib/auth/handoff-store.ts, docs/mobile-handoff.md).
//
// The page already checked the password against the mail server when it
// signed in, and the old hand-off put whatever the page held into the link
// unchecked too, so nothing is verified again here (a second login would
// spend the account's failed-login allowance and a TOTP code twice). What
// is parked only ever goes to whoever holds the verifier for the challenge.
//
// The SSO hand-off parks its tokens in /api/auth/sso/complete itself; they
// never reach the browser.
const NO_STORE = { 'Cache-Control': 'no-store' };
function boundedString(value: unknown, max: number): value is string {
return typeof value === 'string' && value.length > 0 && value.length <= max;
}
export async function POST(request: NextRequest) {
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
try {
const body = await request.json().catch(() => null);
const challenge = body?.code_challenge;
const state = typeof body?.state === 'string' ? body.state : '';
if (
!isHandoffCodeChallenge(challenge)
|| state.length > 512
|| !boundedString(body?.server_url, 2048)
|| !boundedString(body?.username, 512)
|| !boundedString(body?.password, 4096)
) {
return NextResponse.json({ error: 'invalid_request' }, { status: 400, headers: NO_STORE });
}
if (!reserveHandoffCreate(loginClientAddress(request))) {
return NextResponse.json({ error: 'too_many_requests' }, { status: 429, headers: NO_STORE });
}
const code = stashHandoff(
{ flow: 'password', server_url: body.server_url, username: body.username, password: body.password },
{ challenge, state },
);
logger.info('Mobile hand-off parked', { flow: 'password' });
return NextResponse.json({ code }, { headers: NO_STORE });
} catch (error) {
logger.error('Mobile hand-off create error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500, headers: NO_STORE });
}
}
+60
View File
@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import {
isHandoffCode,
isHandoffCodeVerifier,
redeemHandoff,
reserveHandoffRedeem,
} from '@/lib/auth/handoff-store';
import { loginClientAddress } from '@/lib/auth/client-address';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
// The mobile app's side of the browser sign-in hand-off. It POSTs the code
// from its callback link with the PKCE verifier behind the challenge it sent
// to the login page, and gets the sign-in the browser parked for it: the
// same fields older builds read from the link's fragment
// (lib/auth/handoff-store.ts, docs/mobile-handoff.md).
//
// The code and verifier are the only credentials, so this route is
// unauthenticated (the app has no webmail cookies). Every failure is a 400
// and spends the code: invalid_code (never issued or already used),
// expired_code, invalid_verifier; invalid_request for a malformed body.
const NO_STORE = { 'Cache-Control': 'no-store' };
export async function POST(request: NextRequest) {
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
try {
const refund = reserveHandoffRedeem(loginClientAddress(request));
if (!refund) {
return NextResponse.json({ error: 'too_many_requests' }, { status: 429, headers: NO_STORE });
}
const body = await request.json().catch(() => null);
const code = body?.code;
const verifier = body?.code_verifier;
if (!isHandoffCode(code)) {
return NextResponse.json({ error: 'invalid_request' }, { status: 400, headers: NO_STORE });
}
if (!isHandoffCodeVerifier(verifier)) {
// Still spend the code: a malformed verifier is a wrong one.
redeemHandoff(code, '');
return NextResponse.json({ error: 'invalid_verifier' }, { status: 400, headers: NO_STORE });
}
const result = redeemHandoff(code, verifier);
if (!result.ok) {
const error = result.reason === 'verifier_mismatch' ? 'invalid_verifier' : `${result.reason}_code`;
logger.warn('Mobile hand-off redeem refused', { reason: result.reason });
return NextResponse.json({ error }, { status: 400, headers: NO_STORE });
}
refund();
logger.info('Mobile hand-off redeemed', { flow: result.bundle.flow });
return NextResponse.json({ ...result.bundle, state: result.state }, { headers: NO_STORE });
} catch (error) {
logger.error('Mobile hand-off redeem error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500, headers: NO_STORE });
}
}
+22
View File
@@ -9,6 +9,7 @@ import {
hasClientSecret,
} from '@/lib/oauth/token-exchange';
import { buildRedeemBundle } from '@/lib/auth/pair-bundle';
import { isHandoffCodeChallenge, stashHandoff } from '@/lib/auth/handoff-store';
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
import { getCookieOptions, getSignInCookieOptions } from '@/lib/oauth/cookie-config';
import { storeIdToken } from '@/lib/oauth/end-session';
@@ -98,6 +99,9 @@ export async function POST(request: NextRequest) {
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);
const mobileCodeChallenge = isHandoffCodeChallenge(pending.mobile_code_challenge)
? pending.mobile_code_challenge
: null;
const remember = pending.remember !== false;
if (!codeVerifier || !redirectUri) {
@@ -158,6 +162,24 @@ export async function POST(request: NextRequest) {
trusted: true,
}, webmailBaseFromCallback(redirectUri));
if (bundle.flow !== 'oauth') throw new Error('unexpected bundle');
if (mobileCodeChallenge) {
// The app sent a PKCE challenge: park the tokens and give the browser
// only a one-time code for the link (lib/auth/handoff-store.ts). The
// tokens never reach the page. The code starts working when the user
// confirms the hand-off (/api/auth/handoff/confirm).
const handoffCode = stashHandoff(bundle, {
challenge: mobileCodeChallenge,
state: mobileState ?? '',
awaitConfirm: true,
});
logger.info('Mobile hand-off parked', { flow: 'oauth' });
return NextResponse.json({
mobile_handoff_code: handoffCode,
server_url: bundle.server_url,
mobile_redirect_uri: mobileRedirectUri,
mobile_state: mobileState,
});
}
return NextResponse.json({
access_token: bundle.access_token,
expires_in: bundle.expires_in,
+12 -1
View File
@@ -12,7 +12,7 @@ import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { hasSessionSecret } from '@/lib/auth/session-secret';
import { configManager } from '@/lib/admin/config-manager';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { mobileHandoffTarget } from '@/lib/auth/mobile-handoff';
import { isMobileCodeChallenge, mobileHandoffTarget } from '@/lib/auth/mobile-handoff';
const SSO_PENDING_COOKIE = 'sso_pending';
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
@@ -51,6 +51,7 @@ export async function POST(request: NextRequest) {
server_id: bodyServerId,
mobile_redirect_uri: rawMobileRedirectUri,
mobile_state: rawMobileState,
mobile_code_challenge: rawMobileCodeChallenge,
purpose: rawPurpose,
slot: rawSlot,
remember,
@@ -74,6 +75,15 @@ export async function POST(request: NextRequest) {
if (mobileRedirectUri && !mobileHandoffTarget(mobileRedirectUri)) {
return NextResponse.json({ error: 'Invalid mobile_redirect_uri' }, { status: 400 });
}
// An app build that sends a PKCE challenge gets a one-time code instead
// of the tokens (lib/auth/handoff-store.ts). A malformed one is refused
// rather than ignored, which would hand the tokens over in the link.
const mobileCodeChallenge = mobileRedirectUri && rawMobileCodeChallenge != null && rawMobileCodeChallenge !== ''
? rawMobileCodeChallenge
: null;
if (mobileCodeChallenge !== null && !isMobileCodeChallenge(mobileCodeChallenge)) {
return NextResponse.json({ error: 'Invalid mobile_code_challenge' }, { status: 400 });
}
const cookieStore = await cookies();
@@ -125,6 +135,7 @@ export async function POST(request: NextRequest) {
...(serverId ? { server_id: serverId } : {}),
...(mobileRedirectUri ? { mobile_redirect_uri: mobileRedirectUri } : {}),
...(mobileState ? { mobile_state: mobileState } : {}),
...(mobileCodeChallenge ? { mobile_code_challenge: mobileCodeChallenge } : {}),
// The re-auth completes into a grant for the phone paired to this slot.
...(isReauth ? { purpose: 'reauth', slot: reauthSlot } : {}),
// "Remember me" left unticked: /complete writes cookies that end with
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { act, fireEvent, render, screen } from '@testing-library/react';
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, values?: Record<string, string>) =>
@@ -52,3 +52,45 @@ describe('MobileHandoffConsent', () => {
expect(screen.queryByText('mobile_handoff.title')).toBeNull();
});
});
describe('MobileHandoffConsent with a code hand-off', () => {
// docs/mobile-handoff.md: the code only goes live when the user confirms,
// so the link is resolved on confirmation, not before.
const CODE_URL = 'bulwarkmobile://auth/callback?code=abc&state=m-1';
it('makes the code live on confirmation and opens the link it gets', async () => {
let resolve!: (url: string | null) => void;
const resolveUrl = vi.fn(() => new Promise<string | null>((r) => { resolve = r; }));
const onCancel = vi.fn();
render(<MobileHandoffConsent url={null} resolveUrl={resolveUrl} username="alice@example.org" onCancel={onCancel} />);
expect(screen.getByText('mobile_handoff.title')).toBeTruthy();
expect(resolveUrl).not.toHaveBeenCalled();
fireEvent.click(screen.getByText('mobile_handoff.confirm'));
expect(resolveUrl).toHaveBeenCalledTimes(1);
// Confirming is not declining, even though the dialog closes itself.
expect(onCancel).not.toHaveBeenCalled();
expect(replaced).toEqual([]);
await act(async () => { resolve(CODE_URL); });
expect(replaced).toEqual([CODE_URL]);
});
it('reports a failure and opens nothing', async () => {
const onError = vi.fn();
const onCancel = vi.fn();
render(
<MobileHandoffConsent
url={null}
resolveUrl={async () => null}
username="alice@example.org"
onCancel={onCancel}
onError={onError}
/>,
);
await act(async () => { fireEvent.click(screen.getByText('mobile_handoff.confirm')); });
expect(onError).toHaveBeenCalledTimes(1);
expect(onCancel).not.toHaveBeenCalled();
expect(replaced).toEqual([]);
});
});
+32 -4
View File
@@ -1,15 +1,24 @@
"use client";
import { useRef } from "react";
import { useTranslations } from "next-intl";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
interface MobileHandoffConsentProps {
/** The app link the browser will open, with the credential material in its fragment. */
url: string | null;
/**
* For the code hand-off (docs/mobile-handoff.md): makes the code live on
* confirmation and returns the link to open, or null when that failed.
* Shows the dialog on its own, without `url`.
*/
resolveUrl?: (() => Promise<string | null>) | null;
/** The account being handed over, for the explanation. */
username: string;
/** The user stays in the webmail instead. */
/** The user stays in the webmail instead (also runs after the link opened). */
onCancel: () => void;
/** `resolveUrl` returned null: nothing was handed over. */
onError?: () => void;
}
/**
@@ -20,13 +29,32 @@ interface MobileHandoffConsentProps {
* `bulwarkmobile` scheme on the device receives whatever is handed over
* (lib/auth/mobile-handoff.ts). Nothing leaves without this confirmation.
*/
export function MobileHandoffConsent({ url, username, onCancel }: MobileHandoffConsentProps) {
export function MobileHandoffConsent({ url, resolveUrl, username, onCancel, onError }: MobileHandoffConsentProps) {
const t = useTranslations("login");
// The dialog closes itself right after confirming; while the code is being
// made live, that close must not count as declining.
const resolving = useRef(false);
return (
<ConfirmDialog
isOpen={!!url}
onClose={onCancel}
isOpen={!!url || !!resolveUrl}
onClose={() => {
if (!resolving.current) onCancel();
}}
onConfirm={() => {
if (resolveUrl) {
if (resolving.current) return;
resolving.current = true;
void resolveUrl().catch(() => null).then((resolved) => {
resolving.current = false;
if (!resolved) {
onError?.();
return;
}
window.location.replace(resolved);
onCancel();
});
return;
}
if (url) window.location.replace(url);
}}
title={t("mobile_handoff.title")}
+126
View File
@@ -0,0 +1,126 @@
# Mobile sign-in hand-off with a one-time code
The Bulwark mobile app can sign in through the webmail's login page in the
system browser. Until now the page opened the app's callback link with the
sign-in in the fragment (`flow=password` with the password, or `flow=oauth`
with the tokens). Any app on the device can register the `bulwarkmobile`
scheme, so that link can be caught by someone else.
With a PKCE challenge the link carries only a one-time code. The sign-in
stays on the webmail server until the app redeems the code with the matching
verifier. Without a challenge the page behaves as before, so older app builds
keep working.
## 1. Open the login page
```
GET <webmail>/<locale>/login
?mobile_redirect_uri=bulwarkmobile://auth/callback
&mobile_state=<opaque, up to 512 chars>
&mobile_code_challenge=<S256 challenge>
```
- `mobile_code_challenge` is `BASE64URL(SHA256(code_verifier))` without
padding, exactly 43 characters (RFC 7636, method S256 only). The verifier is
43 to 128 characters from `A-Z a-z 0-9 - . _ ~`. Use a fresh verifier for
every sign-in.
- A challenge that is present but malformed makes the page ignore the
hand-off: it acts as a normal sign-in page and gives the app nothing.
- `mobile_redirect_uri` must be exactly `bulwarkmobile://auth/callback`, as
before.
This works for the password form and for SSO/OAuth sign-in (the page routes
mobile OAuth through the server-side SSO flow). Bulwark Lite has no server to
keep the sign-in on; it ignores the challenge and still uses the fragment.
## 2. The callback link
After the user signs in and confirms the hand-off, the browser opens:
```
bulwarkmobile://auth/callback?code=<64 lowercase hex chars>&state=<mobile_state>
```
The parameters are in the query string, not the fragment. There is no
`flow`, no server URL and no secret in the link. Check `state` against the
value you sent before redeeming.
A link with a fragment (`#flow=...`) means the webmail is older than this
change, or is a Lite build; handle it as before.
## 3. Redeem the code
```
POST <webmail>/api/auth/handoff/redeem
Content-Type: application/json
{ "code": "<code from the link>", "code_verifier": "<verifier>" }
```
The code is valid for 60 seconds from the moment the user confirmed the
hand-off in the browser, and works once. Send no `Origin` or
`Sec-Fetch-Site` header (native HTTP clients don't), or the same-origin check
answers 403.
### Success: 200
The same fields the fragment used to carry, as JSON. `state` is included so
existing fragment parsing can be reused.
```json
{
"flow": "password",
"server_url": "https://mail.example.org",
"username": "alice@example.org",
"password": "...",
"state": "<mobile_state>"
}
```
```json
{
"flow": "oauth",
"server_url": "https://mail.example.org",
"access_token": "...",
"refresh_token": "...",
"expires_in": 3540,
"token_endpoint": "https://mail.example.org/auth/token",
"client_id": "bulwark-webmail",
"state": "<mobile_state>"
}
```
- `expires_in` is a number (the fragment had a string) and counts from the
redeem, not from the sign-in.
- `refresh_token` and `expires_in` are optional, as before.
- `token_endpoint` may point at the webmail's token proxy
(`<webmail>/api/auth/pair/token`), with a sealed `refresh_token`, exactly
like the old fragment and the QR pairing bundle.
### Errors
Every failure spends the code. Start a new sign-in after any of them.
| Status | `error` | Meaning |
| ------ | ------------------- | ----------------------------------------------- |
| 400 | `invalid_request` | Body is not JSON or `code` is malformed |
| 400 | `invalid_code` | Never issued, already redeemed or tried |
| 400 | `expired_code` | Confirmed more than 60 seconds ago |
| 400 | `invalid_verifier` | Verifier malformed or does not match |
| 429 | `too_many_requests` | More than 10 failed redeems from this address in 15 minutes |
| 403 | | Cross-origin browser request |
All responses carry `Cache-Control: no-store`.
## Webmail internals
- Store: `lib/auth/handoff-store.ts` (in-process, like the QR pairing store,
so single-instance deployments or sticky sessions).
- Password flow: when the user confirms, the login page parks the sign-in at
`POST /api/auth/handoff/create` and opens the link with the code it gets.
- SSO flow: `POST /api/auth/sso/start` takes `mobile_code_challenge` with the
other mobile parameters. `POST /api/auth/sso/complete` parks the tokens
itself and returns only `mobile_handoff_code`, so the tokens never reach
the browser. That code waits up to five minutes for the user; when they
confirm, the callback page calls `POST /api/auth/handoff/confirm`, which
starts its 60 seconds, and opens the link.
@@ -219,6 +219,9 @@ const MUTATING_HANDLERS: Array<[string, string]> = [
['@/app/api/auth/pair/create/route', 'POST'],
['@/app/api/auth/pair/redeem/route', 'POST'],
['@/app/api/auth/pair/token/route', 'POST'],
['@/app/api/auth/handoff/create/route', 'POST'],
['@/app/api/auth/handoff/confirm/route', 'POST'],
['@/app/api/auth/handoff/redeem/route', 'POST'],
['@/app/api/auth/sso/start/route', 'POST'],
['@/app/api/auth/sso/complete/route', 'POST'],
['@/app/api/auth/reauth/sso/complete/route', 'POST'],
@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
// The browser side of the code hand-off (docs/mobile-handoff.md): parking a
// password sign-in, and confirming an SSO one, each give the app link with
// only the code. A failure gives no link at all, never the old fragment.
const apiFetch = vi.fn();
vi.mock('@/lib/browser-navigation', () => ({
apiFetch: (...args: unknown[]) => apiFetch(...args),
}));
import { confirmMobileHandoffCodeUrl, requestMobileHandoffCodeUrl } from '@/lib/auth/mobile-handoff-client';
const TARGET = 'bulwarkmobile://auth/callback';
const CODE = 'ab'.repeat(32);
const CHALLENGE = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
const payload = { server_url: 'https://mail.example.org', username: 'alice@example.org', password: 'secret' };
function respond(status: number, body: unknown) {
apiFetch.mockResolvedValueOnce(new Response(JSON.stringify(body), { status }));
}
beforeEach(() => {
apiFetch.mockReset();
});
describe('requestMobileHandoffCodeUrl', () => {
it('parks the sign-in and links the app with the code only', async () => {
respond(200, { code: CODE });
const url = await requestMobileHandoffCodeUrl({ target: TARGET, challenge: CHALLENGE, state: 'm-1', payload });
expect(url).toBe(`${TARGET}?code=${CODE}&state=m-1`);
expect(url).not.toContain('secret');
const [path, init] = apiFetch.mock.calls[0];
expect(path).toBe('/api/auth/handoff/create');
expect(JSON.parse(init.body)).toEqual({ code_challenge: CHALLENGE, state: 'm-1', ...payload });
});
it('gives no link when the server refuses', async () => {
respond(429, { error: 'too_many_requests' });
expect(await requestMobileHandoffCodeUrl({ target: TARGET, challenge: CHALLENGE, state: 'm-1', payload })).toBeNull();
});
it('gives no link when the request fails', async () => {
apiFetch.mockRejectedValueOnce(new Error('offline'));
expect(await requestMobileHandoffCodeUrl({ target: TARGET, challenge: CHALLENGE, state: 'm-1', payload })).toBeNull();
});
});
describe('confirmMobileHandoffCodeUrl', () => {
it('confirms the code and links the app with it', async () => {
respond(200, { ok: true });
expect(await confirmMobileHandoffCodeUrl({ target: TARGET, code: CODE, state: 'm-1' })).toBe(`${TARGET}?code=${CODE}&state=m-1`);
expect(apiFetch.mock.calls[0][0]).toBe('/api/auth/handoff/confirm');
expect(JSON.parse(apiFetch.mock.calls[0][1].body)).toEqual({ code: CODE });
});
it('gives no link for a code that is gone', async () => {
respond(400, { error: 'invalid_code' });
expect(await confirmMobileHandoffCodeUrl({ target: TARGET, code: CODE, state: 'm-1' })).toBeNull();
});
});
@@ -0,0 +1,227 @@
// @vitest-environment node
import { createHash } from 'crypto';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { NextRequest } from 'next/server';
// The code hand-off to the mobile app (docs/mobile-handoff.md): the browser
// parks the sign-in (/api/auth/handoff/create, or /api/auth/sso/complete plus
// /api/auth/handoff/confirm), the app link carries only a code, and the app
// redeems it with its PKCE verifier (/api/auth/handoff/redeem). Wrong
// verifier, reuse and expiry are 400s, and every attempt spends the code.
vi.mock('@/lib/logger', () => ({
logger: { warn: () => {}, error: () => {}, info: () => {}, debug: () => {} },
}));
import { HANDOFF_CONFIRM_TTL_MS, HANDOFF_TTL_MS, resetHandoffStoreForTests, stashHandoff } from '@/lib/auth/handoff-store';
import { POST as createPOST } from '@/app/api/auth/handoff/create/route';
import { POST as confirmPOST } from '@/app/api/auth/handoff/confirm/route';
import { POST as redeemPOST } from '@/app/api/auth/handoff/redeem/route';
const WEBMAIL = 'https://webmail.example';
const VERIFIER = 'v'.repeat(43) + '-._~0123456789';
const CHALLENGE = createHash('sha256').update(VERIFIER).digest('base64url');
const OTHER_VERIFIER = 'w'.repeat(64);
// Each test talks from its own address, so the per-client limits of one test
// don't leak into the next.
let ip = 0;
let clientIp = '';
async function call(handler: (req: NextRequest) => Promise<Response>, path: string, body: unknown) {
const res = await handler(new NextRequest(`${WEBMAIL}${path}`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-forwarded-for': clientIp },
body: typeof body === 'string' ? body : JSON.stringify(body),
}));
return { status: res.status, body: await res.json(), cacheControl: res.headers.get('cache-control') };
}
const create = (body: unknown) => call(createPOST, '/api/auth/handoff/create', body);
const confirm = (body: unknown) => call(confirmPOST, '/api/auth/handoff/confirm', body);
const redeem = (body: unknown) => call(redeemPOST, '/api/auth/handoff/redeem', body);
const PASSWORD_SIGN_IN = {
code_challenge: CHALLENGE,
state: 'm-1',
server_url: 'https://mail.example.org',
username: 'alice@example.org',
password: 'secret',
};
beforeEach(() => {
resetHandoffStoreForTests();
ip += 1;
clientIp = `203.0.113.${ip}`;
});
afterEach(() => {
vi.useRealTimers();
});
describe('password hand-off', () => {
it('gives the app the sign-in for the code and the right verifier, once', async () => {
const created = await create(PASSWORD_SIGN_IN);
expect(created.status).toBe(200);
expect(created.body.code).toMatch(/^[0-9a-f]{64}$/);
expect(JSON.stringify(created.body)).not.toContain('secret');
expect(created.cacheControl).toBe('no-store');
const first = await redeem({ code: created.body.code, code_verifier: VERIFIER });
expect(first.status).toBe(200);
expect(first.body).toEqual({
flow: 'password',
server_url: 'https://mail.example.org',
username: 'alice@example.org',
password: 'secret',
state: 'm-1',
});
expect(first.cacheControl).toBe('no-store');
const again = await redeem({ code: created.body.code, code_verifier: VERIFIER });
expect(again.status).toBe(400);
expect(again.body).toEqual({ error: 'invalid_code' });
});
it('spends the code on a wrong verifier', async () => {
const { body } = await create(PASSWORD_SIGN_IN);
const wrong = await redeem({ code: body.code, code_verifier: OTHER_VERIFIER });
expect(wrong.status).toBe(400);
expect(wrong.body).toEqual({ error: 'invalid_verifier' });
const right = await redeem({ code: body.code, code_verifier: VERIFIER });
expect(right.status).toBe(400);
expect(right.body.password).toBeUndefined();
});
it('spends the code on a malformed verifier', async () => {
const { body } = await create(PASSWORD_SIGN_IN);
const wrong = await redeem({ code: body.code, code_verifier: 'short' });
expect(wrong.status).toBe(400);
expect(wrong.body).toEqual({ error: 'invalid_verifier' });
expect((await redeem({ code: body.code, code_verifier: VERIFIER })).status).toBe(400);
});
it('expires the code after 60 seconds', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
const { body } = await create(PASSWORD_SIGN_IN);
vi.setSystemTime(Date.now() + HANDOFF_TTL_MS + 1);
const late = await redeem({ code: body.code, code_verifier: VERIFIER });
expect(late.status).toBe(400);
expect(late.body).toEqual({ error: 'expired_code' });
});
it('still redeems just before the 60 seconds are up', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
const { body } = await create(PASSWORD_SIGN_IN);
vi.setSystemTime(Date.now() + HANDOFF_TTL_MS - 1000);
expect((await redeem({ code: body.code, code_verifier: VERIFIER })).status).toBe(200);
});
it.each([
['no challenge', { ...PASSWORD_SIGN_IN, code_challenge: undefined }],
['a plain challenge', { ...PASSWORD_SIGN_IN, code_challenge: 'abc' }],
['a padded challenge', { ...PASSWORD_SIGN_IN, code_challenge: `${CHALLENGE.slice(0, 42)}=` }],
['no password', { ...PASSWORD_SIGN_IN, password: '' }],
['no username', { ...PASSWORD_SIGN_IN, username: undefined }],
['no server', { ...PASSWORD_SIGN_IN, server_url: undefined }],
['a body that is not JSON', 'code_challenge=x'],
])('refuses to park %s', async (_label, body) => {
const res = await create(body);
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'invalid_request' });
});
it('limits how many sign-ins one client parks', async () => {
for (let i = 0; i < 10; i++) expect((await create(PASSWORD_SIGN_IN)).status).toBe(200);
expect((await create(PASSWORD_SIGN_IN)).status).toBe(429);
});
});
describe('redeem', () => {
it.each([
['no code', { code_verifier: VERIFIER }],
['uppercase hex', { code: 'AB'.repeat(32), code_verifier: VERIFIER }],
['a body that is not JSON', 'code=x'],
])('refuses %s', async (_label, body) => {
const res = await redeem(body);
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'invalid_request' });
});
it('answers invalid_code for a code nobody issued', async () => {
const res = await redeem({ code: 'ab'.repeat(32), code_verifier: VERIFIER });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'invalid_code' });
});
it('stops a client after ten failed redeems, and a success costs nothing', async () => {
const { body } = await create(PASSWORD_SIGN_IN);
expect((await redeem({ code: body.code, code_verifier: VERIFIER })).status).toBe(200);
for (let i = 0; i < 10; i++) {
expect((await redeem({ code: 'cd'.repeat(32), code_verifier: VERIFIER })).status).toBe(400);
}
expect((await redeem({ code: 'cd'.repeat(32), code_verifier: VERIFIER })).status).toBe(429);
});
});
describe('SSO hand-off', () => {
const bundle = {
flow: 'oauth' as const,
server_url: 'https://mail.example.org',
access_token: 'at',
refresh_token: 'rt',
expires_in: 3600,
token_endpoint: 'https://mail.example.org/auth/token',
client_id: 'webmail',
};
it('redeems only after the user confirmed, for 60 seconds from then', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
const start = Date.now();
const code = stashHandoff(bundle, { challenge: CHALLENGE, state: 'm-2', awaitConfirm: true });
// The user reads the confirmation for a few minutes.
vi.setSystemTime(start + HANDOFF_CONFIRM_TTL_MS - 1000);
expect((await confirm({ code })).status).toBe(200);
vi.setSystemTime(start + HANDOFF_CONFIRM_TTL_MS + HANDOFF_TTL_MS - 2000);
const res = await redeem({ code, code_verifier: VERIFIER });
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ flow: 'oauth', access_token: 'at', refresh_token: 'rt', state: 'm-2' });
// Counted from when the tokens were issued, not from the redeem.
expect(res.body.expires_in).toBe(3600 - Math.floor((HANDOFF_CONFIRM_TTL_MS + HANDOFF_TTL_MS - 2000) / 1000));
});
it('refuses and spends a code the user has not confirmed', async () => {
const code = stashHandoff(bundle, { challenge: CHALLENGE, state: 'm-2', awaitConfirm: true });
const early = await redeem({ code, code_verifier: VERIFIER });
expect(early.status).toBe(400);
expect(early.body).toEqual({ error: 'invalid_code' });
expect((await confirm({ code })).status).toBe(400);
});
it('expires 60 seconds after the confirmation', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
const code = stashHandoff(bundle, { challenge: CHALLENGE, state: 'm-2', awaitConfirm: true });
expect((await confirm({ code })).status).toBe(200);
vi.setSystemTime(Date.now() + HANDOFF_TTL_MS + 1);
const late = await redeem({ code, code_verifier: VERIFIER });
expect(late.status).toBe(400);
expect(late.body).toEqual({ error: 'expired_code' });
});
it('cannot be confirmed once the five minutes to confirm are over', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
const code = stashHandoff(bundle, { challenge: CHALLENGE, state: 'm-2', awaitConfirm: true });
vi.setSystemTime(Date.now() + HANDOFF_CONFIRM_TTL_MS + 1);
expect((await confirm({ code })).status).toBe(400);
});
it('confirms a code once', async () => {
const code = stashHandoff(bundle, { challenge: CHALLENGE, state: 'm-2', awaitConfirm: true });
expect((await confirm({ code })).status).toBe(200);
expect((await confirm({ code })).status).toBe(400);
expect((await confirm({ code: 'nope' })).status).toBe(400);
});
});
+31 -1
View File
@@ -1,5 +1,12 @@
import { describe, expect, it } from 'vitest';
import { MOBILE_HANDOFF_REDIRECT_URI, buildMobileHandoffUrl, mobileHandoffTarget, signedInLoginAction } from '@/lib/auth/mobile-handoff';
import {
MOBILE_HANDOFF_REDIRECT_URI,
buildMobileHandoffCodeUrl,
buildMobileHandoffUrl,
mobileCodeChallenge,
mobileHandoffTarget,
signedInLoginAction,
} from '@/lib/auth/mobile-handoff';
// The login page handed the typed password (and the SSO callback the tokens)
// to any `bulwarkmobile://` URL named in the query string, with nothing on
@@ -59,3 +66,26 @@ describe('signedInLoginAction', () => {
expect(signedInLoginAction({ isMobileHandoff: false, isAddAccountMode: true, hasHandoffPayload: false })).toBe('none');
});
});
describe('code hand-off link', () => {
// docs/mobile-handoff.md: an app that sent a challenge gets a link with
// the one-time code and its state, in the query, and nothing else.
it('carries only the code and the state', () => {
const url = new URL(buildMobileHandoffCodeUrl(MOBILE_HANDOFF_REDIRECT_URI, 'ab'.repeat(32), 'st&ate=1'));
expect(`${url.protocol}//${url.host}${url.pathname}`).toBe(MOBILE_HANDOFF_REDIRECT_URI);
expect([...url.searchParams.keys()]).toEqual(['code', 'state']);
expect(url.searchParams.get('code')).toBe('ab'.repeat(32));
expect(url.searchParams.get('state')).toBe('st&ate=1');
expect(url.hash).toBe('');
});
it('accepts only an S256 challenge', () => {
const challenge = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
expect(mobileCodeChallenge(challenge)).toBe(challenge);
expect(mobileCodeChallenge(`${challenge}=`)).toBe('');
expect(mobileCodeChallenge(challenge.slice(1))).toBe('');
expect(mobileCodeChallenge(`${challenge.slice(1)}+`)).toBe('');
expect(mobileCodeChallenge('')).toBe('');
expect(mobileCodeChallenge(null)).toBe('');
});
});
+53
View File
@@ -37,6 +37,8 @@ vi.mock('@/lib/oauth/token-exchange', () => ({
import { encryptPayload } from '@/lib/auth/crypto';
import { openPhoneRefreshToken } from '@/lib/auth/pair-bundle';
import { POST } from '@/app/api/auth/sso/complete/route';
import { createHash } from 'crypto';
import { confirmHandoff, redeemHandoff, resetHandoffStoreForTests } from '@/lib/auth/handoff-store';
function pending(extra: Record<string, unknown> = {}) {
jar.set('sso_pending', encryptPayload({
@@ -59,6 +61,7 @@ async function complete(extra: Record<string, unknown> = {}) {
beforeEach(() => {
jar.clear();
resetHandoffStoreForTests();
tokenEndpoint = 'https://idp.example.net/token';
confidential = false;
exchangeCodeForTokens.mockReset().mockResolvedValue({ access_token: 'at', refresh_token: 'rt', expires_in: 3600 });
@@ -145,3 +148,53 @@ describe('/api/auth/sso/complete for the mobile app handoff', () => {
expect(openPhoneRefreshToken(res.body.refresh_token)?.tokenEndpoint).toBe('https://idp.example.net/token');
});
});
describe('/api/auth/sso/complete for an app that sent a PKCE challenge', () => {
// docs/mobile-handoff.md: the tokens stay on the server and the page gets
// only a one-time code for the app link.
const VERIFIER = 'x'.repeat(50);
const CHALLENGE = createHash('sha256').update(VERIFIER).digest('base64url');
it('returns a code instead of the tokens, and the app redeems the tokens with it', async () => {
tokenEndpoint = 'https://mail.example.org/auth/token';
pending({
redirect_uri: 'https://webmail.example/mail/en/auth/callback',
mobile_redirect_uri: 'bulwarkmobile://auth/callback',
mobile_state: 'm-1',
mobile_code_challenge: CHALLENGE,
});
const res = await complete();
expect(res.status).toBe(200);
expect(res.body).toEqual({
mobile_handoff_code: expect.stringMatching(/^[0-9a-f]{64}$/),
server_url: 'https://mail.example.org',
mobile_redirect_uri: 'bulwarkmobile://auth/callback',
mobile_state: 'm-1',
});
expect(JSON.stringify(res.body)).not.toMatch(/"at"|"rt"/);
expect(jar.has('jmap_rt')).toBe(false);
expect(confirmHandoff(res.body.mobile_handoff_code)).toBe(true);
const redeemed = redeemHandoff(res.body.mobile_handoff_code, VERIFIER);
expect(redeemed).toMatchObject({
ok: true,
state: 'm-1',
bundle: {
flow: 'oauth',
access_token: 'at',
refresh_token: 'rt',
token_endpoint: 'https://mail.example.org/auth/token',
client_id: 'webmail',
server_url: 'https://mail.example.org',
},
});
});
it('keeps answering with the tokens for an app that sent no challenge', async () => {
tokenEndpoint = 'https://mail.example.org/auth/token';
pending({ mobile_redirect_uri: 'bulwarkmobile://auth/callback', mobile_state: 'm-1' });
const res = await complete();
expect(res.body.access_token).toBe('at');
expect(res.body.mobile_handoff_code).toBeUndefined();
});
});
+46 -1
View File
@@ -22,8 +22,12 @@ vi.mock('@/lib/logger', () => ({
logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() },
}));
const encrypted = vi.hoisted(() => ({ last: null as Record<string, unknown> | null }));
vi.mock('@/lib/auth/crypto', () => ({
encryptPayload: () => 'encrypted',
encryptPayload: (payload: Record<string, unknown>) => {
encrypted.last = payload;
return 'encrypted';
},
}));
vi.mock('@/lib/oauth/pkce-server', () => ({
@@ -134,3 +138,44 @@ describe('sso start route', () => {
expect(url.searchParams.has('max_age')).toBe(false);
});
});
describe('sso start for the mobile app', () => {
const CHALLENGE = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
const mobile = (extra: Record<string, unknown> = {}) => callRoute({
redirect_uri: 'https://mail.example.com/auth/callback',
mobile_redirect_uri: 'bulwarkmobile://auth/callback',
mobile_state: 'm-1',
...extra,
});
beforeEach(() => {
encrypted.last = null;
});
it('keeps the PKCE challenge of the app for the complete route', async () => {
const { status } = await mobile({ mobile_code_challenge: CHALLENGE });
expect(status).toBe(200);
expect(encrypted.last).toMatchObject({
mobile_redirect_uri: 'bulwarkmobile://auth/callback',
mobile_state: 'm-1',
mobile_code_challenge: CHALLENGE,
});
});
it('refuses a malformed challenge instead of falling back to handing over tokens', async () => {
const { status } = await mobile({ mobile_code_challenge: 'plain-text' });
expect(status).toBe(400);
expect(encrypted.last).toBeNull();
});
it('starts as before without a challenge', async () => {
const { status } = await mobile();
expect(status).toBe(200);
expect(encrypted.last).not.toHaveProperty('mobile_code_challenge');
});
it('drops a challenge sent without the app callback', async () => {
await callRoute({ redirect_uri: 'https://mail.example.com/auth/callback', mobile_code_challenge: CHALLENGE });
expect(encrypted.last).not.toHaveProperty('mobile_code_challenge');
});
});
+184
View File
@@ -0,0 +1,184 @@
import { createHash, randomBytes, timingSafeEqual } from 'crypto';
import type { RedeemBundle } from '@/lib/auth/pair-bundle';
import { isMobileCodeChallenge } from '@/lib/auth/mobile-handoff';
import { createFailureBudget } from '@/lib/auth/verify-budget';
// One-time codes for the browser sign-in hand-off to the mobile app
// (lib/auth/mobile-handoff.ts, docs/mobile-handoff.md). The app opens the
// login page with a PKCE `mobile_code_challenge`; once the user has signed in
// and confirmed, the sign-in (an OAuth bundle, or username + password) is
// parked here and the browser opens the app's callback link with nothing but
// a random code. The app redeems that code at /api/auth/handoff/redeem with
// the verifier behind its challenge. A custom-scheme link can be caught by
// another app on the device; the code alone is worthless to it.
//
// A code can be redeemed for 60 seconds from the moment the user confirms,
// and only once. Any redeem attempt spends it, including one with the wrong
// verifier, so a code that leaked cannot be tried twice.
//
// The password flow parks on confirmation (the page holds the password), so
// its code is live at once. The SSO flow parks in /api/auth/sso/complete,
// before the user has seen the confirmation: that code waits, unusable, for
// up to five minutes until the page confirms it (/api/auth/handoff/confirm),
// and its 60 seconds start then.
//
// Spent and expired records stay a while without their sign-in, so a late
// redeem is told "expired" rather than "invalid".
//
// Storage is an in-process Map pinned on globalThis, like the pairing store
// (lib/auth/pairing-store.ts), with the same single-instance assumption.
export const HANDOFF_TTL_MS = 60 * 1000;
/** How long an SSO sign-in waits for the user to confirm the hand-off. */
export const HANDOFF_CONFIRM_TTL_MS = 5 * 60 * 1000;
const TOMBSTONE_MS = 10 * 60 * 1000;
const CODE_BYTES = 32; // 256 bits
/** Records at most; the oldest gives way when a new one comes in. */
const MAX_RECORDS = 2000;
/** RFC 7636 §4.1: 43 to 128 unreserved characters. */
const CODE_VERIFIER_RE = /^[A-Za-z0-9._~-]{43,128}$/;
const CODE_RE = /^[0-9a-f]{64}$/;
export const isHandoffCodeChallenge = isMobileCodeChallenge;
export function isHandoffCodeVerifier(value: unknown): value is string {
return typeof value === 'string' && CODE_VERIFIER_RE.test(value);
}
export function isHandoffCode(value: unknown): value is string {
return typeof value === 'string' && CODE_RE.test(value);
}
interface HandoffRecord {
/** Null once redeemed (or tried) or expired. */
bundle: RedeemBundle | null;
state: string;
challenge: string;
/** Whether the user confirmed the hand-off; only confirmed codes redeem. */
confirmed: boolean;
issuedAt: number;
expiresAt: number;
forgetAt: number;
}
export type HandoffRedeemResult =
| { ok: true; bundle: RedeemBundle; state: string }
| { ok: false; reason: 'invalid' | 'expired' | 'verifier_mismatch' };
const STORE_KEY = '__bulwarkMobileHandoffStore';
function store(): Map<string, HandoffRecord> {
const g = globalThis as typeof globalThis & { [STORE_KEY]?: Map<string, HandoffRecord> };
if (!g[STORE_KEY]) g[STORE_KEY] = new Map();
return g[STORE_KEY];
}
// Wipe sign-ins past their expiry and forget tombstones past theirs. Called
// on every operation, so memory stays bounded without a timer.
function sweep(now: number): void {
const codes = store();
for (const [code, record] of codes) {
if (record.expiresAt <= now) record.bundle = null;
if (record.forgetAt <= now) codes.delete(code);
}
}
function s256(verifier: string): string {
return createHash('sha256').update(verifier, 'ascii').digest('base64url');
}
/**
* Park a sign-in for the app; returns the code the browser hands over. With
* `awaitConfirm` the code only becomes redeemable through confirmHandoff.
*/
export function stashHandoff(
bundle: RedeemBundle,
options: { challenge: string; state: string; awaitConfirm?: boolean },
now = Date.now(),
): string {
sweep(now);
const codes = store();
// Map keeps insertion order, so the first key is the oldest.
while (codes.size >= MAX_RECORDS) {
const oldest = codes.keys().next().value;
if (oldest === undefined) break;
codes.delete(oldest);
}
const code = randomBytes(CODE_BYTES).toString('hex');
const expiresAt = now + (options.awaitConfirm ? HANDOFF_CONFIRM_TTL_MS : HANDOFF_TTL_MS);
codes.set(code, {
bundle,
state: options.state,
challenge: options.challenge,
confirmed: !options.awaitConfirm,
issuedAt: now,
expiresAt,
forgetAt: expiresAt + TOMBSTONE_MS,
});
return code;
}
/**
* The user confirmed the hand-off of a code parked with `awaitConfirm`: it
* can be redeemed for the next 60 seconds. False when there is nothing (left)
* to confirm.
*/
export function confirmHandoff(code: string, now = Date.now()): boolean {
sweep(now);
const record = store().get(code);
if (!record || !record.bundle || record.confirmed) return false;
record.confirmed = true;
record.expiresAt = now + HANDOFF_TTL_MS;
record.forgetAt = record.expiresAt + TOMBSTONE_MS;
return true;
}
/**
* Hand out the sign-in behind `code` when `verifier` matches its challenge.
* The code is spent by the attempt, whatever its outcome. `expires_in` of an
* OAuth bundle counts down from when it was parked.
*/
export function redeemHandoff(code: string, verifier: string, now = Date.now()): HandoffRedeemResult {
sweep(now);
const record = store().get(code);
if (!record) return { ok: false, reason: 'invalid' };
const bundle = record.bundle;
record.bundle = null;
if (!bundle) return { ok: false, reason: record.expiresAt <= now ? 'expired' : 'invalid' };
if (!record.confirmed) return { ok: false, reason: 'invalid' };
const expected = Buffer.from(record.challenge, 'ascii');
const actual = Buffer.from(s256(verifier), 'ascii');
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
return { ok: false, reason: 'verifier_mismatch' };
}
if (bundle.flow === 'oauth' && typeof bundle.expires_in === 'number') {
const elapsed = Math.floor((now - record.issuedAt) / 1000);
return { ok: true, bundle: { ...bundle, expires_in: Math.max(0, bundle.expires_in - elapsed) }, state: record.state };
}
return { ok: true, bundle, state: record.state };
}
/** Test hook: forget everything. */
export function resetHandoffStoreForTests(): void {
store().clear();
}
// Per-client limits on the routes. Codes carry 256 bits and die on the first
// attempt, so guessing is hopeless either way; these keep one client from
// filling the store or hammering the redeem route.
const createBudget = createFailureBudget({ clientMax: 10, globalMax: Infinity });
const redeemBudget = createFailureBudget({ clientMax: 10, globalMax: Infinity });
/** Count one parked sign-in for `client`; false when it is over its limit. */
export function reserveHandoffCreate(client: string, now = Date.now()): boolean {
return createBudget.reserve(client, now) !== null;
}
/**
* Reserve one redeem attempt for `client`: null when it is over its limit,
* otherwise a refund to call when the redeem succeeded (only failures count).
*/
export function reserveHandoffRedeem(client: string, now = Date.now()): (() => void) | null {
return redeemBudget.reserve(client, now);
}
+52
View File
@@ -0,0 +1,52 @@
import { apiFetch } from '@/lib/browser-navigation';
import { buildMobileHandoffCodeUrl } from '@/lib/auth/mobile-handoff';
/**
* Park a password sign-in for the mobile app (/api/auth/handoff/create) and
* return the app link that carries only the one-time code. Null when the
* server would not take it; the caller must not fall back to putting the
* password in the link, which is what the app's challenge asked to avoid.
*/
export async function requestMobileHandoffCodeUrl(opts: {
target: string;
challenge: string;
state: string;
payload: { server_url: string; username: string; password: string };
}): Promise<string | null> {
try {
const res = await apiFetch('/api/auth/handoff/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code_challenge: opts.challenge, state: opts.state, ...opts.payload }),
});
if (!res.ok) return null;
const data = (await res.json().catch(() => null)) as { code?: unknown } | null;
if (typeof data?.code !== 'string' || !data.code) return null;
return buildMobileHandoffCodeUrl(opts.target, data.code, opts.state);
} catch {
return null;
}
}
/**
* Confirm an SSO hand-off the server parked (/api/auth/handoff/confirm) and
* return the app link with its code. Null when the code is gone (it waited
* too long); the user has to start the sign-in in the app again.
*/
export async function confirmMobileHandoffCodeUrl(opts: {
target: string;
code: string;
state: string;
}): Promise<string | null> {
try {
const res = await apiFetch('/api/auth/handoff/confirm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: opts.code }),
});
if (!res.ok) return null;
return buildMobileHandoffCodeUrl(opts.target, opts.code, opts.state);
} catch {
return null;
}
}
+22
View File
@@ -32,6 +32,28 @@ export function buildMobileHandoffUrl(target: string, fragment: Record<string, s
return `${target}#${new URLSearchParams(fragment).toString()}`;
}
/**
* App builds that send `mobile_code_challenge` (PKCE S256: the base64url
* SHA-256 of a verifier only the app knows) get no credentials in the link.
* The sign-in is parked on the server (lib/auth/handoff-store.ts) and the
* link carries a one-time code the app redeems with its verifier at
* /api/auth/handoff/redeem. Older builds send no challenge and keep getting
* the fragment above. Contract: docs/mobile-handoff.md.
*/
export function isMobileCodeChallenge(value: unknown): value is string {
return typeof value === 'string' && /^[A-Za-z0-9_-]{43}$/.test(value);
}
/** The challenge when `raw` is a well-formed S256 challenge, else ''. */
export function mobileCodeChallenge(raw: string | null | undefined): string {
return isMobileCodeChallenge(raw) ? raw : '';
}
/** The URL the browser hands to an app that sent a challenge: code and state only. */
export function buildMobileHandoffCodeUrl(target: string, code: string, state: string): string {
return `${target}?${new URLSearchParams({ code, state }).toString()}`;
}
/**
* What the login page does once the browser is signed in:
* - `handoff`: a mobile hand-off whose sign-in just happened here; show the
+5
View File
@@ -100,6 +100,11 @@ export const LITE_API_STRING_ALLOWLIST = [
"/api/auth/reauth/sso/complete",
"/api/auth/pair/create",
"/api/auth/pair/status",
// Mobile sign-in hand-off with a code (login page, SSO callback) - skipped
// in Lite, which has no server to park the sign-in on and hands over in
// the fragment.
"/api/auth/handoff/create",
"/api/auth/handoff/confirm",
"/api/auth/totp-token-exchange",
"/api/auth/session",
"/api/auth/verify",