Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/app/actions/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>): Promise<{ data?: ApiUser; error?: string }> => {
try {
Expand Down Expand Up @@ -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()
Expand Down
5 changes: 5 additions & 0 deletions src/context/authContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
103 changes: 103 additions & 0 deletions src/services/__tests__/step-up.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof apiFetch>
const mockedAuth = startAuthentication as jest.MockedFunction<typeof startAuthentication>

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',
})
})
})
50 changes: 29 additions & 21 deletions src/services/rain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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<T>(opts: RequestOpts): Promise<T> {
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<string, string> = {
Authorization: `Bearer ${jwt}`,
'api-key': PEANUT_API_KEY,
}
if (opts.body !== undefined) headers['Content-Type'] = 'application/json'
const headers: Record<string, string> = { '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(() => ({}))
Expand Down Expand Up @@ -430,6 +433,7 @@ export const rainApi = {
await rainRequest<{ ok: boolean }>({
method: 'POST',
path: '/rain/cards/withdraw/session-approve',
stepUp: true,
body: input,
})
},
Expand All @@ -443,6 +447,7 @@ export const rainApi = {
return rainRequest<PrepareRainWithdrawalResponse>({
method: 'POST',
path: '/rain/cards/withdraw/prepare',
stepUp: true,
body: input,
})
},
Expand Down Expand Up @@ -646,6 +651,7 @@ export const rainApi = {
return rainRequest<RainCardDetailsResponse>({
method: 'GET',
path: `/rain/cards/${cardId}/details`,
stepUp: true,
rateLimitSensitive: true,
noStore: true,
})
Expand Down Expand Up @@ -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,
})
Expand All @@ -689,6 +696,7 @@ export const rainApi = {
await rainRequest<{ ok: boolean }>({
method: 'PUT',
path: `/rain/cards/${cardId}/pin`,
stepUp: true,
body: { pin },
noStore: true,
})
Expand Down
77 changes: 77 additions & 0 deletions src/services/step-up.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
if (cached && cached.expiresAt - EXPIRY_MARGIN_MS > Date.now()) return cached.token
cached = null
Comment on lines +40 to +42

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Deduplicate concurrent proof acquisition.

Concurrent callers both see an empty cache and each start a WebAuthn ceremony; the cache is only populated at Line 70. Keep one in-flight promise and share it until it settles, otherwise parallel sensitive requests can prompt twice or fail one assertion.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/step-up.ts` around lines 40 - 42, Update getStepUpToken to
maintain a single in-flight proof-acquisition promise when the cache is empty,
returning that promise to concurrent callers instead of starting multiple
WebAuthn ceremonies. Assign the promise before acquisition begins and clear it
when it settles, while preserving the existing cached token and expiry behavior.


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<string, string>): Promise<Record<string, string>> {
return { ...headers, [STEP_UP_HEADER]: await getStepUpToken() }
}
Loading