-
Notifications
You must be signed in to change notification settings - Fork 14
feat(security): prove a fresh passkey assertion on sensitive actions #2463
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+216
−21
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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', | ||
| }) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
|
||
| 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() } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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