From a982ce0441206ea987d83b99d0dcee3eac3ddba2 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Thu, 23 Jul 2026 09:54:27 +0100 Subject: [PATCH 1/9] feat(security): move the native session token into biometric-guarded Keychain/Keystore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app lock shipped in #2461 gated rendering only: the JWT sat in plain Preferences, deleting the stored credential id opened the gate, and the /users/me poller kept refreshing behind the lock. This makes it a real control (closes #2472). Three session modes, detected once per launch in auth-token.ts: - guarded: the token lives under AccessControl.BIOMETRY_CURRENT_SET (Keychain SecAccessControl / BiometricPrompt-bound Keystore key) via @capgo/capacitor-native-biometric, pinned 8.6.0. The unlock ceremony IS the token read: one OS prompt releases the credential into memory. authReady() parks every API caller while locked, so a locked app never emits an unauthenticated request that would 401 and tear the session down. On lock/background (same 5-min timeout) the in-memory token is dropped, the user query is disabled and the poller skips its tick. The lock decision derives from a non-secret presence marker — stripping local prefs yields a signed-out app, never an open session (fail closed). - plain: byte-for-byte the previous behavior, kept for older binaries running OTA'd JS (plugin feature-detected) and devices without enrolled biometrics. After the legacy gate opens, the session migrates to guarded storage; the plain copy is deleted only once the next cold start's guarded read proves the round-trip. - none: nothing to protect. Biometric re-enrollment invalidates the guarded item by design; both platforms surface it as not-found, which lands as a clean session-expired logout to /setup, never a stuck lock. Sliding-refresh tokens persist only inside the Android post-auth validity window (Keystore writes prompt outside it — iOS writes are always silent); otherwise they stay memory-only and the server re-mints later. Also routes services/card.ts through authReady()+getAuthHeaders — it read the jwt-token web cookie directly, which never worked on native (flagged in the #2463 re-review). --- android/app/capacitor.build.gradle | 2 + android/capacitor.settings.gradle | 6 + ios/App/App/Info.plist | 2 +- ios/App/CapApp-SPM/Package.swift | 2 + package.json | 1 + pnpm-lock.yaml | 12 + .../AppLock/__tests__/app-lock-gate.test.tsx | 104 ++++++ src/components/Global/AppLock/index.tsx | 145 +++++++-- src/context/authContext.tsx | 14 +- src/hooks/useUserAutoRefresh.ts | 4 + src/services/card.ts | 24 +- src/utils/__tests__/app-lock-state.test.ts | 50 +++ src/utils/__tests__/auth-token.test.ts | 215 ++++++++++++- .../__tests__/secure-token-store.test.ts | 143 +++++++++ src/utils/app-lock-state.ts | 39 +++ src/utils/app-lock.ts | 27 +- src/utils/auth-token.ts | 297 ++++++++++++++++-- src/utils/secure-token-store.ts | 146 +++++++++ 18 files changed, 1139 insertions(+), 94 deletions(-) create mode 100644 src/components/Global/AppLock/__tests__/app-lock-gate.test.tsx create mode 100644 src/utils/__tests__/app-lock-state.test.ts create mode 100644 src/utils/__tests__/secure-token-store.test.ts create mode 100644 src/utils/app-lock-state.ts create mode 100644 src/utils/secure-token-store.ts diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index de320937e6..a968012f38 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -13,12 +13,14 @@ dependencies { implementation project(':capacitor-browser') implementation project(':capacitor-camera') implementation project(':capacitor-clipboard') + implementation project(':capacitor-device') implementation project(':capacitor-haptics') implementation project(':capacitor-keyboard') implementation project(':capacitor-preferences') implementation project(':capacitor-splash-screen') implementation project(':capacitor-status-bar') implementation project(':capgo-capacitor-crisp') + implementation project(':capgo-capacitor-native-biometric') implementation project(':capgo-capacitor-passkey') implementation project(':capgo-capacitor-updater') implementation project(':onesignal-capacitor-plugin') diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index 93bbf1c9ed..8ab36420ff 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -14,6 +14,9 @@ project(':capacitor-camera').projectDir = new File('../node_modules/.pnpm/@capac include ':capacitor-clipboard' project(':capacitor-clipboard').projectDir = new File('../node_modules/.pnpm/@capacitor+clipboard@8.0.1_@capacitor+core@8.2.0/node_modules/@capacitor/clipboard/android') +include ':capacitor-device' +project(':capacitor-device').projectDir = new File('../node_modules/.pnpm/@capacitor+device@8.0.2_@capacitor+core@8.2.0/node_modules/@capacitor/device/android') + include ':capacitor-haptics' project(':capacitor-haptics').projectDir = new File('../node_modules/.pnpm/@capacitor+haptics@8.0.2_@capacitor+core@8.2.0/node_modules/@capacitor/haptics/android') @@ -32,6 +35,9 @@ project(':capacitor-status-bar').projectDir = new File('../node_modules/.pnpm/@c include ':capgo-capacitor-crisp' project(':capgo-capacitor-crisp').projectDir = new File('../node_modules/.pnpm/@capgo+capacitor-crisp@8.0.27_@capacitor+core@8.2.0/node_modules/@capgo/capacitor-crisp/android') +include ':capgo-capacitor-native-biometric' +project(':capgo-capacitor-native-biometric').projectDir = new File('../node_modules/.pnpm/@capgo+capacitor-native-biometric@8.6.0_@capacitor+core@8.2.0/node_modules/@capgo/capacitor-native-biometric/android') + include ':capgo-capacitor-passkey' project(':capgo-capacitor-passkey').projectDir = new File('../node_modules/@capgo/capacitor-passkey/android') diff --git a/ios/App/App/Info.plist b/ios/App/App/Info.plist index 355592c058..8f178a2247 100644 --- a/ios/App/App/Info.plist +++ b/ios/App/App/Info.plist @@ -42,7 +42,7 @@ NSCameraUsageDescription Peanut uses the camera to scan QR codes and verify your identity. NSFaceIDUsageDescription - Peanut uses Face ID to securely sign in with your passkey. + Peanut uses Face ID to sign in with your passkey and to unlock your session. NSPhotoLibraryUsageDescription Peanut needs photo access to upload identity documents during verification. NSLocationWhenInUseUsageDescription diff --git a/ios/App/CapApp-SPM/Package.swift b/ios/App/CapApp-SPM/Package.swift index 4a054a9e19..85f6f90441 100644 --- a/ios/App/CapApp-SPM/Package.swift +++ b/ios/App/CapApp-SPM/Package.swift @@ -23,6 +23,7 @@ let package = Package( .package(name: "CapacitorSplashScreen", path: "../../../node_modules/.pnpm/@capacitor+splash-screen@8.0.1_@capacitor+core@8.2.0/node_modules/@capacitor/splash-screen"), .package(name: "CapacitorStatusBar", path: "../../../node_modules/.pnpm/@capacitor+status-bar@8.0.2_@capacitor+core@8.2.0/node_modules/@capacitor/status-bar"), .package(name: "CapgoCapacitorCrisp", path: "../../../node_modules/.pnpm/@capgo+capacitor-crisp@8.0.27_@capacitor+core@8.2.0/node_modules/@capgo/capacitor-crisp"), + .package(name: "CapgoCapacitorNativeBiometric", path: "../../../node_modules/.pnpm/@capgo+capacitor-native-biometric@8.6.0_@capacitor+core@8.2.0/node_modules/@capgo/capacitor-native-biometric"), .package(name: "CapgoCapacitorPasskey", path: "../../../node_modules/@capgo/capacitor-passkey"), .package(name: "CapgoCapacitorUpdater", path: "../../../node_modules/.pnpm/@capgo+capacitor-updater@8.45.9_@capacitor+core@8.2.0/node_modules/@capgo/capacitor-updater"), .package(name: "OnesignalCapacitorPlugin", path: "../../../node_modules/@onesignal/capacitor-plugin"), @@ -45,6 +46,7 @@ let package = Package( .product(name: "CapacitorSplashScreen", package: "CapacitorSplashScreen"), .product(name: "CapacitorStatusBar", package: "CapacitorStatusBar"), .product(name: "CapgoCapacitorCrisp", package: "CapgoCapacitorCrisp"), + .product(name: "CapgoCapacitorNativeBiometric", package: "CapgoCapacitorNativeBiometric"), .product(name: "CapgoCapacitorPasskey", package: "CapgoCapacitorPasskey"), .product(name: "CapgoCapacitorUpdater", package: "CapgoCapacitorUpdater"), .product(name: "OnesignalCapacitorPlugin", package: "OnesignalCapacitorPlugin"), diff --git a/package.json b/package.json index c5c7cf001c..64de86a399 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "@capacitor/splash-screen": "^8.0.1", "@capacitor/status-bar": "^8.0.2", "@capgo/capacitor-crisp": "^8.0.27", + "@capgo/capacitor-native-biometric": "8.6.0", "@capgo/capacitor-passkey": "^8.2.2", "@capgo/capacitor-updater": "^8.45.9", "@headlessui/react": "^2.2.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3336de9619..4f4deb659d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: '@capgo/capacitor-crisp': specifier: ^8.0.27 version: 8.0.27(@capacitor/core@8.2.0) + '@capgo/capacitor-native-biometric': + specifier: 8.6.0 + version: 8.6.0(@capacitor/core@8.2.0) '@capgo/capacitor-passkey': specifier: ^8.2.2 version: 8.2.2(@capacitor/core@8.2.0) @@ -661,6 +664,11 @@ packages: peerDependencies: '@capacitor/core': '>=8.0.0' + '@capgo/capacitor-native-biometric@8.6.0': + resolution: {integrity: sha512-FvkOzrVzaYdOtv/EqK8GP4lUJblEnLmruSxkrHwnlsgLscN5k4Z4DL7vPohFvTFoce0e/2Y/4VpvV5GwHLJp8Q==} + peerDependencies: + '@capacitor/core': '>=8.0.0' + '@capgo/capacitor-passkey@8.2.2': resolution: {integrity: sha512-jptE6epPEKkXQMt5N1TKghZpA9hvMkCaRpng2EfTXfkWS9HnxAB4Hw5nTULtne2hxvBcTq5XtWzJ/2rhtbC5pA==} peerDependencies: @@ -8672,6 +8680,10 @@ snapshots: dependencies: '@capacitor/core': 8.2.0 + '@capgo/capacitor-native-biometric@8.6.0(@capacitor/core@8.2.0)': + dependencies: + '@capacitor/core': 8.2.0 + '@capgo/capacitor-passkey@8.2.2(@capacitor/core@8.2.0)': dependencies: '@capacitor/core': 8.2.0 diff --git a/src/components/Global/AppLock/__tests__/app-lock-gate.test.tsx b/src/components/Global/AppLock/__tests__/app-lock-gate.test.tsx new file mode 100644 index 0000000000..6046227275 --- /dev/null +++ b/src/components/Global/AppLock/__tests__/app-lock-gate.test.tsx @@ -0,0 +1,104 @@ +// Regression tests for the AppLockGate decision flow. The critical invariant +// (D7): in guarded mode the gate locks from the storage mode ALONE — it must +// never wait for the user query, which cannot settle while its request is +// parked behind the lock. Getting that wrong is a permanent white screen. + +import { render, screen, waitFor } from '@testing-library/react' +import { NextIntlClientProvider } from 'next-intl' +import en from '@/i18n/app/messages/en.json' +import { AppLockGate } from '..' +import { getSessionMode, suspendAuthSession, unlockGuardedToken } from '@/utils/auth-token' +import { isCapacitor } from '@/utils/capacitor' + +jest.mock('@/utils/capacitor', () => ({ + isCapacitor: jest.fn(), +})) + +jest.mock('@/context/authContext', () => ({ + useAuth: () => ({ + user: null, + // never settles — the whole point: guarded mode must not depend on it + isFetchingUser: true, + logoutUser: jest.fn(), + }), +})) + +jest.mock('@/utils/auth-token', () => ({ + getSessionMode: jest.fn(), + migratePlainToGuarded: jest.fn(async () => undefined), + suspendAuthSession: jest.fn(), + unlockGuardedToken: jest.fn(), +})) + +jest.mock('@/utils/app-lock', () => ({ + LOCK_AFTER_BACKGROUND_MS: 5 * 60 * 1000, + requestLocalUserPresence: jest.fn(), +})) + +const mockIsCapacitor = isCapacitor as jest.MockedFunction +const mockGetSessionMode = getSessionMode as jest.MockedFunction +const mockSuspend = suspendAuthSession as jest.MockedFunction +const mockUnlock = unlockGuardedToken as jest.MockedFunction + +function renderGate() { + return render( + + +
protected content
+
+
+ ) +} + +describe('AppLockGate', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('renders children directly on web', () => { + mockIsCapacitor.mockReturnValue(false) + renderGate() + expect(screen.getByTestId('protected')).toBeInTheDocument() + expect(mockGetSessionMode).not.toHaveBeenCalled() + }) + + it('guarded mode: locks and suspends the session without waiting for the user query (D7)', async () => { + mockIsCapacitor.mockReturnValue(true) + mockGetSessionMode.mockResolvedValue('guarded') + // keep the auto-prompt pending so the locked UI stays put + mockUnlock.mockReturnValue(new Promise(() => {})) + + renderGate() + await waitFor(() => expect(screen.getByText(en.appLock.title)).toBeInTheDocument()) + expect(mockSuspend).toHaveBeenCalled() + expect(screen.queryByTestId('protected')).not.toBeInTheDocument() + }) + + it('guarded mode: opens after a successful unlock', async () => { + mockIsCapacitor.mockReturnValue(true) + mockGetSessionMode.mockResolvedValue('guarded') + mockUnlock.mockResolvedValue('unlocked') + + renderGate() + await waitFor(() => expect(screen.getByTestId('protected')).toBeInTheDocument()) + }) + + it('guarded mode: stays locked when the prompt is cancelled', async () => { + mockIsCapacitor.mockReturnValue(true) + mockGetSessionMode.mockResolvedValue('guarded') + mockUnlock.mockResolvedValue('cancelled') + + renderGate() + await waitFor(() => expect(screen.getByText(en.appLock.promptFailed)).toBeInTheDocument()) + expect(screen.queryByTestId('protected')).not.toBeInTheDocument() + }) + + it('none mode: nothing to protect, opens straight through', async () => { + mockIsCapacitor.mockReturnValue(true) + mockGetSessionMode.mockResolvedValue('none') + + renderGate() + await waitFor(() => expect(screen.getByTestId('protected')).toBeInTheDocument()) + expect(mockSuspend).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/Global/AppLock/index.tsx b/src/components/Global/AppLock/index.tsx index 6f3053858f..1b1d9fd29b 100644 --- a/src/components/Global/AppLock/index.tsx +++ b/src/components/Global/AppLock/index.tsx @@ -6,15 +6,24 @@ * outlives the user's attention doesn't hand the account to whoever picks the * phone up next. * - * It is a gate, not an overlay: while locked, the protected tree is not - * rendered at all. Nothing paints behind the lock screen and nothing back - * there is focusable or reachable by assistive tech. The cost is that - * remounting on unlock loses in-flight component state — acceptable, since the - * lock only fires on cold start (no state yet) or after five minutes - * backgrounded (where iOS has often discarded the webview anyway). + * Two regimes, decided by the token-storage mode (getSessionMode): + * + * 'guarded' — the session JWT lives in biometric-guarded Keychain/Keystore + * (issue #2472). The unlock ceremony IS the token read: one OS biometric + * prompt releases the credential into memory. The lock decision derives from + * the guarded token's existence alone — never from the user query, which + * cannot resolve while locked (its request parks on authReady) — and it fails + * CLOSED: stripping local preferences yields a signed-out app, not an open + * session. While locked, the in-memory token is dropped and the authenticated + * session is paused (user query disabled, poller skipped, API callers parked). * - * Web is unaffected — there is no OS-backed presence check to lean on there, - * and a browser tab has no equivalent of "resumed from background". + * 'plain' — legacy deterrent gate for binaries without the plugin or devices + * without enrolled biometrics: a local WebAuthn assertion guards rendering + * only, with the old fail-open semantics. After it opens we opportunistically + * migrate the session into guarded storage. + * + * It is a gate, not an overlay: while locked, the protected tree is not + * rendered at all. Web is unaffected. */ import { useCallback, useEffect, useRef, useState } from 'react' @@ -24,14 +33,15 @@ import { useAuth } from '@/context/authContext' import { isCapacitor } from '@/utils/capacitor' import { getUserPreferences } from '@/utils/general.utils' import { LOCK_AFTER_BACKGROUND_MS, requestLocalUserPresence } from '@/utils/app-lock' +import { setLockState } from '@/utils/app-lock-state' +import { + getSessionMode, + migratePlainToGuarded, + suspendAuthSession, + unlockGuardedToken, + type SessionMode, +} from '@/utils/auth-token' -/** - * `pending` is the state that makes this a boundary rather than a curtain: on - * native we enter it on the very first client paint, before the user query can - * resolve and render balances. Only once auth settles do we learn whether this - * becomes `locked` or, for a signed-out user or one with no usable credential, - * `open`. - */ type GateState = 'open' | 'pending' | 'locked' function LockScreen({ @@ -66,8 +76,10 @@ function LockScreen({ export function AppLockGate({ children }: { children: React.ReactNode }) { const { user, isFetchingUser, logoutUser } = useAuth() + const t = useTranslations('appLock') const userId = user?.user.userId const [state, setState] = useState('open') + const [mode, setMode] = useState(null) const [unlocking, setUnlocking] = useState(false) const [failed, setFailed] = useState(false) const backgroundedAt = useRef(null) @@ -75,32 +87,95 @@ export function AppLockGate({ children }: { children: React.ReactNode }) { const credentialId = userId ? getUserPreferences(userId)?.webAuthnKey?.authenticatorId : undefined // Close the gate on the first client paint, before anything protected can - // render. Runs once — later transitions are driven by resume or unlock. + // render, then resolve the storage mode. Runs once — later transitions are + // driven by resume or unlock. useEffect(() => { - if (isCapacitor()) setState('pending') + if (!isCapacitor()) return + setState('pending') + let cancelled = false + void getSessionMode().then((detected) => { + if (cancelled) return + setMode(detected) + if (detected === 'guarded') { + // Lock straight away — deliberately NOT waiting for the user + // query, which cannot settle while its request is parked + // behind the lock. suspendAuthSession also pauses the + // poller/query and arms the ready gate. + suspendAuthSession() + setState('locked') + } + // 'plain' stays 'pending' until auth settles (legacy flow below); + // 'none' means nothing to protect. + if (detected === 'none') setState('open') + }) + return () => { + cancelled = true + } }, []) + // Legacy (plain-mode) settle: lock iff there is a user with a promptable + // credential — a gate we can't open would strand the user in their own app. useEffect(() => { - if (state !== 'pending' || isFetchingUser) return - // Nothing to protect, or no credential we could ever prompt against — - // a gate we can't open would strand the user in their own app. - setState(userId && credentialId ? 'locked' : 'open') - }, [state, isFetchingUser, userId, credentialId]) + if (mode !== 'plain' || state !== 'pending' || isFetchingUser) return + if (userId && credentialId) { + setLockState('locked') + setState('locked') + } else { + setState('open') + } + }, [mode, state, isFetchingUser, userId, credentialId]) + + const rerunModeDetection = useCallback(() => { + setMode(null) + setState('pending') + void getSessionMode().then((detected) => { + setMode(detected) + if (detected === 'guarded') { + suspendAuthSession() + setState('locked') + } else if (detected === 'none') { + setState('open') + } + }) + }, []) const attemptUnlock = useCallback(async () => { setUnlocking(true) + if (mode === 'guarded') { + const result = await unlockGuardedToken(t('prompt')) + setUnlocking(false) + if (result === 'unlocked') { + setFailed(false) + setState('open') + } else if (result === 'downgraded') { + // interrupted migration: the guarded item is gone but a plain + // token survives — fall back to the legacy flow + rerunModeDetection() + } else if (result === 'session-gone') { + // biometric re-enrollment (or a stripped store) invalidated the + // token; the session is already cleared — land on login, never + // a lock that can't open + window.location.href = '/setup' + } else { + setFailed(true) + } + return + } const outcome = await requestLocalUserPresence(credentialId) setUnlocking(false) if (outcome === 'unlocked' || outcome === 'unsupported') { setFailed(false) + setLockState('unlocked') setState('open') + void migratePlainToGuarded() return } setFailed(true) - }, [credentialId]) + }, [mode, credentialId, rerunModeDetection, t]) useEffect(() => { - if (!isCapacitor() || !userId || !credentialId) return + if (!isCapacitor() || !mode || mode === 'none') return + if (mode === 'plain' && (!userId || !credentialId)) return let removeListener: (() => void) | undefined let cancelled = false @@ -115,6 +190,12 @@ export function AppLockGate({ children }: { children: React.ReactNode }) { const since = backgroundedAt.current backgroundedAt.current = null if (since !== null && Date.now() - since > LOCK_AFTER_BACKGROUND_MS) { + // Guarded: drop the token and re-arm the request gate + // synchronously, BEFORE the lock renders — a + // focus-triggered refetch must park, not race out with + // the old token. + if (mode === 'guarded') suspendAuthSession() + else setLockState('locked') setFailed(false) setState('locked') } @@ -136,7 +217,7 @@ export function AppLockGate({ children }: { children: React.ReactNode }) { cancelled = true removeListener?.() } - }, [userId, credentialId]) + }, [mode, userId, credentialId]) // Prompt as soon as the gate closes, so the common case is one Face ID // prompt and no taps at all. @@ -149,9 +230,10 @@ export function AppLockGate({ children }: { children: React.ReactNode }) { if (state === 'open') return <>{children} - // 'pending': auth hasn't settled, so we don't yet know whether to prompt. - // Show the bare cover rather than the "locked" copy, which would be a lie - // for a signed-out user about to be let straight through. + // 'pending': storage mode / auth hasn't settled, so we don't yet know + // whether to prompt. Show the bare cover rather than the "locked" copy, + // which would be a lie for a signed-out user about to be let straight + // through. if (state === 'pending') return
return ( @@ -159,7 +241,12 @@ export function AppLockGate({ children }: { children: React.ReactNode }) { failed={failed} unlocking={unlocking} onUnlock={() => void attemptUnlock()} - onLogout={() => void logoutUser()} + // Guarded + locked: there is no token in memory, so a backend + // logout call would park on the ready gate forever — local wipe + // only. The guarded JWT is deleted with it; tokenVersion is not + // bumped, which is acceptable since the attacker can't extract the + // guarded token anyway. + onLogout={() => void logoutUser(mode === 'guarded' ? { skipBackendCall: true } : undefined)} /> ) } diff --git a/src/context/authContext.tsx b/src/context/authContext.tsx index a7218c1465..07bb0a9240 100644 --- a/src/context/authContext.tsx +++ b/src/context/authContext.tsx @@ -14,6 +14,7 @@ import { updateUserPreferences, } from '@/utils/general.utils' import { apiFetch } from '@/utils/api-fetch' +import { useAppLocked } from '@/utils/app-lock-state' import { isCapacitor } from '@/utils/capacitor' import { clearAuthToken } from '@/utils/auth-token' import { resetCrispProxySessions } from '@/utils/crisp' @@ -68,7 +69,18 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { const queryClient = useQueryClient() const WEB_AUTHN_COOKIE_KEY = 'web-authn-key' - const { data: user, isLoading: isFetchingUser, refetch: fetchUser, error: userFetchError } = useUserQuery() + // While the native app lock is engaged the session is paused: disabling + // the query here also disables its refetchOnMount/refetchOnWindowFocus, + // so a resume can't race a request out before the unlock ceremony. When + // the lock lifts, react-query refetches stale data on its own — that IS + // the post-unlock refresh. + const appLocked = useAppLocked() + const { + data: user, + isLoading: isFetchingUser, + refetch: fetchUser, + error: userFetchError, + } = useUserQuery(!appLocked) // Singleton auto-refresh poller — keeps the user query fresh while any // rail is provisioning OR a recent submission window is open. Mounted diff --git a/src/hooks/useUserAutoRefresh.ts b/src/hooks/useUserAutoRefresh.ts index 1a7a0cce4c..4b3c348317 100644 --- a/src/hooks/useUserAutoRefresh.ts +++ b/src/hooks/useUserAutoRefresh.ts @@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react' import { useSubmissionWindow, isInSubmissionWindow } from '@/hooks/useSubmissionWindow' +import { getLockState } from '@/utils/app-lock-state' import type { IUserProfile } from '@/interfaces/interfaces' /** @@ -55,6 +56,9 @@ export function useUserAutoRefresh({ let cancelled = false const timer = setInterval(() => { if (cancelled) return + // app lock engaged: the session is paused, not over — skip the + // tick without tearing the interval down + if (getLockState() === 'locked') return if (!hasPendingRailRef.current && !isInSubmissionWindow()) { clearInterval(timer) return diff --git a/src/services/card.ts b/src/services/card.ts index d1814f16eb..11c56ac79b 100644 --- a/src/services/card.ts +++ b/src/services/card.ts @@ -2,15 +2,15 @@ * Card API service — virtual-card waitlist + flow access. * * Client-side fetches to /card and /card/waitlist/*. Matches the pattern in - * services/rain.ts and services/manteca.ts: JWT from cookie, no Next.js - * server-action indirection. + * services/rain.ts and services/manteca.ts: shared auth-token path, no + * Next.js server-action indirection. * * Pioneer purchase API (`purchase()`) was removed in Phase 4 of the M2 * launch — the new free badge-gated waitlist supersedes it. */ -import Cookies from 'js-cookie' import { PEANUT_API_KEY, PEANUT_API_URL } from '@/constants/general.consts' +import { authReady, getAuthHeaders } from '@/utils/auth-token' import { fetchWithSentry } from '@/utils/sentry.utils' export interface CardInfoResponse { @@ -53,13 +53,11 @@ export interface WaitlistStateResponse { releasedAt: string | null } -function authHeaders(): Record { - const jwt = Cookies.get('jwt-token') - if (!jwt) throw new Error('Authentication required') - return { - Authorization: `Bearer ${jwt}`, - 'api-key': PEANUT_API_KEY, - } +async function authHeaders(): Promise> { + await authReady() + const headers = getAuthHeaders({ 'api-key': PEANUT_API_KEY }) + if (!headers['Authorization']) throw new Error('Authentication required') + return headers } export const cardApi = { @@ -67,7 +65,7 @@ export const cardApi = { getInfo: async (): Promise => { const response = await fetchWithSentry(`${PEANUT_API_URL}/card`, { method: 'GET', - headers: authHeaders(), + headers: await authHeaders(), cache: 'no-store', }) if (!response.ok) { @@ -81,7 +79,7 @@ export const cardApi = { joinWaitlist: async (): Promise<{ joinedAt: string; position: number | null }> => { const response = await fetchWithSentry(`${PEANUT_API_URL}/card/waitlist/join`, { method: 'POST', - headers: { ...authHeaders(), 'Content-Type': 'application/json' }, + headers: { ...(await authHeaders()), 'Content-Type': 'application/json' }, body: '{}', cache: 'no-store', }) @@ -96,7 +94,7 @@ export const cardApi = { getWaitlistState: async (): Promise => { const response = await fetchWithSentry(`${PEANUT_API_URL}/card/waitlist/state`, { method: 'GET', - headers: authHeaders(), + headers: await authHeaders(), cache: 'no-store', }) if (!response.ok) { diff --git a/src/utils/__tests__/app-lock-state.test.ts b/src/utils/__tests__/app-lock-state.test.ts new file mode 100644 index 0000000000..23a2c9aa0d --- /dev/null +++ b/src/utils/__tests__/app-lock-state.test.ts @@ -0,0 +1,50 @@ +// tests for the module-level app-lock registry + +type LockModule = typeof import('../app-lock-state') +let lock: LockModule + +function loadModule(): void { + jest.isolateModules(() => { + lock = require('../app-lock-state') + }) +} + +describe('app-lock-state', () => { + beforeEach(() => { + jest.clearAllMocks() + loadModule() + }) + + it('starts unlocked', () => { + expect(lock.getLockState()).toBe('unlocked') + }) + + it('notifies subscribers on every transition and supports unsubscribe', () => { + const cb = jest.fn() + const unsubscribe = lock.subscribeLockState(cb) + + lock.setLockState('locked') + expect(lock.getLockState()).toBe('locked') + expect(cb).toHaveBeenCalledTimes(1) + + // no-op transition does not notify + lock.setLockState('locked') + expect(cb).toHaveBeenCalledTimes(1) + + unsubscribe() + lock.setLockState('unlocked') + expect(cb).toHaveBeenCalledTimes(1) + }) + + it('dispatches the app-lock:changed window event with the new state', () => { + const events: unknown[] = [] + const listener = (e: Event) => events.push((e as CustomEvent).detail) + window.addEventListener(lock.APP_LOCK_CHANGED_EVENT, listener) + + lock.setLockState('locked') + lock.setLockState('unlocked') + expect(events).toEqual(['locked', 'unlocked']) + + window.removeEventListener(lock.APP_LOCK_CHANGED_EVENT, listener) + }) +}) diff --git a/src/utils/__tests__/auth-token.test.ts b/src/utils/__tests__/auth-token.test.ts index 02ab67f99d..a286753fc6 100644 --- a/src/utils/__tests__/auth-token.test.ts +++ b/src/utils/__tests__/auth-token.test.ts @@ -1,14 +1,29 @@ -// tests for auth-token jwt management (web vs capacitor) +// tests for auth-token jwt management (web vs capacitor: guarded/plain/none) import Cookies from 'js-cookie' import { isCapacitor } from '@/utils/capacitor' import { CapacitorCookies } from '@capacitor/core' import { Preferences } from '@capacitor/preferences' +import * as secureStore from '@/utils/secure-token-store' jest.mock('@/utils/capacitor', () => ({ isCapacitor: jest.fn(), })) +jest.mock('@/utils/secure-token-store', () => { + const actual = jest.requireActual('@/utils/secure-token-store') + return { + // real error class so instanceof checks in auth-token keep working + GuardedStoreError: actual.GuardedStoreError, + isGuardedStoreSupported: jest.fn(() => false), + isBiometryEnrolled: jest.fn(async () => false), + guardedRead: jest.fn(), + guardedWrite: jest.fn(async () => undefined), + guardedDelete: jest.fn(async () => undefined), + canWriteSilently: jest.fn(() => true), + } +}) + jest.mock('js-cookie', () => ({ get: jest.fn(), set: jest.fn(), @@ -48,6 +63,26 @@ const mockPreferences = Preferences as unknown as { set: jest.Mock remove: jest.Mock } +const mockSecureStore = secureStore as unknown as { + GuardedStoreError: typeof secureStore.GuardedStoreError + isGuardedStoreSupported: jest.Mock + isBiometryEnrolled: jest.Mock + guardedRead: jest.Mock + guardedWrite: jest.Mock + guardedDelete: jest.Mock + canWriteSilently: jest.Mock +} + +// setAuthToken persistence and mode detection run through several awaited +// dynamic imports and Preferences reads — a timer turn flushes the whole chain +async function flushAsync(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +// Preferences.get is keyed: route marker/jwt reads separately per test +function mockStoredPrefs(values: Record): void { + mockPreferences.get.mockImplementation(async ({ key }: { key: string }) => ({ value: values[key] ?? null })) +} // the module caches the native token and hydration promise, so each test gets // a fresh copy via resetModules + require @@ -141,8 +176,7 @@ describe('auth-token', () => { it('caches in memory and persists to Preferences', async () => { auth.setAuthToken('new-cap-token') expect(auth.getAuthToken()).toBe('new-cap-token') - await Promise.resolve() // flush the fire-and-forget dynamic import chain - await Promise.resolve() + await flushAsync() // fire-and-forget persistence awaits mode detection first expect(mockPreferences.set).toHaveBeenCalledWith({ key: 'jwt-token', value: 'new-cap-token' }) }) @@ -155,8 +189,7 @@ describe('auth-token', () => { it('keeps the in-memory token even when Preferences persistence fails', async () => { mockPreferences.set.mockRejectedValue(new Error('not implemented')) auth.setAuthToken('new-cap-token') - await Promise.resolve() - await Promise.resolve() + await flushAsync() expect(auth.getAuthToken()).toBe('new-cap-token') }) }) @@ -327,4 +360,176 @@ describe('auth-token', () => { }) }) }) + + describe('guarded mode (biometric-guarded token, issue #2472)', () => { + beforeEach(() => { + mockIsCapacitor.mockReturnValue(true) + mockSecureStore.isGuardedStoreSupported.mockReturnValue(true) + mockStoredPrefs({ 'guarded-token-present': '1' }) + }) + + describe('getSessionMode', () => { + it('is guarded when the plugin and the marker are present', async () => { + await expect(auth.getSessionMode()).resolves.toBe('guarded') + }) + + it('is plain when the plugin is missing but a stored token exists', async () => { + mockSecureStore.isGuardedStoreSupported.mockReturnValue(false) + mockStoredPrefs({ 'jwt-token': 'stored' }) + await expect(auth.getSessionMode()).resolves.toBe('plain') + }) + + it('is plain when the plugin is present but only a plain token exists (pre-migration)', async () => { + mockStoredPrefs({ 'jwt-token': 'stored' }) + await expect(auth.getSessionMode()).resolves.toBe('plain') + }) + + it('is none when no storage holds a session', async () => { + mockStoredPrefs({}) + await expect(auth.getSessionMode()).resolves.toBe('none') + }) + + it('fails closed to guarded when the marker cannot be read and the plugin is present', async () => { + mockPreferences.get.mockRejectedValue(new Error('bridge down')) + await expect(auth.getSessionMode()).resolves.toBe('guarded') + }) + }) + + describe('authReady gating', () => { + it('parks while locked and resolves once the guarded token is unlocked', async () => { + mockSecureStore.guardedRead.mockResolvedValue('guarded-jwt') + let readyResolved = false + const ready = auth.authReady().then(() => (readyResolved = true)) + await flushAsync() + expect(readyResolved).toBe(false) + + await expect(auth.unlockGuardedToken('unlock')).resolves.toBe('unlocked') + await ready + expect(readyResolved).toBe(true) + expect(auth.getAuthToken()).toBe('guarded-jwt') + }) + + it('re-arms after suspendAuthSession without bumping the clear epoch', async () => { + mockSecureStore.guardedRead.mockResolvedValue('guarded-jwt') + await auth.unlockGuardedToken('unlock') + const epoch = auth.getClearEpoch() + + auth.suspendAuthSession() + expect(auth.getAuthToken()).toBeNull() + expect(auth.getClearEpoch()).toBe(epoch) + + let readyResolved = false + void auth.authReady().then(() => (readyResolved = true)) + await flushAsync() + expect(readyResolved).toBe(false) + }) + }) + + describe('setAuthToken while locked', () => { + it('drops a token that lands after suspension (late sliding refresh)', async () => { + auth.suspendAuthSession() + auth.setAuthToken('late-refresh-token') + await flushAsync() + expect(auth.getAuthToken()).toBeNull() + expect(mockSecureStore.guardedWrite).not.toHaveBeenCalled() + expect(mockPreferences.set).not.toHaveBeenCalled() + }) + + it('writes to guarded storage only inside the silent window', async () => { + mockSecureStore.guardedRead.mockResolvedValue('guarded-jwt') + await auth.unlockGuardedToken('unlock') + + mockSecureStore.canWriteSilently.mockReturnValue(false) + auth.setAuthToken('re-minted-1') + await flushAsync() + expect(mockSecureStore.guardedWrite).not.toHaveBeenCalled() + expect(auth.getAuthToken()).toBe('re-minted-1') + + mockSecureStore.canWriteSilently.mockReturnValue(true) + auth.setAuthToken('re-minted-2') + await flushAsync() + expect(mockSecureStore.guardedWrite).toHaveBeenCalledWith('re-minted-2') + }) + }) + + describe('unlockGuardedToken failure paths', () => { + it('stays locked on a cancelled prompt', async () => { + mockSecureStore.guardedRead.mockRejectedValue( + new mockSecureStore.GuardedStoreError('cancelled', 'user cancelled') + ) + await expect(auth.unlockGuardedToken('unlock')).resolves.toBe('cancelled') + expect(auth.getAuthToken()).toBeNull() + }) + + it('clears the session when the guarded item is gone (re-enrollment)', async () => { + mockSecureStore.guardedRead.mockRejectedValue( + new mockSecureStore.GuardedStoreError('not-found', 'gone') + ) + await expect(auth.unlockGuardedToken('unlock')).resolves.toBe('session-gone') + expect(mockPreferences.remove).toHaveBeenCalledWith({ key: 'guarded-token-present' }) + expect(mockSecureStore.guardedDelete).toHaveBeenCalled() + expect(auth.getAuthToken()).toBeNull() + }) + + it('downgrades to the plain flow when a plain token survives an interrupted migration', async () => { + mockStoredPrefs({ 'guarded-token-present': '1', 'jwt-token': 'plain-survivor' }) + mockSecureStore.guardedRead.mockRejectedValue( + new mockSecureStore.GuardedStoreError('not-found', 'gone') + ) + await expect(auth.unlockGuardedToken('unlock')).resolves.toBe('downgraded') + // session must NOT have been cleared — the plain token is still valid + expect(auth.getClearEpoch()).toBe(0) + }) + }) + + describe('migratePlainToGuarded', () => { + it('writes the guarded copy and marker but keeps the plain token until round-trip proof', async () => { + mockSecureStore.isBiometryEnrolled.mockResolvedValue(true) + mockStoredPrefs({ 'jwt-token': 'plain-jwt' }) + await auth.authReady() + await auth.migratePlainToGuarded() + expect(mockSecureStore.guardedWrite).toHaveBeenCalledWith('plain-jwt') + expect(mockPreferences.set).toHaveBeenCalledWith({ key: 'guarded-token-present', value: '1' }) + expect(mockPreferences.remove).not.toHaveBeenCalledWith({ key: 'jwt-token' }) + await expect(auth.getSessionMode()).resolves.toBe('guarded') + }) + + it('is a no-op without enrolled biometrics', async () => { + mockSecureStore.isBiometryEnrolled.mockResolvedValue(false) + mockStoredPrefs({ 'jwt-token': 'plain-jwt' }) + await auth.migratePlainToGuarded() + expect(mockSecureStore.guardedWrite).not.toHaveBeenCalled() + }) + + it('deletes the plain copy only after a successful guarded read', async () => { + mockStoredPrefs({ 'guarded-token-present': '1', 'jwt-token': 'plain-leftover' }) + mockSecureStore.guardedRead.mockResolvedValue('guarded-jwt') + await auth.unlockGuardedToken('unlock') + await flushAsync() + expect(mockPreferences.remove).toHaveBeenCalledWith({ key: 'jwt-token' }) + }) + }) + + describe('hasNativeSession', () => { + it('is true from the marker alone and never prompts', async () => { + await expect(auth.hasNativeSession()).resolves.toBe(true) + expect(mockSecureStore.guardedRead).not.toHaveBeenCalled() + }) + }) + + describe('clearAuthToken', () => { + it('removes the guarded item and marker and releases parked callers', async () => { + let readyResolved = false + void auth.authReady().then(() => (readyResolved = true)) + await flushAsync() + expect(readyResolved).toBe(false) + + await auth.clearAuthToken() + await flushAsync() + expect(mockSecureStore.guardedDelete).toHaveBeenCalled() + expect(mockPreferences.remove).toHaveBeenCalledWith({ key: 'guarded-token-present' }) + expect(readyResolved).toBe(true) + }) + }) + }) }) diff --git a/src/utils/__tests__/secure-token-store.test.ts b/src/utils/__tests__/secure-token-store.test.ts new file mode 100644 index 0000000000..d6d7c0a226 --- /dev/null +++ b/src/utils/__tests__/secure-token-store.test.ts @@ -0,0 +1,143 @@ +// tests for the biometric-guarded token store wrapper (error mapping + silent-write window) + +import { isAndroidNative, isCapacitor } from '@/utils/capacitor' + +jest.mock('@/utils/capacitor', () => ({ + isCapacitor: jest.fn(), + isAndroidNative: jest.fn(), +})) + +const mockPlugin = { + isAvailable: jest.fn(), + setCredentials: jest.fn(), + getSecureCredentials: jest.fn(), + deleteCredentials: jest.fn(), +} + +jest.mock('@capgo/capacitor-native-biometric', () => ({ + NativeBiometric: mockPlugin, +})) + +const mockIsCapacitor = isCapacitor as jest.MockedFunction +const mockIsAndroidNative = isAndroidNative as jest.MockedFunction + +type StoreModule = typeof import('../secure-token-store') +let store: StoreModule + +function loadModule(): void { + jest.isolateModules(() => { + store = require('../secure-token-store') + }) +} + +// plugin rejections carry the unified cross-platform code as error.code +function pluginError(code: string, message = 'rejected'): Error & { code: string } { + return Object.assign(new Error(message), { code }) +} + +describe('secure-token-store', () => { + beforeEach(() => { + jest.clearAllMocks() + mockIsCapacitor.mockReturnValue(true) + mockIsAndroidNative.mockReturnValue(false) + mockPlugin.setCredentials.mockResolvedValue(undefined) + mockPlugin.deleteCredentials.mockResolvedValue(undefined) + loadModule() + }) + + describe('isGuardedStoreSupported', () => { + afterEach(() => { + delete (window as any).Capacitor + }) + + it('is false on web', () => { + mockIsCapacitor.mockReturnValue(false) + expect(store.isGuardedStoreSupported()).toBe(false) + }) + + it('is false on an older binary without the plugin', () => { + ;(window as any).Capacitor = { isPluginAvailable: () => false } + expect(store.isGuardedStoreSupported()).toBe(false) + }) + + it('is true when the native plugin is registered', () => { + ;(window as any).Capacitor = { isPluginAvailable: (name: string) => name === 'NativeBiometric' } + expect(store.isGuardedStoreSupported()).toBe(true) + }) + }) + + describe('guardedWrite', () => { + it('stores under BIOMETRY_CURRENT_SET access control', async () => { + await store.guardedWrite('jwt-value') + expect(mockPlugin.setCredentials).toHaveBeenCalledWith( + expect.objectContaining({ + server: 'me.peanut.jwt', + password: 'jwt-value', + accessControl: 1, + }) + ) + }) + }) + + describe('guardedRead error mapping', () => { + it.each([ + ['16', 'cancelled'], // user cancel / negative button + ['15', 'cancelled'], // timeout / system cancel + ['10', 'cancelled'], // failed attempt + ['4', 'cancelled'], // temporary lockout + ['2', 'cancelled'], // permanent lockout — retryable after device re-auth + ['21', 'not-found'], // no protected credentials (incl. re-enrollment invalidation) + ['3', 'not-found'], // no biometrics enrolled — key unusable + ['1', 'transient'], // hardware unavailable + ['0', 'transient'], // unknown — fail closed, stay locked + ])('maps plugin code %s to %s', async (code, reason) => { + mockPlugin.getSecureCredentials.mockRejectedValue(pluginError(code)) + await expect(store.guardedRead('test')).rejects.toMatchObject({ + name: 'GuardedStoreError', + reason, + }) + }) + + it('maps a code-less not-found message to not-found', async () => { + mockPlugin.getSecureCredentials.mockRejectedValue(new Error('No protected credentials found for server')) + await expect(store.guardedRead('test')).rejects.toMatchObject({ reason: 'not-found' }) + }) + + it('returns the released secret on success', async () => { + mockPlugin.getSecureCredentials.mockResolvedValue({ username: 'jwt', password: 'released-jwt' }) + await expect(store.guardedRead('test')).resolves.toBe('released-jwt') + }) + }) + + describe('canWriteSilently', () => { + it('is always true on iOS (Keychain writes never prompt)', () => { + expect(store.canWriteSilently()).toBe(true) + }) + + it('on Android is only true inside the post-auth validity window', async () => { + mockIsAndroidNative.mockReturnValue(true) + expect(store.canWriteSilently()).toBe(false) + + mockPlugin.getSecureCredentials.mockResolvedValue({ username: 'jwt', password: 'released-jwt' }) + await store.guardedRead('unlock') + expect(store.canWriteSilently()).toBe(true) + }) + }) + + describe('guardedDelete', () => { + it('never throws', async () => { + mockPlugin.deleteCredentials.mockRejectedValue(new Error('bridge down')) + await expect(store.guardedDelete()).resolves.toBeUndefined() + }) + }) + + describe('isBiometryEnrolled', () => { + it('is strict: no device-credential fallback counted', async () => { + ;(window as any).Capacitor = { isPluginAvailable: () => true } + mockPlugin.isAvailable.mockResolvedValue({ isAvailable: true }) + await expect(store.isBiometryEnrolled()).resolves.toBe(true) + expect(mockPlugin.isAvailable).toHaveBeenCalledWith({ useFallback: false }) + delete (window as any).Capacitor + }) + }) +}) diff --git a/src/utils/app-lock-state.ts b/src/utils/app-lock-state.ts new file mode 100644 index 0000000000..f4e7869678 --- /dev/null +++ b/src/utils/app-lock-state.ts @@ -0,0 +1,39 @@ +// Module-level lock registry: the one place that knows whether the native app +// lock is currently engaged. Lives outside React because its main consumer is +// auth-token.ts (a plain module), and pulling the Redux store in there would +// create an import cycle. React consumers subscribe via useAppLocked(). + +import { useSyncExternalStore } from 'react' + +export type LockState = 'unlocked' | 'locked' + +export const APP_LOCK_CHANGED_EVENT = 'app-lock:changed' + +let lockState: LockState = 'unlocked' +const subscribers = new Set<() => void>() + +export function getLockState(): LockState { + return lockState +} + +export function setLockState(next: LockState): void { + if (lockState === next) return + lockState = next + subscribers.forEach((cb) => cb()) + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent(APP_LOCK_CHANGED_EVENT, { detail: next })) + } +} + +export function subscribeLockState(cb: () => void): () => void { + subscribers.add(cb) + return () => subscribers.delete(cb) +} + +export function useAppLocked(): boolean { + return useSyncExternalStore( + subscribeLockState, + () => lockState === 'locked', + () => false + ) +} diff --git a/src/utils/app-lock.ts b/src/utils/app-lock.ts index 050f1750e2..5725ff922c 100644 --- a/src/utils/app-lock.ts +++ b/src/utils/app-lock.ts @@ -1,19 +1,16 @@ -// Native app lock: a local user-presence gate shown when the app is opened -// cold or resumed after a spell in the background. +// LEGACY app-lock path: a local user-presence gate for binaries without the +// NativeBiometric plugin and devices without enrolled biometrics ('plain' +// session mode). On current binaries the real control is the biometric-guarded +// token in Keychain/Keystore (issue #2472, see secure-token-store.ts), where +// the unlock ceremony IS the token read and the gate fails closed. // -// This is deliberately a LOCAL check — the assertion is never sent to the API -// for verification. The threat it addresses is physical: someone holding an -// already-unlocked phone. The OS will not produce an assertion without the -// user's biometric or device passcode, which is exactly the property we want. -// Server-side proof of a fresh assertion is a separate concern (step-up auth on -// sensitive endpoints) and does not belong in a UI lock. -// -// It is a privacy screen and a deterrent, NOT account protection: the session -// token stays where it always was (webview storage / cookie jar), reachable by -// anything that can read the app's filesystem, and clearing the stored -// credential id disables the gate entirely. Making it a genuine control means -// moving the token into biometric-guarded Keychain/Keystore — tracked -// separately. +// This fallback is deliberately a LOCAL check — the assertion is never sent to +// the API for verification. The threat it addresses is physical: someone +// holding an already-unlocked phone. It remains a privacy screen and a +// deterrent, NOT account protection: in plain mode the session token is +// reachable by anything that can read the app's filesystem, and clearing the +// stored credential id disables this gate entirely. Those are exactly the +// gaps the guarded mode closes. import { base64URLToBytes } from './native-webauthn' diff --git a/src/utils/auth-token.ts b/src/utils/auth-token.ts index 0a7e304c8b..c124f22867 100644 --- a/src/utils/auth-token.ts +++ b/src/utils/auth-token.ts @@ -1,28 +1,115 @@ // client-side jwt token management. // web: the token lives in a readable cookie; we mirror it into the // Authorization header (existing behavior, 40+ call sites). -// capacitor: the token lives in native Preferences (SharedPreferences / -// UserDefaults), hydrated into an in-memory cache at startup and sent -// as an Authorization header — same as web. Native storage survives -// webview data eviction, which is what broke localStorage-based header -// auth (PEANUT-UI-QTQ). The CapacitorHttp cookie jar is no longer the -// credential store (its Android GET proxy stalls, PEANUT-UI-R44), but -// older cookie-auth binaries keep working: the server still accepts the -// jwt-token cookie, and hasNativeSession falls back to the jar. +// capacitor: three session modes, detected once per launch: +// 'guarded' — the token sits in biometric-guarded Keychain/Keystore +// (issue #2472). JS only materializes it after a successful biometric +// assertion (unlockGuardedToken); suspendAuthSession drops it again when +// the app lock engages. authReady() parks every API caller while locked, +// so a locked app never emits an unauthenticated request. +// 'plain' — legacy Preferences storage (older binaries without the plugin, +// or devices without enrolled biometrics). Byte-for-byte the previous +// behavior: hydrate at startup, WebAuthn deterrent gate only. +// 'none' — no stored session. +// Native storage survives webview data eviction, which is what broke +// localStorage-based header auth (PEANUT-UI-QTQ). The CapacitorHttp cookie jar +// is no longer the credential store (its Android GET proxy stalls, +// PEANUT-UI-R44), but older cookie-auth binaries keep working: the server +// still accepts the jwt-token cookie, and hasNativeSession falls back to the +// jar. import Cookies from 'js-cookie' import { isCapacitor } from './capacitor' import { PEANUT_API_URL } from '@/constants/general.consts' +import { getLockState, setLockState } from './app-lock-state' +import { + GuardedStoreError, + canWriteSilently, + guardedDelete, + guardedRead, + guardedWrite, + isBiometryEnrolled, + isGuardedStoreSupported, +} from './secure-token-store' const JWT_COOKIE_KEY = 'jwt-token' const JWT_STORAGE_KEY = 'jwt-token' +// Non-secret presence marker for the guarded token. Deleting it makes the app +// look signed out (login screen) — never an open session — because the JWT +// itself stays unreadable without a biometric. That is what makes the lock +// decision fail closed without having to prompt just to know a session exists. +const GUARDED_MARKER_KEY = 'guarded-token-present' + +export type SessionMode = 'guarded' | 'plain' | 'none' +export type UnlockResult = 'unlocked' | 'cancelled' | 'session-gone' | 'downgraded' let nativeToken: string | null = null let hydration: Promise | null = null +let sessionMode: Promise | null = null +// bumped on every clearAuthToken; lets in-flight requests detect that the +// session was wiped underneath them (see useUserQuery's sliding refresh) +let clearEpoch = 0 + +// While the guarded session is locked, authReady() returns this gate's promise +// instead of resolving — API callers park here until unlock re-materializes +// the token (or a clear tears the session down). +let readyGate: { promise: Promise; resolve: () => void } | null = null + +function armReadyGate(): void { + if (readyGate) return + let resolve!: () => void + const promise = new Promise((r) => (resolve = r)) + readyGate = { promise, resolve } +} + +function releaseReadyGate(): void { + readyGate?.resolve() + readyGate = null +} + +async function getPreferences() { + const { Preferences } = await import('@capacitor/preferences') + return Preferences +} + +async function detectSessionMode(): Promise { + if (isGuardedStoreSupported()) { + try { + const Preferences = await getPreferences() + const marker = await Preferences.get({ key: GUARDED_MARKER_KEY }) + if (marker.value) return 'guarded' + } catch { + // can't tell whether a guarded session exists — stay locked rather + // than fall open onto a weaker path + return 'guarded' + } + } + try { + const Preferences = await getPreferences() + const { value } = await Preferences.get({ key: JWT_STORAGE_KEY }) + if (value) return 'plain' + } catch {} + try { + const { CapacitorCookies } = await import('@capacitor/core') + const cookies = await CapacitorCookies.getCookies({ url: PEANUT_API_URL }) + if (cookies?.[JWT_COOKIE_KEY]) return 'plain' + } catch {} + return 'none' +} + +/** + * capacitor-only: which token-storage path is this launch on? Memoized — + * login, migration, and clearAuthToken update it in place. + */ +export function getSessionMode(): Promise { + if (!isCapacitor()) return Promise.resolve('none') + if (!sessionMode) sessionMode = detectSessionMode() + return sessionMode +} async function hydrateFromPreferences(): Promise { try { - const { Preferences } = await import('@capacitor/preferences') + const Preferences = await getPreferences() const { value } = await Preferences.get({ key: JWT_STORAGE_KEY }) // a login that raced hydration is fresher than the stored value if (value && nativeToken === null) nativeToken = value @@ -33,38 +120,172 @@ async function hydrateFromPreferences(): Promise { } /** - * resolves once the native token cache is hydrated from Preferences. - * Instant on web. Await this before building auth headers on native so a - * cold start can't race the async Preferences read. + * resolves once a token can legitimately be read on native. Instant on web. + * plain mode: after the Preferences hydration (previous behavior). + * guarded mode: only while unlocked — while the app lock is engaged this + * PARKS, so no caller can fire an unauthenticated request that would 401 and + * tear the session down. Await this before building auth headers. */ export function authReady(): Promise { if (!isCapacitor()) return Promise.resolve() - if (!hydration) hydration = hydrateFromPreferences() - return hydration + return getSessionMode().then((mode) => { + if (mode === 'guarded') { + if (nativeToken !== null) return + armReadyGate() + return readyGate!.promise + } + if (!hydration) hydration = hydrateFromPreferences() + return hydration + }) +} + +/** + * guarded mode: the unlock ceremony. guardedRead shows the OS biometric sheet + * and only returns the token after a successful assertion — one prompt both + * proves presence and releases the credential. + * 'unlocked' — token in memory, parked requests released. + * 'cancelled' — dismissed/failed/lockout; stay locked, retryable. + * 'downgraded' — guarded item gone but a plain token exists (interrupted + * migration); caller should re-run the legacy flow. + * 'session-gone' — guarded item gone (e.g. biometric re-enrollment + * invalidated it) and no fallback: session cleared, route + * to /setup as "session expired". + */ +export async function unlockGuardedToken(reason: string): Promise { + try { + const token = await guardedRead(reason) + nativeToken = token + setLockState('unlocked') + releaseReadyGate() + void finishGuardedMigration() + return 'unlocked' + } catch (error) { + if (error instanceof GuardedStoreError && error.reason === 'not-found') { + let plainToken: string | null = null + try { + const Preferences = await getPreferences() + plainToken = (await Preferences.get({ key: JWT_STORAGE_KEY })).value ?? null + await Preferences.remove({ key: GUARDED_MARKER_KEY }) + } catch {} + await guardedDelete() + sessionMode = null + if (plainToken) return 'downgraded' + await clearAuthToken() + return 'session-gone' + } + return 'cancelled' + } +} + +/** + * guarded mode: engage the lock. Drops the in-memory token and re-arms the + * authReady gate so subsequent API callers park instead of going out + * unauthenticated. A pause, not a logout: storage and clearEpoch untouched. + * Call synchronously BEFORE rendering the lock screen on resume, so a + * focus-triggered refetch can't race out with the old token. + */ +export function suspendAuthSession(): void { + if (!isCapacitor()) return + nativeToken = null + hydration = null + armReadyGate() + setLockState('locked') +} + +/** + * plain-mode migration to guarded storage, called after the legacy WebAuthn + * gate opens. Writes the guarded copy and the presence marker but KEEPS the + * plain token: the next cold start's successful guarded read is the + * round-trip proof, and only then does finishGuardedMigration delete the + * plain copy. On Android this write shows a one-time BiometricPrompt (Keystore + * writes need auth outside the validity window) — accepted migration cost. + */ +export async function migratePlainToGuarded(): Promise { + if (!isCapacitor() || !isGuardedStoreSupported()) return + const mode = await getSessionMode() + if (mode !== 'plain') return + await authReady() + const token = nativeToken + if (!token) return + if (!(await isBiometryEnrolled())) return + try { + await guardedWrite(token) + const Preferences = await getPreferences() + await Preferences.set({ key: GUARDED_MARKER_KEY, value: '1' }) + sessionMode = Promise.resolve('guarded') + } catch { + // stays plain; retried on the next launch + } +} + +// The plain copy (and the legacy cookie-jar remnant) is only deleted here — +// after a guarded read has proven the Keychain/Keystore copy is retrievable. +async function finishGuardedMigration(): Promise { + try { + const Preferences = await getPreferences() + await Preferences.remove({ key: JWT_STORAGE_KEY }) + localStorage.removeItem(JWT_STORAGE_KEY) + } catch {} + try { + const { CapacitorCookies } = await import('@capacitor/core') + await CapacitorCookies.clearCookies({ url: PEANUT_API_URL }) + } catch {} } /** * reads the jwt token for Authorization-header auth. * web: from cookie via js-cookie. capacitor: from the in-memory cache - * (callers that can run at cold start must await authReady() first). + * (callers that can run at cold start must await authReady() first). Null + * while the guarded session is locked — by design. */ export function getAuthToken(): string | null { if (isCapacitor()) return nativeToken return Cookies.get(JWT_COOKIE_KEY) ?? null } +async function persistNativeToken(token: string): Promise { + const mode = await getSessionMode() + if (mode === 'guarded') { + // Outside the silent-write window (Android) the write would prompt — + // skip it; the token stays memory-authoritative and the server + // re-mints on a later /users/me anyway. + if (!canWriteSilently()) return + try { + await guardedWrite(token) + } catch {} + return + } + // plain/none: sessions are born guarded whenever that is silently possible + // (always on iOS; on Android only inside the post-auth window). + if (isGuardedStoreSupported() && canWriteSilently() && (await isBiometryEnrolled())) { + try { + await guardedWrite(token) + const Preferences = await getPreferences() + await Preferences.set({ key: GUARDED_MARKER_KEY, value: '1' }) + sessionMode = Promise.resolve('guarded') + // an existing plain copy is kept until the next unlock round-trip + // proves the guarded one (finishGuardedMigration) + return + } catch {} + } + try { + const Preferences = await getPreferences() + await Preferences.set({ key: JWT_STORAGE_KEY, value: token }) + } catch {} +} + /** * stores the jwt token. web: readable cookie. capacitor: in-memory cache + - * native Preferences (fire-and-forget; the cache is authoritative for this + * native storage (fire-and-forget; the cache is authoritative for this * session). Fed by the login-verify capture and the /users/me sliding - * refresh. + * refresh. Dropped while the app lock is engaged: a refresh response landing + * after suspension must not re-materialize the token behind the lock. */ export function setAuthToken(token: string): void { if (isCapacitor()) { + if (getLockState() === 'locked') return nativeToken = token - import('@capacitor/preferences') - .then(({ Preferences }) => Preferences.set({ key: JWT_STORAGE_KEY, value: token })) - .catch(() => {}) + void persistNativeToken(token) return } Cookies.set(JWT_COOKIE_KEY, token, { expires: 30, path: '/' }) @@ -98,16 +319,21 @@ export async function getSessionTokenForSocket(): Promise { } /** - * capacitor-only: is there a stored session? Awaits hydration, then falls - * back to the legacy CapacitorHttp cookie jar so sessions created by older - * cookie-auth binaries still route to home. Used for cheap routing hints - * (e.g. cold-start home-vs-setup) — the /users/me query remains the + * capacitor-only: is there a stored session? Pure storage presence — never + * prompts and never waits on the lock gate, so cold-start routing (e.g. + * LandingPageCapacitorGate) can run while the app is still locked. Falls back + * to the legacy CapacitorHttp cookie jar so sessions created by older + * cookie-auth binaries still route to home. The /users/me query remains the * authority on whether the session is actually valid. */ export async function hasNativeSession(): Promise { if (!isCapacitor()) return false - await authReady() if (nativeToken) return true + try { + const Preferences = await getPreferences() + if (isGuardedStoreSupported() && (await Preferences.get({ key: GUARDED_MARKER_KEY })).value) return true + if ((await Preferences.get({ key: JWT_STORAGE_KEY })).value) return true + } catch {} try { const { CapacitorCookies } = await import('@capacitor/core') const cookies = await CapacitorCookies.getCookies({ url: PEANUT_API_URL }) @@ -119,8 +345,10 @@ export async function hasNativeSession(): Promise { /** * clears the session. - * capacitor: clears the in-memory cache, native Preferences, the legacy - * cookie jar, and any localStorage remnant from older builds. + * capacitor: clears the in-memory cache, the guarded Keychain/Keystore item + * and its marker, native Preferences, the legacy cookie jar, and any + * localStorage remnant from older builds. Releases any requests parked on the + * lock gate (they proceed tokenless into the /setup teardown). * web: removes the cookie. * returns a promise for the native clears so logout can await it before * reloading; other callers may safely ignore it. @@ -129,14 +357,23 @@ export function clearAuthToken(): Promise { let nativeClear: Promise = Promise.resolve() if (isCapacitor()) { nativeToken = null + hydration = null + sessionMode = null + releaseReadyGate() + setLockState('unlocked') localStorage.removeItem(JWT_STORAGE_KEY) - const prefsClear = import('@capacitor/preferences') - .then(({ Preferences }) => Preferences.remove({ key: JWT_STORAGE_KEY })) + const prefsClear = getPreferences() + .then((Preferences) => + Promise.all([ + Preferences.remove({ key: JWT_STORAGE_KEY }), + Preferences.remove({ key: GUARDED_MARKER_KEY }), + ]) + ) .catch(() => {}) const jarClear = import('@capacitor/core') .then(({ CapacitorCookies }) => CapacitorCookies.clearCookies({ url: PEANUT_API_URL })) .catch(() => {}) - nativeClear = Promise.all([prefsClear, jarClear]).then(() => undefined) + nativeClear = Promise.all([prefsClear, jarClear, guardedDelete()]).then(() => undefined) } // always clear cookie too in case it was set by backend Set-Cookie header Cookies.remove(JWT_COOKIE_KEY, { path: '/' }) diff --git a/src/utils/secure-token-store.ts b/src/utils/secure-token-store.ts new file mode 100644 index 0000000000..6bedd8906c --- /dev/null +++ b/src/utils/secure-token-store.ts @@ -0,0 +1,146 @@ +// Biometric-guarded storage for the native session JWT (issue #2472). +// +// Wraps @capgo/capacitor-native-biometric so the rest of the app never touches +// the plugin directly — if the fork's gated path proves flaky on-device, only +// this file changes (a custom ~200-LOC Keychain/Keystore plugin is the planned +// fallback). The token is stored with AccessControl.BIOMETRY_CURRENT_SET: +// Keychain SecAccessControl on iOS, a BiometricPrompt-bound Keystore key on +// Android. The bytes are cryptographically unreadable without a successful +// biometric assertion — reading IS the unlock ceremony. + +import { isAndroidNative, isCapacitor } from './capacitor' + +const SERVER = 'me.peanut.jwt' +const USERNAME = 'jwt' + +// AccessControl.BIOMETRY_CURRENT_SET — invalidated on biometric re-enrollment, +// which we surface as "session expired". Numeric to keep the plugin out of the +// web bundle (this module is imported by auth-token.ts on every platform). +const BIOMETRY_CURRENT_SET = 1 + +/* + * Android Keystore keys with per-operation auth prompt on WRITES too, which + * would put a BiometricPrompt behind every sliding-token refresh. A short + * auth-validity window fixes that: the unlock read authorizes silent key use + * for AUTH_VALIDITY_S, long enough to cover the re-minted token that + * /users/me ships right after unlock. Writes outside the window are skipped + * by the caller (canWriteSilently) — the token stays memory-only and the + * server re-mints again later. iOS Keychain writes never prompt. + */ +const AUTH_VALIDITY_S = 60 +const SILENT_WRITE_MARGIN_MS = 10_000 + +let lastAuthAt = 0 + +export type GuardedReadError = 'cancelled' | 'not-found' | 'transient' + +export class GuardedStoreError extends Error { + reason: GuardedReadError + constructor(reason: GuardedReadError, message: string) { + super(message) + this.name = 'GuardedStoreError' + this.reason = reason + } +} + +type PluginModule = typeof import('@capgo/capacitor-native-biometric') + +function loadPlugin(): Promise { + return import('@capgo/capacitor-native-biometric') +} + +/** Is the native plugin present in this binary? False on web and on older + * binaries running OTA'd JS — those stay on the plain-Preferences path. */ +export function isGuardedStoreSupported(): boolean { + if (!isCapacitor()) return false + const capacitor = (window as any).Capacitor + return !!capacitor?.isPluginAvailable?.('NativeBiometric') +} + +/** Can the device produce a biometric assertion right now? Strict biometric — + * no device-credential fallback, matching the storage's access control. */ +export async function isBiometryEnrolled(): Promise { + if (!isGuardedStoreSupported()) return false + try { + const { NativeBiometric } = await loadPlugin() + const result = await NativeBiometric.isAvailable({ useFallback: false }) + return !!result.isAvailable + } catch { + return false + } +} + +/** + * Persists the token under biometric access control. Never shows a prompt: + * callers must check canWriteSilently() first on Android (iOS writes are + * always silent). Throws on failure — the caller decides whether the plain + * fallback or memory-only is acceptable. + */ +export async function guardedWrite(token: string): Promise { + const { NativeBiometric } = await loadPlugin() + await NativeBiometric.setCredentials({ + server: SERVER, + username: USERNAME, + password: token, + accessControl: BIOMETRY_CURRENT_SET, + authValidityDuration: AUTH_VALIDITY_S, + }) + if (!isAndroidNative()) return + // an Android write outside the validity window showed its own prompt; a + // successful one (re)opens the window either way + lastAuthAt = Date.now() +} + +/** + * Releases the token — this call shows the OS biometric sheet and only + * resolves with the secret after a successful assertion. Rejections carry a + * GuardedStoreError: + * 'cancelled' — user dismissed / failed / temporary lockout; stay locked, retry. + * 'not-found' — no guarded item, or the key was invalidated by biometric + * re-enrollment (both platforms surface enrollment changes + * this way; Android's plugin self-deletes the dead key). + * Treat as session gone. + * 'transient' — anything else; stay locked with retry + logout escape hatch. + */ +export async function guardedRead(reason: string): Promise { + const { NativeBiometric } = await loadPlugin() + try { + const credentials = await NativeBiometric.getSecureCredentials({ server: SERVER, reason }) + lastAuthAt = Date.now() + return credentials.password + } catch (error) { + throw new GuardedStoreError(classifyReadError(error), error instanceof Error ? error.message : String(error)) + } +} + +/** Best-effort delete of the guarded item; never throws. */ +export async function guardedDelete(): Promise { + try { + const { NativeBiometric } = await loadPlugin() + await NativeBiometric.deleteCredentials({ server: SERVER }) + } catch {} +} + +/** Would a guardedWrite right now complete without an OS prompt? */ +export function canWriteSilently(): boolean { + if (!isAndroidNative()) return true + return Date.now() - lastAuthAt < AUTH_VALIDITY_S * 1000 - SILENT_WRITE_MARGIN_MS +} + +/* + * Unified plugin error codes (convertToPluginErrorCode on Android, mirrored by + * the iOS plugin): 16/15 user or system cancel, 10 failed attempt, 4 temporary + * lockout, 2 permanent lockout — all retryable-while-locked. 21 no protected + * credentials (also how re-enrollment invalidation surfaces on both + * platforms), 3 no biometrics enrolled — session gone. Everything else is + * transient. Unknown codes deliberately stay locked (fail closed) — the lock + * screen's Log out button is the escape hatch. + */ +function classifyReadError(error: unknown): GuardedReadError { + const code = typeof error === 'object' && error !== null ? String((error as { code?: unknown }).code ?? '') : '' + if (['16', '15', '10', '4', '2'].includes(code)) return 'cancelled' + if (['21', '3'].includes(code)) return 'not-found' + const message = error instanceof Error ? error.message : '' + if (/no protected credentials|not found/i.test(message)) return 'not-found' + return 'transient' +} From 5dd9043f681568061fd7e3d8975edb19e5385e00 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Thu, 23 Jul 2026 10:32:24 +0100 Subject: [PATCH 2/9] fix(build): keep React out of the app-lock registry module graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit auth-token.ts is reachable from Server Component pages (charges.ts → [...recipient]/page.tsx), so app-lock-state.ts must stay hook-free — useSyncExternalStore in that module failed the production build on Vercel. Move the useAppLocked hook into its own client file. --- src/context/authContext.tsx | 2 +- src/hooks/useAppLocked.ts | 16 ++++++++++++++++ src/utils/app-lock-state.ts | 15 +++------------ 3 files changed, 20 insertions(+), 13 deletions(-) create mode 100644 src/hooks/useAppLocked.ts diff --git a/src/context/authContext.tsx b/src/context/authContext.tsx index 07bb0a9240..45ad22d21d 100644 --- a/src/context/authContext.tsx +++ b/src/context/authContext.tsx @@ -14,7 +14,7 @@ import { updateUserPreferences, } from '@/utils/general.utils' import { apiFetch } from '@/utils/api-fetch' -import { useAppLocked } from '@/utils/app-lock-state' +import { useAppLocked } from '@/hooks/useAppLocked' import { isCapacitor } from '@/utils/capacitor' import { clearAuthToken } from '@/utils/auth-token' import { resetCrispProxySessions } from '@/utils/crisp' diff --git a/src/hooks/useAppLocked.ts b/src/hooks/useAppLocked.ts new file mode 100644 index 0000000000..1ea3e9980c --- /dev/null +++ b/src/hooks/useAppLocked.ts @@ -0,0 +1,16 @@ +'use client' + +// React binding for the app-lock registry, kept separate from +// utils/app-lock-state.ts so that auth-token.ts (reachable from Server +// Component module graphs) never pulls a React hook in. + +import { useSyncExternalStore } from 'react' +import { getLockState, subscribeLockState } from '@/utils/app-lock-state' + +export function useAppLocked(): boolean { + return useSyncExternalStore( + subscribeLockState, + () => getLockState() === 'locked', + () => false + ) +} diff --git a/src/utils/app-lock-state.ts b/src/utils/app-lock-state.ts index f4e7869678..b731c4056b 100644 --- a/src/utils/app-lock-state.ts +++ b/src/utils/app-lock-state.ts @@ -1,9 +1,8 @@ // Module-level lock registry: the one place that knows whether the native app // lock is currently engaged. Lives outside React because its main consumer is -// auth-token.ts (a plain module), and pulling the Redux store in there would -// create an import cycle. React consumers subscribe via useAppLocked(). - -import { useSyncExternalStore } from 'react' +// auth-token.ts (a plain module reachable from Server Component graphs), so no +// React imports here — React consumers subscribe via the useAppLocked hook +// (src/hooks/useAppLocked.ts). export type LockState = 'unlocked' | 'locked' @@ -29,11 +28,3 @@ export function subscribeLockState(cb: () => void): () => void { subscribers.add(cb) return () => subscribers.delete(cb) } - -export function useAppLocked(): boolean { - return useSyncExternalStore( - subscribeLockState, - () => lockState === 'locked', - () => false - ) -} From ea431d33e99b993e22d9d3f719de1a9dd91c9c05 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Thu, 23 Jul 2026 10:41:53 +0100 Subject: [PATCH 3/9] fix(lint): type window.Capacitor.isPluginAvailable instead of casting any MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the existing global Window.Capacitor declaration — the only eslint error this branch added on top of the known-red baseline. --- src/types/global.d.ts | 4 ++++ src/utils/__tests__/secure-token-store.test.ts | 13 ++++++++----- src/utils/secure-token-store.ts | 3 +-- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 40aca5a973..c557bf109b 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -5,6 +5,10 @@ interface Window { Capacitor?: { getPlatform: () => string isNativePlatform?: () => boolean + // Optional: older bridges predate it. Used to feature-detect native + // plugins (e.g. NativeBiometric) before entering a code path that + // needs them. + isPluginAvailable?: (name: string) => boolean } gtag?: (command: string, ...args: unknown[]) => void // Before client.crisp.chat/l.js loads, $crisp is a plain push-queue array. Once the diff --git a/src/utils/__tests__/secure-token-store.test.ts b/src/utils/__tests__/secure-token-store.test.ts index d6d7c0a226..1406d14a97 100644 --- a/src/utils/__tests__/secure-token-store.test.ts +++ b/src/utils/__tests__/secure-token-store.test.ts @@ -47,7 +47,7 @@ describe('secure-token-store', () => { describe('isGuardedStoreSupported', () => { afterEach(() => { - delete (window as any).Capacitor + window.Capacitor = undefined }) it('is false on web', () => { @@ -56,12 +56,15 @@ describe('secure-token-store', () => { }) it('is false on an older binary without the plugin', () => { - ;(window as any).Capacitor = { isPluginAvailable: () => false } + window.Capacitor = { getPlatform: () => 'ios', isPluginAvailable: () => false } expect(store.isGuardedStoreSupported()).toBe(false) }) it('is true when the native plugin is registered', () => { - ;(window as any).Capacitor = { isPluginAvailable: (name: string) => name === 'NativeBiometric' } + window.Capacitor = { + getPlatform: () => 'ios', + isPluginAvailable: (name: string) => name === 'NativeBiometric', + } expect(store.isGuardedStoreSupported()).toBe(true) }) }) @@ -133,11 +136,11 @@ describe('secure-token-store', () => { describe('isBiometryEnrolled', () => { it('is strict: no device-credential fallback counted', async () => { - ;(window as any).Capacitor = { isPluginAvailable: () => true } + window.Capacitor = { getPlatform: () => 'ios', isPluginAvailable: () => true } mockPlugin.isAvailable.mockResolvedValue({ isAvailable: true }) await expect(store.isBiometryEnrolled()).resolves.toBe(true) expect(mockPlugin.isAvailable).toHaveBeenCalledWith({ useFallback: false }) - delete (window as any).Capacitor + window.Capacitor = undefined }) }) }) diff --git a/src/utils/secure-token-store.ts b/src/utils/secure-token-store.ts index 6bedd8906c..847c88dce5 100644 --- a/src/utils/secure-token-store.ts +++ b/src/utils/secure-token-store.ts @@ -53,8 +53,7 @@ function loadPlugin(): Promise { * binaries running OTA'd JS — those stay on the plain-Preferences path. */ export function isGuardedStoreSupported(): boolean { if (!isCapacitor()) return false - const capacitor = (window as any).Capacitor - return !!capacitor?.isPluginAvailable?.('NativeBiometric') + return !!window.Capacitor?.isPluginAvailable?.('NativeBiometric') } /** Can the device produce a biometric assertion right now? Strict biometric — From c3aa09ff3fc4966a5e82d6316a9430e7358f4f71 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Thu, 23 Jul 2026 14:29:47 +0100 Subject: [PATCH 4/9] fix(app-lock): friendlier lock-screen copy in all locales Match main's #2493: drop the 'locked / could not confirm' framing for a plain log-in ask ('Welcome back!' + 'Please log in to access the app.', button 'Log in'). --- src/i18n/app/messages/en.json | 10 +++++----- src/i18n/app/messages/es-419.json | 10 +++++----- src/i18n/app/messages/pt-BR.json | 10 +++++----- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json index a1b3014126..a90b5bf49d 100644 --- a/src/i18n/app/messages/en.json +++ b/src/i18n/app/messages/en.json @@ -2680,10 +2680,10 @@ "genericSupport": "There was an issue with your request. Please contact support." }, "appLock": { - "title": "Peanut is locked", - "subtitle": "Confirm it is you to continue.", - "subtitleFailed": "Could not confirm it is you. Try again to continue.", - "unlock": "Unlock", - "logout": "Log out" + "title": "Welcome back!", + "prompt": "Please log in to access the app.", + "promptFailed": "Please log in again to access the app.", + "unlock": "Log in", + "logOut": "Log out" } } diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json index 4ee0e21cf2..ab53953380 100644 --- a/src/i18n/app/messages/es-419.json +++ b/src/i18n/app/messages/es-419.json @@ -2680,10 +2680,10 @@ "genericSupport": "Hubo un problema con tu solicitud. Contacta con soporte." }, "appLock": { - "title": "Peanut está bloqueado", - "subtitle": "Confirma tu identidad para continuar.", - "subtitleFailed": "No pudimos confirmar tu identidad. Inténtalo de nuevo para continuar.", - "unlock": "Desbloquear", - "logout": "Cerrar sesión" + "title": "¡Hola de nuevo!", + "prompt": "Inicia sesión para acceder a la app.", + "promptFailed": "Inicia sesión de nuevo para acceder a la app.", + "unlock": "Iniciar sesión", + "logOut": "Cerrar sesión" } } diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json index 436cf4c11e..d34b0a1af5 100644 --- a/src/i18n/app/messages/pt-BR.json +++ b/src/i18n/app/messages/pt-BR.json @@ -2680,10 +2680,10 @@ "genericSupport": "Ocorreu um problema com sua solicitação. Entre em contato com o suporte." }, "appLock": { - "title": "A Peanut está bloqueada", - "subtitle": "Confirme sua identidade para continuar.", - "subtitleFailed": "Não foi possível confirmar sua identidade. Tente novamente para continuar.", - "unlock": "Desbloquear", - "logout": "Sair" + "title": "Olá de novo!", + "prompt": "Faça login para acessar o app.", + "promptFailed": "Faça login novamente para acessar o app.", + "unlock": "Fazer login", + "logOut": "Sair" } } From 1df0ff9302c4d4823d1b6a64f91443a4844f7fb2 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Fri, 24 Jul 2026 10:02:02 +0100 Subject: [PATCH 5/9] feat(app-lock): gate app-open lock behind OPEN_GATED (default off) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening the app and viewing the balance is not treated as a critical vulnerability — matching the web app, where the same read-only view is ungated. Money movement stays passkey-gated at the transaction layer, independently of this flag. - add OPEN_GATED flag (NEXT_PUBLIC_APP_OPEN_GATED, default false) - AppLockGate renders children straight through when the flag is off - guarded-storage use in auth-token gated via guardedModeEnabled(), so the JWT stays in plain Preferences and the session remains readable without a biometric; the guarded infrastructure stays intact for when it is enabled - onramp-quote: await authReady() before building auth headers so the one un-gated caller parks instead of firing unauthenticated mid-lock - app-lock copy: 'Log in' -> 'Unlock' (en/es-419/pt-BR) — the ceremony is a biometric unlock of an existing session, not a login Adds tests covering guarded mode staying dormant when the flag is off. --- src/app/actions/onramp-quote.ts | 5 +++- src/components/Global/AppLock/index.tsx | 7 ++++- src/constants/app-lock.consts.ts | 18 ++++++++++++ src/i18n/app/messages/en.json | 6 ++-- src/i18n/app/messages/es-419.json | 6 ++-- src/i18n/app/messages/pt-BR.json | 6 ++-- src/utils/__tests__/auth-token.test.ts | 37 +++++++++++++++++++++++++ src/utils/auth-token.ts | 16 ++++++++--- 8 files changed, 86 insertions(+), 15 deletions(-) create mode 100644 src/constants/app-lock.consts.ts diff --git a/src/app/actions/onramp-quote.ts b/src/app/actions/onramp-quote.ts index 5692501ffe..e480bf0b37 100644 --- a/src/app/actions/onramp-quote.ts +++ b/src/app/actions/onramp-quote.ts @@ -1,7 +1,7 @@ import { fetchWithSentry } from '@/utils/sentry.utils' import { AccountType } from '@/interfaces/interfaces' import { PEANUT_API_URL } from '@/constants/general.consts' -import { getAuthHeaders } from '@/utils/auth-token' +import { authReady, getAuthHeaders } from '@/utils/auth-token' export interface OnrampQuoteResponse { from: string @@ -34,6 +34,9 @@ export async function getOnrampQuote( url.searchParams.append('sourceAmount', String(sourceAmount)) } + // park until the session token can legitimately be read (guarded mode + // holds this until unlock) so this caller never fires unauthenticated + await authReady() const response = await fetchWithSentry(url.toString(), { method: 'GET', headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, diff --git a/src/components/Global/AppLock/index.tsx b/src/components/Global/AppLock/index.tsx index 1b1d9fd29b..268a7cca38 100644 --- a/src/components/Global/AppLock/index.tsx +++ b/src/components/Global/AppLock/index.tsx @@ -31,6 +31,7 @@ import { useTranslations } from 'next-intl' import { Button } from '@/components/0_Bruddle/Button' import { useAuth } from '@/context/authContext' import { isCapacitor } from '@/utils/capacitor' +import { OPEN_GATED } from '@/constants/app-lock.consts' import { getUserPreferences } from '@/utils/general.utils' import { LOCK_AFTER_BACKGROUND_MS, requestLocalUserPresence } from '@/utils/app-lock' import { setLockState } from '@/utils/app-lock-state' @@ -90,7 +91,11 @@ export function AppLockGate({ children }: { children: React.ReactNode }) { // render, then resolve the storage mode. Runs once — later transitions are // driven by resume or unlock. useEffect(() => { - if (!isCapacitor()) return + // OPEN_GATED off (default): never engage the app-open lock. The state + // stays 'open' so the protected tree renders straight through, exactly + // like web — the guarded-storage path is also disabled (auth-token.ts), + // so the session stays readable and the balance loads without a prompt. + if (!isCapacitor() || !OPEN_GATED) return setState('pending') let cancelled = false void getSessionMode().then((detected) => { diff --git a/src/constants/app-lock.consts.ts b/src/constants/app-lock.consts.ts new file mode 100644 index 0000000000..2fa658f5cc --- /dev/null +++ b/src/constants/app-lock.consts.ts @@ -0,0 +1,18 @@ +// Master switch for the native app-OPEN lock (issue #2472 / PR #2489). +// +// Off by default: opening the app and viewing the balance is NOT gated behind +// a biometric, matching the web app where the same read-only view is ungated. +// We treat "someone opens the app and sees a balance" as non-critical; the +// controls that matter — moving money — stay passkey-gated at the transaction +// layer, independently of this flag. +// +// The biometric-guarded token infrastructure (secure-token-store.ts, the +// 'guarded' session mode, AppLockGate) stays fully in the codebase but is +// dormant when this is false: sessions keep the JWT in plain Preferences so the +// app opens silently and no lock screen renders. +// +// Set NEXT_PUBLIC_APP_OPEN_GATED=true to re-engage the lock — the JWT then +// moves into biometric-guarded Keychain/Keystore and opening the app requires a +// biometric assertion. Next.js inlines `process.env.NEXT_PUBLIC_*` at compile +// time, so a default build keeps the gate off. +export const OPEN_GATED = process.env.NEXT_PUBLIC_APP_OPEN_GATED === 'true' diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json index a90b5bf49d..f58f254000 100644 --- a/src/i18n/app/messages/en.json +++ b/src/i18n/app/messages/en.json @@ -2681,9 +2681,9 @@ }, "appLock": { "title": "Welcome back!", - "prompt": "Please log in to access the app.", - "promptFailed": "Please log in again to access the app.", - "unlock": "Log in", + "prompt": "Unlock to open the app.", + "promptFailed": "Unlock again to open the app.", + "unlock": "Unlock", "logOut": "Log out" } } diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json index ab53953380..96e94f554a 100644 --- a/src/i18n/app/messages/es-419.json +++ b/src/i18n/app/messages/es-419.json @@ -2681,9 +2681,9 @@ }, "appLock": { "title": "¡Hola de nuevo!", - "prompt": "Inicia sesión para acceder a la app.", - "promptFailed": "Inicia sesión de nuevo para acceder a la app.", - "unlock": "Iniciar sesión", + "prompt": "Desbloquea para abrir la app.", + "promptFailed": "Desbloquea de nuevo para abrir la app.", + "unlock": "Desbloquear", "logOut": "Cerrar sesión" } } diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json index d34b0a1af5..1413cea0fd 100644 --- a/src/i18n/app/messages/pt-BR.json +++ b/src/i18n/app/messages/pt-BR.json @@ -2681,9 +2681,9 @@ }, "appLock": { "title": "Olá de novo!", - "prompt": "Faça login para acessar o app.", - "promptFailed": "Faça login novamente para acessar o app.", - "unlock": "Fazer login", + "prompt": "Desbloqueie para abrir o app.", + "promptFailed": "Desbloqueie novamente para abrir o app.", + "unlock": "Desbloquear", "logOut": "Sair" } } diff --git a/src/utils/__tests__/auth-token.test.ts b/src/utils/__tests__/auth-token.test.ts index a286753fc6..8163fada8a 100644 --- a/src/utils/__tests__/auth-token.test.ts +++ b/src/utils/__tests__/auth-token.test.ts @@ -6,6 +6,11 @@ import { CapacitorCookies } from '@capacitor/core' import { Preferences } from '@capacitor/preferences' import * as secureStore from '@/utils/secure-token-store' +// Exercise the guarded-mode paths: with OPEN_GATED on, guarded use defers to the +// isGuardedStoreSupported mock below (default false → plain), so the flag is a +// no-op for the plain/none/web cases and only unlocks the guarded assertions. +process.env.NEXT_PUBLIC_APP_OPEN_GATED = 'true' + jest.mock('@/utils/capacitor', () => ({ isCapacitor: jest.fn(), })) @@ -361,6 +366,38 @@ describe('auth-token', () => { }) }) + describe('OPEN_GATED off — guarded mode stays dormant even with the plugin present', () => { + beforeEach(() => { + process.env.NEXT_PUBLIC_APP_OPEN_GATED = 'false' + mockIsCapacitor.mockReturnValue(true) + mockSecureStore.isGuardedStoreSupported.mockReturnValue(true) + loadModule() + }) + + afterEach(() => { + process.env.NEXT_PUBLIC_APP_OPEN_GATED = 'true' + }) + + it('ignores the guarded marker and falls back to the plain token', async () => { + mockStoredPrefs({ 'guarded-token-present': '1', 'jwt-token': 'stored' }) + await expect(auth.getSessionMode()).resolves.toBe('plain') + }) + + it('is none — never guarded — when only the guarded marker is present', async () => { + mockStoredPrefs({ 'guarded-token-present': '1' }) + await expect(auth.getSessionMode()).resolves.toBe('none') + }) + + it('authReady does not park — hydrates the plain token without an unlock', async () => { + mockStoredPrefs({ 'guarded-token-present': '1', 'jwt-token': 'stored' }) + let readyResolved = false + void auth.authReady().then(() => (readyResolved = true)) + await flushAsync() + expect(readyResolved).toBe(true) + expect(auth.getAuthToken()).toBe('stored') + }) + }) + describe('guarded mode (biometric-guarded token, issue #2472)', () => { beforeEach(() => { mockIsCapacitor.mockReturnValue(true) diff --git a/src/utils/auth-token.ts b/src/utils/auth-token.ts index c124f22867..641bee3e19 100644 --- a/src/utils/auth-token.ts +++ b/src/utils/auth-token.ts @@ -21,6 +21,7 @@ import Cookies from 'js-cookie' import { isCapacitor } from './capacitor' import { PEANUT_API_URL } from '@/constants/general.consts' +import { OPEN_GATED } from '@/constants/app-lock.consts' import { getLockState, setLockState } from './app-lock-state' import { GuardedStoreError, @@ -32,6 +33,13 @@ import { isGuardedStoreSupported, } from './secure-token-store' +// Guarded mode is used only when the app-open lock is enabled AND the native +// plugin is present. With OPEN_GATED off (default) the JWT stays in plain +// Preferences so the app opens without a biometric — see app-lock.consts.ts. +function guardedModeEnabled(): boolean { + return OPEN_GATED && isGuardedStoreSupported() +} + const JWT_COOKIE_KEY = 'jwt-token' const JWT_STORAGE_KEY = 'jwt-token' // Non-secret presence marker for the guarded token. Deleting it makes the app @@ -73,7 +81,7 @@ async function getPreferences() { } async function detectSessionMode(): Promise { - if (isGuardedStoreSupported()) { + if (guardedModeEnabled()) { try { const Preferences = await getPreferences() const marker = await Preferences.get({ key: GUARDED_MARKER_KEY }) @@ -201,7 +209,7 @@ export function suspendAuthSession(): void { * writes need auth outside the validity window) — accepted migration cost. */ export async function migratePlainToGuarded(): Promise { - if (!isCapacitor() || !isGuardedStoreSupported()) return + if (!isCapacitor() || !guardedModeEnabled()) return const mode = await getSessionMode() if (mode !== 'plain') return await authReady() @@ -257,7 +265,7 @@ async function persistNativeToken(token: string): Promise { } // plain/none: sessions are born guarded whenever that is silently possible // (always on iOS; on Android only inside the post-auth window). - if (isGuardedStoreSupported() && canWriteSilently() && (await isBiometryEnrolled())) { + if (guardedModeEnabled() && canWriteSilently() && (await isBiometryEnrolled())) { try { await guardedWrite(token) const Preferences = await getPreferences() @@ -331,7 +339,7 @@ export async function hasNativeSession(): Promise { if (nativeToken) return true try { const Preferences = await getPreferences() - if (isGuardedStoreSupported() && (await Preferences.get({ key: GUARDED_MARKER_KEY })).value) return true + if (guardedModeEnabled() && (await Preferences.get({ key: GUARDED_MARKER_KEY })).value) return true if ((await Preferences.get({ key: JWT_STORAGE_KEY })).value) return true } catch {} try { From 107f6a988371b50ed639106e1525727f02d2db3b Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Fri, 24 Jul 2026 11:30:06 +0100 Subject: [PATCH 6/9] test(app-lock): run gate tests with OPEN_GATED on; cover default pass-through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app-open lock is now dormant behind OPEN_GATED (default off), so the guarded-mode lock tests failed — the effect returns early and never locks. Mock the flag on for those cases and add a case asserting a guarded session opens straight through when the flag is off. --- .../AppLock/__tests__/app-lock-gate.test.tsx | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/components/Global/AppLock/__tests__/app-lock-gate.test.tsx b/src/components/Global/AppLock/__tests__/app-lock-gate.test.tsx index 6046227275..d4bcef31c1 100644 --- a/src/components/Global/AppLock/__tests__/app-lock-gate.test.tsx +++ b/src/components/Global/AppLock/__tests__/app-lock-gate.test.tsx @@ -14,6 +14,16 @@ jest.mock('@/utils/capacitor', () => ({ isCapacitor: jest.fn(), })) +// The app-open lock is dormant behind OPEN_GATED (default off). These tests +// exercise the gate's locking behaviour, so they run with the flag on; the +// default-off pass-through is covered by its own case. +let mockOpenGated = true +jest.mock('@/constants/app-lock.consts', () => ({ + get OPEN_GATED() { + return mockOpenGated + }, +})) + jest.mock('@/context/authContext', () => ({ useAuth: () => ({ user: null, @@ -53,6 +63,7 @@ function renderGate() { describe('AppLockGate', () => { beforeEach(() => { jest.clearAllMocks() + mockOpenGated = true }) it('renders children directly on web', () => { @@ -62,6 +73,17 @@ describe('AppLockGate', () => { expect(mockGetSessionMode).not.toHaveBeenCalled() }) + it('OPEN_GATED off (default): guarded session opens straight through, never locks', async () => { + mockOpenGated = false + mockIsCapacitor.mockReturnValue(true) + mockGetSessionMode.mockResolvedValue('guarded') + + renderGate() + await waitFor(() => expect(screen.getByTestId('protected')).toBeInTheDocument()) + expect(mockGetSessionMode).not.toHaveBeenCalled() + expect(mockSuspend).not.toHaveBeenCalled() + }) + it('guarded mode: locks and suspends the session without waiting for the user query (D7)', async () => { mockIsCapacitor.mockReturnValue(true) mockGetSessionMode.mockResolvedValue('guarded') From c33cc95337ab37ff83f7aa29783faec1632bed0e Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Fri, 24 Jul 2026 11:59:49 +0100 Subject: [PATCH 7/9] test(app-lock): default OPEN_GATED off in tests, opt in per lock case Mirror production, where OPEN_GATED is off when NEXT_PUBLIC_APP_OPEN_GATED is unset. The gate-exercising cases enable it explicitly. --- .../AppLock/__tests__/app-lock-gate.test.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/components/Global/AppLock/__tests__/app-lock-gate.test.tsx b/src/components/Global/AppLock/__tests__/app-lock-gate.test.tsx index d4bcef31c1..994d06c7fa 100644 --- a/src/components/Global/AppLock/__tests__/app-lock-gate.test.tsx +++ b/src/components/Global/AppLock/__tests__/app-lock-gate.test.tsx @@ -14,10 +14,10 @@ jest.mock('@/utils/capacitor', () => ({ isCapacitor: jest.fn(), })) -// The app-open lock is dormant behind OPEN_GATED (default off). These tests -// exercise the gate's locking behaviour, so they run with the flag on; the -// default-off pass-through is covered by its own case. -let mockOpenGated = true +// The app-open lock is dormant behind OPEN_GATED (off unless +// NEXT_PUBLIC_APP_OPEN_GATED=true). Defaults off here too; cases that exercise +// the lock opt in explicitly. +let mockOpenGated = false jest.mock('@/constants/app-lock.consts', () => ({ get OPEN_GATED() { return mockOpenGated @@ -63,7 +63,9 @@ function renderGate() { describe('AppLockGate', () => { beforeEach(() => { jest.clearAllMocks() - mockOpenGated = true + // Default off, matching production when NEXT_PUBLIC_APP_OPEN_GATED is + // unset. Cases that exercise the lock opt in explicitly. + mockOpenGated = false }) it('renders children directly on web', () => { @@ -85,6 +87,7 @@ describe('AppLockGate', () => { }) it('guarded mode: locks and suspends the session without waiting for the user query (D7)', async () => { + mockOpenGated = true mockIsCapacitor.mockReturnValue(true) mockGetSessionMode.mockResolvedValue('guarded') // keep the auto-prompt pending so the locked UI stays put @@ -97,6 +100,7 @@ describe('AppLockGate', () => { }) it('guarded mode: opens after a successful unlock', async () => { + mockOpenGated = true mockIsCapacitor.mockReturnValue(true) mockGetSessionMode.mockResolvedValue('guarded') mockUnlock.mockResolvedValue('unlocked') @@ -106,6 +110,7 @@ describe('AppLockGate', () => { }) it('guarded mode: stays locked when the prompt is cancelled', async () => { + mockOpenGated = true mockIsCapacitor.mockReturnValue(true) mockGetSessionMode.mockResolvedValue('guarded') mockUnlock.mockResolvedValue('cancelled') @@ -116,6 +121,7 @@ describe('AppLockGate', () => { }) it('none mode: nothing to protect, opens straight through', async () => { + mockOpenGated = true mockIsCapacitor.mockReturnValue(true) mockGetSessionMode.mockResolvedValue('none') From 9d16d13b0bf76b83b3b13501e7988ac414d3ebad Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Wed, 29 Jul 2026 13:33:38 +0100 Subject: [PATCH 8/9] fix(rebase): restore clearEpoch guard and lock-screen keys lost rebasing onto dev The epoch guard (getClearEpoch + the sliding-refresh drop), the awaited clearAuthToken on 401/404, and the prompt/logOut message keys predate this branch on the mobile-release lineage; dev has not received them yet, so the rebase silently resolved those regions to dev's older state while the branch's code and tests depend on them. --- src/components/Global/AppLock/index.tsx | 6 +++--- src/content | 2 +- src/hooks/query/__tests__/user.test.tsx | 16 ++++++++++++++++ src/hooks/query/user.ts | 13 ++++++++++--- src/utils/auth-token.ts | 11 +++++++++++ 5 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/components/Global/AppLock/index.tsx b/src/components/Global/AppLock/index.tsx index 268a7cca38..63cdacd944 100644 --- a/src/components/Global/AppLock/index.tsx +++ b/src/components/Global/AppLock/index.tsx @@ -26,8 +26,8 @@ * rendered at all. Web is unaffected. */ -import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslations } from 'next-intl' +import { useCallback, useEffect, useRef, useState } from 'react' import { Button } from '@/components/0_Bruddle/Button' import { useAuth } from '@/context/authContext' import { isCapacitor } from '@/utils/capacitor' @@ -61,14 +61,14 @@ function LockScreen({

{t('title')}

-

{failed ? t('subtitleFailed') : t('subtitle')}

+

{failed ? t('promptFailed') : t('prompt')}

diff --git a/src/content b/src/content index c268e5d9c4..ca7f9ca5e8 160000 --- a/src/content +++ b/src/content @@ -1 +1 @@ -Subproject commit c268e5d9c436ae742915b26873e4066748883acc +Subproject commit ca7f9ca5e8a49336925570bcfb6b00e77db2b6c4 diff --git a/src/hooks/query/__tests__/user.test.tsx b/src/hooks/query/__tests__/user.test.tsx index a666bbaea0..5fbbb67799 100644 --- a/src/hooks/query/__tests__/user.test.tsx +++ b/src/hooks/query/__tests__/user.test.tsx @@ -10,6 +10,7 @@ jest.mock('@/utils/api-fetch', () => ({ apiFetch: jest.fn() })) jest.mock('@/utils/auth-token', () => ({ setAuthToken: jest.fn(), clearAuthToken: jest.fn(), + getClearEpoch: jest.fn(() => 0), })) jest.mock('@/hooks/usePWAStatus', () => ({ usePWAStatus: () => false })) jest.mock('@/hooks/useGetDeviceType', () => ({ useDeviceType: () => ({ deviceType: 'desktop' }) })) @@ -58,6 +59,21 @@ describe('useUserQuery — JWT sliding refresh', () => { expect(mockSetAuthToken).toHaveBeenCalledTimes(1) }) + it('drops a refreshed token when the session was cleared mid-flight (epoch changed)', async () => { + const { getClearEpoch } = jest.requireMock('@/utils/auth-token') + // epoch reads: once before the request, once after — logout in between + getClearEpoch.mockReturnValueOnce(0).mockReturnValueOnce(1) + mockApiFetch.mockResolvedValueOnce( + mockResponse(200, { user: { userId: 'u1', username: 'alice' }, token: 'resurrected.jwt' }) + ) + + const { result } = renderHook(() => useUserQuery(), { wrapper: makeWrapper() }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(mockSetAuthToken).not.toHaveBeenCalled() + expect(result.current.data).not.toHaveProperty('token') + }) + it('does NOT call setAuthToken when the response has no token field', async () => { mockApiFetch.mockResolvedValueOnce(mockResponse(200, { user: { userId: 'u1', username: 'alice' } })) diff --git a/src/hooks/query/user.ts b/src/hooks/query/user.ts index 79ceaff19a..d2f1d02e74 100644 --- a/src/hooks/query/user.ts +++ b/src/hooks/query/user.ts @@ -8,7 +8,7 @@ import { usePWAStatus } from '../usePWAStatus' import { useDeviceType } from '../useGetDeviceType' import { USER } from '@/constants/query.consts' import { apiFetch } from '@/utils/api-fetch' -import { clearAuthToken, setAuthToken } from '@/utils/auth-token' +import { clearAuthToken, getClearEpoch, setAuthToken } from '@/utils/auth-token' import { isDemoMode } from '@/utils/demo' import { DEMO_USER } from '@/constants/demo-data' @@ -35,6 +35,7 @@ export const useUserQuery = (dependsOn: boolean = true) => { return DEMO_USER } + const epochAtRequest = getClearEpoch() const userResponse = await apiFetch('/users/me', { method: 'GET' }) if (userResponse.ok) { const payload: (IUserProfile & { token?: string }) | null = await userResponse.json() @@ -44,8 +45,11 @@ export const useUserQuery = (dependsOn: boolean = true) => { // it in client-side so active users never hit the 30d hard logout. // Strip `token` unconditionally so auth state never leaks into the // user store, even if the backend ever sends a falsy value. + // epoch guard: if logout cleared the session while this request + // was in flight, re-persisting the refreshed token would resurrect + // it (Android stuck-splash loop) — drop it instead. if (payload && 'token' in payload) { - if (payload.token) setAuthToken(payload.token) + if (payload.token && getClearEpoch() === epochAtRequest) setAuthToken(payload.token) delete payload.token } @@ -68,8 +72,11 @@ export const useUserQuery = (dependsOn: boolean = true) => { // DB re-seeded out from under a stale cookie) both mean the JWT is // irrecoverable. Wipe the token so the next render escapes to /setup // instead of looping on the same dead JWT. + // await: the native Preferences.remove must be dispatched before the + // redirect-to-/setup teardown, or the dead JWT survives into the next + // cold start and re-enters the home→401→setup loop. if (userResponse.status === 401 || userResponse.status === 404) { - clearAuthToken() + await clearAuthToken() } // 4xx = auth failure, clear stale redux so layout redirects to /setup diff --git a/src/utils/auth-token.ts b/src/utils/auth-token.ts index 641bee3e19..f88076e819 100644 --- a/src/utils/auth-token.ts +++ b/src/utils/auth-token.ts @@ -362,6 +362,7 @@ export async function hasNativeSession(): Promise { * reloading; other callers may safely ignore it. */ export function clearAuthToken(): Promise { + clearEpoch++ let nativeClear: Promise = Promise.resolve() if (isCapacitor()) { nativeToken = null @@ -389,6 +390,16 @@ export function clearAuthToken(): Promise { return nativeClear } +/** + * monotonic counter incremented by every clearAuthToken. Capture it before an + * authenticated request and compare after: a changed value means the session + * was cleared while the request was in flight, so any token the response + * carries must not be re-persisted. + */ +export function getClearEpoch(): number { + return clearEpoch +} + /** * builds headers for authenticated api calls: Authorization bearer token on * both web and capacitor when a token is available. From f1942d9ea843e90aa32a01b1563f3c08cdd460a8 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:20:24 +0530 Subject: [PATCH 9/9] chore(content): align src/content pointer with dev to clear PR merge conflict Points src/content at dev's current commit (6ad00061928298ea34cf0a76f5a91ef9d1dc2b42) so the feat->dev merge is a trivial (same-value) resolution. No dev history merged in, so the branch's verified-signatures rule only sees this one commit. --- src/content | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/content b/src/content index ca7f9ca5e8..6ad0006192 160000 --- a/src/content +++ b/src/content @@ -1 +1 @@ -Subproject commit ca7f9ca5e8a49336925570bcfb6b00e77db2b6c4 +Subproject commit 6ad00061928298ea34cf0a76f5a91ef9d1dc2b42