Repository navigation
fix(mosaic): reuse valid OTP preparation across method navigation #10566
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
Changes from all commits
d1c8bde
0524111
26b4231
b5722b9
6f2c70b
b5d4fae
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@clerk/mosaic': patch | ||
| --- | ||
|
|
||
| Reuse an unexpired verification code when switching back to the same email address or phone number without attempting another verification method. Returning to a code preserves its resend countdown, and pending code sends finish before another method is prepared or submitted. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,11 +19,16 @@ export interface FakeVerificationState { | |
| firstFactors: SignInFirstFactorJSON[]; | ||
| secondFactors: SignInSecondFactorJSON[]; | ||
| secrets: Partial<Record<VerificationSecretStrategy, string>>; | ||
| codesByIdentification: Partial<Record<string, string>>; | ||
| firstFactorCode: string | null; | ||
| secondFactorCode: string | null; | ||
| firstFactorVerification: VerificationJSON | null; | ||
| secondFactorVerification: VerificationJSON | null; | ||
| } | ||
|
|
||
| export type FakeVerificationSeed = Partial<Pick<FakeVerificationState, 'firstFactors' | 'secondFactors' | 'secrets'>>; | ||
| export type FakeVerificationSeed = Partial< | ||
| Pick<FakeVerificationState, 'firstFactors' | 'secondFactors' | 'secrets' | 'codesByIdentification'> | ||
| >; | ||
|
|
||
| export interface VerificationHost { | ||
| client: ClientJSON; | ||
|
|
@@ -37,6 +42,9 @@ export function createVerificationState(seed: FakeVerificationSeed = {}): FakeVe | |
| firstFactors: [{ strategy: 'password' }], | ||
| secondFactors: [], | ||
| secrets: {}, | ||
| codesByIdentification: {}, | ||
| firstFactorCode: null, | ||
| secondFactorCode: null, | ||
| firstFactorVerification: null, | ||
| secondFactorVerification: null, | ||
| ...seed, | ||
|
|
@@ -195,7 +203,18 @@ export function verificationHandlers(state: VerificationHost, url: (path: string | |
| return verificationErrors.missingResource(idParam); | ||
| } | ||
| } | ||
| setStageVerification(state, stage, fapiVerification(strategy)); | ||
| if (strategy === 'email_code' || strategy === 'phone_code') { | ||
| const identification = body.get(strategy === 'email_code' ? 'email_address_id' : 'phone_number_id'); | ||
| const code = | ||
| (identification ? state.verification.codesByIdentification[identification] : undefined) ?? | ||
| state.verification.secrets[strategy] ?? | ||
| null; | ||
| state.verification = | ||
| stage === 'first' | ||
| ? { ...state.verification, firstFactorCode: code } | ||
| : { ...state.verification, secondFactorCode: code }; | ||
| } | ||
| setStageVerification(state, stage, fapiVerification(strategy, { expire_at: Date.now() + 600_000 })); | ||
| return verificationReply(state, session); | ||
| }; | ||
|
|
||
|
|
@@ -214,15 +233,33 @@ export function verificationHandlers(state: VerificationHost, url: (path: string | |
| if (PREPARABLE_STRATEGIES.includes(strategy) && prepared?.strategy !== strategy) { | ||
| return verificationErrors.notSent(); | ||
| } | ||
| if (strategy !== 'passkey' && state.verification.secrets[strategy as VerificationSecretStrategy] === undefined) { | ||
| if ( | ||
| PREPARABLE_STRATEGIES.includes(strategy) && | ||
| prepared && | ||
| prepared.expire_at !== null && | ||
| prepared.expire_at <= Date.now() | ||
| ) { | ||
| return apiError(422, 'verification_expired', 'This code has expired.'); | ||
| } | ||
| const secret = | ||
| strategy === 'email_code' || strategy === 'phone_code' | ||
| ? stage === 'first' | ||
| ? state.verification.firstFactorCode | ||
| : state.verification.secondFactorCode | ||
| : strategy === 'password' || strategy === 'totp' || strategy === 'backup_code' | ||
| ? state.verification.secrets[strategy] | ||
| : undefined; | ||
| if (strategy !== 'passkey' && secret === undefined) { | ||
| throw new Error(`fake FAPI: no secret seeded for strategy "${strategy}"`); | ||
| } | ||
| if (strategy === 'password') { | ||
| setStageVerification(state, stage, fapiVerification('password')); | ||
| state.verification.firstFactorCode = null; | ||
| if (body.get('password') !== state.verification.secrets.password) { | ||
| return verificationErrors.passwordIncorrect(); | ||
| } | ||
| } else if (strategy !== 'passkey') { | ||
| if (body.get('code') !== state.verification.secrets[strategy as VerificationSecretStrategy]) { | ||
| if (body.get('code') !== secret) { | ||
| return verificationErrors.codeIncorrect(); | ||
| } | ||
| } | ||
|
Comment on lines
255
to
265
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
@@ -253,6 +290,8 @@ export function verificationHandlers(state: VerificationHost, url: (path: string | |
| status: level === 'second_factor' ? 'needs_second_factor' : 'needs_first_factor', | ||
| firstFactorVerification: null, | ||
| secondFactorVerification: null, | ||
| firstFactorCode: null, | ||
| secondFactorCode: null, | ||
| }; | ||
| return verificationReply(state, session); | ||
| }), | ||
|
|
||
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.
Restore the "no secret seeded" guard for
email_codeandphone_codein the fakeattempthandler.firstFactorCodeandsecondFactorCodeare typedstring | nulland start asnull. Thepreparehandler also storesnullwhen neithercodesByIdentificationnorsecrets[strategy]has a code. Foremail_codeandphone_code,secretis thereforenulland neverundefined. The check on Line 252 can no longer fire for those strategies.Two effects follow:
fake FAPI: no secret seeded…. The attempt gets aform_code_incorrecterror instead.code,body.get('code')returnsnull. The checkbody.get('code') !== secretthen comparesnulltonulland passes. The attempt succeeds without a code.Convert
nulltoundefinedwhen you read the stored code. This keeps the previous fail-fast behavior.Proposed fix
Committable suggestion