diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index c3d3f0c46..7819c93d2 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -120,7 +120,6 @@ export default async function LocaleLayout({ {/* Offline mode isn't in Lite (no service worker there). */} {!IS_LITE && } - {!IS_LITE && } {!IS_LITE && } @@ -135,6 +134,8 @@ export default async function LocaleLayout({ + {/* Beside the gate, not under it: a stolen device at the lock screen still has to hear a wipe. */} + {!IS_LITE && } ); } diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index fa1d82e00..df771c490 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { freshLoginRequired } from "@/lib/app-lock/sign-out"; import { Suspense, useState, useEffect, useRef, useCallback, useId, useMemo } from "react"; import { IS_DESKTOP_BUILD } from "@/lib/desktop/flag"; import { forgetDesktopServer, getDesktopBridge } from "@/lib/desktop/bridge"; @@ -678,6 +679,7 @@ function LoginPageContent() { locale: params.locale, server_id: selectedServer?.id, remember: persistProviderSession, + ...(freshLoginRequired() ? { fresh_login: true } : {}), ...(isMobileHandoff ? { mobile_redirect_uri: mobileRedirectUri, @@ -931,6 +933,7 @@ function LoginPageContent() { if (isAddAccountMode) { authUrl.searchParams.set("prompt", "select_account"); } + if (freshLoginRequired()) authUrl.searchParams.set("prompt", "login"); window.location.href = authUrl.toString(); }; @@ -1029,6 +1032,7 @@ function LoginPageContent() { if (isAddAccountMode) { authUrl.searchParams.set("prompt", "select_account"); } + if (freshLoginRequired()) authUrl.searchParams.set("prompt", "login"); window.location.href = authUrl.toString(); }; diff --git a/app/api/auth/sso/start/route.ts b/app/api/auth/sso/start/route.ts index be4666a94..6a8963af8 100644 --- a/app/api/auth/sso/start/route.ts +++ b/app/api/auth/sso/start/route.ts @@ -55,6 +55,7 @@ export async function POST(request: NextRequest) { purpose: rawPurpose, slot: rawSlot, remember, + fresh_login: freshLogin, } = await request.json(); // `reauth` drives the step-up flow for device pairing: it forces a fresh @@ -178,7 +179,9 @@ export async function POST(request: NextRequest) { // depends on the IdP supporting this OIDC param. Deliberately no max_age=0: // some IdPs (Authelia) re-evaluate max_age at the consent step and loop // back to login forever, and nothing on our side reads auth_time. - if (isReauth) { + // The same after a forgotten app lock PIN: the provider's session outlived it, and + // someone at the device shouldn't walk back in through it (lib/app-lock/sign-out.ts). + if (isReauth || freshLogin === true) { authUrl.searchParams.set('prompt', 'login'); } diff --git a/components/app-lock/__tests__/lock-screen.test.tsx b/components/app-lock/__tests__/lock-screen.test.tsx index b2be7f72e..6316958fa 100644 --- a/components/app-lock/__tests__/lock-screen.test.tsx +++ b/components/app-lock/__tests__/lock-screen.test.tsx @@ -108,6 +108,43 @@ describe('the lock screen', () => { expect(await screen.findByTestId('app-lock-pin')).toBeInTheDocument(); }); + describe('in a pop-out', () => { + class Channel { + static open = new Set(); + onmessage: ((event: MessageEvent) => void) | null = null; + constructor() { Channel.open.add(this); } + postMessage(data: unknown): void { + for (const channel of Channel.open) if (channel !== this) channel.onmessage?.({ data } as MessageEvent); + } + close(): void { Channel.open.delete(this); } + } + + beforeEach(() => { + Channel.open.clear(); + vi.stubGlobal('BroadcastChannel', Channel); + window.history.replaceState(null, '', '/popout/compose'); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + window.history.replaceState(null, '', '/'); + }); + + it('sends people to the main window, and asks for the PIN itself once that has closed', async () => { + (await createVault('correct-pin')).fill(0); + const main = new Channel(); + let mainOpen = true; + main.onmessage = (event) => { + if (mainOpen && (event.data as { type: string }).type === 'main-ping') main.postMessage({ type: 'main-pong' }); + }; + render(); + expect(await screen.findByRole('button', { name: 'open_main' })).toBeInTheDocument(); + mainOpen = false; + main.close(); + expect(await screen.findByTestId('app-lock-pin', {}, { timeout: 6000 })).toBeInTheDocument(); + }, 15_000); + }); + it('says it\'s saving rather than ask for anything while it locks', () => { render(); expect(screen.queryByTestId('app-lock-pin')).toBeNull(); @@ -122,14 +159,28 @@ describe('the lock screen', () => { fireEvent.change(screen.getByTestId('app-lock-new-pin'), { target: { value: '12345' } }); fireEvent.change(screen.getByTestId('app-lock-repeat-pin'), { target: { value: '12345' } }); expect(save).toBeDisabled(); - fireEvent.change(screen.getByTestId('app-lock-new-pin'), { target: { value: '123456' } }); + fireEvent.change(screen.getByTestId('app-lock-new-pin'), { target: { value: '482913' } }); expect(save).toBeDisabled(); - fireEvent.change(screen.getByTestId('app-lock-repeat-pin'), { target: { value: '123456' } }); + fireEvent.change(screen.getByTestId('app-lock-repeat-pin'), { target: { value: '482913' } }); expect(save).toBeEnabled(); }); }); describe('the settings', () => { + it('say what a PIN protects against here', () => { + render(); + expect(screen.getByText('protects_desc')).toBeInTheDocument(); + }); + + it('won\'t save a PIN anyone would guess', async () => { + render(); + fireEvent.click(screen.getByTestId('app-lock-switch')); + fireEvent.change(await screen.findByTestId('app-lock-new-pin'), { target: { value: '123456' } }); + fireEvent.change(screen.getByTestId('app-lock-repeat-pin'), { target: { value: '123456' } }); + expect(screen.getByText('pin_obvious')).toBeInTheDocument(); + expect(screen.getByTestId('app-lock-dialog-save')).toBeDisabled(); + }); + it('are off by default, with nothing else shown', () => { render(); expect(screen.getByTestId('app-lock-switch')).toHaveAttribute('aria-checked', 'false'); diff --git a/components/app-lock/lock-screen.tsx b/components/app-lock/lock-screen.tsx index 9d69b3529..b18523ef1 100644 --- a/components/app-lock/lock-screen.tsx +++ b/components/app-lock/lock-screen.tsx @@ -16,8 +16,9 @@ import type { DesktopOsUnlockKind } from "@/lib/desktop/types"; import { isPopoutWindow } from "@/lib/popout/window-role"; import { productName } from "@/lib/product-name"; -/** How long a pop-out waits to hear from a main window before it asks for the PIN itself. */ -const MAIN_ANSWER_MS = 500; +/** How often a pop-out asks whether a main window is there, and how long a silence means it's gone. */ +const MAIN_PING_MS = 1_000; +const MAIN_GONE_MS = 2_500; function secondsLeft(until: number): number { return Math.max(0, Math.ceil((until - Date.now()) / 1000)); @@ -39,20 +40,28 @@ function useBlockedUntil(): [number, (until: number) => void] { /** * Whether a main window is there. The tab that unlocks keeps the key for as long as it lives, - * and a pop-out is about to close: so a pop-out sends people to the main window. + * and a pop-out is about to close: so a pop-out sends people to the main window. It keeps + * asking, because a main window that closes or crashes says nothing, and with none left the + * pop-out has to take the PIN itself or nobody can get in. */ function useMainWindowThere(): boolean { const [there, setThere] = useState(false); useEffect(() => { if (!isPopoutWindow() || typeof BroadcastChannel === "undefined") return; const channel = new BroadcastChannel(APP_LOCK_CHANNEL); + let heardAt = 0; channel.onmessage = (event: MessageEvent) => { - if (event.data.type === "main-pong") setThere(true); + if (event.data.type !== "main-pong") return; + heardAt = Date.now(); + setThere(true); }; postAppLockMessage({ type: "main-ping" }); - const timer = setTimeout(() => postAppLockMessage({ type: "main-ping" }), MAIN_ANSWER_MS); + const timer = setInterval(() => { + if (Date.now() - heardAt > MAIN_GONE_MS) setThere(false); + postAppLockMessage({ type: "main-ping" }); + }, MAIN_PING_MS); return () => { - clearTimeout(timer); + clearInterval(timer); channel.close(); }; }, []); @@ -182,6 +191,7 @@ function UnlockForm() { if (forgetting) { return ( +

{t("forgot_provider")}