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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -189,30 +189,27 @@ 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;
arguments: HaapiWebAuthnRegistrationArgs;
continueActions: [HaapiFormAction];
}

export type HaapiWebAuthnPasskeysRegistrationAction = Omit<HaapiWebAuthnRegistrationClientOperationAction, 'model'> & {
export interface HaapiWebAuthnPasskeysRegistrationAction extends HaapiClientOperationAction {
model: Omit<HaapiWebAuthnRegistrationClientOperationModel, 'arguments'> & {
arguments: HaapiWebAuthnPasskeysArgs;
};
};
}

export type HaapiWebAuthnAnyDeviceRegistrationAction = Omit<HaapiWebAuthnRegistrationClientOperationAction, 'model'> & {
export interface HaapiWebAuthnAnyDeviceRegistrationAction extends HaapiClientOperationAction {
model: Omit<HaapiWebAuthnRegistrationClientOperationModel, 'arguments'> & {
arguments: HaapiWebAuthnAnyDeviceArgs;
};
};
}

/**
* Discriminated union of `webauthn-registration` action arguments.
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -351,4 +351,26 @@ export interface HaapiMetadata {
templateArea?: string;
/** The name for the view that produced the response */
viewName?: string;
/** Categorised view-specific data */
viewData?: HaapiMetadataViewData;
}

export interface HaapiMetadataViewData {
error?: HaapiMetadataViewDataError;
}

export interface HaapiMetadataViewDataError {
clientOperation?: HaapiClientOperationErrorMessages;
}
export interface HaapiClientOperationErrorMessages {
webauthn?: HaapiWebAuthnErrorMessages;
}

export interface HaapiWebAuthnErrorMessages {
/** Cancel or timeout — shared between registration and authentication ceremonies. */
cancelOrTimeout?: string;
/** Registration ceremony failed for any reason other than cancel/timeout. */
registration?: string;
/** Authentication ceremony failed for any reason other than cancel/timeout. */
authentication?: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,15 @@ import {
HAAPI_ACTION_TYPES,
HaapiAction,
} from '../../../../data-access/types/haapi-action.types';
import { HaapiLink } from '../../../../data-access/types/haapi-step.types';
import {
HAAPI_PROBLEM_STEPS,
HaapiLink,
HaapiStep,
HaapiUserMessage,
} from '../../../../data-access/types/haapi-step.types';
import { RefObject } from 'react';
import { HaapiStepperAction, HaapiStepperLink } from '../../../stepper/haapi-stepper.types';
import { HaapiFetchFormAction } from '../../../../data-access/types/haapi-fetch.types';
import { HaapiStepperAction, HaapiStepperError, HaapiStepperLink } from '../../../stepper/haapi-stepper.types';
import { formatErrorStepData } from '../../../stepper/data-formatters/problem-step';
import { isBankIdClientOperation, runBankIdAuthentication } from './bankid';
import { isExternalBrowserFlowClientOperation, runExternalBrowserFlow } from './external-browser-flow';
import {
Expand All @@ -26,52 +31,62 @@ import {
runWebAuthnAuthentication,
runWebAuthnRegistration,
} from './webauthn';
import { ClientOperationResult } from './typings';

export function isClientOperation(
action: HaapiAction | HaapiStepperAction | HaapiLink | HaapiStepperLink
): action is HaapiClientOperationAction {
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<AbortController | NodeJS.Timeout | null>
): Promise<HaapiFetchFormAction | null> {
pendingOperation: RefObject<AbortController | NodeJS.Timeout | null>,
currentStep: HaapiStep | null
): Promise<ClientOperationResult> {
const abortController = new AbortController();
pendingOperation.current = abortController;
const signal = abortController.signal;

try {
if (isExternalBrowserFlowClientOperation(action)) {
return await runExternalBrowserFlow(action, 2500, abortController.signal);
}

if (isWebAuthnRegistrationClientOperation(action)) {
return await runWebAuthnRegistration(action, abortController.signal);
}
if (isWebAuthnRegistrationClientOperation(action)) {
return runWebAuthnRegistration(action, signal, currentStep);
}

if (isWebAuthnAuthenticationClientOperation(action)) {
return await runWebAuthnAuthentication(action, abortController.signal);
}
if (isWebAuthnAuthenticationClientOperation(action)) {
return runWebAuthnAuthentication(action, signal, currentStep);
}

if (isBankIdClientOperation(action)) {
return await runBankIdAuthentication(action);
}
} catch (err) {
/**
* If the operation was aborted by the caller, convert to null - i.e. no further action - instead of error
* Note that the cancellation is triggered by code on this file and a 'reason' is not provided, so we can rely on
* the error being the default AbortError.
*/
if (abortController.signal.aborted && err instanceof DOMException && err.name === 'AbortError') {
return null;
}
if (isExternalBrowserFlowClientOperation(action)) {
return runExternalBrowserFlow(action, 2500, signal).then(clientOperationData => ({ clientOperationData }));
}

throw err;
if (isBankIdClientOperation(action)) {
return runBankIdAuthentication(action).then(clientOperationData => ({ clientOperationData }));
}

throw new Error(`Unsupported client operation: ${action.model.name}`);
}

/**
* Synthesises a {@link HaapiStepperError} for a client-operation failure (IS-11327).
*
* Client-operation failures (WebAuthn ceremony cancel / timeout / parse error / unsupported
* API today; BankID / EBF on the same pattern when their per-operation error handling lands)
* happen on the client and aren't part of the HAAPI response, so the stepper has no native
* category for them. We treat them as `AppError`-class problems of the current step — building
* a `HaapiUnexpectedProblemStep` via {@link formatErrorStepData} — so they surface via
* `useHaapiStepper().error.app` like any server-driven problem and consumers handle them
* through the same channel (e.g. `HaapiStepperErrorNotifier`).
*
* Callers resolve the user-facing copy themselves (typically from
* `step.metadata.viewData.error.clientOperation.<operationKey>`) 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,
});
}
Original file line number Diff line number Diff line change
@@ -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 };
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export enum WEBAUTHN_ERROR_TYPE {
CANCEL_OR_TIMEOUT = 'cancelOrTimeout',
FAILED = 'failed',
}

export enum WEBAUTHN_OPERATION {
REGISTRATION = 'registration',
AUTHENTICATION = 'authentication',
}
Loading
Loading