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..2ebd1be8b1 100644 --- a/src/services/rain.ts +++ b/src/services/rain.ts @@ -7,11 +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 { fetchWithSentry } from '@/utils/sentry.utils' +import { PEANUT_API_KEY } from '@/constants/general.consts' +import { getStepUpToken, STEP_UP_HEADER } from './step-up' +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 ────────────────────────────────────────────────────────────────── @@ -320,31 +322,32 @@ 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 + * ID unless a proof from the last few minutes is still good. + */ + stepUp?: boolean } 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(() => ({})) @@ -430,6 +433,7 @@ export const rainApi = { await rainRequest<{ ok: boolean }>({ method: 'POST', path: '/rain/cards/withdraw/session-approve', + stepUp: true, body: input, }) }, @@ -443,6 +447,7 @@ export const rainApi = { return rainRequest({ method: 'POST', path: '/rain/cards/withdraw/prepare', + stepUp: true, body: input, }) }, @@ -646,6 +651,7 @@ export const rainApi = { return rainRequest({ method: 'GET', path: `/rain/cards/${cardId}/details`, + stepUp: true, rateLimitSensitive: true, noStore: true, }) @@ -678,6 +684,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 +696,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() } +}