mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 00:13:37 +00:00
feat: hand the mobile app a one-time code instead of the sign-in in its link
This commit is contained in:
@@ -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`));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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,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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user