diff --git a/src/login-web-app/src/haapi-stepper/data-access/types/haapi-action.types.ts b/src/login-web-app/src/haapi-stepper/data-access/types/haapi-action.types.ts index ef4eb3a..7480631 100644 --- a/src/login-web-app/src/haapi-stepper/data-access/types/haapi-action.types.ts +++ b/src/login-web-app/src/haapi-stepper/data-access/types/haapi-action.types.ts @@ -189,12 +189,9 @@ export interface HaapiExternalBrowserArguments { href: string; } -/** - * Client operation WebAuthn registration action - */ -export interface HaapiWebAuthnRegistrationClientOperationAction extends HaapiClientOperationAction { - model: HaapiWebAuthnRegistrationClientOperationModel; -} +export type HaapiWebAuthnRegistrationClientOperationAction = + | HaapiWebAuthnPasskeysRegistrationAction + | HaapiWebAuthnAnyDeviceRegistrationAction; export interface HaapiWebAuthnRegistrationClientOperationModel extends HaapiBaseClientOperationModel { name: HAAPI_ACTION_CLIENT_OPERATIONS.WEBAUTHN_REGISTRATION; @@ -202,17 +199,17 @@ export interface HaapiWebAuthnRegistrationClientOperationModel extends HaapiBase continueActions: [HaapiFormAction]; } -export type HaapiWebAuthnPasskeysRegistrationAction = Omit & { +export interface HaapiWebAuthnPasskeysRegistrationAction extends HaapiClientOperationAction { model: Omit & { arguments: HaapiWebAuthnPasskeysArgs; }; -}; +} -export type HaapiWebAuthnAnyDeviceRegistrationAction = Omit & { +export interface HaapiWebAuthnAnyDeviceRegistrationAction extends HaapiClientOperationAction { model: Omit & { arguments: HaapiWebAuthnAnyDeviceArgs; }; -}; +} /** * Discriminated union of `webauthn-registration` action arguments. @@ -228,10 +225,15 @@ export interface HaapiWebAuthnPasskeysArgs { credentialCreationOptions: HaapiPublicKeyCredentialCreationOptions; } -export interface HaapiWebAuthnAnyDeviceArgs { - platformCredentialCreationOptions?: HaapiPublicKeyCredentialCreationOptions; - crossPlatformCredentialCreationOptions?: HaapiPublicKeyCredentialCreationOptions; -} +export type HaapiWebAuthnAnyDeviceArgs = + | { + platformCredentialCreationOptions: HaapiPublicKeyCredentialCreationOptions; + crossPlatformCredentialCreationOptions?: HaapiPublicKeyCredentialCreationOptions; + } + | { + platformCredentialCreationOptions?: undefined; + crossPlatformCredentialCreationOptions: HaapiPublicKeyCredentialCreationOptions; + }; /** * Continue-action payload key for the `webauthn-registration` operation. The value matches the diff --git a/src/login-web-app/src/haapi-stepper/data-access/types/haapi-step.types.ts b/src/login-web-app/src/haapi-stepper/data-access/types/haapi-step.types.ts index 5fa2e30..d72a0ec 100644 --- a/src/login-web-app/src/haapi-stepper/data-access/types/haapi-step.types.ts +++ b/src/login-web-app/src/haapi-stepper/data-access/types/haapi-step.types.ts @@ -351,4 +351,26 @@ export interface HaapiMetadata { templateArea?: string; /** The name for the view that produced the response */ viewName?: string; + /** Categorised view-specific data */ + viewData?: HaapiMetadataViewData; +} + +export interface HaapiMetadataViewData { + error?: HaapiMetadataViewDataError; +} + +export interface HaapiMetadataViewDataError { + clientOperation?: HaapiClientOperationErrorMessages; +} +export interface HaapiClientOperationErrorMessages { + webauthn?: HaapiWebAuthnErrorMessages; +} + +export interface HaapiWebAuthnErrorMessages { + /** Cancel or timeout — shared between registration and authentication ceremonies. */ + cancelOrTimeout?: string; + /** Registration ceremony failed for any reason other than cancel/timeout. */ + registration?: string; + /** Authentication ceremony failed for any reason other than cancel/timeout. */ + authentication?: string; } diff --git a/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/client-operations.ts b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/client-operations.ts index 5821d77..19ba00e 100644 --- a/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/client-operations.ts +++ b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/client-operations.ts @@ -14,10 +14,15 @@ import { HAAPI_ACTION_TYPES, HaapiAction, } from '../../../../data-access/types/haapi-action.types'; -import { HaapiLink } from '../../../../data-access/types/haapi-step.types'; +import { + HAAPI_PROBLEM_STEPS, + HaapiLink, + HaapiStep, + HaapiUserMessage, +} from '../../../../data-access/types/haapi-step.types'; import { RefObject } from 'react'; -import { HaapiStepperAction, HaapiStepperLink } from '../../../stepper/haapi-stepper.types'; -import { HaapiFetchFormAction } from '../../../../data-access/types/haapi-fetch.types'; +import { HaapiStepperAction, HaapiStepperError, HaapiStepperLink } from '../../../stepper/haapi-stepper.types'; +import { formatErrorStepData } from '../../../stepper/data-formatters/problem-step'; import { isBankIdClientOperation, runBankIdAuthentication } from './bankid'; import { isExternalBrowserFlowClientOperation, runExternalBrowserFlow } from './external-browser-flow'; import { @@ -26,6 +31,7 @@ import { runWebAuthnAuthentication, runWebAuthnRegistration, } from './webauthn'; +import { ClientOperationResult } from './typings'; export function isClientOperation( action: HaapiAction | HaapiStepperAction | HaapiLink | HaapiStepperLink @@ -33,45 +39,54 @@ export function isClientOperation( return 'template' in action && action.template === HAAPI_ACTION_TYPES.CLIENT_OPERATION; } -/** - * Performs a client operation, returning a continuation action and values if further action is required, or null if - * no further action is required or if the operation was aborted. - */ export async function performClientOperation( action: HaapiClientOperationAction, - pendingOperation: RefObject -): Promise { + pendingOperation: RefObject, + currentStep: HaapiStep | null +): Promise { const abortController = new AbortController(); pendingOperation.current = abortController; + const signal = abortController.signal; - try { - if (isExternalBrowserFlowClientOperation(action)) { - return await runExternalBrowserFlow(action, 2500, abortController.signal); - } - - if (isWebAuthnRegistrationClientOperation(action)) { - return await runWebAuthnRegistration(action, abortController.signal); - } + if (isWebAuthnRegistrationClientOperation(action)) { + return runWebAuthnRegistration(action, signal, currentStep); + } - if (isWebAuthnAuthenticationClientOperation(action)) { - return await runWebAuthnAuthentication(action, abortController.signal); - } + if (isWebAuthnAuthenticationClientOperation(action)) { + return runWebAuthnAuthentication(action, signal, currentStep); + } - if (isBankIdClientOperation(action)) { - return await runBankIdAuthentication(action); - } - } catch (err) { - /** - * If the operation was aborted by the caller, convert to null - i.e. no further action - instead of error - * Note that the cancellation is triggered by code on this file and a 'reason' is not provided, so we can rely on - * the error being the default AbortError. - */ - if (abortController.signal.aborted && err instanceof DOMException && err.name === 'AbortError') { - return null; - } + if (isExternalBrowserFlowClientOperation(action)) { + return runExternalBrowserFlow(action, 2500, signal).then(clientOperationData => ({ clientOperationData })); + } - throw err; + if (isBankIdClientOperation(action)) { + return runBankIdAuthentication(action).then(clientOperationData => ({ clientOperationData })); } throw new Error(`Unsupported client operation: ${action.model.name}`); } + +/** + * Synthesises a {@link HaapiStepperError} for a client-operation failure (IS-11327). + * + * Client-operation failures (WebAuthn ceremony cancel / timeout / parse error / unsupported + * API today; BankID / EBF on the same pattern when their per-operation error handling lands) + * happen on the client and aren't part of the HAAPI response, so the stepper has no native + * category for them. We treat them as `AppError`-class problems of the current step — building + * a `HaapiUnexpectedProblemStep` via {@link formatErrorStepData} — so they surface via + * `useHaapiStepper().error.app` like any server-driven problem and consumers handle them + * through the same channel (e.g. `HaapiStepperErrorNotifier`). + * + * Callers resolve the user-facing copy themselves (typically from + * `step.metadata.viewData.error.clientOperation.`) and pass the resolved string + * here. Empty/undefined → synthesised step has no `messages` and consumers fall back to + * whatever copy they choose. + */ +export function getHaapiStepperError(messageText: string | undefined): HaapiStepperError { + const messages: HaapiUserMessage[] = messageText ? [{ text: messageText }] : []; + return formatErrorStepData({ + type: HAAPI_PROBLEM_STEPS.UNEXPECTED, + messages, + }); +} diff --git a/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/typings.ts b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/typings.ts new file mode 100644 index 0000000..2f30779 --- /dev/null +++ b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/typings.ts @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2025 Curity AB. All rights reserved. + * + * The contents of this file are the property of Curity AB. + * You may not copy or use this file, in either source code + * or executable form, except in compliance with terms + * set by Curity AB. + * + * For further information, please contact Curity AB. + */ + +import { HaapiFetchFormAction } from '../../../../data-access'; +import { HaapiStepperError } from '../../../stepper/haapi-stepper.types'; + +/** + * Discriminated-union return shape shared by all client-operation runners (WebAuthn, + * external-browser-flow, BankID — as each ports onto this pattern per IS-11327). + * + * Runners always resolve. Success carries the continuation form action + payload; failure + * carries a synthesised {@link HaapiStepperError} which `performClientOperation` forwards to the + * stepper, which routes it through `setError` → `useHaapiStepper().error.app`. Programming + * bugs / unexpected runtime errors are not represented here — those still throw and escape to + * the React error boundary. + */ +export type ClientOperationResult = + | { clientOperationData: HaapiFetchFormAction; clientOperationError?: undefined } + | { clientOperationData?: undefined; clientOperationError: HaapiStepperError }; diff --git a/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/webauthn/typings.ts b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/webauthn/typings.ts new file mode 100644 index 0000000..cf468ef --- /dev/null +++ b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/webauthn/typings.ts @@ -0,0 +1,9 @@ +export enum WEBAUTHN_ERROR_TYPE { + CANCEL_OR_TIMEOUT = 'cancelOrTimeout', + FAILED = 'failed', +} + +export enum WEBAUTHN_OPERATION { + REGISTRATION = 'registration', + AUTHENTICATION = 'authentication', +} diff --git a/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/webauthn/webauthn.spec.ts b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/webauthn/webauthn.spec.ts index b25a39a..ab0dd74 100644 --- a/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/webauthn/webauthn.spec.ts +++ b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/webauthn/webauthn.spec.ts @@ -11,6 +11,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { runWebAuthnAuthentication, runWebAuthnRegistration } from './webauthn'; +import { + HAAPI_PROBLEM_STEPS, + HAAPI_STEPS, + HaapiAuthenticationStep, + HaapiMetadata, + HaapiStep, +} from '../../../../../data-access/types/haapi-step.types'; import { createMockWebAuthnAuthenticationAction, createMockWebAuthnCrossPlatformOnlyAnyDeviceAction, @@ -20,9 +27,14 @@ import { describe('webauthn', () => { const abortSignal = new AbortController().signal; + const stepWithoutMetadata: HaapiStep | null = null; beforeEach(() => { vi.clearAllMocks(); + mockParseCreationOptionsFromJSON.mockReset(); + mockParseRequestOptionsFromJSON.mockReset(); + mockCredentialsCreate.mockReset(); + mockCredentialsGet.mockReset(); vi.stubGlobal('PublicKeyCredential', stubPublicKeyCredential()); installNavigatorCredentials(); }); @@ -33,108 +45,200 @@ describe('webauthn', () => { }); describe('runWebAuthnRegistration', () => { - it('throws when WebAuthn API is not supported', async () => { - vi.unstubAllGlobals(); - const action = createMockWebAuthnRegistrationAction(); - - await expect(runWebAuthnRegistration(action, abortSignal)).rejects.toMatchObject({ - message: 'WebAuthn API is not supported in this browser', + describe('success', () => { + describe('passkey', () => { + it('parses credentialCreationOptions, creates a credential, and returns a continuation under "credential"', async () => { + const parsedOptions = { challenge: 'parsed' }; + const credentialJSON = { id: 'passkey-cred', type: 'public-key' }; + + mockParseCreationOptionsFromJSON.mockReturnValue(parsedOptions); + mockCredentialsCreate.mockResolvedValue(mockCredential(credentialJSON)); + + const action = createMockWebAuthnRegistrationAction(); + const result = await runWebAuthnRegistration(action, abortSignal, stepWithoutMetadata); + + expect(mockParseCreationOptionsFromJSON).toHaveBeenCalledWith( + action.model.arguments.credentialCreationOptions.publicKey + ); + expect(mockCredentialsCreate).toHaveBeenCalledWith({ publicKey: parsedOptions, signal: abortSignal }); + expect(result).toEqual({ + clientOperationData: { + action: action.model.continueActions[0], + payload: { credential: credentialJSON }, + }, + }); + }); }); - }); - it('throws when navigator.credentials.create returns null', async () => { - mockCredentialsCreate.mockResolvedValue(null); - const action = createMockWebAuthnRegistrationAction(); + describe('any-device', () => { + it('platform-only: parses platformCredentialCreationOptions, creates a credential, and returns a continuation under "platformCredential"', async () => { + const parsedOptions = { challenge: 'platform' }; + const credentialJSON = { id: 'platform-cred', type: 'public-key' }; + + mockParseCreationOptionsFromJSON.mockReturnValue(parsedOptions); + mockCredentialsCreate.mockResolvedValue(mockCredential(credentialJSON)); + + const action = createMockWebAuthnPlatformOnlyAnyDeviceAction(); + const result = await runWebAuthnRegistration(action, abortSignal, stepWithoutMetadata); + + expect(mockParseCreationOptionsFromJSON).toHaveBeenCalledWith( + action.model.arguments.platformCredentialCreationOptions?.publicKey + ); + expect(mockCredentialsCreate).toHaveBeenCalledWith({ publicKey: parsedOptions, signal: abortSignal }); + expect(result).toEqual({ + clientOperationData: { + action: action.model.continueActions[0], + payload: { platformCredential: credentialJSON }, + }, + }); + }); - await expect(runWebAuthnRegistration(action, abortSignal)).rejects.toMatchObject({ - message: 'Could not create credential', + it('cross-platform-only: parses crossPlatformCredentialCreationOptions, creates a credential, and returns a continuation under "crossPlatformCredential"', async () => { + const parsedOptions = { challenge: 'cross-platform' }; + const credentialJSON = { id: 'cross-platform-cred', type: 'public-key' }; + + mockParseCreationOptionsFromJSON.mockReturnValue(parsedOptions); + mockCredentialsCreate.mockResolvedValue(mockCredential(credentialJSON)); + + const action = createMockWebAuthnCrossPlatformOnlyAnyDeviceAction(); + const result = await runWebAuthnRegistration(action, abortSignal, stepWithoutMetadata); + + expect(mockParseCreationOptionsFromJSON).toHaveBeenCalledWith( + action.model.arguments.crossPlatformCredentialCreationOptions?.publicKey + ); + expect(mockCredentialsCreate).toHaveBeenCalledWith({ publicKey: parsedOptions, signal: abortSignal }); + expect(result).toEqual({ + clientOperationData: { + action: action.model.continueActions[0], + payload: { crossPlatformCredential: credentialJSON }, + }, + }); + }); }); }); - describe('passkey', () => { - it('parses credentialCreationOptions, creates a credential, and returns a continuation under "credential"', async () => { - const parsedOptions = { challenge: 'parsed' }; - const credentialJSON = { id: 'passkey-cred', type: 'public-key' }; - - mockParseCreationOptionsFromJSON.mockReturnValue(parsedOptions); - mockCredentialsCreate.mockResolvedValue(mockCredential(credentialJSON)); - - const action = createMockWebAuthnRegistrationAction(); - const result = await runWebAuthnRegistration(action, abortSignal); + describe('error', () => { + const cancelStep = makeStepWithMetadata({ + viewData: { error: { clientOperation: { webauthn: { cancelOrTimeout: 'You cancelled the registration.' } } } }, + }); + const failedStep = makeStepWithMetadata({ + viewData: { error: { clientOperation: { webauthn: { registration: 'Registration failed.' } } } }, + }); - expect(mockParseCreationOptionsFromJSON).toHaveBeenCalledWith( - action.model.arguments.credentialCreationOptions.publicKey - ); - expect(mockCredentialsCreate).toHaveBeenCalledWith({ publicKey: parsedOptions, signal: abortSignal }); - expect(result).toEqual({ - action: action.model.continueActions[0], - payload: { credential: credentialJSON }, + it('WebAuthn API not supported → registrationError copy', async () => { + vi.unstubAllGlobals(); + + await expect( + runWebAuthnRegistration(createMockWebAuthnRegistrationAction(), abortSignal, failedStep) + ).resolves.toMatchObject({ + clientOperationError: { + app: { + type: HAAPI_PROBLEM_STEPS.UNEXPECTED, + messages: [{ text: failedStep.metadata?.viewData?.error?.clientOperation?.webauthn?.registration }], + }, + }, }); }); - }); - - describe('any-device', () => { - it('platform-only: parses platformCredentialCreationOptions, creates a credential, and returns a continuation under "platformCredential"', async () => { - const parsedOptions = { challenge: 'platform' }; - const credentialJSON = { id: 'platform-cred', type: 'public-key' }; - mockParseCreationOptionsFromJSON.mockReturnValue(parsedOptions); - mockCredentialsCreate.mockResolvedValue(mockCredential(credentialJSON)); - - const action = createMockWebAuthnPlatformOnlyAnyDeviceAction(); - const result = await runWebAuthnRegistration(action, abortSignal); - - expect(mockParseCreationOptionsFromJSON).toHaveBeenCalledWith( - action.model.arguments.platformCredentialCreationOptions?.publicKey - ); - expect(mockCredentialsCreate).toHaveBeenCalledWith({ publicKey: parsedOptions, signal: abortSignal }); - expect(result).toEqual({ - action: action.model.continueActions[0], - payload: { platformCredential: credentialJSON }, + it('navigator.credentials.create returns null → cancelOrTimeoutError copy', async () => { + mockCredentialsCreate.mockResolvedValue(null); + + await expect( + runWebAuthnRegistration(createMockWebAuthnRegistrationAction(), abortSignal, cancelStep) + ).resolves.toMatchObject({ + clientOperationError: { + app: { + type: HAAPI_PROBLEM_STEPS.UNEXPECTED, + messages: [{ text: cancelStep.metadata?.viewData?.error?.clientOperation?.webauthn?.cancelOrTimeout }], + }, + }, }); }); - it('cross-platform-only: parses crossPlatformCredentialCreationOptions, creates a credential, and returns a continuation under "crossPlatformCredential"', async () => { - const parsedOptions = { challenge: 'cross-platform' }; - const credentialJSON = { id: 'cross-platform-cred', type: 'public-key' }; - - mockParseCreationOptionsFromJSON.mockReturnValue(parsedOptions); - mockCredentialsCreate.mockResolvedValue(mockCredential(credentialJSON)); + describe('parseCreationOptionsFromJSON throws', () => { + it.each(['EncodingError', 'SecurityError'] as const)( + '%s → registrationError copy (failed bucket)', + async errorName => { + mockParseCreationOptionsFromJSON.mockImplementation(() => { + throw new DOMException(`${errorName} message`, errorName); + }); + + await expect( + runWebAuthnRegistration(createMockWebAuthnRegistrationAction(), abortSignal, failedStep) + ).resolves.toMatchObject({ + clientOperationError: { + app: { + type: HAAPI_PROBLEM_STEPS.UNEXPECTED, + messages: [{ text: failedStep.metadata?.viewData?.error?.clientOperation?.webauthn?.registration }], + }, + }, + }); + } + ); + }); - const action = createMockWebAuthnCrossPlatformOnlyAnyDeviceAction(); - const result = await runWebAuthnRegistration(action, abortSignal); + describe('navigator.credentials.create throws', () => { + it.each([ + ['NotAllowedError', new DOMException('user cancelled', 'NotAllowedError')], + ['AbortError (non-caller-triggered, signal not aborted)', new DOMException('internal timeout', 'AbortError')], + ] as const)('%s → cancelOrTimeoutError copy', async (_label, error) => { + mockCredentialsCreate.mockRejectedValue(error); + + await expect( + runWebAuthnRegistration(createMockWebAuthnRegistrationAction(), abortSignal, cancelStep) + ).resolves.toMatchObject({ + clientOperationError: { + app: { + type: HAAPI_PROBLEM_STEPS.UNEXPECTED, + messages: [{ text: cancelStep.metadata?.viewData?.error?.clientOperation?.webauthn?.cancelOrTimeout }], + }, + }, + }); + }); - expect(mockParseCreationOptionsFromJSON).toHaveBeenCalledWith( - action.model.arguments.crossPlatformCredentialCreationOptions?.publicKey - ); - expect(mockCredentialsCreate).toHaveBeenCalledWith({ publicKey: parsedOptions, signal: abortSignal }); - expect(result).toEqual({ - action: action.model.continueActions[0], - payload: { crossPlatformCredential: credentialJSON }, + it.each([ + ['TypeError', new TypeError('bad options')], + ['arbitrary non-DOMException', new Error('something else')], + ] as const)('%s → registrationError copy (failed bucket)', async (_label, error) => { + mockCredentialsCreate.mockRejectedValue(error); + + await expect( + runWebAuthnRegistration(createMockWebAuthnRegistrationAction(), abortSignal, failedStep) + ).resolves.toMatchObject({ + clientOperationError: { + app: { + type: HAAPI_PROBLEM_STEPS.UNEXPECTED, + messages: [{ text: failedStep.metadata?.viewData?.error?.clientOperation?.webauthn?.registration }], + }, + }, + }); }); }); - }); - }); - describe('runWebAuthnAuthentication', () => { - it('throws when WebAuthn API is not supported', async () => { - vi.unstubAllGlobals(); - const action = createMockWebAuthnAuthenticationAction(); + it('falls back to no message when the matching metadata key is absent (BE has not emitted it yet)', async () => { + mockCredentialsCreate.mockResolvedValue(null); + const step = makeStepWithMetadata({ templateArea: 'lwa', viewName: 'unrelated' }); - await expect(runWebAuthnAuthentication(action, abortSignal)).rejects.toMatchObject({ - message: 'WebAuthn API is not supported in this browser', + await expect( + runWebAuthnRegistration(createMockWebAuthnRegistrationAction(), abortSignal, step) + ).resolves.toMatchObject({ + clientOperationError: { app: { type: HAAPI_PROBLEM_STEPS.UNEXPECTED, messages: [] } }, + }); }); - }); - it('throws when navigator.credentials.get returns null', async () => { - mockCredentialsGet.mockResolvedValue(null); - const action = createMockWebAuthnAuthenticationAction(); + it('falls back to no message when currentStep is null', async () => { + mockCredentialsCreate.mockResolvedValue(null); - await expect(runWebAuthnAuthentication(action, abortSignal)).rejects.toMatchObject({ - message: 'Could not get credential', + await expect( + runWebAuthnRegistration(createMockWebAuthnRegistrationAction(), abortSignal, null) + ).resolves.toMatchObject({ + clientOperationError: { app: { type: HAAPI_PROBLEM_STEPS.UNEXPECTED, messages: [] } }, + }); }); }); + }); + describe('runWebAuthnAuthentication', () => { it('parses credentialRequestOptions, gets a credential, and returns a continuation under "credential"', async () => { const parsedOptions = { challenge: 'auth' }; const credentialJSON = { id: 'auth-cred', type: 'public-key' }; @@ -143,15 +247,108 @@ describe('webauthn', () => { mockCredentialsGet.mockResolvedValue(mockCredential(credentialJSON)); const action = createMockWebAuthnAuthenticationAction(); - const result = await runWebAuthnAuthentication(action, abortSignal); + const result = await runWebAuthnAuthentication(action, abortSignal, stepWithoutMetadata); expect(mockParseRequestOptionsFromJSON).toHaveBeenCalledWith( action.model.arguments.credentialRequestOptions.publicKey ); expect(mockCredentialsGet).toHaveBeenCalledWith({ publicKey: parsedOptions, signal: abortSignal }); expect(result).toEqual({ - action: action.model.continueActions[0], - payload: { credential: credentialJSON }, + clientOperationData: { + action: action.model.continueActions[0], + payload: { credential: credentialJSON }, + }, + }); + }); + + describe('error', () => { + const cancelStep = makeStepWithMetadata({ + viewData: { error: { clientOperation: { webauthn: { cancelOrTimeout: 'You cancelled the sign-in.' } } } }, + }); + const failedStep = makeStepWithMetadata({ + viewData: { error: { clientOperation: { webauthn: { authentication: 'Authentication failed.' } } } }, + }); + + it('WebAuthn API not supported → authenticationError copy (failed bucket)', async () => { + vi.unstubAllGlobals(); + + await expect( + runWebAuthnAuthentication(createMockWebAuthnAuthenticationAction(), abortSignal, failedStep) + ).resolves.toMatchObject({ + clientOperationError: { + app: { + type: HAAPI_PROBLEM_STEPS.UNEXPECTED, + messages: [{ text: failedStep.metadata?.viewData?.error?.clientOperation?.webauthn?.authentication }], + }, + }, + }); + }); + + it('navigator.credentials.get returns null → cancelOrTimeoutError copy', async () => { + mockCredentialsGet.mockResolvedValue(null); + + await expect( + runWebAuthnAuthentication(createMockWebAuthnAuthenticationAction(), abortSignal, cancelStep) + ).resolves.toMatchObject({ + clientOperationError: { + app: { + type: HAAPI_PROBLEM_STEPS.UNEXPECTED, + messages: [{ text: cancelStep.metadata?.viewData?.error?.clientOperation?.webauthn?.cancelOrTimeout }], + }, + }, + }); + }); + + it('parseRequestOptionsFromJSON throws SecurityError → authenticationError copy', async () => { + mockParseRequestOptionsFromJSON.mockImplementation(() => { + throw new DOMException('rp id mismatch', 'SecurityError'); + }); + + await expect( + runWebAuthnAuthentication(createMockWebAuthnAuthenticationAction(), abortSignal, failedStep) + ).resolves.toMatchObject({ + clientOperationError: { + app: { + type: HAAPI_PROBLEM_STEPS.UNEXPECTED, + messages: [{ text: failedStep.metadata?.viewData?.error?.clientOperation?.webauthn?.authentication }], + }, + }, + }); + }); + + describe('navigator.credentials.get throws', () => { + it.each(['NotAllowedError', 'AbortError'])('%s → cancelOrTimeoutError copy', async errorName => { + mockCredentialsGet.mockRejectedValue(new DOMException(`${errorName} message`, errorName)); + + await expect( + runWebAuthnAuthentication(createMockWebAuthnAuthenticationAction(), abortSignal, cancelStep) + ).resolves.toMatchObject({ + clientOperationError: { + app: { + type: HAAPI_PROBLEM_STEPS.UNEXPECTED, + messages: [{ text: cancelStep.metadata?.viewData?.error?.clientOperation?.webauthn?.cancelOrTimeout }], + }, + }, + }); + }); + + it.each(['TimeoutError', 'NetworkError', 'IdentityCredentialError', 'SecurityError'])( + '%s → authenticationError copy (failed bucket)', + async errorName => { + mockCredentialsGet.mockRejectedValue(new DOMException(`${errorName} message`, errorName)); + + await expect( + runWebAuthnAuthentication(createMockWebAuthnAuthenticationAction(), abortSignal, failedStep) + ).resolves.toMatchObject({ + clientOperationError: { + app: { + type: HAAPI_PROBLEM_STEPS.UNEXPECTED, + messages: [{ text: failedStep.metadata?.viewData?.error?.clientOperation?.webauthn?.authentication }], + }, + }, + }); + } + ); }); }); }); @@ -182,3 +379,12 @@ const restoreNavigatorCredentials = () => { const mockCredential = (toJSONResult: unknown = { id: 'cred-id', type: 'public-key' }) => ({ toJSON: vi.fn(() => toJSONResult), }); + +function makeStepWithMetadata(metadata: HaapiMetadata = {}): HaapiStep { + const step: HaapiAuthenticationStep = { + type: HAAPI_STEPS.AUTHENTICATION, + actions: [], + metadata, + }; + return step; +} diff --git a/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/webauthn/webauthn.ts b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/webauthn/webauthn.ts index 1706e9f..b7054fb 100644 --- a/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/webauthn/webauthn.ts +++ b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/webauthn/webauthn.ts @@ -14,94 +14,158 @@ import { HaapiWebAuthnAuthenticationClientOperationAction, HaapiWebAuthnRegistrationClientOperationAction, } from '../../../../../data-access/types/haapi-action.types'; -import { HaapiFetchFormAction } from '../../../../../data-access/types/haapi-fetch.types'; -import { - isAnyDeviceWebAuthnRegistrationAction, - isPasskeysWebAuthnRegistrationAction, - isWebAuthnApiSupported, -} from './utils'; +import { HaapiStep } from '../../../../../data-access/types/haapi-step.types'; +import type { HaapiStepperError } from '../../../../stepper/haapi-stepper.types'; +import { isPasskeysWebAuthnRegistrationAction, isWebAuthnApiSupported } from './utils'; +import { WEBAUTHN_ERROR_TYPE, WEBAUTHN_OPERATION } from './typings'; +import { ClientOperationResult } from '../typings'; +import { getHaapiStepperError } from '../client-operations'; -const WEBAUTHN_API_NOT_SUPPORTED_ERROR_MESSAGE = 'WebAuthn API is not supported in this browser'; +type WebAuthnCredentialResult = + | { credential: PublicKeyCredential; error?: undefined } + | { credential?: undefined; error: HaapiStepperError }; -/** - * Executes the `webauthn-registration` ceremony: prompts the browser for a new public-key - * credential and returns the HAAPI continue-action with the credential serialised under the - * payload key matching the option the server offered (`credential` / `platformCredential` / - * `crossPlatformCredential` (`HAAPI_WEBAUTHN_REGISTRATION_SELECTED_OPTION`)). - */ export async function runWebAuthnRegistration( action: HaapiWebAuthnRegistrationClientOperationAction, - abortSignal: AbortSignal -): Promise { - if (!isWebAuthnApiSupported()) { - throw new Error(WEBAUTHN_API_NOT_SUPPORTED_ERROR_MESSAGE); - } - + abortSignal: AbortSignal, + currentStep: HaapiStep | null +): Promise { const selectedOption = getWebAuthnRegistrationSelectedOption(action); - const credential = await createWebAuthnRegistrationCredential(action, abortSignal); + const { credential, error } = await createWebAuthnRegistrationCredential(action, abortSignal, currentStep); + + if (error) { + return { clientOperationError: error }; + } return { - action: action.model.continueActions[0], - payload: { [selectedOption]: credential.toJSON() as unknown }, + clientOperationData: { + action: action.model.continueActions[0], + payload: { [selectedOption]: credential.toJSON() as unknown }, + }, }; } -/** - * Executes the `webauthn-authentication` ceremony: prompts the browser for an existing - * public-key credential and returns the HAAPI continue-action with the credential serialised - * under the `credential` payload key. - */ export async function runWebAuthnAuthentication( action: HaapiWebAuthnAuthenticationClientOperationAction, - abortSignal: AbortSignal -): Promise { - if (!isWebAuthnApiSupported()) { - throw new Error(WEBAUTHN_API_NOT_SUPPORTED_ERROR_MESSAGE); - } + abortSignal: AbortSignal, + currentStep: HaapiStep | null +): Promise { + const { credential, error } = await getWebAuthnAuthenticationCredential(action, abortSignal, currentStep); - const credential = await getWebAuthnAuthenticationCredential(action, abortSignal); + if (error) { + return { clientOperationError: error }; + } return { - action: action.model.continueActions[0], - payload: { credential: credential.toJSON() as unknown }, + clientOperationData: { + action: action.model.continueActions[0], + payload: { credential: credential.toJSON() as unknown }, + }, }; } async function createWebAuthnRegistrationCredential( action: HaapiWebAuthnRegistrationClientOperationAction, - abortSignal: AbortSignal -): Promise { - const creationOptions = getWebAuthnRegistrationCreationOptions(action); - const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(creationOptions); - const credential = (await navigator.credentials.create({ - publicKey, - signal: abortSignal, - })) as PublicKeyCredential | null; - - if (credential === null) { - throw new Error('Could not create credential'); + abortSignal: AbortSignal, + currentStep: HaapiStep | null +): Promise { + if (!isWebAuthnApiSupported()) { + return { + error: getHaapiStepperError( + getWebAuthnErrorMessage(WEBAUTHN_ERROR_TYPE.FAILED, WEBAUTHN_OPERATION.REGISTRATION, currentStep) + ), + }; } - return credential; + try { + const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(getWebAuthnRegistrationCreationOptions(action)); + const credential = (await navigator.credentials.create({ + publicKey, + signal: abortSignal, + })) as PublicKeyCredential | null; + + if (credential === null) { + return { + error: getHaapiStepperError( + getWebAuthnErrorMessage(WEBAUTHN_ERROR_TYPE.CANCEL_OR_TIMEOUT, WEBAUTHN_OPERATION.REGISTRATION, currentStep) + ), + }; + } + + return { credential }; + } catch (error) { + return { + error: getHaapiStepperError( + getWebAuthnErrorMessage(getWebAuthnErrorType(error), WEBAUTHN_OPERATION.REGISTRATION, currentStep) + ), + }; + } } async function getWebAuthnAuthenticationCredential( action: HaapiWebAuthnAuthenticationClientOperationAction, - abortSignal: AbortSignal -): Promise { - const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON( - action.model.arguments.credentialRequestOptions.publicKey - ); - const credential = (await navigator.credentials.get({ - publicKey, - signal: abortSignal, - })) as PublicKeyCredential | null; - - if (credential === null) { - throw new Error('Could not get credential'); + abortSignal: AbortSignal, + currentStep: HaapiStep | null +): Promise { + if (!isWebAuthnApiSupported()) { + return { + error: getHaapiStepperError( + getWebAuthnErrorMessage(WEBAUTHN_ERROR_TYPE.FAILED, WEBAUTHN_OPERATION.AUTHENTICATION, currentStep) + ), + }; } - return credential; + try { + const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON( + action.model.arguments.credentialRequestOptions.publicKey + ); + const credential = (await navigator.credentials.get({ + publicKey, + signal: abortSignal, + })) as PublicKeyCredential | null; + + if (credential === null) { + return { + error: getHaapiStepperError( + getWebAuthnErrorMessage(WEBAUTHN_ERROR_TYPE.CANCEL_OR_TIMEOUT, WEBAUTHN_OPERATION.AUTHENTICATION, currentStep) + ), + }; + } + + return { credential }; + } catch (error) { + return { + error: getHaapiStepperError( + getWebAuthnErrorMessage(getWebAuthnErrorType(error), WEBAUTHN_OPERATION.AUTHENTICATION, currentStep) + ), + }; + } +} + +/** + * Resolve the user-facing copy for a WebAuthn ceremony failure from the current step's + * `metadata.viewData.error.clientOperation.webauthn.`, picking the key per the two-tone + * discriminator (`cancelOrTimeout` / per-ceremony failure). Returns `undefined` when the key + * is absent so the synthesised error has no message and consumers fall back to their own copy. + */ +function getWebAuthnErrorMessage( + type: WEBAUTHN_ERROR_TYPE, + operation: WEBAUTHN_OPERATION, + currentStep: HaapiStep | null +): string | undefined { + const webauthnErrors = currentStep?.metadata?.viewData?.error?.clientOperation?.webauthn; + return type === WEBAUTHN_ERROR_TYPE.CANCEL_OR_TIMEOUT + ? webauthnErrors?.cancelOrTimeout + : operation === WEBAUTHN_OPERATION.REGISTRATION + ? webauthnErrors?.registration + : webauthnErrors?.authentication; +} + +function getWebAuthnErrorType(error: unknown): WEBAUTHN_ERROR_TYPE { + if (error instanceof DOMException && (error.name === 'NotAllowedError' || error.name === 'AbortError')) { + return WEBAUTHN_ERROR_TYPE.CANCEL_OR_TIMEOUT; + } + return WEBAUTHN_ERROR_TYPE.FAILED; } function getWebAuthnRegistrationSelectedOption( @@ -111,17 +175,12 @@ function getWebAuthnRegistrationSelectedOption( return HAAPI_WEBAUTHN_REGISTRATION_SELECTED_OPTION.CREDENTIAL; } - if (isAnyDeviceWebAuthnRegistrationAction(action)) { - const args = action.model.arguments; - if (args.platformCredentialCreationOptions) { - return HAAPI_WEBAUTHN_REGISTRATION_SELECTED_OPTION.PLATFORM_CREDENTIAL; - } - if (args.crossPlatformCredentialCreationOptions) { - return HAAPI_WEBAUTHN_REGISTRATION_SELECTED_OPTION.CROSS_PLATFORM_CREDENTIAL; - } + const anyDeviceWebAuthRegistrationArgs = action.model.arguments; + if (anyDeviceWebAuthRegistrationArgs.platformCredentialCreationOptions) { + return HAAPI_WEBAUTHN_REGISTRATION_SELECTED_OPTION.PLATFORM_CREDENTIAL; + } else { + return HAAPI_WEBAUTHN_REGISTRATION_SELECTED_OPTION.CROSS_PLATFORM_CREDENTIAL; } - - throw new Error('webauthn-registration action has no credential creation options'); } function getWebAuthnRegistrationCreationOptions( @@ -131,15 +190,10 @@ function getWebAuthnRegistrationCreationOptions( return action.model.arguments.credentialCreationOptions.publicKey; } - if (isAnyDeviceWebAuthnRegistrationAction(action)) { - const args = action.model.arguments; - if (args.platformCredentialCreationOptions) { - return args.platformCredentialCreationOptions.publicKey; - } - if (args.crossPlatformCredentialCreationOptions) { - return args.crossPlatformCredentialCreationOptions.publicKey; - } + const anyDeviceWebAuthRegistrationArgs = action.model.arguments; + if (anyDeviceWebAuthRegistrationArgs.platformCredentialCreationOptions) { + return anyDeviceWebAuthRegistrationArgs.platformCredentialCreationOptions.publicKey; + } else { + return anyDeviceWebAuthRegistrationArgs.crossPlatformCredentialCreationOptions.publicKey; } - - throw new Error('webauthn-registration action has no credential creation options'); } diff --git a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx index d738d7f..510e02f 100644 --- a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx @@ -42,6 +42,7 @@ import { createMockWebAuthnRegistrationAction, } from '../../util/tests/mocks'; import type { HaapiClientOperationAction } from '../../data-access/types/haapi-action.types'; +import { formatErrorStepData } from './data-formatters/problem-step'; describe('HaapiStepper', () => { const initialStepType = HAAPI_STEPS.AUTHENTICATION; @@ -386,10 +387,12 @@ describe('HaapiStepper', () => { }); describe('Authentication / Registration Step', () => { - describe('WebAuthn Auto-Start', () => { + describe('WebAuthn', () => { + const authorizationResponseUrl = completedWithSuccessStep.links?.find( + link => link.rel === 'authorization-response' + )?.href; + beforeEach(() => { - mockRunWebAuthnRegistration.mockResolvedValue(null); - mockRunWebAuthnAuthentication.mockResolvedValue(null); vi.stubGlobal('PublicKeyCredential', stubPublicKeyCredential()); }); @@ -398,78 +401,19 @@ describe('HaapiStepper', () => { Reflect.deleteProperty(navigator, 'userAgent'); }); - it('should not auto-start when WebAuthn API is not supported', async () => { - // Remove the PublicKeyCredential stub installed by beforeEach so isWebAuthnApiSupported() returns false. - vi.unstubAllGlobals(); - mockHaapiFetchWebAuthnStep(HAAPI_STEPS.REGISTRATION, createMockWebAuthnRegistrationAction()); - - render( - - - - ); - - const stepRendered = await screen.findByTestId('step-type'); - expect(stepRendered).toHaveTextContent(HAAPI_STEPS.REGISTRATION); - - expect(mockRunWebAuthnRegistration).not.toHaveBeenCalled(); - }); - - it('should not auto-start on a non-WebAuthn auth step (e.g. username/password form)', async () => { - render( - - - - ); - - const stepRendered = await screen.findByTestId('step-type'); - expect(stepRendered).toHaveTextContent(initialStepType); - - expect(mockRunWebAuthnRegistration).not.toHaveBeenCalled(); - expect(mockRunWebAuthnAuthentication).not.toHaveBeenCalled(); - }); - - it('should not auto-start on Safari (non-Chromium) — Safari blocks auto-initiated WebAuthn ceremonies', async () => { - stubUserAgent( - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15' - ); - mockHaapiFetchWebAuthnStep(HAAPI_STEPS.REGISTRATION, createMockWebAuthnRegistrationAction()); - - render( - - - - ); - - const stepRendered = await screen.findByTestId('step-type'); - expect(stepRendered).toHaveTextContent(HAAPI_STEPS.REGISTRATION); - - expect(mockRunWebAuthnRegistration).not.toHaveBeenCalled(); - }); - - it('should auto-start on Chromium browsers whose user agent also contains "Safari"', async () => { - stubUserAgent( - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' - ); - mockHaapiFetchWebAuthnStep(HAAPI_STEPS.REGISTRATION, createMockWebAuthnRegistrationAction()); - - render( - - - - ); - - await waitFor(() => { - expect(mockRunWebAuthnRegistration).toHaveBeenCalledTimes(1); + describe('Auto-Start', () => { + beforeEach(() => { + mockRunWebAuthnRegistration.mockResolvedValue(undefined); + mockRunWebAuthnAuthentication.mockResolvedValue(undefined); }); - }); - describe('config.webAuthnAutostart', () => { - it('should not auto-start a WebAuthn registration when config.webAuthnAutostart is false', async () => { + it('should not auto-start when WebAuthn API is not supported', async () => { + // Remove the PublicKeyCredential stub installed by beforeEach so isWebAuthnApiSupported() returns false. + vi.unstubAllGlobals(); mockHaapiFetchWebAuthnStep(HAAPI_STEPS.REGISTRATION, createMockWebAuthnRegistrationAction()); render( - + ); @@ -480,42 +424,43 @@ describe('HaapiStepper', () => { expect(mockRunWebAuthnRegistration).not.toHaveBeenCalled(); }); - it('should not auto-start a WebAuthn authentication when config.webAuthnAutostart is false', async () => { - mockHaapiFetchWebAuthnStep(HAAPI_STEPS.AUTHENTICATION, createMockWebAuthnAuthenticationAction()); - + it('should not auto-start on a non-WebAuthn auth step (e.g. username/password form)', async () => { render( - + ); const stepRendered = await screen.findByTestId('step-type'); - expect(stepRendered).toHaveTextContent(HAAPI_STEPS.AUTHENTICATION); + expect(stepRendered).toHaveTextContent(initialStepType); + expect(mockRunWebAuthnRegistration).not.toHaveBeenCalled(); expect(mockRunWebAuthnAuthentication).not.toHaveBeenCalled(); }); - it('should still run the WebAuthn ceremony on manual click when config.webAuthnAutostart is false', async () => { + it('should not auto-start on Safari (non-Chromium) — Safari blocks auto-initiated WebAuthn ceremonies', async () => { + stubUserAgent( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15' + ); mockHaapiFetchWebAuthnStep(HAAPI_STEPS.REGISTRATION, createMockWebAuthnRegistrationAction()); render( - + ); - const button = await screen.findByTestId('action-button'); - act(() => button.click()); + const stepRendered = await screen.findByTestId('step-type'); + expect(stepRendered).toHaveTextContent(HAAPI_STEPS.REGISTRATION); - await waitFor(() => { - expect(mockRunWebAuthnRegistration).toHaveBeenCalledTimes(1); - }); + expect(mockRunWebAuthnRegistration).not.toHaveBeenCalled(); }); - }); - describe('WebAuthn Authentication', () => { - it('should auto-start a single WebAuthn authentication action', async () => { - mockHaapiFetchWebAuthnStep(HAAPI_STEPS.AUTHENTICATION, createMockWebAuthnAuthenticationAction()); + it('should auto-start on Chromium browsers whose user agent also contains "Safari"', async () => { + stubUserAgent( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + ); + mockHaapiFetchWebAuthnStep(HAAPI_STEPS.REGISTRATION, createMockWebAuthnRegistrationAction()); render( @@ -524,36 +469,158 @@ describe('HaapiStepper', () => { ); await waitFor(() => { - expect(mockRunWebAuthnAuthentication).toHaveBeenCalledTimes(1); + expect(mockRunWebAuthnRegistration).toHaveBeenCalledTimes(1); }); - const action = firstCallAction(mockRunWebAuthnAuthentication); - expect(action.model.name).toBe(HAAPI_ACTION_CLIENT_OPERATIONS.WEBAUTHN_AUTHENTICATION); }); - }); - describe('WebAuthn Registration', () => { - describe('passkey', () => { - it('should auto-start a single passkeys WebAuthn registration', async () => { + describe('config.webAuthnAutostart', () => { + it('should not auto-start a WebAuthn registration when config.webAuthnAutostart is false', async () => { mockHaapiFetchWebAuthnStep(HAAPI_STEPS.REGISTRATION, createMockWebAuthnRegistrationAction()); render( - + ); + const stepRendered = await screen.findByTestId('step-type'); + expect(stepRendered).toHaveTextContent(HAAPI_STEPS.REGISTRATION); + + expect(mockRunWebAuthnRegistration).not.toHaveBeenCalled(); + }); + + it('should not auto-start a WebAuthn authentication when config.webAuthnAutostart is false', async () => { + mockHaapiFetchWebAuthnStep(HAAPI_STEPS.AUTHENTICATION, createMockWebAuthnAuthenticationAction()); + + render( + + + + ); + + const stepRendered = await screen.findByTestId('step-type'); + expect(stepRendered).toHaveTextContent(HAAPI_STEPS.AUTHENTICATION); + + expect(mockRunWebAuthnAuthentication).not.toHaveBeenCalled(); + }); + + it('should still run the WebAuthn ceremony on manual click when config.webAuthnAutostart is false', async () => { + mockHaapiFetchWebAuthnStep(HAAPI_STEPS.REGISTRATION, createMockWebAuthnRegistrationAction()); + + render( + + + + ); + + const button = await screen.findByTestId('action-button'); + act(() => button.click()); + await waitFor(() => { expect(mockRunWebAuthnRegistration).toHaveBeenCalledTimes(1); }); - const action = firstCallAction(mockRunWebAuthnRegistration); - expect(action.model.name).toBe(HAAPI_ACTION_CLIENT_OPERATIONS.WEBAUTHN_REGISTRATION); - expect(action.model.arguments).toHaveProperty('credentialCreationOptions'); }); }); - describe('any-device', () => { - it('should auto-start a single platform-only any-device registration when a platform authenticator is available', async () => { - mockHaapiFetchWebAuthnStep(HAAPI_STEPS.REGISTRATION, createMockWebAuthnPlatformOnlyAnyDeviceAction()); + describe('Registration', () => { + describe('passkey', () => { + it('should auto-start a single passkeys WebAuthn registration', async () => { + mockHaapiFetchWebAuthnStep(HAAPI_STEPS.REGISTRATION, createMockWebAuthnRegistrationAction()); + + render( + + + + ); + + await waitFor(() => { + expect(mockRunWebAuthnRegistration).toHaveBeenCalledTimes(1); + }); + const action = firstCallAction(mockRunWebAuthnRegistration); + expect(action.model.name).toBe(HAAPI_ACTION_CLIENT_OPERATIONS.WEBAUTHN_REGISTRATION); + expect(action.model.arguments).toHaveProperty('credentialCreationOptions'); + }); + }); + + describe('any-device', () => { + it('should auto-start a single platform-only any-device registration when a platform authenticator is available', async () => { + mockHaapiFetchWebAuthnStep(HAAPI_STEPS.REGISTRATION, createMockWebAuthnPlatformOnlyAnyDeviceAction()); + + render( + + + + ); + + await waitFor(() => { + expect(mockRunWebAuthnRegistration).toHaveBeenCalledTimes(1); + }); + const action = firstCallAction(mockRunWebAuthnRegistration); + expect(action.model.name).toBe(HAAPI_ACTION_CLIENT_OPERATIONS.WEBAUTHN_REGISTRATION); + expect(action.model.arguments).toHaveProperty('platformCredentialCreationOptions'); + expect(action.model.arguments).not.toHaveProperty('crossPlatformCredentialCreationOptions'); + }); + + it('should auto-start a single cross-platform-only any-device registration', async () => { + mockHaapiFetchWebAuthnStep( + HAAPI_STEPS.REGISTRATION, + createMockWebAuthnCrossPlatformOnlyAnyDeviceAction() + ); + + render( + + + + ); + + await waitFor(() => { + expect(mockRunWebAuthnRegistration).toHaveBeenCalledTimes(1); + }); + const action = firstCallAction(mockRunWebAuthnRegistration); + expect(action.model.name).toBe(HAAPI_ACTION_CLIENT_OPERATIONS.WEBAUTHN_REGISTRATION); + expect(action.model.arguments).toHaveProperty('crossPlatformCredentialCreationOptions'); + expect(action.model.arguments).not.toHaveProperty('platformCredentialCreationOptions'); + }); + + it('should not auto-start platform-only any-device when no platform authenticator is available', async () => { + vi.stubGlobal( + 'PublicKeyCredential', + stubPublicKeyCredential({ platformAuthenticatorAvailable: false }) + ); + mockHaapiFetchWebAuthnStep(HAAPI_STEPS.REGISTRATION, createMockWebAuthnPlatformOnlyAnyDeviceAction()); + + render( + + + + ); + + const stepRendered = await screen.findByTestId('step-type'); + expect(stepRendered).toHaveTextContent(HAAPI_STEPS.REGISTRATION); + + expect(mockRunWebAuthnRegistration).not.toHaveBeenCalled(); + }); + + it('should not auto-start when an any-device step has both platform and cross-platform actions', async () => { + mockHaapiFetchWebAuthnStep(HAAPI_STEPS.REGISTRATION, createMockWebAuthnAnyDeviceBothOptionsAction()); + + render( + + + + ); + + const stepRendered = await screen.findByTestId('step-type'); + expect(stepRendered).toHaveTextContent(HAAPI_STEPS.REGISTRATION); + + expect(mockRunWebAuthnRegistration).not.toHaveBeenCalled(); + }); + }); + }); + + describe('Authentication', () => { + it('should auto-start a single WebAuthn authentication action', async () => { + mockHaapiFetchWebAuthnStep(HAAPI_STEPS.AUTHENTICATION, createMockWebAuthnAuthenticationAction()); render( @@ -562,64 +629,172 @@ describe('HaapiStepper', () => { ); await waitFor(() => { - expect(mockRunWebAuthnRegistration).toHaveBeenCalledTimes(1); + expect(mockRunWebAuthnAuthentication).toHaveBeenCalledTimes(1); }); - const action = firstCallAction(mockRunWebAuthnRegistration); - expect(action.model.name).toBe(HAAPI_ACTION_CLIENT_OPERATIONS.WEBAUTHN_REGISTRATION); - expect(action.model.arguments).toHaveProperty('platformCredentialCreationOptions'); - expect(action.model.arguments).not.toHaveProperty('crossPlatformCredentialCreationOptions'); + const action = firstCallAction(mockRunWebAuthnAuthentication); + expect(action.model.name).toBe(HAAPI_ACTION_CLIENT_OPERATIONS.WEBAUTHN_AUTHENTICATION); + }); + }); + }); + + describe('Registration', () => { + it('success: continuation routes through processHaapiNextStep and completes by redirecting to the client', async () => { + const action = createMockWebAuthnRegistrationAction(); + mockHaapiFetchWebAuthnStep(HAAPI_STEPS.REGISTRATION, action); + mockRunWebAuthnRegistration.mockResolvedValueOnce({ + clientOperationData: { + action: action.model.continueActions[0], + payload: { credential: { id: 'cred-id', type: 'public-key' } }, + }, }); + mockHaapiFetchStep(HAAPI_STEPS.COMPLETED_WITH_SUCCESS); - it('should auto-start a single cross-platform-only any-device registration', async () => { - mockHaapiFetchWebAuthnStep( - HAAPI_STEPS.REGISTRATION, - createMockWebAuthnCrossPlatformOnlyAnyDeviceAction() + render( + + + + ); + + const button = await screen.findByTestId('action-button'); + act(() => button.click()); + + await waitFor(() => { + expect(window.location.href).toBe(authorizationResponseUrl); + }); + }); + + describe('error', () => { + beforeEach(() => { + mockHaapiFetchWebAuthnStep(HAAPI_STEPS.REGISTRATION, createMockWebAuthnRegistrationAction()); + mockRunWebAuthnRegistration.mockResolvedValue({ clientOperationError: failedWebAuthnCeremonyError() }); + }); + + it('provides the error via error.app', async () => { + render( + + + ); + const button = await screen.findByTestId('action-button'); + act(() => button.click()); + + await screen.findByTestId('error-app'); + }); + + it('does not throw to the React error boundary', async () => { render( - + + + + ); + + const button = await screen.findByTestId('action-button'); + act(() => button.click()); + + await screen.findByTestId('error-app'); + expect(mockThrowErrorToAppErrorBoundary).not.toHaveBeenCalled(); + }); + + it('allows the user to retry — the ceremony replays', async () => { + render( + ); + const button = await screen.findByTestId('action-button'); + + act(() => button.click()); await waitFor(() => { expect(mockRunWebAuthnRegistration).toHaveBeenCalledTimes(1); }); - const action = firstCallAction(mockRunWebAuthnRegistration); - expect(action.model.name).toBe(HAAPI_ACTION_CLIENT_OPERATIONS.WEBAUTHN_REGISTRATION); - expect(action.model.arguments).toHaveProperty('crossPlatformCredentialCreationOptions'); - expect(action.model.arguments).not.toHaveProperty('platformCredentialCreationOptions'); + + act(() => button.click()); + await waitFor(() => { + expect(mockRunWebAuthnRegistration).toHaveBeenCalledTimes(2); + }); }); + }); + }); - it('should not auto-start platform-only any-device when no platform authenticator is available', async () => { - vi.stubGlobal('PublicKeyCredential', stubPublicKeyCredential({ platformAuthenticatorAvailable: false })); - mockHaapiFetchWebAuthnStep(HAAPI_STEPS.REGISTRATION, createMockWebAuthnPlatformOnlyAnyDeviceAction()); + describe('Authentication', () => { + it('success: continuation routes through processHaapiNextStep and completes by redirecting to the client', async () => { + const action = createMockWebAuthnAuthenticationAction(); + mockHaapiFetchWebAuthnStep(HAAPI_STEPS.AUTHENTICATION, action); + mockRunWebAuthnAuthentication.mockResolvedValueOnce({ + clientOperationData: { + action: action.model.continueActions[0], + payload: { credential: { id: 'cred-id', type: 'public-key' } }, + }, + }); + mockHaapiFetchStep(HAAPI_STEPS.COMPLETED_WITH_SUCCESS); + render( + + + + ); + + const button = await screen.findByTestId('action-button'); + act(() => button.click()); + + await waitFor(() => { + expect(window.location.href).toBe(authorizationResponseUrl); + }); + }); + + describe('error', () => { + beforeEach(() => { + mockHaapiFetchWebAuthnStep(HAAPI_STEPS.AUTHENTICATION, createMockWebAuthnAuthenticationAction()); + mockRunWebAuthnAuthentication.mockResolvedValue({ clientOperationError: failedWebAuthnCeremonyError() }); + }); + + it('provides the error via error.app', async () => { render( - + ); - const stepRendered = await screen.findByTestId('step-type'); - expect(stepRendered).toHaveTextContent(HAAPI_STEPS.REGISTRATION); + const button = await screen.findByTestId('action-button'); + act(() => button.click()); - expect(mockRunWebAuthnRegistration).not.toHaveBeenCalled(); + await screen.findByTestId('error-app'); }); - it('should not auto-start when an any-device step has both platform and cross-platform actions', async () => { - mockHaapiFetchWebAuthnStep(HAAPI_STEPS.REGISTRATION, createMockWebAuthnAnyDeviceBothOptionsAction()); + it('does not throw to the React error boundary', async () => { + render( + + + + ); + + const button = await screen.findByTestId('action-button'); + act(() => button.click()); + await screen.findByTestId('error-app'); + expect(mockThrowErrorToAppErrorBoundary).not.toHaveBeenCalled(); + }); + + it('allows the user to retry — the ceremony replays', async () => { render( - + ); - const stepRendered = await screen.findByTestId('step-type'); - expect(stepRendered).toHaveTextContent(HAAPI_STEPS.REGISTRATION); + const button = await screen.findByTestId('action-button'); - expect(mockRunWebAuthnRegistration).not.toHaveBeenCalled(); + act(() => button.click()); + await waitFor(() => { + expect(mockRunWebAuthnAuthentication).toHaveBeenCalledTimes(1); + }); + + act(() => button.click()); + await waitFor(() => { + expect(mockRunWebAuthnAuthentication).toHaveBeenCalledTimes(2); + }); }); }); }); @@ -1248,3 +1423,5 @@ function getTextContent(element: HTMLElement): string { function getHistoryData(element: HTMLElement): HaapiStepperHistoryEntry[] { return JSON.parse(getTextContent(element)) as HaapiStepperHistoryEntry[]; } + +const failedWebAuthnCeremonyError = () => formatErrorStepData({ type: HAAPI_PROBLEM_STEPS.UNEXPECTED, messages: [] }); diff --git a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.tsx b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.tsx index 54a597f..5ecbd88 100644 --- a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.tsx @@ -371,16 +371,20 @@ async function processHaapiNextStep( nextStepError?: HaapiStepperError; }> { if (isClientOperation(action)) { - const clientOperationResponse = await performClientOperation(action, pendingOperation); + const { clientOperationData, clientOperationError } = await performClientOperation( + action, + pendingOperation, + currentStep + ); - if (!clientOperationResponse) { - return {}; + if (clientOperationError) { + return { nextStepError: clientOperationError }; } return processHaapiNextStep( currentStep, - clientOperationResponse.action, - clientOperationResponse.payload, + clientOperationData.action, + clientOperationData.payload, pendingOperation, nextStep, config,