Skip to content

feat(security): prove a fresh passkey assertion on sensitive actions - #2463

Merged
kushagrasarathe merged 2 commits into
mainfrom
feat/step-up-auth
Jul 22, 2026
Merged

feat(security): prove a fresh passkey assertion on sensitive actions#2463
kushagrasarathe merged 2 commits into
mainfrom
feat/step-up-auth

Conversation

@innolope-dev

@innolope-dev innolope-dev commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Why

Card PAN/CVV reveal, PIN read/set, withdrawals and bank-account add are authorized today by the session cookie alone. Sessions persist for weeks, so a borrowed unlocked phone or an exfiltrated web cookie reaches all of it with no further checks.

This is the client half of item A — the last of the session-security series and the one that closes the real gap. API half: peanut-api-ts#1217. Companions: app lock (#2461), report-only CSP (#2462).

What

src/services/step-up.ts runs a WebAuthn assertion against the user's own passkey and exchanges it for a 5-minute proof token, sent as x-step-up-token.

Gated calls:

  • Rain: card details, PIN get/set, withdraw/session-approve, withdraw/prepare — opted in with stepUp: true at the single rainRequest choke point rather than threading a header through every call site.
  • POST /users/accounts (add bank account) via withStepUpHeader.

Prompt economy

The proof is cached for its lifetime with a 30s safety margin. A withdrawal (session-approveprepare) costs one Face ID prompt, not two. The cache is dropped on logout — a proof outliving its session would let the next person on the device skip verification.

Safe to merge alone

The API accepts the header today but doesn't require it: enforcement sits behind STEP_UP_ENFORCED, default off. So this can ship in either order, and the flag only flips once a build carrying it is out. Until then the extra prompt appears but nothing depends on it.

Testing

8 unit tests, passing — ceremony, cache reuse, expiry-margin re-prompt, clear-on-logout, the no-passkey message, no caching on rejection, and that a cancelled prompt propagates rather than silently proceeding unverified. Full src/services suite green (18). Typecheck clean.

Not verified on a device. The web path is exercised by tests with @simplewebauthn/browser mocked; the native path goes through the @capgo/capacitor-passkey shim, which mocks can't stand in for. Worth a native pass on card reveal and a withdrawal before STEP_UP_ENFORCED is flipped.

One thing worth a reviewer's eye

rainRequest reads the JWT with Cookies.get('jwt-token') and throws Authentication required when it's absent. On native, getAuthToken() returns null by design — the token lives in the native cookie jar — so this path looks like it should already fail there. I left it alone since it's pre-existing and unrelated, but if Rain card flows do work on native today, I'm misreading something and the step-up wiring may need the same treatment.

Summary by CodeRabbit

  • New Features

    • Added step-up authentication for sensitive banking and card actions using passkey verification.
    • Step-up verification tokens are securely refreshed, reused briefly, and added automatically to protected requests.
    • Added clear handling for missing passkeys, cancelled verification, and expired authentication proofs.
  • Bug Fixes

    • Cleared cached step-up credentials during logout to prevent reuse across sessions.

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.
@innolope-dev innolope-dev self-assigned this Jul 21, 2026
@vercel

vercel Bot commented Jul 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
peanut-wallet Ready Ready Preview, Comment Jul 22, 2026 9:48am

Request Review

@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 6212.38 → 6218.98 (+6.6)
Findings: +1 net (+14 new, -13 resolved)

🆕 New findings (14)

  • critical complexity — src/services/rain.ts — CC 65, MI 59.99, SLOC 264
  • high complexity — src/app/actions/users.ts — CC 33, MI 53.19, SLOC 135
  • high hotspot — src/context/authContext.tsx — 30 commits, +247/-185 lines since 6 months ago
  • medium high-mdd — src/context/authContext.tsx:64 — AuthProvider: MDD 81.5 (uses across many lines from declarations)
  • medium high-dlt — src/context/authContext.tsx:64 — AuthProvider: DLT 46 (calls 46 distinct functions — high context load)
  • medium method-complexity — src/services/rain.ts:334 — rainRequest CC 29 SLOC 62
  • medium complexity — src/context/authContext.tsx — CC 26, MI 56.23, SLOC 191
  • medium hotspot — src/services/rain.ts — 25 commits, +856/-115 lines since 6 months ago
  • medium high-mdd — src/context/authContext.tsx:194 — : MDD 23.5 (uses across many lines from declarations)
  • medium high-mdd — src/services/rain.ts:334 — rainRequest: MDD 22.7 (uses across many lines from declarations)
  • medium complexity — src/services/step-up.ts — CC 12, MI 61.53, SLOC 55
  • low high-dlt — src/context/authContext.tsx:194 — : DLT 23 (calls 23 distinct functions — high context load)
  • low high-mdd — src/context/authContext.tsx:128 — addAccount: MDD 16.0 (uses across many lines from declarations)
  • low missing-return-type — src/context/authContext.tsx:64 — AuthProvider: exported fn missing return type annotation

✅ Resolved (13)

  • src/services/rain.ts — CC 64, MI 60.26, SLOC 258
  • src/app/actions/users.ts — CC 33, MI 53.31, SLOC 133
  • src/context/authContext.tsx:63 — AuthProvider: MDD 79.8 (uses across many lines from declarations)
  • src/context/authContext.tsx:63 — AuthProvider: DLT 45 (calls 45 distinct functions — high context load)
  • src/context/authContext.tsx — 29 commits, +242/-185 lines since 6 months ago
  • src/services/rain.ts:327 — rainRequest CC 28 SLOC 61
  • src/context/authContext.tsx — CC 26, MI 56.28, SLOC 190
  • src/components/Card/share-asset/ShareAssetD3.tsx — 24 commits, +1525/-1137 lines since 6 months ago
  • src/context/authContext.tsx:193 — : MDD 23.5 (uses across many lines from declarations)
  • src/services/rain.ts:327 — rainRequest: MDD 23.2 (uses across many lines from declarations)
  • src/context/authContext.tsx:193 — : DLT 22 (calls 22 distinct functions — high context load)
  • src/context/authContext.tsx:127 — addAccount: MDD 16.0 (uses across many lines from declarations)
  • src/context/authContext.tsx:63 — AuthProvider: exported fn missing return type annotation

📈 Painscore deltas (top movers)

File Before After Δ
src/services/step-up.ts 0.0 5.8 +5.8

@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • unit: 2008 ran, 0 failed, 0 skipped, 34.5s

📊 Coverage (unit)

metric %
statements 59.6%
branches 43.4%
functions 48.5%
lines 59.9%
⏱ 10 slowest test cases
time test
3.5s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › never places two stickers in heavy overlap (broad seed sweep)
1.1s src/utils/__tests__/demo-api.test.ts › isDemoMode() is false when not running under Capacitor
0.5s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › every sticker stays within canvas at any count
0.3s src/app/actions/__tests__/api-headers-extended.test.ts › should not include apiKey in validateInviteCode body
0.3s src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx › Bank withdrawal keeps the $1 minimum for sub-$1 amounts
0.3s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › keeps stickers off the username pill (final pass respects the keep-out)
0.3s src/app/actions/__tests__/api-headers.test.ts › should include Content-Type in validateInviteCode
0.2s src/utils/__tests__/url.utils.test.ts › uses the public BASE_URL in Capacitor, not the localhost WebView origin
0.1s src/utils/__tests__/demo-balance.test.ts › starts at the full balance on a fresh install and stamps a timestamp
0.1s src/utils/__tests__/demo-balance.test.ts › keeps a spent-down balance across a cold start within the TTL
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

@kushagrasarathe kushagrasarathe left a comment

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.

Review — FE half of step-up (with peanut-api-ts#1217) — SAFE-with-conditions

Sound: routes step-up through the single rainRequest choke point (stepUp: true), sends a fresh passkey assertion, and the BE independently verifies it. No client-only gating, no backdoor. Reviewed together with the BE half.

Notes / conditions (mostly shared with #1217):

  • Enforcement is contingent on #1217's STEP_UP_ENFORCED flag — until flipped, this ships the prompt UX with no server enforcement.
  • Native path untested (@capgo/capacitor-passkey) — needs an on-device iOS+Android pass.
  • Confirm native carries the session: rainRequest reads Cookies.get('jwt-token'), which may not resolve on native where the token lives in the native cookie jar / Preferences. Verify native Rain/card flows authenticate before relying on step-up there.

The challenge-TTL fix (the one real security gap) is BE-side — see #1217.

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.
@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a WebAuthn-based step-up authentication service with cached proofs, expiry handling, header injection, and logout clearing. Sensitive Rain Card operations and bank-account creation now include step-up authentication.

Changes

Step-up authentication

Layer / File(s) Summary
Step-up proof lifecycle
src/services/step-up.ts, src/services/__tests__/step-up.test.ts
Implements WebAuthn proof acquisition, expiry-aware caching, error handling, cache clearing, header injection, and corresponding tests.
Protected request integration
src/services/rain.ts, src/app/actions/users.ts
Uses shared authentication utilities for Rain requests and enables step-up headers for sensitive card operations and bank-account creation.
Logout cache cleanup
src/context/authContext.tsx
Clears the cached step-up proof during local authentication-state cleanup.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant UserAction
  participant rainApi
  participant rainRequest
  participant StepUpService
  participant AuthAPI
  participant RainAPI
  UserAction->>rainApi: Start sensitive card operation
  rainApi->>rainRequest: Request with stepUp=true
  rainRequest->>StepUpService: Get step-up token
  StepUpService->>AuthAPI: WebAuthn options and verification
  AuthAPI-->>StepUpService: Proof token
  StepUpService-->>rainRequest: x-step-up-token
  rainRequest->>RainAPI: Protected request
  RainAPI-->>UserAction: Operation response
Loading

Suggested labels: enhancement

Suggested reviewers: hugo0

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the PR’s main change: adding fresh passkey-based step-up verification for sensitive actions.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/step-up-auth

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed: dependency version conflict. Check your lock file or package.json.


Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Prompt for all review comments with 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.

Inline comments:
In `@src/services/step-up.ts`:
- Around line 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.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e4e4c3a3-ad18-4fa9-804c-db3791bc610c

📥 Commits

Reviewing files that changed from the base of the PR and between 4d23ef9 and b52b2bc.

📒 Files selected for processing (5)
  • src/app/actions/users.ts
  • src/context/authContext.tsx
  • src/services/__tests__/step-up.test.ts
  • src/services/rain.ts
  • src/services/step-up.ts

Comment thread src/services/step-up.ts
Comment on lines +40 to +42
export async function getStepUpToken(): Promise<string> {
if (cached && cached.expiresAt - EXPIRY_MARGIN_MS > Date.now()) return cached.token
cached = null

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.

@innolope-dev

Copy link
Copy Markdown
Collaborator Author

Both conditions in the review are now handled on this side; the rest live on the BE PR.

Native session — fixed in b52b2bc. You were right to flag Cookies.get('jwt-token'): on native the webview's document.cookie doesn't hold the API session (it lives in the native cookie jar), so rainRequest would have thrown Authentication required before step-up ever ran. It now goes through apiFetch like every other service — Authorization header on web, native cookie jar on Capacitor — and the hard "no token" guard only applies on web. Typecheck and the step-up/rain unit suites are green.

Challenge TTL — landed BE-side (peanut-api-ts#1217, EXTRACT(EPOCH FROM (now() - created_at)) check in verify, integration-tested both sides of the boundary).

Still outstanding, tracked in #1217's pre-flip checklist: the on-device iOS+Android pass through @capgo/capacitor-passkey, which nothing in CI exercises. Enforcement remains off until that and the STEP_UP_ENFORCED flip, so merging this ships prompt UX only — as the review noted.

@kushagrasarathe kushagrasarathe left a comment

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.

Re-reviewed — native-auth condition RESOLVED. rainRequest no longer reads Cookies.get(jwt-token); it routes through apiFetch → getAuthHeaders → getAuthToken (localStorage on Capacitor, cookie on web). Residual guard is correctly !isCapacitor() && !token. Step-up options/verify go through the same native-safe path; headers preserved; cache cleared on logout. SAFE. (Follow-up, non-gating: card.ts still reads the cookie directly — separate ticket.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants