From 969d498a6b1fb4705e2d09f1f6b26adc8532b9a5 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Tue, 21 Jul 2026 16:44:13 +0100 Subject: [PATCH 1/2] feat(security): prove a fresh passkey assertion on sensitive actions Card PAN/CVV, PIN, withdrawals and bank-account add were authorized by the session cookie alone. They now carry an x-step-up-token proving a WebAuthn assertion from the last five minutes. The proof is cached for its lifetime, so a multi-step flow (approve then prepare) costs one Face ID prompt rather than one per request, and is dropped on logout so it can't outlive the session it belongs to. Rain calls opt in with stepUp: true on the single rainRequest choke point instead of threading a header through every call site. Pairs with peanut-api-ts, where enforcement stays behind STEP_UP_ENFORCED until a build carrying this ships. --- src/app/actions/users.ts | 2 + src/context/authContext.tsx | 5 ++ src/services/__tests__/step-up.test.ts | 103 +++++++++++++++++++++++++ src/services/rain.ts | 12 +++ src/services/step-up.ts | 77 ++++++++++++++++++ 5 files changed, 199 insertions(+) create mode 100644 src/services/__tests__/step-up.test.ts create mode 100644 src/services/step-up.ts diff --git a/src/app/actions/users.ts b/src/app/actions/users.ts index a57b505eca..0bed58670f 100644 --- a/src/app/actions/users.ts +++ b/src/app/actions/users.ts @@ -3,6 +3,7 @@ import { type AddBankAccountPayload, BridgeEndorsementType, type InitiateKycResp import { type CounterpartyUser } from '@/interfaces' import { type ContactsResponse } from '@/interfaces' import { serverFetch } from '@/utils/api-fetch' +import { withStepUpHeader } from '@/services/step-up' export const updateUserById = async (payload: Record): Promise<{ data?: ApiUser; error?: string }> => { try { @@ -51,6 +52,7 @@ export const addBankAccount = async (payload: AddBankAccountPayload): Promise<{ const response = await serverFetch('/users/accounts', { method: 'POST', body: JSON.stringify(payload), + headers: await withStepUpHeader({}), }) const responseJson = await response.json() diff --git a/src/context/authContext.tsx b/src/context/authContext.tsx index 1c13495230..652be925d0 100644 --- a/src/context/authContext.tsx +++ b/src/context/authContext.tsx @@ -25,6 +25,7 @@ import { createContext, type ReactNode, useContext, useState, useEffect, useMemo import { captureException, setUser as setSentryUser } from '@sentry/nextjs' // import { PUBLIC_ROUTES_REGEX } from '@/constants/routes' import { USER_DATA_CACHE_PATTERNS } from '@/constants/cache.consts' +import { clearStepUpToken } from '@/services/step-up' interface AuthContextType { user: IUserProfile | null @@ -201,6 +202,10 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { // clear redirect url clearRedirectUrl() + // A cached step-up proof outliving the session would let the next user + // of this device skip verification on card and withdrawal screens. + clearStepUpToken() + // cancel + remove all queries to prevent refetches with cleared jwt try { queryClient.cancelQueries() diff --git a/src/services/__tests__/step-up.test.ts b/src/services/__tests__/step-up.test.ts new file mode 100644 index 0000000000..fadfaebe52 --- /dev/null +++ b/src/services/__tests__/step-up.test.ts @@ -0,0 +1,103 @@ +/** + * The cache is the risky part: too eager and one Face ID prompt unlocks + * sensitive routes indefinitely, too shy and a withdrawal prompts twice. + */ +import { clearStepUpToken, getStepUpToken, STEP_UP_HEADER, withStepUpHeader } from '../step-up' +import { apiFetch } from '@/utils/api-fetch' +import { startAuthentication } from '@simplewebauthn/browser' + +jest.mock('@/utils/api-fetch', () => ({ apiFetch: jest.fn() })) +jest.mock('@/utils/capacitor', () => ({ isCapacitor: () => false, getNativeRpId: () => 'peanut.me' })) + +const mockedFetch = apiFetch as jest.MockedFunction +const mockedAuth = startAuthentication as jest.MockedFunction + +function jsonResponse(body: unknown, ok = true, status = 200) { + return { ok, status, json: async () => body } as Response +} + +function happyPath(token = 'proof-token', expiresIn = 300) { + mockedFetch.mockReset() + mockedFetch + .mockResolvedValueOnce(jsonResponse({ challenge: 'abc' })) + .mockResolvedValueOnce(jsonResponse({ token, expiresIn })) +} + +describe('getStepUpToken', () => { + beforeEach(() => { + clearStepUpToken() + mockedAuth.mockReset() + mockedAuth.mockResolvedValue({ id: 'cred' } as never) + }) + + it('runs the ceremony and returns the proof token', async () => { + happyPath() + await expect(getStepUpToken()).resolves.toBe('proof-token') + expect(mockedFetch).toHaveBeenCalledTimes(2) + expect(mockedFetch.mock.calls[0][0]).toBe('/auth/step-up/options') + expect(mockedFetch.mock.calls[1][0]).toBe('/auth/step-up/verify') + }) + + it('reuses a live proof instead of prompting again', async () => { + happyPath() + await getStepUpToken() + await expect(getStepUpToken()).resolves.toBe('proof-token') + expect(mockedAuth).toHaveBeenCalledTimes(1) + }) + + it('re-prompts once the proof is close enough to expiry to be unusable', async () => { + happyPath('first', 20) // inside the 30s safety margin + await getStepUpToken() + happyPath('second') + await expect(getStepUpToken()).resolves.toBe('second') + expect(mockedAuth).toHaveBeenCalledTimes(2) + }) + + it('re-prompts after the cache is cleared (logout)', async () => { + happyPath('first') + await getStepUpToken() + clearStepUpToken() + happyPath('second') + await expect(getStepUpToken()).resolves.toBe('second') + }) + + it('explains the failure when the account has no passkey', async () => { + mockedFetch.mockReset() + mockedFetch.mockResolvedValueOnce(jsonResponse({ error: 'none' }, false, 404)) + await expect(getStepUpToken()).rejects.toThrow('No passkey is registered') + }) + + it('does not cache a proof when verification is rejected', async () => { + mockedFetch.mockReset() + mockedFetch + .mockResolvedValueOnce(jsonResponse({ challenge: 'abc' })) + .mockResolvedValueOnce(jsonResponse({ error: 'nope' }, false, 401)) + await expect(getStepUpToken()).rejects.toThrow('Could not confirm it is you') + + happyPath('after-retry') + await expect(getStepUpToken()).resolves.toBe('after-retry') + }) + + it('propagates a cancelled prompt rather than proceeding unverified', async () => { + mockedFetch.mockReset() + mockedFetch.mockResolvedValueOnce(jsonResponse({ challenge: 'abc' })) + mockedAuth.mockRejectedValueOnce(Object.assign(new Error('cancelled'), { name: 'NotAllowedError' })) + await expect(getStepUpToken()).rejects.toThrow('cancelled') + }) +}) + +describe('withStepUpHeader', () => { + beforeEach(() => { + clearStepUpToken() + mockedAuth.mockReset() + mockedAuth.mockResolvedValue({ id: 'cred' } as never) + }) + + it('adds the proof header while preserving existing ones', async () => { + happyPath() + await expect(withStepUpHeader({ 'Content-Type': 'application/json' })).resolves.toEqual({ + 'Content-Type': 'application/json', + [STEP_UP_HEADER]: 'proof-token', + }) + }) +}) diff --git a/src/services/rain.ts b/src/services/rain.ts index 9c7d5486f1..47d8c9a32f 100644 --- a/src/services/rain.ts +++ b/src/services/rain.ts @@ -11,6 +11,7 @@ import Cookies from 'js-cookie' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { PEANUT_API_KEY, PEANUT_API_URL } from '@/constants/general.consts' +import { getStepUpToken, STEP_UP_HEADER } from './step-up' import { fetchWithSentry } from '@/utils/sentry.utils' import type { SignedRainWithdrawal } from '@/hooks/wallet/useSignSpendBundle' @@ -322,6 +323,11 @@ interface RequestOpts { noStore?: boolean /** Override fetchWithSentry's default 10s timeout (e.g. UserOp submissions). */ timeoutMs?: number + /** + * Prove a fresh WebAuthn assertion alongside the session. Prompts for Face + * ID unless a proof from the last few minutes is still good. + */ + stepUp?: boolean } async function rainRequest(opts: RequestOpts): Promise { @@ -334,6 +340,7 @@ async function rainRequest(opts: RequestOpts): Promise { } if (opts.body !== undefined) headers['Content-Type'] = 'application/json' if (opts.noStore) headers['Cache-Control'] = 'no-store' + if (opts.stepUp) headers[STEP_UP_HEADER] = await getStepUpToken() const response = await fetchWithSentry( `${PEANUT_API_URL}${opts.path}`, @@ -430,6 +437,7 @@ export const rainApi = { await rainRequest<{ ok: boolean }>({ method: 'POST', path: '/rain/cards/withdraw/session-approve', + stepUp: true, body: input, }) }, @@ -443,6 +451,7 @@ export const rainApi = { return rainRequest({ method: 'POST', path: '/rain/cards/withdraw/prepare', + stepUp: true, body: input, }) }, @@ -646,6 +655,7 @@ export const rainApi = { return rainRequest({ method: 'GET', path: `/rain/cards/${cardId}/details`, + stepUp: true, rateLimitSensitive: true, noStore: true, }) @@ -678,6 +688,7 @@ export const rainApi = { const { pin } = await rainRequest<{ pin: string | null }>({ method: 'GET', path: `/rain/cards/${cardId}/pin`, + stepUp: true, rateLimitSensitive: true, noStore: true, }) @@ -689,6 +700,7 @@ export const rainApi = { await rainRequest<{ ok: boolean }>({ method: 'PUT', path: `/rain/cards/${cardId}/pin`, + stepUp: true, body: { pin }, noStore: true, }) diff --git a/src/services/step-up.ts b/src/services/step-up.ts new file mode 100644 index 0000000000..1c46a1c854 --- /dev/null +++ b/src/services/step-up.ts @@ -0,0 +1,77 @@ +/** + * Step-up authentication client. + * + * Sensitive routes (card PAN/CVV, PIN, withdrawal, bank-account add) want more + * than a week-old session cookie: they want proof the person is still holding + * the device. This runs a WebAuthn assertion against the user's own passkey and + * exchanges it for a short-lived proof token. + * + * The token is cached for its lifetime so a multi-step flow (approve → prepare) + * costs one Face ID prompt, not one per request. + */ + +import { startAuthentication } from '@simplewebauthn/browser' +import { apiFetch } from '@/utils/api-fetch' +import { getNativeRpId, isCapacitor } from '@/utils/capacitor' + +export const STEP_UP_HEADER = 'x-step-up-token' + +/** Retire the token early so a request never leaves with one about to expire. */ +const EXPIRY_MARGIN_MS = 30_000 + +let cached: { token: string; expiresAt: number } | null = null + +export class StepUpError extends Error { + constructor(message: string) { + super(message) + this.name = 'StepUpError' + } +} + +function currentRpId(): string { + return isCapacitor() ? getNativeRpId() : window.location.hostname.replace(/^www\./, '') +} + +/** Drops the cached proof. Call on logout, or after a 401 from a gated route. */ +export function clearStepUpToken(): void { + cached = null +} + +export async function getStepUpToken(): Promise { + if (cached && cached.expiresAt - EXPIRY_MARGIN_MS > Date.now()) return cached.token + cached = null + + const rpID = currentRpId() + + const optionsResponse = await apiFetch('/auth/step-up/options', { + method: 'POST', + body: JSON.stringify({ rpID }), + }) + if (!optionsResponse.ok) { + throw new StepUpError( + optionsResponse.status === 404 + ? 'No passkey is registered for this account.' + : 'Could not start verification.' + ) + } + const options = await optionsResponse.json() + + const cred = await startAuthentication(options) + + const verifyResponse = await apiFetch('/auth/step-up/verify', { + method: 'POST', + body: JSON.stringify({ cred, rpID }), + }) + if (!verifyResponse.ok) { + throw new StepUpError('Could not confirm it is you.') + } + + const { token, expiresIn } = (await verifyResponse.json()) as { token: string; expiresIn: number } + cached = { token, expiresAt: Date.now() + expiresIn * 1000 } + return token +} + +/** Adds the proof header, acquiring one if needed. */ +export async function withStepUpHeader(headers: Record): Promise> { + return { ...headers, [STEP_UP_HEADER]: await getStepUpToken() } +} From b52b2bc6bb1b3f6f0cf61bc1a6a35a06db85030b Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Wed, 22 Jul 2026 10:42:13 +0100 Subject: [PATCH 2/2] fix(review): route rainRequest auth through apiFetch Reading the jwt cookie directly wrongly threw 'Authentication required' on native, where JS never holds the token and the native cookie jar authenticates requests. apiFetch is the path every other service uses: Authorization header on web, cookie jar on Capacitor, demo-mode routing included. --- src/services/rain.ts | 38 +++++++++++++++++--------------------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/src/services/rain.ts b/src/services/rain.ts index 47d8c9a32f..2ebd1be8b1 100644 --- a/src/services/rain.ts +++ b/src/services/rain.ts @@ -7,12 +7,13 @@ * (via `js-cookie`) — matches the pattern in `services/manteca.ts`. */ -import Cookies from 'js-cookie' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' -import { PEANUT_API_KEY, PEANUT_API_URL } from '@/constants/general.consts' +import { PEANUT_API_KEY } from '@/constants/general.consts' import { getStepUpToken, STEP_UP_HEADER } from './step-up' -import { fetchWithSentry } from '@/utils/sentry.utils' +import { apiFetch } from '@/utils/api-fetch' +import { getAuthToken } from '@/utils/auth-token' +import { isCapacitor } from '@/utils/capacitor' import type { SignedRainWithdrawal } from '@/hooks/wallet/useSignSpendBundle' // ─── Types ────────────────────────────────────────────────────────────────── @@ -321,7 +322,7 @@ interface RequestOpts { rateLimitSensitive?: boolean /** Mirror PCI no-cache intent on the client fetch for secrets endpoints. */ noStore?: boolean - /** Override fetchWithSentry's default 10s timeout (e.g. UserOp submissions). */ + /** Override the default 10s fetch timeout (e.g. UserOp submissions). */ timeoutMs?: number /** * Prove a fresh WebAuthn assertion alongside the session. Prompts for Face @@ -331,27 +332,22 @@ interface RequestOpts { } async function rainRequest(opts: RequestOpts): Promise { - const jwt = Cookies.get('jwt-token') - if (!jwt) throw new Error('Authentication required') + // Auth rides apiFetch: Authorization header on web, the native cookie jar + // on Capacitor — reading the cookie here would wrongly 401 native, where + // JS never holds the token. + if (!isCapacitor() && !getAuthToken()) throw new Error('Authentication required') - const headers: Record = { - Authorization: `Bearer ${jwt}`, - 'api-key': PEANUT_API_KEY, - } - if (opts.body !== undefined) headers['Content-Type'] = 'application/json' + const headers: Record = { 'api-key': PEANUT_API_KEY } if (opts.noStore) headers['Cache-Control'] = 'no-store' if (opts.stepUp) headers[STEP_UP_HEADER] = await getStepUpToken() - const response = await fetchWithSentry( - `${PEANUT_API_URL}${opts.path}`, - { - method: opts.method, - headers, - body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, - cache: 'no-store', - }, - opts.timeoutMs - ) + const response = await apiFetch(opts.path, { + method: opts.method, + headers, + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, + cache: 'no-store', + timeoutMs: opts.timeoutMs, + }) if (response.status === 429 && opts.rateLimitSensitive) { const err = await response.json().catch(() => ({}))