Skip to content

Commit 9e9aa55

Browse files
Merge pull request #100 from auth0/feat/mfa-list-challenge-verify-flow-UIC-573
feat(react): MFA step-up challenge & enrolment flow
2 parents 3175b2d + f190b79 commit 9e9aa55

16 files changed

Lines changed: 1602 additions & 204 deletions

File tree

packages/core/src/auth/__tests__/spa-token-retriever.test.ts

Lines changed: 12 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -373,46 +373,28 @@ describe('spa-token-retriever', () => {
373373
});
374374

375375
describe('error handling with fallback', () => {
376-
it('should use popup with consent prompt for consent_required error', async () => {
377-
const mockToken = 'popup-token';
378-
vi.mocked(mockContextInterface.getAccessTokenSilently).mockRejectedValue({
379-
error: 'consent_required',
380-
});
381-
vi.mocked(mockContextInterface.getAccessTokenWithPopup).mockResolvedValue(mockToken);
376+
it('should throw error for consent_required error (handled by interactiveErrorHandler)', async () => {
377+
const consentError = { error: 'consent_required' };
378+
vi.mocked(mockContextInterface.getAccessTokenSilently).mockRejectedValue(consentError);
382379

383380
const auth = createAuthConfig();
384381
const tokenManager = createSpaTokenRetriever(auth);
385-
const token = await tokenManager.getToken('read:users', 'management');
386382

387-
expect(token).toBe(mockToken);
388-
expect(mockContextInterface.getAccessTokenWithPopup).toHaveBeenCalledWith({
389-
authorizationParams: {
390-
audience: `https://${TEST_DOMAIN}/management/`,
391-
scope: 'read:users',
392-
prompt: 'consent',
393-
},
394-
});
383+
await expect(tokenManager.getToken('read:users', 'management')).rejects.toEqual(
384+
consentError,
385+
);
386+
expect(mockContextInterface.getAccessTokenWithPopup).not.toHaveBeenCalled();
395387
});
396388

397-
it('should use popup with login prompt for login_required error', async () => {
398-
const mockToken = 'popup-token';
399-
vi.mocked(mockContextInterface.getAccessTokenSilently).mockRejectedValue({
400-
error: 'login_required',
401-
});
402-
vi.mocked(mockContextInterface.getAccessTokenWithPopup).mockResolvedValue(mockToken);
389+
it('should throw error for login_required error (handled by interactiveErrorHandler)', async () => {
390+
const loginError = { error: 'login_required' };
391+
vi.mocked(mockContextInterface.getAccessTokenSilently).mockRejectedValue(loginError);
403392

404393
const auth = createAuthConfig();
405394
const tokenManager = createSpaTokenRetriever(auth);
406-
const token = await tokenManager.getToken('read:users', 'management');
407395

408-
expect(token).toBe(mockToken);
409-
expect(mockContextInterface.getAccessTokenWithPopup).toHaveBeenCalledWith({
410-
authorizationParams: {
411-
audience: `https://${TEST_DOMAIN}/management/`,
412-
scope: 'read:users',
413-
prompt: 'login',
414-
},
415-
});
396+
await expect(tokenManager.getToken('read:users', 'management')).rejects.toEqual(loginError);
397+
expect(mockContextInterface.getAccessTokenWithPopup).not.toHaveBeenCalled();
416398
});
417399

418400
it('should throw error for mfa_required error (not in fallback list)', async () => {

packages/core/src/auth/spa-token-retriever.ts

Lines changed: 6 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,6 @@
11
import type { AuthDetails } from './auth-types';
22
import { AuthUtils } from './auth-utils';
33

4-
const FALLBACK_ERRORS = new Set(['consent_required', 'login_required']);
5-
6-
/**
7-
* Checks if an error has an error property.
8-
* @param error - The error to check.
9-
* @returns True if the error has an error property.
10-
*/
11-
function hasErrorProperty(error: unknown): error is { error: string } {
12-
return typeof error === 'object' && error !== null && 'error' in error;
13-
}
14-
154
/**
165
* Builds the audience URL from a domain and audience path.
176
* @param domain - The Auth0 tenant domain.
@@ -59,25 +48,12 @@ export function createSpaTokenRetriever(auth: AuthDetails) {
5948

6049
const audience = buildAudience(domain, audiencePath);
6150

62-
try {
63-
const tokenResponse = await auth.contextInterface.getAccessTokenSilently({
64-
authorizationParams: { audience, scope },
65-
detailedResponse: true,
66-
...(ignoreCache && { cacheMode: 'off' }),
67-
});
68-
return tokenResponse.access_token;
69-
} catch (error) {
70-
if (hasErrorProperty(error) && FALLBACK_ERRORS.has(error.error)) {
71-
const prompt = error.error === 'login_required' ? 'login' : 'consent';
72-
const token = await auth.contextInterface.getAccessTokenWithPopup({
73-
authorizationParams: { audience, scope, prompt },
74-
});
75-
if (!token) throw error;
76-
return token;
77-
}
78-
79-
throw error;
80-
}
51+
const tokenResponse = await auth.contextInterface.getAccessTokenSilently({
52+
authorizationParams: { audience, scope },
53+
detailedResponse: true,
54+
...(ignoreCache && { cacheMode: 'off' }),
55+
});
56+
return tokenResponse.access_token;
8157
},
8258
};
8359
}

packages/core/src/i18n/translations/en-US.json

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,35 @@
1010
"error": {
1111
"generic": "There was an issue processing your request. Please try again or contact support if the issue persists.",
1212
"mfa": {
13-
"title": "Verify its you"
13+
"title": "Verify your account",
14+
"subtitle": "You must provide a second factor from one of the options below to perform this action.",
15+
"verify_button": "Verify",
16+
"verifying": "Verifying...",
17+
"back": "Back",
18+
"cancel": "Cancel",
19+
"continue": "Continue",
20+
"enroll_button": "Set up",
21+
"fetch_failed": "There was an issue loading your authentication methods. Please try again.",
22+
"no_authenticators": "No authentication methods found. Please contact support.",
23+
"factor_available": "Available for setup",
24+
"challenge_error": "Failed to start the verification. Please try again.",
25+
"verify_error": "Verification failed. Please check your code and try again.",
26+
"enroll_error": "Failed to set up the authentication method. Please try again.",
27+
"otp_instruction": "Please enter the one-time code shown in your authenticator app.",
28+
"oob_instruction": "Please enter the one-time code sent to your device.",
29+
"recovery_code_instruction": "Enter the recovery code you were provided during your initial enrollment.",
30+
"enter_code_label": "One-time passcode",
31+
"recovery_code_label": "Recovery code",
32+
"registered_on": "Registered on ${date}",
33+
"authenticator_type": {
34+
"otp": "Authenticator",
35+
"oob": "Push Notification with Guardian App",
36+
"recovery-code": "Recovery Codes",
37+
"email": "Email OTP",
38+
"sms": "SMS",
39+
"push": "Push Notification with Guardian App",
40+
"voice": "Voice"
41+
}
1442
}
1543
}
1644
},
@@ -994,6 +1022,8 @@
9941022
"enroll_sms_description": "Enter your phone number to receive a verification code",
9951023
"show_auth0_guardian_title": "Scan this QR code with your Auth0 Guardian App to register this Authentication method or copy the url.",
9961024
"recovery_code_description": "Copy this recovery code and keep it somewhere safe. You'll need it if you ever need to log in without your device.",
1025+
"recovery_code_title": "Generated recovery codes",
1026+
"recovery_code_acknowledged": "I have safely recorded this code",
9971027
"show_otp": {
9981028
"title": "Scan this QR code with your Authenticator App to register this Authentication method or copy the code.",
9991029
"save_recovery": "Save these recovery codes!",

packages/core/src/i18n/translations/ja.json

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,46 @@
11
{
22
"common": {
33
"copy": "コピー",
4-
"copied": "コピーしました"
4+
"copied": "コピーしました",
5+
"fallback": {
6+
"title": "情報を読み込めませんでした",
7+
"description": "再度お試しいただくか、問題が解決しない場合はサポートまでお問い合わせください。",
8+
"retry": "再試行"
9+
},
10+
"error": {
11+
"generic": "リクエストの処理中に問題が発生しました。再度お試しいただくか、問題が解決しない場合はサポートまでお問い合わせください。",
12+
"mfa": {
13+
"title": "アカウントを確認してください",
14+
"subtitle": "このアクションを実行するには、以下のいずれかの方法で第2要素を提供する必要があります。",
15+
"verify_button": "確認",
16+
"verifying": "確認中...",
17+
"back": "戻る",
18+
"cancel": "キャンセル",
19+
"continue": "続ける",
20+
"enroll_button": "設定",
21+
"fetch_failed": "認証方法の読み込み中に問題が発生しました。再度お試しください。",
22+
"no_authenticators": "認証方法が見つかりません。サポートにお問い合わせください。",
23+
"factor_available": "設定可能",
24+
"challenge_error": "確認の開始に失敗しました。再度お試しください。",
25+
"verify_error": "確認に失敗しました。コードを確認して再度お試しください。",
26+
"enroll_error": "認証方法の設定に失敗しました。再度お試しください。",
27+
"otp_instruction": "認証アプリに表示されているワンタイムコードを入力してください。",
28+
"oob_instruction": "デバイスに送信されたワンタイムコードを入力してください。",
29+
"recovery_code_instruction": "初回登録時に提供されたリカバリーコードを入力してください。",
30+
"enter_code_label": "ワンタイムパスコード",
31+
"recovery_code_label": "リカバリーコード",
32+
"registered_on": "${date}に登録",
33+
"authenticator_type": {
34+
"otp": "認証アプリ",
35+
"oob": "Auth0 Guardianプッシュ通知",
36+
"recovery-code": "リカバリーコード",
37+
"email": "メールOTP",
38+
"sms": "SMS",
39+
"push": "Auth0 Guardianプッシュ通知",
40+
"voice": "音声通話"
41+
}
42+
}
43+
}
544
},
645
"domain_management": {
746
"domain_table": {
@@ -985,6 +1024,8 @@
9851024
"enroll_sms_description": "認証コードを受信するためにあなたの電話番号を入力してください",
9861025
"show_auth0_guardian_title": "このQRコードをAuth0 Guardianアプリでスキャンするか、URLをコピーして、この認証方法を登録してください",
9871026
"recovery_code_description": "このリカバリーコードをコピーして、安全な場所に保管してください。デバイスなしでログインする必要がある場合に使用します。",
1027+
"recovery_code_title": "リカバリーコードの生成",
1028+
"recovery_code_acknowledged": "このコードを安全に記録しました",
9881029
"show_otp": {
9891030
"title": "この QR コードを Authenticator アプリでスキャンして、この認証方法を登録するか、コードをコピーしてください。",
9901031
"save_recovery": "これらのリカバリーコードを保存してください!",

packages/core/src/services/step-up/step-up-types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import type { ChallengeType } from '../../auth/auth-types';
2+
13
export interface MfaRequirements {
24
/** Required enrollment types (user needs to enroll new authenticator) */
35
enroll?: Array<{ type: string }>;
46
/** Available challenge types (existing authenticators) */
5-
challenge?: Array<{ type: string }>;
7+
challenge?: Array<{ type: ChallengeType }>;
68
}
79

810
export interface MfaRequiredError extends Error {

packages/react/src/components/auth0/my-account/shared/mfa/otp-verification-form.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ export function OTPVerificationForm({
9999
authSession,
100100
authenticationMethodId,
101101
onBack,
102+
buttonSize = 'default',
103+
buttonAlignment = 'justify-end',
102104
styling = {
103105
variables: { common: {}, light: {}, dark: {} },
104106
classes: {},
@@ -194,12 +196,12 @@ export function OTPVerificationForm({
194196
)}
195197
/>
196198

197-
<div className="flex flex-row justify-end gap-3 mt-6 mb-6">
199+
<div className={cn('flex flex-row gap-3 mt-6 mb-6', buttonAlignment)}>
198200
<Button
199201
type="button"
200202
className="text-sm"
201-
variant="outline"
202-
size="default"
203+
variant="ghost"
204+
size={buttonSize}
203205
onClick={onBack}
204206
aria-label={t('back')}
205207
>
@@ -209,7 +211,7 @@ export function OTPVerificationForm({
209211
<Button
210212
type="submit"
211213
className="text-sm"
212-
size="default"
214+
size={buttonSize}
213215
disabled={userOtp?.length !== 6 || loading}
214216
aria-label={buttonText}
215217
>

packages/react/src/components/auth0/shared/__tests__/gatekeeper.test.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -164,8 +164,8 @@ describe('GateKeeper', () => {
164164
});
165165

166166
await waitFor(() => {
167-
expect(screen.getByText('otp')).toBeInTheDocument();
168-
expect(screen.getByText('sms')).toBeInTheDocument();
167+
expect(screen.getByText('error.mfa.authenticator_type.otp')).toBeInTheDocument();
168+
expect(screen.getByText('error.mfa.authenticator_type.sms')).toBeInTheDocument();
169169
});
170170
});
171171

@@ -291,13 +291,12 @@ describe('GateKeeper', () => {
291291

292292
await waitFor(() => {
293293
expect(screen.getByText('Test Authenticator')).toBeInTheDocument();
294-
expect(screen.getByText(/Type: otp/)).toBeInTheDocument();
295-
expect(screen.getByText(/Active: Yes/)).toBeInTheDocument();
296294
});
297295

298296
await waitFor(() => {
299-
expect(screen.getByText('webauthn-roaming')).toBeInTheDocument();
300-
expect(screen.getByText(/Active: No/)).toBeInTheDocument();
297+
expect(
298+
screen.getByText('error.mfa.authenticator_type.webauthn-roaming'),
299+
).toBeInTheDocument();
301300
});
302301
});
303302
});

0 commit comments

Comments
 (0)