Skip to content

Commit d2d258c

Browse files
committed
feat(mfa): offer passkey verification alongside the OTP code form
Ported forward from the closed PR #61 (feat/enforce-mfa-passkey-gate) and adapted to the resolveAuthStep-based flow the MFA redesign introduced. When a login/signup verify step reports should_offer_webauthn_mfa_verify, AuthorizerVerifyOtp now offers a "Verify with a passkey" button alongside (or instead of) the OTP code form, using authorizerRef.loginWithPasskey(email). Two bugs the original branch didn't have to deal with (its own design predates the redesign) were introduced while adapting and fixed before landing: - onVerifyWithPasskey now routes its response through resolveAuthStep instead of treating any truthy response as a successful login - a withheld (null access_token) response from webauthn_login_verify was otherwise being silently treated as success, the exact class of bug the redesign fixed at every other call site. - showCodeForm now keys off a real has_code_factor signal (step.totp || step.email || step.mobile) instead of !is_totp, so a user with a verified email/SMS-OTP factor alongside webauthn keeps access to their code form instead of being forced into a passkey they may not have enrolled on this device.
1 parent ef58ceb commit d2d258c

4 files changed

Lines changed: 233 additions & 90 deletions

File tree

src/components/AuthorizerBasicAuthLogin.tsx

Lines changed: 14 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -131,31 +131,18 @@ export const AuthorizerBasicAuthLogin: FC<{
131131
return;
132132
}
133133
if (step.kind === 'verify') {
134-
if (step.totp) {
135-
setOtpData({
136-
is_screen_visible: true,
137-
email: data.email || ``,
138-
phone_number: data.phone_number || ``,
139-
is_totp: true,
140-
});
141-
return;
142-
}
143-
if (step.email || step.mobile) {
144-
setOtpData({
145-
is_screen_visible: true,
146-
email: data.email || ``,
147-
phone_number: data.phone_number || ``,
148-
is_totp: false,
149-
});
150-
return;
151-
}
152-
// WebAuthn-verify-only case (should_offer_webauthn_mfa_verify with no
153-
// TOTP/email/mobile fallback offered) - this component has no passkey-
154-
// verify ceremony of its own; report the situation via the existing
155-
// error banner rather than silently doing nothing.
156-
setError(
157-
'This account requires passkey verification. Use "Sign in with a passkey" instead.',
158-
);
134+
// resolveAuthStep only returns 'verify' when at least one of
135+
// totp/email/mobile/webauthn is true, so a single unconditional
136+
// offer covers every case - AuthorizerVerifyOtp decides how to
137+
// render it (code form, passkey button, or both side by side).
138+
setOtpData({
139+
is_screen_visible: true,
140+
email: data.email || ``,
141+
phone_number: data.phone_number || ``,
142+
is_totp: step.totp,
143+
offer_webauthn_verify: step.webauthn,
144+
has_code_factor: step.totp || step.email || step.mobile,
145+
});
159146
return;
160147
}
161148
// step.kind === 'complete'
@@ -252,6 +239,8 @@ export const AuthorizerBasicAuthLogin: FC<{
252239
email: otpData.email || ``,
253240
phone_number: otpData.phone_number || ``,
254241
is_totp: otpData.is_totp || false,
242+
offerWebauthnVerify: otpData.offer_webauthn_verify || false,
243+
hasCodeFactor: otpData.has_code_factor || false,
255244
}}
256245
urlProps={urlProps}
257246
/>

src/components/AuthorizerSignup.tsx

Lines changed: 14 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -180,27 +180,18 @@ export const AuthorizerSignup: FC<{
180180
return;
181181
}
182182
if (step.kind === 'verify') {
183-
if (step.totp) {
184-
setOtpData({
185-
is_screen_visible: true,
186-
email: data.email || ``,
187-
phone_number: data.phone_number || ``,
188-
is_totp: true,
189-
});
190-
return;
191-
}
192-
if (step.email || step.mobile) {
193-
setOtpData({
194-
is_screen_visible: true,
195-
email: data.email || ``,
196-
phone_number: data.phone_number || ``,
197-
is_totp: false,
198-
});
199-
return;
200-
}
201-
setError(
202-
'This account requires passkey verification. Use "Sign in with a passkey" instead.',
203-
);
183+
// resolveAuthStep only returns 'verify' when at least one of
184+
// totp/email/mobile/webauthn is true, so a single unconditional
185+
// offer covers every case - AuthorizerVerifyOtp decides how to
186+
// render it (code form, passkey button, or both side by side).
187+
setOtpData({
188+
is_screen_visible: true,
189+
email: data.email || ``,
190+
phone_number: data.phone_number || ``,
191+
is_totp: step.totp,
192+
offer_webauthn_verify: step.webauthn,
193+
has_code_factor: step.totp || step.email || step.mobile,
194+
});
204195
return;
205196
}
206197
// step.kind === 'complete'
@@ -363,6 +354,8 @@ export const AuthorizerSignup: FC<{
363354
email: otpData.email || ``,
364355
phone_number: otpData.phone_number || ``,
365356
is_totp: otpData.is_totp || false,
357+
offerWebauthnVerify: otpData.offer_webauthn_verify || false,
358+
hasCodeFactor: otpData.has_code_factor || false,
366359
}}
367360
urlProps={urlProps}
368361
/>

src/components/AuthorizerVerifyOtp.tsx

Lines changed: 199 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { FC, useEffect, useState } from 'react';
2-
import { VerifyOTPRequest } from '@authorizerdev/authorizer-js';
2+
import { VerifyOTPRequest, isWebauthnSupported } from '@authorizerdev/authorizer-js';
33
import '../styles/default.css';
44

55
import { ButtonAppearance, MessageType, Views } from '../constants';
@@ -8,6 +8,9 @@ import { StyledButton, StyledFooter, StyledLink } from '../styledComponents';
88
import { Message } from './Message';
99
import { TotpDataType } from '../types';
1010
import { AuthorizerTOTPScanner } from './AuthorizerTOTPScanner';
11+
import { AuthorizerMfaLocked } from './AuthorizerMfaLocked';
12+
import { IconPasskey } from '../icons/mfa';
13+
import { resolveAuthStep } from '../utils/mfaTriage';
1114

1215
interface InputDataType {
1316
otp: string | null;
@@ -29,7 +32,18 @@ export const AuthorizerVerifyOtp: FC<{
2932
phone_number?: string;
3033
urlProps?: Record<string, any>;
3134
is_totp?: boolean;
32-
}> = ({ setView, onLogin, email, phone_number, urlProps, is_totp }) => {
35+
offerWebauthnVerify?: boolean;
36+
hasCodeFactor?: boolean;
37+
}> = ({
38+
setView,
39+
onLogin,
40+
email,
41+
phone_number,
42+
urlProps,
43+
is_totp,
44+
offerWebauthnVerify,
45+
hasCodeFactor,
46+
}) => {
3347
const [error, setError] = useState(``);
3448
const [successMessage, setSuccessMessage] = useState(``);
3549
const [loading, setLoading] = useState(false);
@@ -49,6 +63,76 @@ export const AuthorizerVerifyOtp: FC<{
4963
}
5064
}, []);
5165

66+
const [webauthnError, setWebauthnError] = useState(``);
67+
const [webauthnLoading, setWebauthnLoading] = useState(false);
68+
const [webauthnLocked, setWebauthnLocked] = useState(false);
69+
const passkeySupported = isWebauthnSupported();
70+
71+
// A cancelled ceremony surfaces as NotAllowedError/AbortError (same
72+
// browser behavior AuthorizerPasskeyLogin already handles) - dismiss
73+
// silently and let the user fall back to the code form when one exists.
74+
const isUserDismissed = (e?: { code?: string }): boolean =>
75+
e?.code === `NotAllowedError` || e?.code === `AbortError`;
76+
77+
const onVerifyWithPasskey = async () => {
78+
setWebauthnError(``);
79+
try {
80+
setWebauthnLoading(true);
81+
const { data: res, errors } = await authorizerRef.loginWithPasskey(email);
82+
if (errors && errors.length) {
83+
if (!isUserDismissed(errors[0])) {
84+
setWebauthnError(errors[0]?.message || ``);
85+
}
86+
return;
87+
}
88+
// Route through resolveAuthStep rather than treating any truthy `res`
89+
// as success - webauthn_login_verify can return the same withheld
90+
// (null access_token) shape any other login endpoint can, the exact
91+
// bug the MFA redesign fixed at every other call site.
92+
const step = resolveAuthStep(res, errors || []);
93+
if (step.kind === 'error') {
94+
setWebauthnError(step.message);
95+
return;
96+
}
97+
if (step.kind === 'locked') {
98+
setWebauthnLocked(true);
99+
return;
100+
}
101+
if (step.kind === 'offer') {
102+
// Unexpected here - this screen only renders once a factor is
103+
// already verified, so a first-time-offer response would mean the
104+
// server's state disagrees with what got us to this screen. Surface
105+
// it as an error rather than silently completing or guessing at a
106+
// setup UI.
107+
setWebauthnError(`Unable to verify with passkey. Please try again.`);
108+
return;
109+
}
110+
if (step.kind === 'verify') {
111+
setWebauthnError(
112+
`Additional verification is still required after passkey. Please use the code form below instead.`,
113+
);
114+
return;
115+
}
116+
// step.kind === 'complete'
117+
setError(``);
118+
setAuthData({
119+
user: step.response.user || null,
120+
token: step.response,
121+
config,
122+
loading: false,
123+
});
124+
if (onLogin) {
125+
onLogin(step.response);
126+
}
127+
} catch (err) {
128+
if (!isUserDismissed(err as { code?: string })) {
129+
setWebauthnError((err as Error).message);
130+
}
131+
} finally {
132+
setWebauthnLoading(false);
133+
}
134+
};
135+
52136
const onInputChange = async (field: string, value: string) => {
53137
setFormData({ ...formData, [field]: value });
54138
};
@@ -188,6 +272,25 @@ export const AuthorizerVerifyOtp: FC<{
188272
);
189273
}
190274

275+
if (webauthnLocked) {
276+
return <AuthorizerMfaLocked />;
277+
}
278+
279+
// A code-based factor (TOTP or a verified email/SMS OTP authenticator)
280+
// was offered alongside webauthn: show the passkey button first, with the
281+
// code form as a fallback below it. The code form is only hidden when no
282+
// code factor exists at all - resolveAuthStep guarantees 'verify' never
283+
// has all of totp/webauthn/email/mobile false, so !hasCodeFactor already
284+
// implies webauthn is the sole option, regardless of passkeySupported
285+
// (an unsupported browser still shouldn't render a dead-end code form -
286+
// passkeyOnlyUnsupported below covers that case with an explanation).
287+
const showCodeForm = !!hasCodeFactor;
288+
// Passkey is the user's only MFA factor but this browser can't do WebAuthn:
289+
// no code factor exists to fall back to, so the code form below would be
290+
// a dead end.
291+
const passkeyOnlyUnsupported =
292+
!!offerWebauthnVerify && !passkeySupported && !hasCodeFactor;
293+
191294
return (
192295
<>
193296
{successMessage && (
@@ -204,53 +307,105 @@ export const AuthorizerVerifyOtp: FC<{
204307
onClose={isLockedOut ? undefined : onErrorClose}
205308
/>
206309
)}
207-
<p style={{ textAlign: 'center', margin: '10px 0px' }}>
208-
Please enter the OTP sent to your email or phone number or authenticator
209-
</p>
210-
<br />
211-
<form onSubmit={onSubmit} name="authorizer-mfa-otp-form">
212-
<div className="styled-form-group">
213-
<label className="form-input-label" htmlFor="authorizer-verify-otp">
214-
<span>* </span>OTP (One Time Password)
215-
</label>
216-
<input
217-
name="otp"
218-
id="authorizer-verify-otp"
219-
className={`form-input-field ${
220-
errorData.otp ? 'input-error-content' : ''
221-
}`}
222-
placeholder="e.g.- AB123C"
223-
type="password"
224-
autoComplete="one-time-code"
225-
value={formData.otp || ''}
226-
onChange={(e) => onInputChange('otp', e.target.value)}
227-
disabled={isLockedOut}
228-
/>
229-
{errorData.otp && (
230-
<div className="form-input-error">{errorData.otp}</div>
231-
)}
232-
{is_totp && (
233-
<Message
234-
type={MessageType.Info}
235-
text={`If you have lost access to your device, please enter recovery code that were shared while enabling Multifactor Authentication.`}
236-
extraStyles={{
237-
color: 'var(--authorizer-text-color)',
310+
{webauthnError && (
311+
<Message
312+
type={MessageType.Error}
313+
text={webauthnError}
314+
onClose={() => setWebauthnError(``)}
315+
/>
316+
)}
317+
{offerWebauthnVerify && passkeySupported && (
318+
<>
319+
<p style={{ textAlign: 'center', margin: '10px 0px' }}>
320+
Verify with your passkey
321+
</p>
322+
<StyledButton
323+
type="button"
324+
appearance={ButtonAppearance.Default}
325+
disabled={webauthnLoading}
326+
onClick={onVerifyWithPasskey}
327+
>
328+
<span
329+
style={{
330+
display: 'inline-flex',
331+
alignItems: 'center',
332+
justifyContent: 'center',
333+
gap: '8px',
238334
}}
239-
/>
335+
>
336+
<IconPasskey />
337+
{webauthnLoading ? `Waiting for passkey ...` : `Verify with a passkey`}
338+
</span>
339+
</StyledButton>
340+
<br />
341+
</>
342+
)}
343+
{passkeyOnlyUnsupported && (
344+
<Message
345+
type={MessageType.Info}
346+
text={`This browser doesn't support passkeys. Please try again on a device or browser that does.`}
347+
extraStyles={{
348+
color: 'var(--authorizer-text-color)',
349+
}}
350+
/>
351+
)}
352+
{showCodeForm && (
353+
<>
354+
{offerWebauthnVerify && passkeySupported && (
355+
<p style={{ textAlign: 'center', margin: '10px 0px' }}>
356+
Or enter a code instead
357+
</p>
240358
)}
241-
</div>
242-
<br />
243-
<StyledButton
244-
type="submit"
245-
disabled={loading || !formData.otp || !!errorData.otp || isLockedOut}
246-
appearance={ButtonAppearance.Primary}
247-
>
248-
{loading ? `Processing ...` : `Submit`}
249-
</StyledButton>
250-
</form>
359+
<p style={{ textAlign: 'center', margin: '10px 0px' }}>
360+
Please enter the OTP sent to your email or phone number or authenticator
361+
</p>
362+
<br />
363+
<form onSubmit={onSubmit} name="authorizer-mfa-otp-form">
364+
<div className="styled-form-group">
365+
<label className="form-input-label" htmlFor="authorizer-verify-otp">
366+
<span>* </span>OTP (One Time Password)
367+
</label>
368+
<input
369+
name="otp"
370+
id="authorizer-verify-otp"
371+
className={`form-input-field ${
372+
errorData.otp ? 'input-error-content' : ''
373+
}`}
374+
placeholder="e.g.- AB123C"
375+
type="password"
376+
autoComplete="one-time-code"
377+
value={formData.otp || ''}
378+
onChange={(e) => onInputChange('otp', e.target.value)}
379+
disabled={isLockedOut}
380+
/>
381+
{errorData.otp && (
382+
<div className="form-input-error">{errorData.otp}</div>
383+
)}
384+
{is_totp && (
385+
<Message
386+
type={MessageType.Info}
387+
text={`If you have lost access to your device, please enter recovery code that were shared while enabling Multifactor Authentication.`}
388+
extraStyles={{
389+
color: 'var(--authorizer-text-color)',
390+
}}
391+
/>
392+
)}
393+
</div>
394+
<br />
395+
<StyledButton
396+
type="submit"
397+
disabled={loading || !formData.otp || !!errorData.otp || isLockedOut}
398+
appearance={ButtonAppearance.Primary}
399+
>
400+
{loading ? `Processing ...` : `Submit`}
401+
</StyledButton>
402+
</form>
403+
</>
404+
)}
251405
{setView && (
252406
<StyledFooter>
253407
{!is_totp &&
408+
showCodeForm &&
254409
(sendingOtp ? (
255410
<div style={{ marginBottom: '10px' }}>Sending ...</div>
256411
) : (

0 commit comments

Comments
 (0)