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 @@
NSCameraUsageDescriptionPeanut 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.NSPhotoLibraryUsageDescriptionPeanut 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/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/__tests__/app-lock-gate.test.tsx b/src/components/Global/AppLock/__tests__/app-lock-gate.test.tsx
new file mode 100644
index 0000000000..994d06c7fa
--- /dev/null
+++ b/src/components/Global/AppLock/__tests__/app-lock-gate.test.tsx
@@ -0,0 +1,132 @@
+// 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(),
+}))
+
+// 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
+ },
+}))
+
+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()
+ // 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', () => {
+ mockIsCapacitor.mockReturnValue(false)
+ renderGate()
+ expect(screen.getByTestId('protected')).toBeInTheDocument()
+ 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 () => {
+ mockOpenGated = true
+ 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 () => {
+ mockOpenGated = true
+ 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 () => {
+ mockOpenGated = true
+ 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 () => {
+ mockOpenGated = true
+ 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..63cdacd944 100644
--- a/src/components/Global/AppLock/index.tsx
+++ b/src/components/Global/AppLock/index.tsx
@@ -6,32 +6,43 @@
* 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).
+ *
+ * '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.
*
- * 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".
+ * 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'
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'
+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'
+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({
@@ -50,14 +61,14 @@ function LockScreen({
{t('title')}
-
{failed ? t('subtitleFailed') : t('subtitle')}
+
{failed ? t('promptFailed') : t('prompt')}
@@ -66,8 +77,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 +88,99 @@ 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')
+ // 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) => {
+ 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 +195,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 +222,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 +235,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 +246,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/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/content b/src/content
index c268e5d9c4..6ad0006192 160000
--- a/src/content
+++ b/src/content
@@ -1 +1 @@
-Subproject commit c268e5d9c436ae742915b26873e4066748883acc
+Subproject commit 6ad00061928298ea34cf0a76f5a91ef9d1dc2b42
diff --git a/src/context/authContext.tsx b/src/context/authContext.tsx
index a7218c1465..45ad22d21d 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 '@/hooks/useAppLocked'
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/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/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/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/i18n/app/messages/en.json b/src/i18n/app/messages/en.json
index a1b3014126..f58f254000 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.",
+ "title": "Welcome back!",
+ "prompt": "Unlock to open the app.",
+ "promptFailed": "Unlock again to open the app.",
"unlock": "Unlock",
- "logout": "Log out"
+ "logOut": "Log out"
}
}
diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json
index 4ee0e21cf2..96e94f554a 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.",
+ "title": "¡Hola de nuevo!",
+ "prompt": "Desbloquea para abrir la app.",
+ "promptFailed": "Desbloquea de nuevo para abrir la app.",
"unlock": "Desbloquear",
- "logout": "Cerrar 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..1413cea0fd 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.",
+ "title": "Olá de novo!",
+ "prompt": "Desbloqueie para abrir o app.",
+ "promptFailed": "Desbloqueie novamente para abrir o app.",
"unlock": "Desbloquear",
- "logout": "Sair"
+ "logOut": "Sair"
}
}
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/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__/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..8163fada8a 100644
--- a/src/utils/__tests__/auth-token.test.ts
+++ b/src/utils/__tests__/auth-token.test.ts
@@ -1,14 +1,34 @@
-// 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'
+
+// 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(),
}))
+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 +68,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 +181,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 +194,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 +365,208 @@ 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)
+ 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..1406d14a97
--- /dev/null
+++ b/src/utils/__tests__/secure-token-store.test.ts
@@ -0,0 +1,146 @@
+// 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(() => {
+ window.Capacitor = undefined
+ })
+
+ it('is false on web', () => {
+ mockIsCapacitor.mockReturnValue(false)
+ expect(store.isGuardedStoreSupported()).toBe(false)
+ })
+
+ it('is false on an older binary without the plugin', () => {
+ window.Capacitor = { getPlatform: () => 'ios', isPluginAvailable: () => false }
+ expect(store.isGuardedStoreSupported()).toBe(false)
+ })
+
+ it('is true when the native plugin is registered', () => {
+ window.Capacitor = {
+ getPlatform: () => 'ios',
+ 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.Capacitor = { getPlatform: () => 'ios', isPluginAvailable: () => true }
+ mockPlugin.isAvailable.mockResolvedValue({ isAvailable: true })
+ await expect(store.isBiometryEnrolled()).resolves.toBe(true)
+ expect(mockPlugin.isAvailable).toHaveBeenCalledWith({ useFallback: false })
+ window.Capacitor = undefined
+ })
+ })
+})
diff --git a/src/utils/app-lock-state.ts b/src/utils/app-lock-state.ts
new file mode 100644
index 0000000000..b731c4056b
--- /dev/null
+++ b/src/utils/app-lock-state.ts
@@ -0,0 +1,30 @@
+// 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 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'
+
+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)
+}
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..f88076e819 100644
--- a/src/utils/auth-token.ts
+++ b/src/utils/auth-token.ts
@@ -1,28 +1,123 @@
// 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 { OPEN_GATED } from '@/constants/app-lock.consts'
+import { getLockState, setLockState } from './app-lock-state'
+import {
+ GuardedStoreError,
+ canWriteSilently,
+ guardedDelete,
+ guardedRead,
+ guardedWrite,
+ isBiometryEnrolled,
+ 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
+// 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 (guardedModeEnabled()) {
+ 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 +128,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() || !guardedModeEnabled()) 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 (guardedModeEnabled() && 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 +327,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 (guardedModeEnabled() && (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,24 +353,36 @@ 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.
*/
export function clearAuthToken(): Promise {
+ clearEpoch++
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: '/' })
@@ -144,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.
diff --git a/src/utils/secure-token-store.ts b/src/utils/secure-token-store.ts
new file mode 100644
index 0000000000..847c88dce5
--- /dev/null
+++ b/src/utils/secure-token-store.ts
@@ -0,0 +1,145 @@
+// 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
+ return !!window.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'
+}