Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export {
ConfirmEnrollmentOptions,
CreateAuthenticationMethodRequestContent,
CreateAuthenticationMethodResponseContent,
VerifyAuthenticationMethodResponseContent,
} from './services/my-account/mfa/mfa-types';

export {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { mfaQueryKeys } from '@auth0/universal-components-core';
import { renderHook, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';

import { useUserMFAService } from '@/hooks/my-account/shared/services/use-user-mfa-service';
import * as useCoreClientModule from '@/hooks/shared/use-core-client';
import { mockCore, setupMockUseCoreClient, createQueryClientWrapper } from '@/tests/utils';

const { initMockCoreClient } = mockCore();
let mockCoreClient: ReturnType<typeof initMockCoreClient>;

describe('useUserMFAService', () => {
beforeEach(() => {
vi.clearAllMocks();
mockCoreClient = initMockCoreClient();
setupMockUseCoreClient(mockCoreClient, useCoreClientModule);
});

const renderService = (onlyActive = false) => {
const { wrapper } = createQueryClientWrapper();
return renderHook(() => useUserMFAService(onlyActive), { wrapper });
};

it('returns loading state initially', () => {
const { result } = renderService();
expect(result.current.factorsQuery.isLoading).toBe(true);
});

it('fetches and maps factors on success', async () => {
const { result } = renderService();
await waitFor(() => expect(result.current.factorsQuery.isSuccess).toBe(true));
expect(result.current.factorsQuery.data).toBeDefined();
const apiClient = mockCoreClient.getMyAccountApiClient();
expect(apiClient.factors.list).toHaveBeenCalledTimes(1);
expect(apiClient.authenticationMethods.list).toHaveBeenCalledTimes(1);
});

it('does not fetch when coreClient is null', () => {
vi.spyOn(useCoreClientModule, 'useCoreClient').mockReturnValue({ coreClient: null });

const { wrapper } = createQueryClientWrapper();
const { result } = renderHook(() => useUserMFAService(false), { wrapper });

expect(result.current.factorsQuery.fetchStatus).toBe('idle');
});

it('calls authenticationMethods.create with mapped params on enroll', async () => {
const apiClient = mockCoreClient.getMyAccountApiClient();
vi.mocked(apiClient.authenticationMethods.create).mockResolvedValue({
id: 'new_id',
auth_session: 'sess',
} as never);

const { result } = renderService();
await waitFor(() => expect(result.current.factorsQuery.isSuccess).toBe(true));

await result.current.enrollMutation.mutateAsync({ factorType: 'totp', options: {} });

expect(apiClient.authenticationMethods.create).toHaveBeenCalled();
});

it('calls authenticationMethods.delete and invalidates query on success', async () => {
const apiClient = mockCoreClient.getMyAccountApiClient();

const { result } = renderService();
await waitFor(() => expect(result.current.factorsQuery.isSuccess).toBe(true));

const initialCallCount = vi.mocked(apiClient.factors.list).mock.calls.length;
await result.current.deleteMutation.mutateAsync('auth-id-123');

expect(apiClient.authenticationMethods.delete).toHaveBeenCalledWith('auth-id-123');
await waitFor(() => {
expect(vi.mocked(apiClient.factors.list).mock.calls.length).toBeGreaterThan(initialCallCount);
});
});

it('calls authenticationMethods.verify with correct params on confirm', async () => {
const apiClient = mockCoreClient.getMyAccountApiClient();

const { result } = renderService();
await waitFor(() => expect(result.current.factorsQuery.isSuccess).toBe(true));

await result.current.verifyMutation.mutateAsync({
factorType: 'totp',
authSession: 'sess-abc',
authenticationMethodId: 'method-123',
options: { userOtpCode: '123456' },
});

expect(apiClient.authenticationMethods.verify).toHaveBeenCalledWith(
'method-123',
expect.anything(),
);
});

it('registers query under the onlyActive=true key when onlyActive is true', async () => {
const { wrapper, queryClient } = createQueryClientWrapper();
const { result } = renderHook(() => useUserMFAService(true), { wrapper });

await waitFor(() => expect(result.current.factorsQuery.isSuccess).toBe(true));

const cachedKeys = queryClient
.getQueryCache()
.getAll()
.map((q) => q.queryKey);
expect(cachedKeys).toContainEqual(mfaQueryKeys.factors(true));
expect(cachedKeys).not.toContainEqual(mfaQueryKeys.factors(false));
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* MFA service hook with TanStack Query.
* @module use-user-mfa-service
* @internal
*/

import {
MFAMappers,
mfaQueryKeys,
type Authenticator,
type MFAType,
type EnrollOptions,
type ConfirmEnrollmentOptions,
} from '@auth0/universal-components-core';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

import { useCoreClient } from '@/hooks/shared/use-core-client';
import type { UseUserMFAServiceReturn } from '@/types/my-account/mfa/mfa-types';

/**
* Internal service hook for MFA operations backed by TanStack Query.
* Provides queries and mutations; use `useUserMFA` for the public API.
* @param onlyActive - Whether to return only active factors.
* @returns MFA query and mutation handlers for factor listing and enrollment lifecycle operations.
* @internal
*/
export function useUserMFAService(onlyActive: boolean): UseUserMFAServiceReturn {
Comment thread
rax7389 marked this conversation as resolved.
const { coreClient } = useCoreClient();
const queryClient = useQueryClient();

const factorsQuery = useQuery<Record<MFAType, Authenticator[]>>({
queryKey: mfaQueryKeys.factors(onlyActive),
queryFn: async () => {
const client = coreClient!.getMyAccountApiClient();
const [availableFactors, enrolledFactors] = await Promise.all([
client.factors.list(),
client.authenticationMethods.list(),
]);
return MFAMappers.fromAPI(availableFactors, enrolledFactors, onlyActive) as Record<
MFAType,
Authenticator[]
>;
},
enabled: !!coreClient,
});

const enrollMutation = useMutation({
mutationFn: ({
factorType,
options = {},
}: {
factorType: MFAType;
options?: EnrollOptions;
}) => {
const client = coreClient!.getMyAccountApiClient();
const params = MFAMappers.buildEnrollParams(factorType, options);
return client.authenticationMethods.create(params);
},
});

const deleteMutation = useMutation({
mutationFn: (authenticatorId: string) =>
coreClient!.getMyAccountApiClient().authenticationMethods.delete(authenticatorId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: mfaQueryKeys.factors(onlyActive) });
},
});

const verifyMutation = useMutation({
mutationFn: ({
factorType,
authSession,
authenticationMethodId,
options,
}: {
factorType: MFAType;
authSession: string;
authenticationMethodId: string;
options: ConfirmEnrollmentOptions;
}) => {
const client = coreClient!.getMyAccountApiClient();
const params = MFAMappers.buildConfirmEnrollmentParams(factorType, authSession, options);
return client.authenticationMethods.verify(authenticationMethodId, params);
},
});

return {
factorsQuery,
enrollMutation,
deleteMutation,
verifyMutation,
};
}
22 changes: 22 additions & 0 deletions packages/react/src/types/my-account/mfa/mfa-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,38 @@

import type {
CreateAuthenticationMethodResponseContent,
VerifyAuthenticationMethodResponseContent,
Authenticator,
MFAType,
EnrollOptions,
ConfirmEnrollmentOptions,
MFAMessages,
SharedComponentProps,
} from '@auth0/universal-components-core';
import type { UseMutationResult, UseQueryResult } from '@tanstack/react-query';

import type { ENROLL, CONFIRM } from '@/lib/constants/my-account/mfa/mfa-constants';

export interface UseUserMFAServiceReturn {
factorsQuery: UseQueryResult<Record<MFAType, Authenticator[]>>;
enrollMutation: UseMutationResult<
CreateAuthenticationMethodResponseContent,
Error,
{ factorType: MFAType; options?: EnrollOptions }
>;
deleteMutation: UseMutationResult<void, Error, string>;
verifyMutation: UseMutationResult<
VerifyAuthenticationMethodResponseContent,
Error,
{
factorType: MFAType;
authSession: string;
authenticationMethodId: string;
options: ConfirmEnrollmentOptions;
}
>;
}

/** Configuration for an individual MFA factor type. */
export interface FactorConfigOptions {
visible?: boolean;
Expand Down
Loading