Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 12 additions & 30 deletions packages/core/src/auth/__tests__/spa-token-retriever.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,46 +373,28 @@ describe('spa-token-retriever', () => {
});

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

const auth = createAuthConfig();
const tokenManager = createSpaTokenRetriever(auth);
const token = await tokenManager.getToken('read:users', 'management');

expect(token).toBe(mockToken);
expect(mockContextInterface.getAccessTokenWithPopup).toHaveBeenCalledWith({
authorizationParams: {
audience: `https://${TEST_DOMAIN}/management/`,
scope: 'read:users',
prompt: 'consent',
},
});
await expect(tokenManager.getToken('read:users', 'management')).rejects.toEqual(
consentError,
);
expect(mockContextInterface.getAccessTokenWithPopup).not.toHaveBeenCalled();
});

it('should use popup with login prompt for login_required error', async () => {
const mockToken = 'popup-token';
vi.mocked(mockContextInterface.getAccessTokenSilently).mockRejectedValue({
error: 'login_required',
});
vi.mocked(mockContextInterface.getAccessTokenWithPopup).mockResolvedValue(mockToken);
it('should throw error for login_required error (handled by interactiveErrorHandler)', async () => {
const loginError = { error: 'login_required' };
vi.mocked(mockContextInterface.getAccessTokenSilently).mockRejectedValue(loginError);

const auth = createAuthConfig();
const tokenManager = createSpaTokenRetriever(auth);
const token = await tokenManager.getToken('read:users', 'management');

expect(token).toBe(mockToken);
expect(mockContextInterface.getAccessTokenWithPopup).toHaveBeenCalledWith({
authorizationParams: {
audience: `https://${TEST_DOMAIN}/management/`,
scope: 'read:users',
prompt: 'login',
},
});
await expect(tokenManager.getToken('read:users', 'management')).rejects.toEqual(loginError);
expect(mockContextInterface.getAccessTokenWithPopup).not.toHaveBeenCalled();
});

it('should throw error for mfa_required error (not in fallback list)', async () => {
Expand Down
36 changes: 6 additions & 30 deletions packages/core/src/auth/spa-token-retriever.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,6 @@
import type { AuthDetails } from './auth-types';
import { AuthUtils } from './auth-utils';

const FALLBACK_ERRORS = new Set(['consent_required', 'login_required']);

/**
* Checks if an error has an error property.
* @param error - The error to check.
* @returns True if the error has an error property.
*/
function hasErrorProperty(error: unknown): error is { error: string } {
return typeof error === 'object' && error !== null && 'error' in error;
}

/**
* Builds the audience URL from a domain and audience path.
* @param domain - The Auth0 tenant domain.
Expand Down Expand Up @@ -59,25 +48,12 @@ export function createSpaTokenRetriever(auth: AuthDetails) {

const audience = buildAudience(domain, audiencePath);

try {
const tokenResponse = await auth.contextInterface.getAccessTokenSilently({
authorizationParams: { audience, scope },
detailedResponse: true,
...(ignoreCache && { cacheMode: 'off' }),
});
return tokenResponse.access_token;
} catch (error) {
if (hasErrorProperty(error) && FALLBACK_ERRORS.has(error.error)) {
const prompt = error.error === 'login_required' ? 'login' : 'consent';
const token = await auth.contextInterface.getAccessTokenWithPopup({
authorizationParams: { audience, scope, prompt },
});
if (!token) throw error;
return token;
}

throw error;
}
const tokenResponse = await auth.contextInterface.getAccessTokenSilently({
authorizationParams: { audience, scope },
detailedResponse: true,
...(ignoreCache && { cacheMode: 'off' }),
});
return tokenResponse.access_token;
},
};
}
32 changes: 31 additions & 1 deletion packages/core/src/i18n/translations/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,35 @@
"error": {
"generic": "There was an issue processing your request. Please try again or contact support if the issue persists.",
"mfa": {
"title": "Verify its you"
"title": "Verify your account",
"subtitle": "You must provide a second factor from one of the options below to perform this action.",
"verify_button": "Verify",
"verifying": "Verifying...",
"back": "Back",
"cancel": "Cancel",
"continue": "Continue",
"enroll_button": "Set up",
"fetch_failed": "There was an issue loading your authentication methods. Please try again.",
"no_authenticators": "No authentication methods found. Please contact support.",
"factor_available": "Available for setup",
"challenge_error": "Failed to start the verification. Please try again.",
"verify_error": "Verification failed. Please check your code and try again.",
"enroll_error": "Failed to set up the authentication method. Please try again.",
"otp_instruction": "Please enter the one-time code shown in your authenticator app.",
"oob_instruction": "Please enter the one-time code sent to your device.",
"recovery_code_instruction": "Enter the recovery code you were provided during your initial enrollment.",
"enter_code_label": "One-time passcode",
"recovery_code_label": "Recovery code",
"registered_on": "Registered on ${date}",
"authenticator_type": {
"otp": "Authenticator",
"oob": "Push Notification with Guardian App",
"recovery-code": "Recovery Codes",
"email": "Email OTP",
"sms": "SMS",
"push": "Push Notification with Guardian App",
"voice": "Voice"
}
}
}
},
Expand Down Expand Up @@ -994,6 +1022,8 @@
"enroll_sms_description": "Enter your phone number to receive a verification code",
"show_auth0_guardian_title": "Scan this QR code with your Auth0 Guardian App to register this Authentication method or copy the url.",
"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.",
"recovery_code_title": "Generated recovery codes",
"recovery_code_acknowledged": "I have safely recorded this code",
"show_otp": {
"title": "Scan this QR code with your Authenticator App to register this Authentication method or copy the code.",
"save_recovery": "Save these recovery codes!",
Expand Down
43 changes: 42 additions & 1 deletion packages/core/src/i18n/translations/ja.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,46 @@
{
"common": {
"copy": "コピー",
"copied": "コピーしました"
"copied": "コピーしました",
"fallback": {
"title": "情報を読み込めませんでした",
"description": "再度お試しいただくか、問題が解決しない場合はサポートまでお問い合わせください。",
"retry": "再試行"
},
"error": {
"generic": "リクエストの処理中に問題が発生しました。再度お試しいただくか、問題が解決しない場合はサポートまでお問い合わせください。",
"mfa": {
"title": "アカウントを確認してください",
"subtitle": "このアクションを実行するには、以下のいずれかの方法で第2要素を提供する必要があります。",
"verify_button": "確認",
"verifying": "確認中...",
"back": "戻る",
"cancel": "キャンセル",
"continue": "続ける",
"enroll_button": "設定",
"fetch_failed": "認証方法の読み込み中に問題が発生しました。再度お試しください。",
"no_authenticators": "認証方法が見つかりません。サポートにお問い合わせください。",
"factor_available": "設定可能",
"challenge_error": "確認の開始に失敗しました。再度お試しください。",
"verify_error": "確認に失敗しました。コードを確認して再度お試しください。",
"enroll_error": "認証方法の設定に失敗しました。再度お試しください。",
"otp_instruction": "認証アプリに表示されているワンタイムコードを入力してください。",
"oob_instruction": "デバイスに送信されたワンタイムコードを入力してください。",
"recovery_code_instruction": "初回登録時に提供されたリカバリーコードを入力してください。",
"enter_code_label": "ワンタイムパスコード",
"recovery_code_label": "リカバリーコード",
"registered_on": "${date}に登録",
"authenticator_type": {
"otp": "認証アプリ",
"oob": "Auth0 Guardianプッシュ通知",
"recovery-code": "リカバリーコード",
"email": "メールOTP",
"sms": "SMS",
"push": "Auth0 Guardianプッシュ通知",
"voice": "音声通話"
}
}
}
},
"domain_management": {
"domain_table": {
Expand Down Expand Up @@ -985,6 +1024,8 @@
"enroll_sms_description": "認証コードを受信するためにあなたの電話番号を入力してください",
"show_auth0_guardian_title": "このQRコードをAuth0 Guardianアプリでスキャンするか、URLをコピーして、この認証方法を登録してください",
"recovery_code_description": "このリカバリーコードをコピーして、安全な場所に保管してください。デバイスなしでログインする必要がある場合に使用します。",
"recovery_code_title": "リカバリーコードの生成",
"recovery_code_acknowledged": "このコードを安全に記録しました",
"show_otp": {
"title": "この QR コードを Authenticator アプリでスキャンして、この認証方法を登録するか、コードをコピーしてください。",
"save_recovery": "これらのリカバリーコードを保存してください!",
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/services/step-up/step-up-types.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { ChallengeType } from '../../auth/auth-types';

export interface MfaRequirements {
/** Required enrollment types (user needs to enroll new authenticator) */
enroll?: Array<{ type: string }>;
/** Available challenge types (existing authenticators) */
challenge?: Array<{ type: string }>;
challenge?: Array<{ type: ChallengeType }>;
}

export interface MfaRequiredError extends Error {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ export function OTPVerificationForm({
authSession,
authenticationMethodId,
onBack,
buttonSize = 'default',
buttonAlignment = 'justify-end',
styling = {
variables: { common: {}, light: {}, dark: {} },
classes: {},
Expand Down Expand Up @@ -194,12 +196,12 @@ export function OTPVerificationForm({
)}
/>

<div className="flex flex-row justify-end gap-3 mt-6 mb-6">
<div className={cn('flex flex-row gap-3 mt-6 mb-6', buttonAlignment)}>
<Button
type="button"
className="text-sm"
variant="outline"
size="default"
variant="ghost"
size={buttonSize}
onClick={onBack}
aria-label={t('back')}
>
Expand All @@ -209,7 +211,7 @@ export function OTPVerificationForm({
<Button
type="submit"
className="text-sm"
size="default"
size={buttonSize}
disabled={userOtp?.length !== 6 || loading}
aria-label={buttonText}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,8 @@ describe('GateKeeper', () => {
});

await waitFor(() => {
expect(screen.getByText('otp')).toBeInTheDocument();
expect(screen.getByText('sms')).toBeInTheDocument();
expect(screen.getByText('error.mfa.authenticator_type.otp')).toBeInTheDocument();
expect(screen.getByText('error.mfa.authenticator_type.sms')).toBeInTheDocument();
});
});

Expand Down Expand Up @@ -291,13 +291,12 @@ describe('GateKeeper', () => {

await waitFor(() => {
expect(screen.getByText('Test Authenticator')).toBeInTheDocument();
expect(screen.getByText(/Type: otp/)).toBeInTheDocument();
expect(screen.getByText(/Active: Yes/)).toBeInTheDocument();
});

await waitFor(() => {
expect(screen.getByText('webauthn-roaming')).toBeInTheDocument();
expect(screen.getByText(/Active: No/)).toBeInTheDocument();
expect(
screen.getByText('error.mfa.authenticator_type.webauthn-roaming'),
).toBeInTheDocument();
});
});
});
Expand Down
Loading