Skip to content

Commit 6776df5

Browse files
authored
feat(mfa): offer passkey enrollment during the login-time MFA setup (#63)
* feat(mfa): offer passkey enrollment during the login-time MFA setup AuthorizerMFASetup hardcoded passkey: false everywhere it was rendered with loginContext set (login/signup/OAuth/passkey-primary), and force-hid the option whenever loginContext was present at all - because webauthn_registration_options/verify required a bearer token that doesn't exist until the MFA gate is satisfied. The backend (authorizerdev/authorizer, authorizerdev/authorizer-js#47) now supports authenticating that ceremony via the MFA session cookie instead, so passkey is a real option here. Also, using the new mfaGate signal on the OAuth/magic-link return path: route to AuthorizerVerifyOtp for an already-configured factor instead of always showing the setup screen, and let AuthorizerPasskeyLogin's verify step render the same challenge form (previously told the user to sign in with a password instead). Removed a stale useEffect in AuthorizerVerifyOtp that flagged "Email or Phone Number is required" on mount even for the legitimate session-only resolution path these two flows now exercise. * feat(mfa): passkey login + MFA setup components, shared BackLink * chore(release): bump authorizer-js to 3.3.0-rc.2; v2.2.0-rc.1
1 parent c56d058 commit 6776df5

16 files changed

Lines changed: 525 additions & 168 deletions

package-lock.json

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"version": "2.2.0-rc.0",
2+
"version": "2.2.0-rc.1",
33
"license": "MIT",
44
"main": "dist/index.cjs",
55
"module": "dist/index.mjs",
@@ -91,7 +91,7 @@
9191
"typescript": "^5.7.2"
9292
},
9393
"dependencies": {
94-
"@authorizerdev/authorizer-js": "^3.3.0-rc.1",
94+
"@authorizerdev/authorizer-js": "^3.3.0-rc.2",
9595
"@storybook/preset-scss": "^1.0.3",
9696
"validator": "^13.11.0"
9797
}

src/components/AuthorizerBasicAuthLogin.tsx

Lines changed: 38 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { ButtonAppearance, MessageType, Views } from '../constants';
88
import { useAuthorizer } from '../contexts/AuthorizerContext';
99
import { StyledButton, StyledFooter, StyledLink } from '../styledComponents';
1010
import { Message } from './Message';
11+
import { PasswordInput } from './PasswordInput';
1112
import { AuthorizerVerifyOtp } from './AuthorizerVerifyOtp';
1213
import { OtpDataType } from '../types';
1314
import { AuthorizerMFASetup } from './AuthorizerMFASetup';
@@ -26,6 +27,7 @@ type MfaOfferData = {
2627
email: string;
2728
phone_number: string;
2829
totpEnrollment: TotpEnrollment | null;
30+
passkey: boolean;
2931
emailOtp: boolean;
3032
smsOtp: boolean;
3133
state?: string;
@@ -36,6 +38,7 @@ const initMfaOfferData: MfaOfferData = {
3638
email: '',
3739
phone_number: '',
3840
totpEnrollment: null,
41+
passkey: false,
3942
emailOtp: false,
4043
smsOtp: false,
4144
};
@@ -45,12 +48,20 @@ interface InputDataType {
4548
password: string | null;
4649
}
4750

51+
export type BasicAuthLoginStep = 'form' | 'mfa-setup' | 'otp-verify' | 'locked';
52+
4853
export const AuthorizerBasicAuthLogin: FC<{
4954
setView?: (v: Views) => void;
5055
onLogin?: (data: AuthToken | void) => void;
5156
urlProps?: Record<string, any>;
5257
roles?: string[];
53-
}> = ({ setView, onLogin, urlProps, roles }) => {
58+
// Fired whenever this component switches between its own screens. See
59+
// AuthorizerSignup's identical prop for why hosts need this: a successful
60+
// login that still needs MFA setup/verification takes over the whole
61+
// login surface - other login options (social buttons, passkey button)
62+
// don't belong stacked on top of those screens.
63+
onStepChange?: (step: BasicAuthLoginStep) => void;
64+
}> = ({ setView, onLogin, urlProps, roles, onStepChange }) => {
5465
const [error, setError] = useState(``);
5566
const [loading, setLoading] = useState(false);
5667
const [otpData, setOtpData] = useState<OtpDataType>({ ...initOtpData });
@@ -68,6 +79,19 @@ export const AuthorizerBasicAuthLogin: FC<{
6879
});
6980
const { setAuthData, config, authorizerRef } = useAuthorizer();
7081

82+
useEffect(() => {
83+
if (!onStepChange) return;
84+
if (locked) {
85+
onStepChange('locked');
86+
} else if (mfaOfferData.is_screen_visible) {
87+
onStepChange('mfa-setup');
88+
} else if (otpData.is_screen_visible) {
89+
onStepChange('otp-verify');
90+
} else {
91+
onStepChange('form');
92+
}
93+
}, [locked, mfaOfferData.is_screen_visible, otpData.is_screen_visible]);
94+
7195
const onInputChange = async (field: string, value: string) => {
7296
setFormData({ ...formData, [field]: value });
7397
};
@@ -124,6 +148,7 @@ export const AuthorizerBasicAuthLogin: FC<{
124148
email: data.email || ``,
125149
phone_number: data.phone_number || ``,
126150
totpEnrollment: step.totpEnrollment,
151+
passkey: step.passkey,
127152
emailOtp: step.emailOtp,
128153
smsOtp: step.smsOtp,
129154
state: urlProps?.state,
@@ -204,12 +229,13 @@ export const AuthorizerBasicAuthLogin: FC<{
204229
<AuthorizerMFASetup
205230
availableMfaMethods={{
206231
totp: !!mfaOfferData.totpEnrollment || config.is_totp_mfa_enabled,
207-
passkey: false,
232+
passkey: mfaOfferData.passkey,
208233
emailOtp: mfaOfferData.emailOtp,
209234
smsOtp: mfaOfferData.smsOtp,
210235
}}
211236
totpEnrollment={mfaOfferData.totpEnrollment || undefined}
212237
heading="Set up multi-factor authentication"
238+
onBack={() => setMfaOfferData({ ...initMfaOfferData })}
213239
loginContext={{
214240
email: mfaOfferData.email,
215241
phone_number: mfaOfferData.phone_number,
@@ -241,6 +267,7 @@ export const AuthorizerBasicAuthLogin: FC<{
241267
is_totp: otpData.is_totp || false,
242268
offerWebauthnVerify: otpData.offer_webauthn_verify || false,
243269
hasCodeFactor: otpData.has_code_factor || false,
270+
onBack: () => setOtpData({ ...initOtpData }),
244271
}}
245272
urlProps={urlProps}
246273
/>
@@ -282,28 +309,15 @@ export const AuthorizerBasicAuthLogin: FC<{
282309
</div>
283310
)}
284311
</div>
285-
<div className="styled-form-group">
286-
<label
287-
className="form-input-label"
288-
htmlFor="authorizer-login-password"
289-
>
290-
<span>* </span>Password
291-
</label>
292-
<input
293-
name="password"
294-
id="authorizer-login-password"
295-
className={`form-input-field ${
296-
errorData.password ? 'input-error-content' : ''
297-
}`}
298-
placeholder="********"
299-
type="password"
300-
value={formData.password || ''}
301-
onChange={e => onInputChange('password', e.target.value)}
302-
/>
303-
{errorData.password && (
304-
<div className="form-input-error">{errorData.password}</div>
305-
)}
306-
</div>
312+
<PasswordInput
313+
id="authorizer-login-password"
314+
name="password"
315+
label="Password"
316+
autoComplete="current-password"
317+
value={formData.password || ''}
318+
onChange={(value) => onInputChange('password', value)}
319+
error={errorData.password}
320+
/>
307321
<br />
308322
<StyledButton
309323
type="submit"

src/components/AuthorizerMFASetup.tsx

Lines changed: 52 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,23 +11,13 @@ import {
1111
} from '../icons/mfa';
1212
import { StyledButton } from '../styledComponents';
1313
import { Message } from './Message';
14+
import { BackLink } from './BackLink';
1415
import { AuthorizerTOTPScanner } from './AuthorizerTOTPScanner';
1516
import { AuthorizerPasskeyRegister } from './AuthorizerPasskeyRegister';
1617
import { AuthorizerVerifyOtp } from './AuthorizerVerifyOtp';
1718
import { useAuthorizer } from '../contexts/AuthorizerContext';
1819
import { TotpEnrollment } from '../utils/mfaTriage';
1920

20-
const BackLink: FC<{ onClick: () => void }> = ({ onClick }) => (
21-
<button
22-
type="button"
23-
className="mfa-icon-button"
24-
onClick={onClick}
25-
style={{ border: 'none', background: 'none', padding: '4px 0' }}
26-
>
27-
&larr; All methods
28-
</button>
29-
);
30-
3121
export type MfaMethod = 'totp' | 'passkey' | 'email_otp' | 'sms_otp';
3222

3323
// Which MFA methods the server offers. This shape is designed to map 1:1
@@ -76,12 +66,24 @@ export const AuthorizerMFASetup: FC<{
7666
state?: string;
7767
onComplete: (response: AuthTokenLike) => void;
7868
};
69+
// When present, a "Back" link is shown on the top-level method list so the
70+
// host can let the user leave MFA setup entirely (distinct from BackLink's
71+
// existing "All methods" links, which only return from a method's
72+
// sub-flow to this same list).
73+
onBack?: () => void;
74+
// Whether the signed-in user already has at least one passkey registered
75+
// (the host knows this via webauthnCredentials - there's no per-user
76+
// enrolment signal for TOTP/email-OTP/SMS-OTP available to the client, so
77+
// only passkey can be accurately highlighted as already set up).
78+
passkeyRegistered?: boolean;
7979
}> = ({
8080
availableMfaMethods,
8181
totpEnrollment,
8282
onSetupMethod,
8383
heading = 'Add a second step to sign in',
8484
loginContext,
85+
onBack,
86+
passkeyRegistered,
8587
}) => {
8688
const [selected, setSelected] = useState<MfaMethod | null>(null);
8789
const [notice, setNotice] = useState('');
@@ -110,6 +112,7 @@ export const AuthorizerMFASetup: FC<{
110112
description: string;
111113
disabled?: boolean;
112114
disabledReason?: string;
115+
enabled?: boolean;
113116
}[] = [
114117
{
115118
key: 'totp',
@@ -120,12 +123,13 @@ export const AuthorizerMFASetup: FC<{
120123
},
121124
{
122125
key: 'passkey',
123-
available: !!availableMfaMethods.passkey && !loginContext,
126+
available: !!availableMfaMethods.passkey,
124127
icon: <IconPasskey />,
125128
title: 'Passkey',
126129
description: 'Sign in with your fingerprint, face, or device PIN.',
127130
disabled: !passkeySupported,
128131
disabledReason: 'Not supported on this browser or device.',
132+
enabled: !!passkeyRegistered,
129133
},
130134
{
131135
key: 'email_otp',
@@ -264,12 +268,11 @@ export const AuthorizerMFASetup: FC<{
264268
if (selected === 'totp' && effectiveTotpEnrollment) {
265269
return (
266270
<>
267-
<BackLink onClick={backToList} />
271+
<BackLink onClick={backToList} label="All methods" />
268272
<AuthorizerTOTPScanner
269273
{...effectiveTotpEnrollment}
270274
email={loginContext?.email}
271275
phone_number={loginContext?.phone_number}
272-
setView={backToList}
273276
onLogin={(data) => {
274277
if (loginContext && data && (data as AuthTokenLike).access_token) {
275278
loginContext.onComplete(data as AuthTokenLike);
@@ -285,7 +288,7 @@ export const AuthorizerMFASetup: FC<{
285288
if (otpMethodPending) {
286289
return (
287290
<>
288-
<BackLink onClick={() => setOtpMethodPending(null)} />
291+
<BackLink onClick={() => setOtpMethodPending(null)} label="All methods" />
289292
<AuthorizerVerifyOtp
290293
email={loginContext?.email}
291294
phone_number={loginContext?.phone_number}
@@ -306,15 +309,36 @@ export const AuthorizerMFASetup: FC<{
306309
if (selected === 'passkey') {
307310
return (
308311
<>
309-
<BackLink onClick={backToList} />
312+
<BackLink onClick={backToList} label="All methods" />
310313
<p style={{ margin: '10px 0px', fontWeight: 'bold' }}>Add a passkey</p>
311-
<AuthorizerPasskeyRegister onSuccess={backToList} showCredentials />
314+
<AuthorizerPasskeyRegister
315+
// showCredentials calls webauthn_credentials, which requires a
316+
// bearer token - never available yet during a login-time offer.
317+
showCredentials={!loginContext}
318+
mfaSetup={
319+
loginContext
320+
? {
321+
email: loginContext.email,
322+
phoneNumber: loginContext.phone_number,
323+
state: loginContext.state,
324+
}
325+
: undefined
326+
}
327+
onSuccess={(data) => {
328+
if (loginContext && data && (data as AuthTokenLike).access_token) {
329+
loginContext.onComplete(data as AuthTokenLike);
330+
return;
331+
}
332+
backToList();
333+
}}
334+
/>
312335
</>
313336
);
314337
}
315338

316339
return (
317340
<>
341+
{onBack && <BackLink onClick={onBack} />}
318342
<p style={{ margin: '10px 0px', fontWeight: 'bold' }}>{heading}</p>
319343
{notice && (
320344
<Message
@@ -339,10 +363,18 @@ export const AuthorizerMFASetup: FC<{
339363
) : (
340364
<ul className="mfa-list" aria-label="Available multi-factor methods">
341365
{visibleMethods.map((m) => (
342-
<li key={m.key} className="mfa-method">
366+
<li
367+
key={m.key}
368+
className={`mfa-method${m.enabled ? ' mfa-method-enabled' : ''}`}
369+
>
343370
<span className="mfa-method-icon">{m.icon}</span>
344371
<div className="mfa-method-body">
345-
<p className="mfa-method-title">{m.title}</p>
372+
<p className="mfa-method-title">
373+
{m.title}
374+
{m.enabled && (
375+
<span className="mfa-method-badge">Enabled</span>
376+
)}
377+
</p>
346378
<p className="mfa-method-desc">
347379
{m.disabled && m.disabledReason
348380
? m.disabledReason
@@ -357,7 +389,7 @@ export const AuthorizerMFASetup: FC<{
357389
onClick={() => handleSetup(m.key)}
358390
style={{ width: 'auto' }}
359391
>
360-
Set up
392+
{m.enabled ? 'Manage' : 'Set up'}
361393
</StyledButton>
362394
</div>
363395
</li>

0 commit comments

Comments
 (0)