From 35b21bd8d7e5a61a771f5f562a14603ddf474993 Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Wed, 20 May 2026 14:46:13 +0200 Subject: [PATCH 1/5] IS-11327 LWA: WebAuthn ceremony errors as HAAPI step AppErrors Catch every WebAuthn ceremony failure in the runners and surface it as a synthesised HaapiUnexpectedProblemStep via the existing error.app pipeline (rather than escalating to the React error boundary as a programming bug). Two-bucket discriminator (cancelOrTimeout / failed) matching Velocity parity. Copy comes from step.metadata.messages.error.clientOperation.webauthn with empty-string fallback while the BE keys land. * Runner-level synthesis: runWebAuthn{Registration,Authentication} catch the full error catalog (parse DOMException, create/get DOMException, TypeError, null credential, unsupported API) and throw a synthesised HaapiStepperError built via formatErrorStepData(HaapiUnexpectedProblemStep). * Dispatcher wrapping: performClientOperation wraps each WebAuthn runner in a .then/.catch chain returning ClientOperationResult discriminated union. The catch discriminates HaapiStepperError vs raw rejections via a type guard; raw errors propagate to the React boundary as programming bugs. * Type hierarchy: HaapiWebAuthnRegistrationClientOperationAction is now a proper discriminated union (passkeys | any-device) so consumers narrow via positive + negative type guards without casts. HaapiWebAuthnAnyDeviceArgs is also a discriminated union (platform-required | crossPlatform-required) expressing the "at least one of two" HAAPI spec invariant. * Metadata surface: HaapiMetadata.messages.error.clientOperation.webauthn carries the per-ceremony copy (cancelOrTimeout shared, registration, authentication). * Auto-start parity: manageWebAuthnAutoStart stays fire-and-forget via nextStep(); ceremony failures surface the same way as manual click (matching Velocity's .catch(handleError) pattern). * Tests: 23 runner-level tests in webauthn.spec.ts cover the full catalog per ceremony; HaapiStepper.spec.tsx wiring tests consolidated under one WebAuthn suite (Auto-Start gating + Registration/Authentication success+error routing), dropping the duplicate auto-start error-surfacing cases since manual click + auto-start share the dispatcher plumbing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../data-access/types/haapi-action.types.ts | 30 +- .../data-access/types/haapi-step.types.ts | 24 + .../operations/client-operations.ts | 76 ++-- .../operations/webauthn/typings.ts | 16 + .../operations/webauthn/webauthn.spec.ts | 328 +++++++++++--- .../operations/webauthn/webauthn.ts | 175 ++++--- .../feature/stepper/HaapiStepper.spec.tsx | 427 ++++++++++++------ .../feature/stepper/HaapiStepper.tsx | 14 +- 8 files changed, 773 insertions(+), 317 deletions(-) create mode 100644 src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/webauthn/typings.ts 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 ef4eb3aa..74806312 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 5fa2e30f..fae4bad6 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,28 @@ export interface HaapiMetadata { templateArea?: string; /** The name for the view that produced the response */ viewName?: string; + /** + * Categorised user-facing messages + */ + messages?: HaapiMetadataMessages; +} + +export interface HaapiMetadataMessages { + error?: HaapiMetadataErrorMessages; +} + +export interface HaapiMetadataErrorMessages { + 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 5821d776..1aa44447 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,9 @@ import { HAAPI_ACTION_TYPES, HaapiAction, } from '../../../../data-access/types/haapi-action.types'; -import { HaapiLink } from '../../../../data-access/types/haapi-step.types'; +import { HaapiLink, HaapiStep } 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 { isBankIdClientOperation, runBankIdAuthentication } from './bankid'; import { isExternalBrowserFlowClientOperation, runExternalBrowserFlow } from './external-browser-flow'; import { @@ -26,6 +25,7 @@ import { runWebAuthnAuthentication, runWebAuthnRegistration, } from './webauthn'; +import { ClientOperationResult } from './webauthn/typings'; export function isClientOperation( action: HaapiAction | HaapiStepperAction | HaapiLink | HaapiStepperLink @@ -33,45 +33,55 @@ 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) + .then(clientOperationData => ({ clientOperationData })) + .catch(wrapStepperErrorOrRethrow); + } - if (isWebAuthnAuthenticationClientOperation(action)) { - return await runWebAuthnAuthentication(action, abortController.signal); - } + if (isWebAuthnAuthenticationClientOperation(action)) { + return runWebAuthnAuthentication(action, signal, currentStep) + .then(clientOperationData => ({ clientOperationData })) + .catch(wrapStepperErrorOrRethrow); + } - 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}`); } + +/** + * Catch-handler shared by the WebAuthn dispatcher branches. WebAuthn runners throw a + * synthesised {@link HaapiStepperError} on ceremony failure (IS-11327); anything else is a + * programming bug or an unexpected runtime error and should escape to the React error boundary + * rather than being misrouted into `error.app`. The type guard discriminates between the two. + */ +function isHaapiStepperError(value: unknown): value is HaapiStepperError { + return typeof value === 'object' && value !== null && ('app' in value || 'input' in value); +} + +function wrapStepperErrorOrRethrow(rejection: unknown): ClientOperationResult { + if (isHaapiStepperError(rejection)) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- `rejection` is narrowed to HaapiStepperError by the guard above; the rule still flags it as error-typed because the parent type was `unknown`. + return { clientOperationError: rejection }; + } + // Rethrow non-conforming rejections (programming bugs / unexpected runtime errors) so the + // React error boundary handles them — they're not routed into `error.app`. + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw rejection; +} 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 00000000..df5ecbc7 --- /dev/null +++ b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/webauthn/typings.ts @@ -0,0 +1,16 @@ +import { HaapiFetchFormAction } from '../../../../../data-access'; +import { HaapiStepperError } from '../../../../stepper/haapi-stepper.types'; + +export enum WEBAUTHN_ERROR_TYPE { + CANCEL_OR_TIMEOUT = 'cancelOrTimeout', + FAILED = 'failed', +} + +export enum WEBAUTHN_OPERATION { + REGISTRATION = 'registration', + AUTHENTICATION = 'authentication', +} + +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/webauthn.spec.ts b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/webauthn/webauthn.spec.ts index b25a39a1..9e3b78e0 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,184 @@ 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({ + action: action.model.continueActions[0], + payload: { credential: credentialJSON }, + }); + }); }); - }); - it('throws when navigator.credentials.create returns null', async () => { - mockCredentialsCreate.mockResolvedValue(null); - const action = createMockWebAuthnRegistrationAction(); - - await expect(runWebAuthnRegistration(action, abortSignal)).rejects.toMatchObject({ - message: 'Could not create credential', - }); - }); + 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({ + action: action.model.continueActions[0], + payload: { platformCredential: 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' }; + 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)); + mockParseCreationOptionsFromJSON.mockReturnValue(parsedOptions); + mockCredentialsCreate.mockResolvedValue(mockCredential(credentialJSON)); - const action = createMockWebAuthnRegistrationAction(); - const result = await runWebAuthnRegistration(action, abortSignal); + const action = createMockWebAuthnCrossPlatformOnlyAnyDeviceAction(); + 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({ - action: action.model.continueActions[0], - payload: { credential: credentialJSON }, + 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 }, + }); }); }); }); - 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)); + describe('error', () => { + const cancelStep = makeStepWithMetadata({ + messages: { error: { clientOperation: { webauthn: { cancelOrTimeout: 'You cancelled the registration.' } } } }, + }); + const failedStep = makeStepWithMetadata({ + messages: { error: { clientOperation: { webauthn: { registration: 'Registration failed.' } } } }, + }); - const action = createMockWebAuthnPlatformOnlyAnyDeviceAction(); - const result = await runWebAuthnRegistration(action, abortSignal); + it('WebAuthn API not supported → registrationError copy', async () => { + vi.unstubAllGlobals(); - 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 }, + await expect( + runWebAuthnRegistration(createMockWebAuthnRegistrationAction(), abortSignal, failedStep) + ).rejects.toMatchObject({ + app: { + type: HAAPI_PROBLEM_STEPS.UNEXPECTED, + messages: [{ text: failedStep.metadata?.messages?.error?.clientOperation?.webauthn?.registration }], + }, }); }); - 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)); + it('navigator.credentials.create returns null → cancelOrTimeoutError copy', async () => { + mockCredentialsCreate.mockResolvedValue(null); - const action = createMockWebAuthnCrossPlatformOnlyAnyDeviceAction(); - const result = await runWebAuthnRegistration(action, abortSignal); + await expect( + runWebAuthnRegistration(createMockWebAuthnRegistrationAction(), abortSignal, cancelStep) + ).rejects.toMatchObject({ + app: { + type: HAAPI_PROBLEM_STEPS.UNEXPECTED, + messages: [{ text: cancelStep.metadata?.messages?.error?.clientOperation?.webauthn?.cancelOrTimeout }], + }, + }); + }); - expect(mockParseCreationOptionsFromJSON).toHaveBeenCalledWith( - action.model.arguments.crossPlatformCredentialCreationOptions?.publicKey + 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) + ).rejects.toMatchObject({ + app: { + type: HAAPI_PROBLEM_STEPS.UNEXPECTED, + messages: [{ text: failedStep.metadata?.messages?.error?.clientOperation?.webauthn?.registration }], + }, + }); + } ); - expect(mockCredentialsCreate).toHaveBeenCalledWith({ publicKey: parsedOptions, signal: abortSignal }); - expect(result).toEqual({ - action: action.model.continueActions[0], - payload: { crossPlatformCredential: credentialJSON }, + }); + + 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) + ).rejects.toMatchObject({ + app: { + type: HAAPI_PROBLEM_STEPS.UNEXPECTED, + messages: [{ text: cancelStep.metadata?.messages?.error?.clientOperation?.webauthn?.cancelOrTimeout }], + }, + }); + }); + + 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) + ).rejects.toMatchObject({ + app: { + type: HAAPI_PROBLEM_STEPS.UNEXPECTED, + messages: [{ text: failedStep.metadata?.messages?.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) + ).rejects.toMatchObject({ + 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) + ).rejects.toMatchObject({ + 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,7 +231,7 @@ 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 @@ -154,6 +242,87 @@ describe('webauthn', () => { payload: { credential: credentialJSON }, }); }); + + describe('error catalog — throws HaapiStepperError', () => { + const cancelStep = makeStepWithMetadata({ + messages: { error: { clientOperation: { webauthn: { cancelOrTimeout: 'You cancelled the sign-in.' } } } }, + }); + const failedStep = makeStepWithMetadata({ + messages: { error: { clientOperation: { webauthn: { authentication: 'Authentication failed.' } } } }, + }); + + it('WebAuthn API not supported → authenticationError copy (failed bucket)', async () => { + vi.unstubAllGlobals(); + + await expect( + runWebAuthnAuthentication(createMockWebAuthnAuthenticationAction(), abortSignal, failedStep) + ).rejects.toMatchObject({ + app: { + type: HAAPI_PROBLEM_STEPS.UNEXPECTED, + messages: [{ text: failedStep.metadata?.messages?.error?.clientOperation?.webauthn?.authentication }], + }, + }); + }); + + it('navigator.credentials.get returns null → cancelOrTimeoutError copy', async () => { + mockCredentialsGet.mockResolvedValue(null); + + await expect( + runWebAuthnAuthentication(createMockWebAuthnAuthenticationAction(), abortSignal, cancelStep) + ).rejects.toMatchObject({ + app: { + type: HAAPI_PROBLEM_STEPS.UNEXPECTED, + messages: [{ text: cancelStep.metadata?.messages?.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) + ).rejects.toMatchObject({ + app: { + type: HAAPI_PROBLEM_STEPS.UNEXPECTED, + messages: [{ text: failedStep.metadata?.messages?.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) + ).rejects.toMatchObject({ + app: { + type: HAAPI_PROBLEM_STEPS.UNEXPECTED, + messages: [{ text: cancelStep.metadata?.messages?.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) + ).rejects.toMatchObject({ + app: { + type: HAAPI_PROBLEM_STEPS.UNEXPECTED, + messages: [{ text: failedStep.metadata?.messages?.error?.clientOperation?.webauthn?.authentication }], + }, + }); + } + ); + }); + }); }); }); @@ -182,3 +351,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 1706e9fb..7f598493 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 @@ -15,30 +15,26 @@ import { HaapiWebAuthnRegistrationClientOperationAction, } from '../../../../../data-access/types/haapi-action.types'; import { HaapiFetchFormAction } from '../../../../../data-access/types/haapi-fetch.types'; -import { - isAnyDeviceWebAuthnRegistrationAction, - isPasskeysWebAuthnRegistrationAction, - isWebAuthnApiSupported, -} from './utils'; - -const WEBAUTHN_API_NOT_SUPPORTED_ERROR_MESSAGE = 'WebAuthn API is not supported in this browser'; +import { HAAPI_PROBLEM_STEPS, HaapiStep, HaapiUserMessage } from '../../../../../data-access/types/haapi-step.types'; +import type { HaapiStepperError } from '../../../../stepper/haapi-stepper.types'; +import { formatErrorStepData } from '../../../../stepper/data-formatters/problem-step'; +import { isPasskeysWebAuthnRegistrationAction, isWebAuthnApiSupported } from './utils'; +import { WEBAUTHN_ERROR_TYPE, WEBAUTHN_OPERATION } from './typings'; /** - * 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`)). + * Executes the `webauthn-registration` ceremony and returns the HAAPI continuation form + * action and optional payload on success. + * + * Throws a synthesised {@link HaapiStepperError} on every ceremony including unsupported-API + * and null credential returns. */ export async function runWebAuthnRegistration( action: HaapiWebAuthnRegistrationClientOperationAction, - abortSignal: AbortSignal + abortSignal: AbortSignal, + currentStep: HaapiStep | null ): Promise { - if (!isWebAuthnApiSupported()) { - throw new Error(WEBAUTHN_API_NOT_SUPPORTED_ERROR_MESSAGE); - } - const selectedOption = getWebAuthnRegistrationSelectedOption(action); - const credential = await createWebAuthnRegistrationCredential(action, abortSignal); + const credential = await createWebAuthnRegistrationCredential(action, abortSignal, currentStep); return { action: action.model.continueActions[0], @@ -47,19 +43,18 @@ export async function runWebAuthnRegistration( } /** - * 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. + * Executes the `webauthn-authentication` ceremony and returns the HAAPI continuation form + * action and optional payload on success. + * + * Throws a synthesised {@link HaapiStepperError} on every ceremony including unsupported-API + * and null credential returns. */ export async function runWebAuthnAuthentication( action: HaapiWebAuthnAuthenticationClientOperationAction, - abortSignal: AbortSignal + abortSignal: AbortSignal, + currentStep: HaapiStep | null ): Promise { - if (!isWebAuthnApiSupported()) { - throw new Error(WEBAUTHN_API_NOT_SUPPORTED_ERROR_MESSAGE); - } - - const credential = await getWebAuthnAuthenticationCredential(action, abortSignal); + const credential = await getWebAuthnAuthenticationCredential(action, abortSignal, currentStep); return { action: action.model.continueActions[0], @@ -69,17 +64,29 @@ export async function runWebAuthnAuthentication( async function createWebAuthnRegistrationCredential( action: HaapiWebAuthnRegistrationClientOperationAction, - abortSignal: AbortSignal + abortSignal: AbortSignal, + currentStep: HaapiStep | null ): Promise { - const creationOptions = getWebAuthnRegistrationCreationOptions(action); - const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(creationOptions); - const credential = (await navigator.credentials.create({ - publicKey, - signal: abortSignal, - })) as PublicKeyCredential | null; + if (!isWebAuthnApiSupported()) { + // eslint-disable-next-line @typescript-eslint/only-throw-error -- synthesised HaapiStepperError (data, not Error instance); caught + wrapped by `performClientOperation` (IS-11327) + throw getHaapiStepperError(WEBAUTHN_ERROR_TYPE.FAILED, WEBAUTHN_OPERATION.REGISTRATION, currentStep); + } + + let credential: PublicKeyCredential | null; + try { + const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(getWebAuthnRegistrationCreationOptions(action)); + credential = (await navigator.credentials.create({ + publicKey, + signal: abortSignal, + })) as PublicKeyCredential | null; + } catch (error) { + // eslint-disable-next-line @typescript-eslint/only-throw-error -- synthesised HaapiStepperError (data, not Error instance); caught + wrapped by `performClientOperation` (IS-11327) + throw getHaapiStepperError(getWebAuthnErrorType(error), WEBAUTHN_OPERATION.REGISTRATION, currentStep); + } if (credential === null) { - throw new Error('Could not create credential'); + // eslint-disable-next-line @typescript-eslint/only-throw-error -- synthesised HaapiStepperError (data, not Error instance); caught + wrapped by `performClientOperation` (IS-11327) + throw getHaapiStepperError(WEBAUTHN_ERROR_TYPE.CANCEL_OR_TIMEOUT, WEBAUTHN_OPERATION.REGISTRATION, currentStep); } return credential; @@ -87,23 +94,75 @@ async function createWebAuthnRegistrationCredential( async function getWebAuthnAuthenticationCredential( action: HaapiWebAuthnAuthenticationClientOperationAction, - abortSignal: AbortSignal + abortSignal: AbortSignal, + currentStep: HaapiStep | null ): Promise { - const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON( - action.model.arguments.credentialRequestOptions.publicKey - ); - const credential = (await navigator.credentials.get({ - publicKey, - signal: abortSignal, - })) as PublicKeyCredential | null; + if (!isWebAuthnApiSupported()) { + // eslint-disable-next-line @typescript-eslint/only-throw-error -- synthesised HaapiStepperError (data, not Error instance); caught + wrapped by `performClientOperation` (IS-11327) + throw getHaapiStepperError(WEBAUTHN_ERROR_TYPE.FAILED, WEBAUTHN_OPERATION.AUTHENTICATION, currentStep); + } + + let credential: PublicKeyCredential | null; + try { + const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON( + action.model.arguments.credentialRequestOptions.publicKey + ); + credential = (await navigator.credentials.get({ + publicKey, + signal: abortSignal, + })) as PublicKeyCredential | null; + } catch (error) { + // eslint-disable-next-line @typescript-eslint/only-throw-error -- synthesised HaapiStepperError (data, not Error instance); caught + wrapped by `performClientOperation` (IS-11327) + throw getHaapiStepperError(getWebAuthnErrorType(error), WEBAUTHN_OPERATION.AUTHENTICATION, currentStep); + } if (credential === null) { - throw new Error('Could not get credential'); + // eslint-disable-next-line @typescript-eslint/only-throw-error -- synthesised HaapiStepperError (data, not Error instance); caught + wrapped by `performClientOperation` (IS-11327) + throw getHaapiStepperError(WEBAUTHN_ERROR_TYPE.CANCEL_OR_TIMEOUT, WEBAUTHN_OPERATION.AUTHENTICATION, currentStep); } return credential; } +/** + * Synthesises a {@link HaapiStepperError} for a WebAuthn ceremony failure. + * + * Client-operation failures 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`). + * + * Message copy comes from `step.metadata.messages.error` per + * `type` and `operation`. + */ +function getHaapiStepperError( + type: WEBAUTHN_ERROR_TYPE, + operation: WEBAUTHN_OPERATION, + currentStep: HaapiStep | null +): HaapiStepperError { + const webauthnErrors = currentStep?.metadata?.messages?.error?.clientOperation?.webauthn; + const messageText = + type === WEBAUTHN_ERROR_TYPE.CANCEL_OR_TIMEOUT + ? (webauthnErrors?.cancelOrTimeout ?? '') + : operation === WEBAUTHN_OPERATION.REGISTRATION + ? (webauthnErrors?.registration ?? '') + : (webauthnErrors?.authentication ?? ''); + const messages: HaapiUserMessage[] = messageText ? [{ text: messageText }] : []; + + return formatErrorStepData({ + type: HAAPI_PROBLEM_STEPS.UNEXPECTED, + messages, + }); +} + +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( action: HaapiWebAuthnRegistrationClientOperationAction ): HAAPI_WEBAUTHN_REGISTRATION_SELECTED_OPTION { @@ -111,17 +170,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 +185,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 d738d7ff..bcf9952b 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,168 @@ 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({ + 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.mockRejectedValue(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); + }); + }); + }); + }); + + 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({ + 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); }); + }); - 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('error', () => { + beforeEach(() => { + mockHaapiFetchWebAuthnStep(HAAPI_STEPS.AUTHENTICATION, createMockWebAuthnAuthenticationAction()); + mockRunWebAuthnAuthentication.mockRejectedValue(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 +1419,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 54a597f6..5ecbd889 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, From cb48e2b4650e376566bb7eeca1a995a2b41b5b6a Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Wed, 20 May 2026 17:30:58 +0200 Subject: [PATCH 2/5] IS-11327 Rename metadata.messages to metadata.viewData; extract getHaapiStepperError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Rename `HaapiMetadata.messages` → `HaapiMetadata.viewData` (and the dependent type aliases) so the metadata branch reads as view-customisation data emitted by the server rather than HAAPI step messages. Path becomes `step.metadata.viewData.error.clientOperation.webauthn.`. * Extract `getHaapiStepperError` from `webauthn.ts` to `client-operations.ts` as a shared helper that takes a resolved message string and synthesises a `HaapiStepperError` from a `HaapiUnexpectedProblemStep`. WebAuthn-specific message resolution stays in `webauthn.ts` (`getWebAuthnErrorMessage`); other client operations (BankID, EBF) can reuse the helper when their per-runtime error handling lands. * Test fixtures + path-access assertions in `webauthn.spec.ts` updated to use the new `viewData` key. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../data-access/types/haapi-step.types.ts | 12 ++-- .../operations/client-operations.ts | 32 ++++++++- .../operations/webauthn/webauthn.spec.ts | 28 ++++---- .../operations/webauthn/webauthn.ts | 66 +++++++++---------- 4 files changed, 84 insertions(+), 54 deletions(-) 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 fae4bad6..b9c7a0aa 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 @@ -352,16 +352,18 @@ export interface HaapiMetadata { /** The name for the view that produced the response */ viewName?: string; /** - * Categorised user-facing messages + * Categorised view-specific data emitted by the Identity Server alongside step responses. + * Currently scopes error-tone copy for client-operation failures (WebAuthn today; BankID / + * EBF on the same pattern when their per-operation error handling lands). */ - messages?: HaapiMetadataMessages; + viewData?: HaapiMetadataViewData; } -export interface HaapiMetadataMessages { - error?: HaapiMetadataErrorMessages; +export interface HaapiMetadataViewData { + error?: HaapiMetadataViewDataError; } -export interface HaapiMetadataErrorMessages { +export interface HaapiMetadataViewDataError { clientOperation?: HaapiClientOperationErrorMessages; } export interface HaapiClientOperationErrorMessages { 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 1aa44447..5181945a 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,9 +14,15 @@ import { HAAPI_ACTION_TYPES, HaapiAction, } from '../../../../data-access/types/haapi-action.types'; -import { HaapiLink, HaapiStep } 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, 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 { @@ -85,3 +91,27 @@ function wrapStepperErrorOrRethrow(rejection: unknown): ClientOperationResult { // eslint-disable-next-line @typescript-eslint/only-throw-error throw rejection; } + +/** + * 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/webauthn/webauthn.spec.ts b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/webauthn/webauthn.spec.ts index 9e3b78e0..be68fe63 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 @@ -113,10 +113,10 @@ describe('webauthn', () => { describe('error', () => { const cancelStep = makeStepWithMetadata({ - messages: { error: { clientOperation: { webauthn: { cancelOrTimeout: 'You cancelled the registration.' } } } }, + viewData: { error: { clientOperation: { webauthn: { cancelOrTimeout: 'You cancelled the registration.' } } } }, }); const failedStep = makeStepWithMetadata({ - messages: { error: { clientOperation: { webauthn: { registration: 'Registration failed.' } } } }, + viewData: { error: { clientOperation: { webauthn: { registration: 'Registration failed.' } } } }, }); it('WebAuthn API not supported → registrationError copy', async () => { @@ -127,7 +127,7 @@ describe('webauthn', () => { ).rejects.toMatchObject({ app: { type: HAAPI_PROBLEM_STEPS.UNEXPECTED, - messages: [{ text: failedStep.metadata?.messages?.error?.clientOperation?.webauthn?.registration }], + messages: [{ text: failedStep.metadata?.viewData?.error?.clientOperation?.webauthn?.registration }], }, }); }); @@ -140,7 +140,7 @@ describe('webauthn', () => { ).rejects.toMatchObject({ app: { type: HAAPI_PROBLEM_STEPS.UNEXPECTED, - messages: [{ text: cancelStep.metadata?.messages?.error?.clientOperation?.webauthn?.cancelOrTimeout }], + messages: [{ text: cancelStep.metadata?.viewData?.error?.clientOperation?.webauthn?.cancelOrTimeout }], }, }); }); @@ -158,7 +158,7 @@ describe('webauthn', () => { ).rejects.toMatchObject({ app: { type: HAAPI_PROBLEM_STEPS.UNEXPECTED, - messages: [{ text: failedStep.metadata?.messages?.error?.clientOperation?.webauthn?.registration }], + messages: [{ text: failedStep.metadata?.viewData?.error?.clientOperation?.webauthn?.registration }], }, }); } @@ -177,7 +177,7 @@ describe('webauthn', () => { ).rejects.toMatchObject({ app: { type: HAAPI_PROBLEM_STEPS.UNEXPECTED, - messages: [{ text: cancelStep.metadata?.messages?.error?.clientOperation?.webauthn?.cancelOrTimeout }], + messages: [{ text: cancelStep.metadata?.viewData?.error?.clientOperation?.webauthn?.cancelOrTimeout }], }, }); }); @@ -193,7 +193,7 @@ describe('webauthn', () => { ).rejects.toMatchObject({ app: { type: HAAPI_PROBLEM_STEPS.UNEXPECTED, - messages: [{ text: failedStep.metadata?.messages?.error?.clientOperation?.webauthn?.registration }], + messages: [{ text: failedStep.metadata?.viewData?.error?.clientOperation?.webauthn?.registration }], }, }); }); @@ -245,10 +245,10 @@ describe('webauthn', () => { describe('error catalog — throws HaapiStepperError', () => { const cancelStep = makeStepWithMetadata({ - messages: { error: { clientOperation: { webauthn: { cancelOrTimeout: 'You cancelled the sign-in.' } } } }, + viewData: { error: { clientOperation: { webauthn: { cancelOrTimeout: 'You cancelled the sign-in.' } } } }, }); const failedStep = makeStepWithMetadata({ - messages: { error: { clientOperation: { webauthn: { authentication: 'Authentication failed.' } } } }, + viewData: { error: { clientOperation: { webauthn: { authentication: 'Authentication failed.' } } } }, }); it('WebAuthn API not supported → authenticationError copy (failed bucket)', async () => { @@ -259,7 +259,7 @@ describe('webauthn', () => { ).rejects.toMatchObject({ app: { type: HAAPI_PROBLEM_STEPS.UNEXPECTED, - messages: [{ text: failedStep.metadata?.messages?.error?.clientOperation?.webauthn?.authentication }], + messages: [{ text: failedStep.metadata?.viewData?.error?.clientOperation?.webauthn?.authentication }], }, }); }); @@ -272,7 +272,7 @@ describe('webauthn', () => { ).rejects.toMatchObject({ app: { type: HAAPI_PROBLEM_STEPS.UNEXPECTED, - messages: [{ text: cancelStep.metadata?.messages?.error?.clientOperation?.webauthn?.cancelOrTimeout }], + messages: [{ text: cancelStep.metadata?.viewData?.error?.clientOperation?.webauthn?.cancelOrTimeout }], }, }); }); @@ -287,7 +287,7 @@ describe('webauthn', () => { ).rejects.toMatchObject({ app: { type: HAAPI_PROBLEM_STEPS.UNEXPECTED, - messages: [{ text: failedStep.metadata?.messages?.error?.clientOperation?.webauthn?.authentication }], + messages: [{ text: failedStep.metadata?.viewData?.error?.clientOperation?.webauthn?.authentication }], }, }); }); @@ -301,7 +301,7 @@ describe('webauthn', () => { ).rejects.toMatchObject({ app: { type: HAAPI_PROBLEM_STEPS.UNEXPECTED, - messages: [{ text: cancelStep.metadata?.messages?.error?.clientOperation?.webauthn?.cancelOrTimeout }], + messages: [{ text: cancelStep.metadata?.viewData?.error?.clientOperation?.webauthn?.cancelOrTimeout }], }, }); }); @@ -316,7 +316,7 @@ describe('webauthn', () => { ).rejects.toMatchObject({ app: { type: HAAPI_PROBLEM_STEPS.UNEXPECTED, - messages: [{ text: failedStep.metadata?.messages?.error?.clientOperation?.webauthn?.authentication }], + messages: [{ text: failedStep.metadata?.viewData?.error?.clientOperation?.webauthn?.authentication }], }, }); } 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 7f598493..dfceae35 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 @@ -15,11 +15,10 @@ import { HaapiWebAuthnRegistrationClientOperationAction, } from '../../../../../data-access/types/haapi-action.types'; import { HaapiFetchFormAction } from '../../../../../data-access/types/haapi-fetch.types'; -import { HAAPI_PROBLEM_STEPS, HaapiStep, HaapiUserMessage } from '../../../../../data-access/types/haapi-step.types'; -import type { HaapiStepperError } from '../../../../stepper/haapi-stepper.types'; -import { formatErrorStepData } from '../../../../stepper/data-formatters/problem-step'; +import { HaapiStep } from '../../../../../data-access/types/haapi-step.types'; import { isPasskeysWebAuthnRegistrationAction, isWebAuthnApiSupported } from './utils'; import { WEBAUTHN_ERROR_TYPE, WEBAUTHN_OPERATION } from './typings'; +import { getHaapiStepperError } from '../client-operations'; /** * Executes the `webauthn-registration` ceremony and returns the HAAPI continuation form @@ -69,7 +68,9 @@ async function createWebAuthnRegistrationCredential( ): Promise { if (!isWebAuthnApiSupported()) { // eslint-disable-next-line @typescript-eslint/only-throw-error -- synthesised HaapiStepperError (data, not Error instance); caught + wrapped by `performClientOperation` (IS-11327) - throw getHaapiStepperError(WEBAUTHN_ERROR_TYPE.FAILED, WEBAUTHN_OPERATION.REGISTRATION, currentStep); + throw getHaapiStepperError( + getWebAuthnErrorMessage(WEBAUTHN_ERROR_TYPE.FAILED, WEBAUTHN_OPERATION.REGISTRATION, currentStep) + ); } let credential: PublicKeyCredential | null; @@ -81,12 +82,16 @@ async function createWebAuthnRegistrationCredential( })) as PublicKeyCredential | null; } catch (error) { // eslint-disable-next-line @typescript-eslint/only-throw-error -- synthesised HaapiStepperError (data, not Error instance); caught + wrapped by `performClientOperation` (IS-11327) - throw getHaapiStepperError(getWebAuthnErrorType(error), WEBAUTHN_OPERATION.REGISTRATION, currentStep); + throw getHaapiStepperError( + getWebAuthnErrorMessage(getWebAuthnErrorType(error), WEBAUTHN_OPERATION.REGISTRATION, currentStep) + ); } if (credential === null) { // eslint-disable-next-line @typescript-eslint/only-throw-error -- synthesised HaapiStepperError (data, not Error instance); caught + wrapped by `performClientOperation` (IS-11327) - throw getHaapiStepperError(WEBAUTHN_ERROR_TYPE.CANCEL_OR_TIMEOUT, WEBAUTHN_OPERATION.REGISTRATION, currentStep); + throw getHaapiStepperError( + getWebAuthnErrorMessage(WEBAUTHN_ERROR_TYPE.CANCEL_OR_TIMEOUT, WEBAUTHN_OPERATION.REGISTRATION, currentStep) + ); } return credential; @@ -99,7 +104,9 @@ async function getWebAuthnAuthenticationCredential( ): Promise { if (!isWebAuthnApiSupported()) { // eslint-disable-next-line @typescript-eslint/only-throw-error -- synthesised HaapiStepperError (data, not Error instance); caught + wrapped by `performClientOperation` (IS-11327) - throw getHaapiStepperError(WEBAUTHN_ERROR_TYPE.FAILED, WEBAUTHN_OPERATION.AUTHENTICATION, currentStep); + throw getHaapiStepperError( + getWebAuthnErrorMessage(WEBAUTHN_ERROR_TYPE.FAILED, WEBAUTHN_OPERATION.AUTHENTICATION, currentStep) + ); } let credential: PublicKeyCredential | null; @@ -113,47 +120,38 @@ async function getWebAuthnAuthenticationCredential( })) as PublicKeyCredential | null; } catch (error) { // eslint-disable-next-line @typescript-eslint/only-throw-error -- synthesised HaapiStepperError (data, not Error instance); caught + wrapped by `performClientOperation` (IS-11327) - throw getHaapiStepperError(getWebAuthnErrorType(error), WEBAUTHN_OPERATION.AUTHENTICATION, currentStep); + throw getHaapiStepperError( + getWebAuthnErrorMessage(getWebAuthnErrorType(error), WEBAUTHN_OPERATION.AUTHENTICATION, currentStep) + ); } if (credential === null) { // eslint-disable-next-line @typescript-eslint/only-throw-error -- synthesised HaapiStepperError (data, not Error instance); caught + wrapped by `performClientOperation` (IS-11327) - throw getHaapiStepperError(WEBAUTHN_ERROR_TYPE.CANCEL_OR_TIMEOUT, WEBAUTHN_OPERATION.AUTHENTICATION, currentStep); + throw getHaapiStepperError( + getWebAuthnErrorMessage(WEBAUTHN_ERROR_TYPE.CANCEL_OR_TIMEOUT, WEBAUTHN_OPERATION.AUTHENTICATION, currentStep) + ); } return credential; } /** - * Synthesises a {@link HaapiStepperError} for a WebAuthn ceremony failure. - * - * Client-operation failures 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`). - * - * Message copy comes from `step.metadata.messages.error` per - * `type` and `operation`. + * 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 getHaapiStepperError( +function getWebAuthnErrorMessage( type: WEBAUTHN_ERROR_TYPE, operation: WEBAUTHN_OPERATION, currentStep: HaapiStep | null -): HaapiStepperError { - const webauthnErrors = currentStep?.metadata?.messages?.error?.clientOperation?.webauthn; - const messageText = - type === WEBAUTHN_ERROR_TYPE.CANCEL_OR_TIMEOUT - ? (webauthnErrors?.cancelOrTimeout ?? '') - : operation === WEBAUTHN_OPERATION.REGISTRATION - ? (webauthnErrors?.registration ?? '') - : (webauthnErrors?.authentication ?? ''); - const messages: HaapiUserMessage[] = messageText ? [{ text: messageText }] : []; - - return formatErrorStepData({ - type: HAAPI_PROBLEM_STEPS.UNEXPECTED, - messages, - }); +): 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 { From 65476cc4c5549f75f2b737007396884b8fc21319 Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Wed, 20 May 2026 17:36:50 +0200 Subject: [PATCH 3/5] IS-11327: reduce doc useless verbosity --- .../src/haapi-stepper/data-access/types/haapi-step.types.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) 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 b9c7a0aa..d72a0ec3 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,11 +351,7 @@ export interface HaapiMetadata { templateArea?: string; /** The name for the view that produced the response */ viewName?: string; - /** - * Categorised view-specific data emitted by the Identity Server alongside step responses. - * Currently scopes error-tone copy for client-operation failures (WebAuthn today; BankID / - * EBF on the same pattern when their per-operation error handling lands). - */ + /** Categorised view-specific data */ viewData?: HaapiMetadataViewData; } From 16cc0cb5ea6742f9a9c33fcba28de0be18f87d85 Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Wed, 20 May 2026 18:25:46 +0200 Subject: [PATCH 4/5] IS-11327: refactor webauthn error handling --- .../operations/client-operations.ts | 29 +--- .../operations/webauthn/webauthn.spec.ts | 134 +++++++++++------- .../operations/webauthn/webauthn.ts | 134 +++++++++--------- .../feature/stepper/HaapiStepper.spec.tsx | 16 ++- 4 files changed, 163 insertions(+), 150 deletions(-) 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 5181945a..703bd971 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 @@ -49,15 +49,11 @@ export async function performClientOperation( const signal = abortController.signal; if (isWebAuthnRegistrationClientOperation(action)) { - return runWebAuthnRegistration(action, signal, currentStep) - .then(clientOperationData => ({ clientOperationData })) - .catch(wrapStepperErrorOrRethrow); + return runWebAuthnRegistration(action, signal, currentStep); } if (isWebAuthnAuthenticationClientOperation(action)) { - return runWebAuthnAuthentication(action, signal, currentStep) - .then(clientOperationData => ({ clientOperationData })) - .catch(wrapStepperErrorOrRethrow); + return runWebAuthnAuthentication(action, signal, currentStep); } if (isExternalBrowserFlowClientOperation(action)) { @@ -71,27 +67,6 @@ export async function performClientOperation( throw new Error(`Unsupported client operation: ${action.model.name}`); } -/** - * Catch-handler shared by the WebAuthn dispatcher branches. WebAuthn runners throw a - * synthesised {@link HaapiStepperError} on ceremony failure (IS-11327); anything else is a - * programming bug or an unexpected runtime error and should escape to the React error boundary - * rather than being misrouted into `error.app`. The type guard discriminates between the two. - */ -function isHaapiStepperError(value: unknown): value is HaapiStepperError { - return typeof value === 'object' && value !== null && ('app' in value || 'input' in value); -} - -function wrapStepperErrorOrRethrow(rejection: unknown): ClientOperationResult { - if (isHaapiStepperError(rejection)) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- `rejection` is narrowed to HaapiStepperError by the guard above; the rule still flags it as error-typed because the parent type was `unknown`. - return { clientOperationError: rejection }; - } - // Rethrow non-conforming rejections (programming bugs / unexpected runtime errors) so the - // React error boundary handles them — they're not routed into `error.app`. - // eslint-disable-next-line @typescript-eslint/only-throw-error - throw rejection; -} - /** * Synthesises a {@link HaapiStepperError} for a client-operation failure (IS-11327). * 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 be68fe63..ab0dd748 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 @@ -62,8 +62,10 @@ describe('webauthn', () => { ); expect(mockCredentialsCreate).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 }, + }, }); }); }); @@ -84,8 +86,10 @@ describe('webauthn', () => { ); expect(mockCredentialsCreate).toHaveBeenCalledWith({ publicKey: parsedOptions, signal: abortSignal }); expect(result).toEqual({ - action: action.model.continueActions[0], - payload: { platformCredential: credentialJSON }, + clientOperationData: { + action: action.model.continueActions[0], + payload: { platformCredential: credentialJSON }, + }, }); }); @@ -104,8 +108,10 @@ describe('webauthn', () => { ); expect(mockCredentialsCreate).toHaveBeenCalledWith({ publicKey: parsedOptions, signal: abortSignal }); expect(result).toEqual({ - action: action.model.continueActions[0], - payload: { crossPlatformCredential: credentialJSON }, + clientOperationData: { + action: action.model.continueActions[0], + payload: { crossPlatformCredential: credentialJSON }, + }, }); }); }); @@ -124,10 +130,12 @@ describe('webauthn', () => { await expect( runWebAuthnRegistration(createMockWebAuthnRegistrationAction(), abortSignal, failedStep) - ).rejects.toMatchObject({ - app: { - type: HAAPI_PROBLEM_STEPS.UNEXPECTED, - messages: [{ text: failedStep.metadata?.viewData?.error?.clientOperation?.webauthn?.registration }], + ).resolves.toMatchObject({ + clientOperationError: { + app: { + type: HAAPI_PROBLEM_STEPS.UNEXPECTED, + messages: [{ text: failedStep.metadata?.viewData?.error?.clientOperation?.webauthn?.registration }], + }, }, }); }); @@ -137,10 +145,12 @@ describe('webauthn', () => { await expect( runWebAuthnRegistration(createMockWebAuthnRegistrationAction(), abortSignal, cancelStep) - ).rejects.toMatchObject({ - app: { - type: HAAPI_PROBLEM_STEPS.UNEXPECTED, - messages: [{ text: cancelStep.metadata?.viewData?.error?.clientOperation?.webauthn?.cancelOrTimeout }], + ).resolves.toMatchObject({ + clientOperationError: { + app: { + type: HAAPI_PROBLEM_STEPS.UNEXPECTED, + messages: [{ text: cancelStep.metadata?.viewData?.error?.clientOperation?.webauthn?.cancelOrTimeout }], + }, }, }); }); @@ -155,10 +165,12 @@ describe('webauthn', () => { await expect( runWebAuthnRegistration(createMockWebAuthnRegistrationAction(), abortSignal, failedStep) - ).rejects.toMatchObject({ - app: { - type: HAAPI_PROBLEM_STEPS.UNEXPECTED, - messages: [{ text: failedStep.metadata?.viewData?.error?.clientOperation?.webauthn?.registration }], + ).resolves.toMatchObject({ + clientOperationError: { + app: { + type: HAAPI_PROBLEM_STEPS.UNEXPECTED, + messages: [{ text: failedStep.metadata?.viewData?.error?.clientOperation?.webauthn?.registration }], + }, }, }); } @@ -174,10 +186,12 @@ describe('webauthn', () => { await expect( runWebAuthnRegistration(createMockWebAuthnRegistrationAction(), abortSignal, cancelStep) - ).rejects.toMatchObject({ - app: { - type: HAAPI_PROBLEM_STEPS.UNEXPECTED, - messages: [{ text: cancelStep.metadata?.viewData?.error?.clientOperation?.webauthn?.cancelOrTimeout }], + ).resolves.toMatchObject({ + clientOperationError: { + app: { + type: HAAPI_PROBLEM_STEPS.UNEXPECTED, + messages: [{ text: cancelStep.metadata?.viewData?.error?.clientOperation?.webauthn?.cancelOrTimeout }], + }, }, }); }); @@ -190,10 +204,12 @@ describe('webauthn', () => { await expect( runWebAuthnRegistration(createMockWebAuthnRegistrationAction(), abortSignal, failedStep) - ).rejects.toMatchObject({ - app: { - type: HAAPI_PROBLEM_STEPS.UNEXPECTED, - messages: [{ text: failedStep.metadata?.viewData?.error?.clientOperation?.webauthn?.registration }], + ).resolves.toMatchObject({ + clientOperationError: { + app: { + type: HAAPI_PROBLEM_STEPS.UNEXPECTED, + messages: [{ text: failedStep.metadata?.viewData?.error?.clientOperation?.webauthn?.registration }], + }, }, }); }); @@ -205,8 +221,8 @@ describe('webauthn', () => { await expect( runWebAuthnRegistration(createMockWebAuthnRegistrationAction(), abortSignal, step) - ).rejects.toMatchObject({ - app: { type: HAAPI_PROBLEM_STEPS.UNEXPECTED, messages: [] }, + ).resolves.toMatchObject({ + clientOperationError: { app: { type: HAAPI_PROBLEM_STEPS.UNEXPECTED, messages: [] } }, }); }); @@ -215,8 +231,8 @@ describe('webauthn', () => { await expect( runWebAuthnRegistration(createMockWebAuthnRegistrationAction(), abortSignal, null) - ).rejects.toMatchObject({ - app: { type: HAAPI_PROBLEM_STEPS.UNEXPECTED, messages: [] }, + ).resolves.toMatchObject({ + clientOperationError: { app: { type: HAAPI_PROBLEM_STEPS.UNEXPECTED, messages: [] } }, }); }); }); @@ -238,12 +254,14 @@ describe('webauthn', () => { ); 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 catalog — throws HaapiStepperError', () => { + describe('error', () => { const cancelStep = makeStepWithMetadata({ viewData: { error: { clientOperation: { webauthn: { cancelOrTimeout: 'You cancelled the sign-in.' } } } }, }); @@ -256,10 +274,12 @@ describe('webauthn', () => { await expect( runWebAuthnAuthentication(createMockWebAuthnAuthenticationAction(), abortSignal, failedStep) - ).rejects.toMatchObject({ - app: { - type: HAAPI_PROBLEM_STEPS.UNEXPECTED, - messages: [{ text: failedStep.metadata?.viewData?.error?.clientOperation?.webauthn?.authentication }], + ).resolves.toMatchObject({ + clientOperationError: { + app: { + type: HAAPI_PROBLEM_STEPS.UNEXPECTED, + messages: [{ text: failedStep.metadata?.viewData?.error?.clientOperation?.webauthn?.authentication }], + }, }, }); }); @@ -269,10 +289,12 @@ describe('webauthn', () => { await expect( runWebAuthnAuthentication(createMockWebAuthnAuthenticationAction(), abortSignal, cancelStep) - ).rejects.toMatchObject({ - app: { - type: HAAPI_PROBLEM_STEPS.UNEXPECTED, - messages: [{ text: cancelStep.metadata?.viewData?.error?.clientOperation?.webauthn?.cancelOrTimeout }], + ).resolves.toMatchObject({ + clientOperationError: { + app: { + type: HAAPI_PROBLEM_STEPS.UNEXPECTED, + messages: [{ text: cancelStep.metadata?.viewData?.error?.clientOperation?.webauthn?.cancelOrTimeout }], + }, }, }); }); @@ -284,10 +306,12 @@ describe('webauthn', () => { await expect( runWebAuthnAuthentication(createMockWebAuthnAuthenticationAction(), abortSignal, failedStep) - ).rejects.toMatchObject({ - app: { - type: HAAPI_PROBLEM_STEPS.UNEXPECTED, - messages: [{ text: failedStep.metadata?.viewData?.error?.clientOperation?.webauthn?.authentication }], + ).resolves.toMatchObject({ + clientOperationError: { + app: { + type: HAAPI_PROBLEM_STEPS.UNEXPECTED, + messages: [{ text: failedStep.metadata?.viewData?.error?.clientOperation?.webauthn?.authentication }], + }, }, }); }); @@ -298,10 +322,12 @@ describe('webauthn', () => { await expect( runWebAuthnAuthentication(createMockWebAuthnAuthenticationAction(), abortSignal, cancelStep) - ).rejects.toMatchObject({ - app: { - type: HAAPI_PROBLEM_STEPS.UNEXPECTED, - messages: [{ text: cancelStep.metadata?.viewData?.error?.clientOperation?.webauthn?.cancelOrTimeout }], + ).resolves.toMatchObject({ + clientOperationError: { + app: { + type: HAAPI_PROBLEM_STEPS.UNEXPECTED, + messages: [{ text: cancelStep.metadata?.viewData?.error?.clientOperation?.webauthn?.cancelOrTimeout }], + }, }, }); }); @@ -313,10 +339,12 @@ describe('webauthn', () => { await expect( runWebAuthnAuthentication(createMockWebAuthnAuthenticationAction(), abortSignal, failedStep) - ).rejects.toMatchObject({ - app: { - type: HAAPI_PROBLEM_STEPS.UNEXPECTED, - messages: [{ text: failedStep.metadata?.viewData?.error?.clientOperation?.webauthn?.authentication }], + ).resolves.toMatchObject({ + clientOperationError: { + app: { + type: HAAPI_PROBLEM_STEPS.UNEXPECTED, + messages: [{ text: failedStep.metadata?.viewData?.error?.clientOperation?.webauthn?.authentication }], + }, }, }); } 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 dfceae35..925a03a6 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,50 +14,52 @@ import { HaapiWebAuthnAuthenticationClientOperationAction, HaapiWebAuthnRegistrationClientOperationAction, } from '../../../../../data-access/types/haapi-action.types'; -import { HaapiFetchFormAction } from '../../../../../data-access/types/haapi-fetch.types'; 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, WEBAUTHN_ERROR_TYPE, WEBAUTHN_OPERATION } from './typings'; import { getHaapiStepperError } from '../client-operations'; -/** - * Executes the `webauthn-registration` ceremony and returns the HAAPI continuation form - * action and optional payload on success. - * - * Throws a synthesised {@link HaapiStepperError} on every ceremony including unsupported-API - * and null credential returns. - */ +type WebAuthnCredentialResult = + | { credential: PublicKeyCredential; error?: undefined } + | { credential?: undefined; error: HaapiStepperError }; + export async function runWebAuthnRegistration( action: HaapiWebAuthnRegistrationClientOperationAction, abortSignal: AbortSignal, currentStep: HaapiStep | null -): Promise { +): Promise { const selectedOption = getWebAuthnRegistrationSelectedOption(action); - const credential = await createWebAuthnRegistrationCredential(action, abortSignal, currentStep); + 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 and returns the HAAPI continuation form - * action and optional payload on success. - * - * Throws a synthesised {@link HaapiStepperError} on every ceremony including unsupported-API - * and null credential returns. - */ export async function runWebAuthnAuthentication( action: HaapiWebAuthnAuthenticationClientOperationAction, abortSignal: AbortSignal, currentStep: HaapiStep | null -): Promise { - const credential = await getWebAuthnAuthenticationCredential(action, abortSignal, currentStep); +): Promise { + const { credential, error } = await getWebAuthnAuthenticationCredential(action, abortSignal, currentStep); + + 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 }, + }, }; } @@ -65,74 +67,78 @@ async function createWebAuthnRegistrationCredential( action: HaapiWebAuthnRegistrationClientOperationAction, abortSignal: AbortSignal, currentStep: HaapiStep | null -): Promise { +): Promise { if (!isWebAuthnApiSupported()) { - // eslint-disable-next-line @typescript-eslint/only-throw-error -- synthesised HaapiStepperError (data, not Error instance); caught + wrapped by `performClientOperation` (IS-11327) - throw getHaapiStepperError( - getWebAuthnErrorMessage(WEBAUTHN_ERROR_TYPE.FAILED, WEBAUTHN_OPERATION.REGISTRATION, currentStep) - ); + return { + error: getHaapiStepperError( + getWebAuthnErrorMessage(WEBAUTHN_ERROR_TYPE.FAILED, WEBAUTHN_OPERATION.REGISTRATION, currentStep) + ), + }; } - let credential: PublicKeyCredential | null; try { const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(getWebAuthnRegistrationCreationOptions(action)); - credential = (await navigator.credentials.create({ + const credential = (await navigator.credentials.create({ publicKey, signal: abortSignal, })) as PublicKeyCredential | null; - } catch (error) { - // eslint-disable-next-line @typescript-eslint/only-throw-error -- synthesised HaapiStepperError (data, not Error instance); caught + wrapped by `performClientOperation` (IS-11327) - throw getHaapiStepperError( - getWebAuthnErrorMessage(getWebAuthnErrorType(error), WEBAUTHN_OPERATION.REGISTRATION, currentStep) - ); - } - if (credential === null) { - // eslint-disable-next-line @typescript-eslint/only-throw-error -- synthesised HaapiStepperError (data, not Error instance); caught + wrapped by `performClientOperation` (IS-11327) - throw getHaapiStepperError( - getWebAuthnErrorMessage(WEBAUTHN_ERROR_TYPE.CANCEL_OR_TIMEOUT, WEBAUTHN_OPERATION.REGISTRATION, currentStep) - ); - } + if (credential === null) { + return { + error: getHaapiStepperError( + getWebAuthnErrorMessage(WEBAUTHN_ERROR_TYPE.CANCEL_OR_TIMEOUT, WEBAUTHN_OPERATION.REGISTRATION, currentStep) + ), + }; + } - return credential; + return { credential }; + } catch (error) { + return { + error: getHaapiStepperError( + getWebAuthnErrorMessage(getWebAuthnErrorType(error), WEBAUTHN_OPERATION.REGISTRATION, currentStep) + ), + }; + } } async function getWebAuthnAuthenticationCredential( action: HaapiWebAuthnAuthenticationClientOperationAction, abortSignal: AbortSignal, currentStep: HaapiStep | null -): Promise { +): Promise { if (!isWebAuthnApiSupported()) { - // eslint-disable-next-line @typescript-eslint/only-throw-error -- synthesised HaapiStepperError (data, not Error instance); caught + wrapped by `performClientOperation` (IS-11327) - throw getHaapiStepperError( - getWebAuthnErrorMessage(WEBAUTHN_ERROR_TYPE.FAILED, WEBAUTHN_OPERATION.AUTHENTICATION, currentStep) - ); + return { + error: getHaapiStepperError( + getWebAuthnErrorMessage(WEBAUTHN_ERROR_TYPE.FAILED, WEBAUTHN_OPERATION.AUTHENTICATION, currentStep) + ), + }; } - let credential: PublicKeyCredential | null; try { const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON( action.model.arguments.credentialRequestOptions.publicKey ); - credential = (await navigator.credentials.get({ + const credential = (await navigator.credentials.get({ publicKey, signal: abortSignal, })) as PublicKeyCredential | null; - } catch (error) { - // eslint-disable-next-line @typescript-eslint/only-throw-error -- synthesised HaapiStepperError (data, not Error instance); caught + wrapped by `performClientOperation` (IS-11327) - throw getHaapiStepperError( - getWebAuthnErrorMessage(getWebAuthnErrorType(error), WEBAUTHN_OPERATION.AUTHENTICATION, currentStep) - ); - } - if (credential === null) { - // eslint-disable-next-line @typescript-eslint/only-throw-error -- synthesised HaapiStepperError (data, not Error instance); caught + wrapped by `performClientOperation` (IS-11327) - throw getHaapiStepperError( - getWebAuthnErrorMessage(WEBAUTHN_ERROR_TYPE.CANCEL_OR_TIMEOUT, WEBAUTHN_OPERATION.AUTHENTICATION, currentStep) - ); - } + if (credential === null) { + return { + error: getHaapiStepperError( + getWebAuthnErrorMessage(WEBAUTHN_ERROR_TYPE.CANCEL_OR_TIMEOUT, WEBAUTHN_OPERATION.AUTHENTICATION, currentStep) + ), + }; + } - return credential; + return { credential }; + } catch (error) { + return { + error: getHaapiStepperError( + getWebAuthnErrorMessage(getWebAuthnErrorType(error), WEBAUTHN_OPERATION.AUTHENTICATION, currentStep) + ), + }; + } } /** 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 bcf9952b..510e02fe 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 @@ -642,8 +642,10 @@ describe('HaapiStepper', () => { const action = createMockWebAuthnRegistrationAction(); mockHaapiFetchWebAuthnStep(HAAPI_STEPS.REGISTRATION, action); mockRunWebAuthnRegistration.mockResolvedValueOnce({ - action: action.model.continueActions[0], - payload: { credential: { id: 'cred-id', type: 'public-key' } }, + clientOperationData: { + action: action.model.continueActions[0], + payload: { credential: { id: 'cred-id', type: 'public-key' } }, + }, }); mockHaapiFetchStep(HAAPI_STEPS.COMPLETED_WITH_SUCCESS); @@ -664,7 +666,7 @@ describe('HaapiStepper', () => { describe('error', () => { beforeEach(() => { mockHaapiFetchWebAuthnStep(HAAPI_STEPS.REGISTRATION, createMockWebAuthnRegistrationAction()); - mockRunWebAuthnRegistration.mockRejectedValue(failedWebAuthnCeremonyError()); + mockRunWebAuthnRegistration.mockResolvedValue({ clientOperationError: failedWebAuthnCeremonyError() }); }); it('provides the error via error.app', async () => { @@ -721,8 +723,10 @@ describe('HaapiStepper', () => { const action = createMockWebAuthnAuthenticationAction(); mockHaapiFetchWebAuthnStep(HAAPI_STEPS.AUTHENTICATION, action); mockRunWebAuthnAuthentication.mockResolvedValueOnce({ - action: action.model.continueActions[0], - payload: { credential: { id: 'cred-id', type: 'public-key' } }, + clientOperationData: { + action: action.model.continueActions[0], + payload: { credential: { id: 'cred-id', type: 'public-key' } }, + }, }); mockHaapiFetchStep(HAAPI_STEPS.COMPLETED_WITH_SUCCESS); @@ -743,7 +747,7 @@ describe('HaapiStepper', () => { describe('error', () => { beforeEach(() => { mockHaapiFetchWebAuthnStep(HAAPI_STEPS.AUTHENTICATION, createMockWebAuthnAuthenticationAction()); - mockRunWebAuthnAuthentication.mockRejectedValue(failedWebAuthnCeremonyError()); + mockRunWebAuthnAuthentication.mockResolvedValue({ clientOperationError: failedWebAuthnCeremonyError() }); }); it('provides the error via error.app', async () => { From 58574efba09dba34a2ddef970c8866c2f58d2f82 Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Thu, 21 May 2026 08:20:04 +0200 Subject: [PATCH 5/5] IS-11327 Move shared ClientOperationResult to operations/typings.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClientOperationResult is no longer WebAuthn-specific — the EBF and BankID runners will need it next. Extract it into a new sibling typings.ts file alongside the operation runners; keep the WebAuthn-specific enums where they are. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../operations/client-operations.ts | 2 +- .../client-operation/operations/typings.ts | 27 +++++++++++++++++++ .../operations/webauthn/typings.ts | 7 ----- .../operations/webauthn/webauthn.ts | 3 ++- 4 files changed, 30 insertions(+), 9 deletions(-) create mode 100644 src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/typings.ts 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 703bd971..19ba00e7 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 @@ -31,7 +31,7 @@ import { runWebAuthnAuthentication, runWebAuthnRegistration, } from './webauthn'; -import { ClientOperationResult } from './webauthn/typings'; +import { ClientOperationResult } from './typings'; export function isClientOperation( action: HaapiAction | HaapiStepperAction | HaapiLink | HaapiStepperLink 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 00000000..2f307798 --- /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 index df5ecbc7..cf468efc 100644 --- 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 @@ -1,6 +1,3 @@ -import { HaapiFetchFormAction } from '../../../../../data-access'; -import { HaapiStepperError } from '../../../../stepper/haapi-stepper.types'; - export enum WEBAUTHN_ERROR_TYPE { CANCEL_OR_TIMEOUT = 'cancelOrTimeout', FAILED = 'failed', @@ -10,7 +7,3 @@ export enum WEBAUTHN_OPERATION { REGISTRATION = 'registration', AUTHENTICATION = 'authentication', } - -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/webauthn.ts b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/webauthn/webauthn.ts index 925a03a6..b7054fb9 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 @@ -17,7 +17,8 @@ import { import { HaapiStep } from '../../../../../data-access/types/haapi-step.types'; import type { HaapiStepperError } from '../../../../stepper/haapi-stepper.types'; import { isPasskeysWebAuthnRegistrationAction, isWebAuthnApiSupported } from './utils'; -import { ClientOperationResult, WEBAUTHN_ERROR_TYPE, WEBAUTHN_OPERATION } from './typings'; +import { WEBAUTHN_ERROR_TYPE, WEBAUTHN_OPERATION } from './typings'; +import { ClientOperationResult } from '../typings'; import { getHaapiStepperError } from '../client-operations'; type WebAuthnCredentialResult =