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 1946b150..82e1d8da 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 @@ -198,15 +198,55 @@ export interface HaapiWebAuthnRegistrationClientOperationAction extends HaapiCli export interface HaapiWebAuthnRegistrationClientOperationModel extends HaapiBaseClientOperationModel { name: HAAPI_ACTION_CLIENT_OPERATIONS.WEBAUTHN_REGISTRATION; - arguments: - | { credentialCreationOptions: HaapiPublicKeyCredentialCreationOptions } - | { - platformCredentialCreationOptions?: HaapiPublicKeyCredentialCreationOptions; - crossPlatformCredentialCreationOptions?: HaapiPublicKeyCredentialCreationOptions; - }; + arguments: HaapiWebAuthnRegistrationArgs; continueActions: [HaapiFormAction]; } +export type HaapiWebAuthnPasskeysRegistrationAction = Omit & { + model: Omit & { + arguments: HaapiWebAuthnPasskeysArgs; + }; +}; + +export type HaapiWebAuthnAnyDeviceRegistrationAction = Omit & { + model: Omit & { + arguments: HaapiWebAuthnAnyDeviceArgs; + }; +}; + +/** + * Discriminated union of `webauthn-registration` action arguments. + * + * - Passkeys-mode (`passkey` authenticator or `webauthn` in passkeys-mode): only + * `credentialCreationOptions` is present. + * - Any-device-mode (`webauthn` authenticator): one or both of + * `platformCredentialCreationOptions` / `crossPlatformCredentialCreationOptions`. + */ +export type HaapiWebAuthnRegistrationArgs = HaapiWebAuthnPasskeysArgs | HaapiWebAuthnAnyDeviceArgs; + +export interface HaapiWebAuthnPasskeysArgs { + credentialCreationOptions: HaapiPublicKeyCredentialCreationOptions; +} + +export interface HaapiWebAuthnAnyDeviceArgs { + platformCredentialCreationOptions?: HaapiPublicKeyCredentialCreationOptions; + crossPlatformCredentialCreationOptions?: HaapiPublicKeyCredentialCreationOptions; +} + +/** + * Continue-action payload key for the `webauthn-registration` operation. The value matches the + * `*CreationOptions` key the client picked from `model.arguments`. + * + * - `CREDENTIAL` — passkeys-mode (when `HaapiWebAuthnPasskeysArgs.credentialCreationOptions` is present). + * - `PLATFORM_CREDENTIAL` — any-device-mode (when `HaapiWebAuthnAnyDeviceArgs.platformCredentialCreationOptions` is present). + * - `CROSS_PLATFORM_CREDENTIAL` — any-device-mode (when `HaapiWebAuthnAnyDeviceArgs.crossPlatformCredentialCreationOptions` is present). + */ +export enum HAAPI_WEBAUTHN_REGISTRATION_SELECTED_OPTION { + CREDENTIAL = 'credential', + PLATFORM_CREDENTIAL = 'platformCredential', + CROSS_PLATFORM_CREDENTIAL = 'crossPlatformCredential', +} + export interface HaapiPublicKeyCredentialCreationOptions { publicKey: PublicKeyCredentialCreationOptionsJSON; } diff --git a/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/HaapiStepperClientOperationUI.spec.tsx b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/HaapiStepperClientOperationUI.spec.tsx new file mode 100644 index 00000000..e9935b58 --- /dev/null +++ b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/HaapiStepperClientOperationUI.spec.tsx @@ -0,0 +1,158 @@ +/* + * 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { HAAPI_STEPS } from '../../../data-access/types/haapi-step.types'; +import { + createMockBankIdAction, + createMockExternalBrowserFlowAction, + createMockStep, + createMockWebAuthnAnyDeviceBothOptionsAction, + createMockWebAuthnPlatformOnlyAnyDeviceAction, + createMockWebAuthnRegistrationAction, + externalBrowserFlowActionTitle, + webAuthnAnyDeviceActionTitle, + webAuthnPlatformOnlyAnyDeviceActionTitle, + webAuthnRegistrationActionTitle, +} from '../../../util/tests/mocks'; +import { HaapiStepperActionsUI } from '../../../ui/actions/HaapiStepperActionsUI'; +import { HaapiStepperClientOperationUI } from './HaapiStepperClientOperationUI'; +import { useIsWebAuthnPlatformAuthenticatorAvailable } from './operations/webauthn'; + +vi.mock('./operations/webauthn', async () => { + const actual = await vi.importActual('./operations/webauthn'); + return { + ...(actual as object), + useIsWebAuthnPlatformAuthenticatorAvailable: vi.fn(() => undefined), + }; +}); + +describe('HaapiStepperClientOperationUI', () => { + let user: ReturnType; + + beforeEach(() => { + user = userEvent.setup(); + }); + + describe('Default rendering', () => { + it('renders the action title as an enabled button', () => { + const action = createMockExternalBrowserFlowAction(); + + render(); + + expect(screen.getByRole('button', { name: externalBrowserFlowActionTitle })).toBeEnabled(); + }); + + it('does not render a progress bar when the action has no remaining wait time', () => { + const action = createMockExternalBrowserFlowAction(); + + render(); + + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + + it('forwards the action to onAction when clicked', async () => { + const action = createMockExternalBrowserFlowAction(); + const onAction = vi.fn(); + + render(); + + await user.click(screen.getByRole('button', { name: externalBrowserFlowActionTitle })); + + expect(onAction).toHaveBeenCalledTimes(1); + expect(onAction).toHaveBeenCalledWith(action); + }); + }); + + describe('BankID polling progress', () => { + it('renders a progress bar reflecting the session remaining time', () => { + const action = createMockBankIdAction({ maxWaitTime: 60, maxWaitRemainingTime: 30 }); + + render(); + + const progress = screen.getByRole('progressbar'); + expect(progress).toHaveAttribute('value', '30'); + expect(progress).toHaveAttribute('max', '60'); + }); + + it('hides the progress bar when showBankIdSessionTimeLeft is false', () => { + const action = createMockBankIdAction({ maxWaitTime: 60, maxWaitRemainingTime: 30 }); + + render(); + + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + }); + + describe('WebAuthn', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.mocked(useIsWebAuthnPlatformAuthenticatorAvailable).mockReset(); + }); + + it('enables the button when the WebAuthn API is available', () => { + vi.stubGlobal('PublicKeyCredential', stubPublicKeyCredential()); + const action = createMockWebAuthnRegistrationAction(); + + render(); + + expect(screen.getByRole('button', { name: webAuthnRegistrationActionTitle })).toBeEnabled(); + }); + + it('disables the button when the WebAuthn API is unavailable', () => { + // jsdom does not expose `PublicKeyCredential`, so `isWebAuthnApiSupported()` returns false + // and the gate disables WebAuthn buttons. + const action = createMockWebAuthnRegistrationAction(); + + render(); + + expect(screen.getByRole('button', { name: webAuthnRegistrationActionTitle })).toBeDisabled(); + }); + + it('disables a platform-only any-device registration when no platform authenticator is available', () => { + vi.stubGlobal('PublicKeyCredential', stubPublicKeyCredential()); + vi.mocked(useIsWebAuthnPlatformAuthenticatorAvailable).mockReturnValue(false); + const action = createMockWebAuthnPlatformOnlyAnyDeviceAction(); + + render(); + + expect(screen.getByRole('button', { name: webAuthnPlatformOnlyAnyDeviceActionTitle })).toBeDisabled(); + }); + + it('renders one button per credential option for any-device-mode with both options, suffixing the original title', () => { + const action = createMockWebAuthnAnyDeviceBothOptionsAction(); + const step = createMockStep(HAAPI_STEPS.AUTHENTICATION, { actions: [action] }); + + render(); + + expect(screen.getByRole('button', { name: `${webAuthnAnyDeviceActionTitle} (This device)` })).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: `${webAuthnAnyDeviceActionTitle} (Another device)` }) + ).toBeInTheDocument(); + }); + }); +}); + +/** + * Minimal stand-in for the static `PublicKeyCredential` interface — enough for + * `isWebAuthnApiSupported()` to return true and for the platform-authenticator hook to resolve. + * jsdom doesn't expose `PublicKeyCredential`, so tests stub it to emulate a WebAuthn-capable + * browser without reaching into the real `navigator.credentials` API. + */ +const stubPublicKeyCredential = () => + Object.assign(vi.fn(), { + parseCreationOptionsFromJSON: vi.fn(), + parseRequestOptionsFromJSON: vi.fn(), + isUserVerifyingPlatformAuthenticatorAvailable: vi.fn(() => Promise.resolve(true)), + }); diff --git a/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/HaapiStepperClientOperationUI.tsx b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/HaapiStepperClientOperationUI.tsx index db887366..99673374 100644 --- a/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/HaapiStepperClientOperationUI.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/HaapiStepperClientOperationUI.tsx @@ -9,35 +9,33 @@ * For further information, please contact Curity AB. */ -import { ReactNode } from 'react'; import { HaapiStepperClientOperationAction, HaapiStepperFormAction } from '../../stepper/haapi-stepper.types'; +import { useIsClientOperationAvailable } from './useIsClientOperationAvailable'; interface HaapiStepperClientOperationUIProps { action: HaapiStepperClientOperationAction; onAction: (action: HaapiStepperClientOperationAction | HaapiStepperFormAction) => void; showBankIdSessionTimeLeft?: boolean; - render?: ( - action: HaapiStepperClientOperationAction, - onAction: (action: HaapiStepperClientOperationAction | HaapiStepperFormAction) => void, - showBankIdSessionTimeLeft: boolean - ) => ReactNode; } /** * @description * # CLIENT OPERATION ACTION COMPONENT * - * Provides the default UI wrapper for HAAPI client-operation actions and exposes a render - * prop to fully customise the look and feel while keeping the underlying behaviour intact. - * It also forwards the client operation option clicked to the provided `onAction` handler. + * Renders the default UI for a HAAPI client-operation action and forwards the click to + * `onAction`. The button is disabled when the action's runtime capability requirements are + * not met (e.g. WebAuthn API missing, or platform authenticator unavailable for platform-only + * WebAuthn registration). * * @example * ```tsx * function HaapiComponentExample() { - * const { currentStep, nextStep } = useHaapiStepper(); * - * const clientOperationAction = currentStep?.dataHelpers.clientOperationActions?.[0]; + * const { currentStep, nextStep } = useHaapiStepper(); + * const clientOperationAction = currentStep?.dataHelpers.actions?.clientOperation?.[0]; * - * return { clientOperationAction && }; + * return clientOperationAction && ( + * + * ); * } * * @@ -49,26 +47,21 @@ export function HaapiStepperClientOperationUI({ action, onAction, showBankIdSessionTimeLeft = true, - render = defaultRenderClientOperation, }: HaapiStepperClientOperationUIProps) { - return render(action, onAction, showBankIdSessionTimeLeft); -} + const isAvailable = useIsClientOperationAvailable(action); -const defaultRenderClientOperation = ( - action: HaapiStepperClientOperationAction, - onAction: (action: HaapiStepperClientOperationAction | HaapiStepperFormAction) => void, - showBankIdSessionTimeLeft: boolean -) => ( -
- {showBankIdSessionTimeLeft && action.maxWaitRemainingTime !== undefined && ( - - )} - -
-); + return ( +
+ {showBankIdSessionTimeLeft && action.maxWaitRemainingTime !== undefined && ( + + )} + +
+ ); +} diff --git a/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/client-operations.ts b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/client-operations.ts deleted file mode 100644 index ab28d14d..00000000 --- a/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/client-operations.ts +++ /dev/null @@ -1,272 +0,0 @@ -/* - * 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 { - HaapiClientOperationAction, - HaapiExternalBrowserFlowClientOperationAction, - HAAPI_ACTION_CLIENT_OPERATIONS, - HAAPI_ACTION_TYPES, - HaapiAction, - HaapiWebAuthnAuthenticationClientOperationAction, - HaapiWebAuthnRegistrationClientOperationAction, - HaapiBankIdClientOperationAction, -} from '../../../data-access/types/haapi-action.types'; -import { exhaustiveCheck } from '../../../../shared/util/type-utils'; -import { HaapiLink } 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 { openBankIdApp } from './openBankIdApp'; - -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 -): Promise { - const abortController = new AbortController(); - pendingOperation.current = abortController; - - try { - if (isExternalBrowserFlowClientOperation(action)) { - return await runExternalBrowserFlow(action, 2500, abortController.signal); - } - - if (isWebAuthnRegistrationClientOperation(action)) { - // TODO how to handle the different types of credentials on registration - return await runWebAuthnRegistration(action, 'platformCredential', abortController.signal); - } - - if (isWebAuthnAuthenticationClientOperation(action)) { - return await runWebAuthnAuthentication(action, abortController.signal); - } - - 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; - } - - throw err; - } - - throw new Error(`Unsupported client operation: ${action.model.name}`); -} - -/** - * Executes an external browser flow by opening a new window in the launch URL defined by the action and waiting for - * the completion message from that window. - * - * When the flow completes, the returned promise resolves with the form action and values that should be used to resume - * the flow via HAAPI. - * - * The flow can be cancelled by aborting the provided AbortSignal, in which case the external window is closed and the - * returned promise is rejected. - * - * @param action the external browser flow action to execute - * @param closeDelay the delay in milliseconds before closing the external window after successful completion - * @param abortSignal an AbortSignal to listen to for cancellation of the flow - * @returns a promise that represents the execution of the external browser flow - */ -export function runExternalBrowserFlow( - action: HaapiExternalBrowserFlowClientOperationAction, - closeDelay: number, - abortSignal: AbortSignal -): Promise { - return new Promise((resolve, reject) => { - const launchUrl = new URL(action.model.arguments.href); - launchUrl.searchParams.set('for_origin', window.location.origin); - - const externalWindow = window.open(launchUrl); - if (!externalWindow) { - reject(new Error('Failed to open external browser window')); - return; - } - - const onMessage = (event: MessageEvent) => { - if (event.source !== externalWindow) { - return; - } - if (event.origin !== launchUrl.origin || typeof event.data !== 'string') { - reject(new Error('External browser flow: unexpected origin or type in resume message')); - return; - } - - cleanup(false); - resolve({ action: action.model.continueActions[0], payload: new Map([['_resume_nonce', event.data]]) }); - }; - - const onAbort = () => { - cleanup(true); - reject(abortSignal.reason as Error); - }; - - window.addEventListener('message', onMessage); - abortSignal.addEventListener('abort', onAbort); - - const cleanup = (closeImmediately: boolean) => { - window.removeEventListener('message', onMessage); - abortSignal.removeEventListener('abort', onAbort); - if (closeImmediately) { - externalWindow.close(); - } else { - setTimeout(() => externalWindow.close(), closeDelay); - } - }; - }); -} - -/** - * Executes a WebAuthn registration operation, returning a request to continue the flow with the created credential. - * - * @param action the WebAuthn registration action to execute - * @param selectedOption the selected credential option - * @param abortSignal an AbortSignal to listen to for cancellation of the operation - */ -export async function runWebAuthnRegistration( - action: HaapiWebAuthnRegistrationClientOperationAction, - selectedOption: 'credential' | 'platformCredential' | 'crossPlatformCredential', - abortSignal: AbortSignal -): Promise { - const isSupported = - typeof PublicKeyCredential === 'function' && typeof PublicKeyCredential.parseCreationOptionsFromJSON === 'function'; - - if (!isSupported) { - throw new Error('PublicKeyCredential not supported'); - } - - let options: PublicKeyCredentialCreationOptionsJSON | undefined; - - switch (selectedOption) { - case 'credential': { - if ('credentialCreationOptions' in action.model.arguments) { - options = action.model.arguments.credentialCreationOptions.publicKey; - } - break; - } - - case 'platformCredential': { - if ('platformCredentialCreationOptions' in action.model.arguments) { - options = action.model.arguments.platformCredentialCreationOptions?.publicKey; - } - break; - } - - case 'crossPlatformCredential': { - if ('crossPlatformCredentialCreationOptions' in action.model.arguments) { - options = action.model.arguments.crossPlatformCredentialCreationOptions?.publicKey; - } - break; - } - - default: - exhaustiveCheck(selectedOption); - } - - if (options === undefined) { - throw new Error('Could not find options for selected credential type'); - } - - const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(options); - const credential = (await navigator.credentials.create({ - publicKey, - signal: abortSignal, - })) as PublicKeyCredential | null; - - if (credential === null) { - throw new Error('Could not create credential'); - } - - const nextAction = action.model.continueActions[0]; - const payload = { - [selectedOption]: credential.toJSON() as unknown, - }; - - return { action: nextAction, payload }; -} - -/** - * Executes a WebAuthn authentication operation, returning a request to continue the flow with the obtained credential. - * - * @param action the WebAuthn authentication action to execute - * @param abortSignal an AbortSignal to listen to for cancellation of the operation - */ -export async function runWebAuthnAuthentication( - action: HaapiWebAuthnAuthenticationClientOperationAction, - abortSignal: AbortSignal -): Promise { - const isSupported = - typeof PublicKeyCredential === 'function' && typeof PublicKeyCredential.parseRequestOptionsFromJSON === 'function'; - - if (!isSupported) { - throw new Error('PublicKeyCredential not supported'); - } - - const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON( - action.model.arguments.credentialRequestOptions.publicKey - ); - const credential = (await navigator.credentials.get({ - publicKey, - signal: abortSignal, - })) as PublicKeyCredential | null; - - if (credential === null) { - throw new Error('Could not get credential'); - } - - const nextAction = action.model.continueActions[0]; - const payload = { - credential: credential.toJSON() as unknown, - }; - return { action: nextAction, payload }; -} - -export async function runBankIdAuthentication(action: HaapiBankIdClientOperationAction): Promise { - openBankIdApp(action); - - const nextAction = action.model.continueActions[0]; - - return Promise.resolve({ action: nextAction }); -} - -export const isBankIdClientOperation = (action: HaapiAction): action is HaapiBankIdClientOperationAction => - action.template === HAAPI_ACTION_TYPES.CLIENT_OPERATION && - action.model.name === HAAPI_ACTION_CLIENT_OPERATIONS.BANKID; - -export const isExternalBrowserFlowClientOperation = ( - action: HaapiClientOperationAction -): action is HaapiExternalBrowserFlowClientOperationAction => - action.model.name === HAAPI_ACTION_CLIENT_OPERATIONS.EXTERNAL_BROWSER_FLOW; - -export const isWebAuthnRegistrationClientOperation = ( - action: HaapiClientOperationAction -): action is HaapiWebAuthnRegistrationClientOperationAction => - action.model.name === HAAPI_ACTION_CLIENT_OPERATIONS.WEBAUTHN_REGISTRATION; - -export const isWebAuthnAuthenticationClientOperation = ( - action: HaapiClientOperationAction -): action is HaapiWebAuthnAuthenticationClientOperationAction => - action.model.name === HAAPI_ACTION_CLIENT_OPERATIONS.WEBAUTHN_AUTHENTICATION; diff --git a/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/openBankIdApp.ts b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/openBankIdApp.ts deleted file mode 100644 index 07391f50..00000000 --- a/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/openBankIdApp.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { HaapiBankIdClientOperationAction } from '../../../data-access/types/haapi-action.types'; -import { isMobileDevice } from '../../../util/isMobileDevice'; - -export function openBankIdApp(action: HaapiBankIdClientOperationAction) { - const token = action.model.arguments.autoStartToken; - const bankIDAppHref = isMobileDevice() - ? `https://app.bankid.com/?autostarttoken=${token}` - : `bankid:///?autostarttoken=${token}`; - - const anchor = document.createElement('a'); - anchor.href = bankIDAppHref; - anchor.referrerPolicy = 'origin'; - anchor.click(); -} diff --git a/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/bankid/bankid.ts b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/bankid/bankid.ts new file mode 100644 index 00000000..c318a086 --- /dev/null +++ b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/bankid/bankid.ts @@ -0,0 +1,22 @@ +/* + * 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 { HaapiBankIdClientOperationAction } from '../../../../../data-access/types/haapi-action.types'; +import { HaapiFetchFormAction } from '../../../../../data-access/types/haapi-fetch.types'; +import { openBankIdApp } from './open-bankid-app'; + +export async function runBankIdAuthentication(action: HaapiBankIdClientOperationAction): Promise { + openBankIdApp(action); + + const nextAction = action.model.continueActions[0]; + + return Promise.resolve({ action: nextAction }); +} diff --git a/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/bankid/index.ts b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/bankid/index.ts new file mode 100644 index 00000000..a527a1ba --- /dev/null +++ b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/bankid/index.ts @@ -0,0 +1,14 @@ +/* + * 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. + */ + +export * from './bankid'; +export * from './open-bankid-app'; +export * from './utils'; diff --git a/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/bankid/open-bankid-app.ts b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/bankid/open-bankid-app.ts new file mode 100644 index 00000000..9d0d532e --- /dev/null +++ b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/bankid/open-bankid-app.ts @@ -0,0 +1,25 @@ +/* + * 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 { HaapiBankIdClientOperationAction } from '../../../../../data-access'; +import { isMobileDevice } from '../../../../../util/isMobileDevice'; + +export function openBankIdApp(action: HaapiBankIdClientOperationAction) { + const token = action.model.arguments.autoStartToken; + const bankIDAppHref = isMobileDevice() + ? `https://app.bankid.com/?autostarttoken=${token}` + : `bankid:///?autostarttoken=${token}`; + + const anchor = document.createElement('a'); + anchor.href = bankIDAppHref; + anchor.referrerPolicy = 'origin'; + anchor.click(); +} diff --git a/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/bankid/utils.ts b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/bankid/utils.ts new file mode 100644 index 00000000..c94e545a --- /dev/null +++ b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/bankid/utils.ts @@ -0,0 +1,21 @@ +/* + * 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 { + HAAPI_ACTION_CLIENT_OPERATIONS, + HAAPI_ACTION_TYPES, + HaapiAction, + HaapiBankIdClientOperationAction, +} from '../../../../../data-access/types/haapi-action.types'; + +export const isBankIdClientOperation = (action: HaapiAction): action is HaapiBankIdClientOperationAction => + action.template === HAAPI_ACTION_TYPES.CLIENT_OPERATION && + action.model.name === HAAPI_ACTION_CLIENT_OPERATIONS.BANKID; 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 new file mode 100644 index 00000000..5821d776 --- /dev/null +++ b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/client-operations.ts @@ -0,0 +1,77 @@ +/* + * 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 { + HaapiClientOperationAction, + HAAPI_ACTION_TYPES, + HaapiAction, +} from '../../../../data-access/types/haapi-action.types'; +import { HaapiLink } 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 { isBankIdClientOperation, runBankIdAuthentication } from './bankid'; +import { isExternalBrowserFlowClientOperation, runExternalBrowserFlow } from './external-browser-flow'; +import { + isWebAuthnAuthenticationClientOperation, + isWebAuthnRegistrationClientOperation, + runWebAuthnAuthentication, + runWebAuthnRegistration, +} from './webauthn'; + +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 +): Promise { + const abortController = new AbortController(); + pendingOperation.current = abortController; + + try { + if (isExternalBrowserFlowClientOperation(action)) { + return await runExternalBrowserFlow(action, 2500, abortController.signal); + } + + if (isWebAuthnRegistrationClientOperation(action)) { + return await runWebAuthnRegistration(action, abortController.signal); + } + + if (isWebAuthnAuthenticationClientOperation(action)) { + return await runWebAuthnAuthentication(action, abortController.signal); + } + + 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; + } + + throw err; + } + + throw new Error(`Unsupported client operation: ${action.model.name}`); +} diff --git a/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/external-browser-flow.ts b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/external-browser-flow.ts new file mode 100644 index 00000000..582e5717 --- /dev/null +++ b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/external-browser-flow.ts @@ -0,0 +1,87 @@ +/* + * 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 { + HaapiAction, + HAAPI_ACTION_CLIENT_OPERATIONS, + HAAPI_ACTION_TYPES, + HaapiExternalBrowserFlowClientOperationAction, +} from '../../../../data-access/types/haapi-action.types'; +import { HaapiFetchFormAction } from '../../../../data-access/types/haapi-fetch.types'; + +/** + * Executes an external browser flow by opening a new window in the launch URL defined by the action and waiting for + * the completion message from that window. + * + * When the flow completes, the returned promise resolves with the form action and values that should be used to resume + * the flow via HAAPI. + * + * The flow can be cancelled by aborting the provided AbortSignal, in which case the external window is closed and the + * returned promise is rejected. + * + * @param action the external browser flow action to execute + * @param closeDelay the delay in milliseconds before closing the external window after successful completion + * @param abortSignal an AbortSignal to listen to for cancellation of the flow + * @returns a promise that represents the execution of the external browser flow + */ +export function runExternalBrowserFlow( + action: HaapiExternalBrowserFlowClientOperationAction, + closeDelay: number, + abortSignal: AbortSignal +): Promise { + return new Promise((resolve, reject) => { + const launchUrl = new URL(action.model.arguments.href); + launchUrl.searchParams.set('for_origin', window.location.origin); + + const externalWindow = window.open(launchUrl); + if (!externalWindow) { + reject(new Error('Failed to open external browser window')); + return; + } + + const onMessage = (event: MessageEvent) => { + if (event.source !== externalWindow) { + return; + } + if (event.origin !== launchUrl.origin || typeof event.data !== 'string') { + reject(new Error('External browser flow: unexpected origin or type in resume message')); + return; + } + + cleanup(false); + resolve({ action: action.model.continueActions[0], payload: new Map([['_resume_nonce', event.data]]) }); + }; + + const onAbort = () => { + cleanup(true); + reject(abortSignal.reason as Error); + }; + + window.addEventListener('message', onMessage); + abortSignal.addEventListener('abort', onAbort); + + const cleanup = (closeImmediately: boolean) => { + window.removeEventListener('message', onMessage); + abortSignal.removeEventListener('abort', onAbort); + if (closeImmediately) { + externalWindow.close(); + } else { + setTimeout(() => externalWindow.close(), closeDelay); + } + }; + }); +} + +export const isExternalBrowserFlowClientOperation = ( + action: HaapiAction +): action is HaapiExternalBrowserFlowClientOperationAction => + action.template === HAAPI_ACTION_TYPES.CLIENT_OPERATION && + action.model.name === HAAPI_ACTION_CLIENT_OPERATIONS.EXTERNAL_BROWSER_FLOW; diff --git a/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/webauthn/index.ts b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/webauthn/index.ts new file mode 100644 index 00000000..1e3fa2fe --- /dev/null +++ b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/webauthn/index.ts @@ -0,0 +1,14 @@ +/* + * 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. + */ + +export * from './webauthn'; +export * from './utils'; +export * from './useIsWebAuthnPlatformAuthenticatorAvailable'; diff --git a/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/webauthn/useIsWebAuthnPlatformAuthenticatorAvailable.ts b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/webauthn/useIsWebAuthnPlatformAuthenticatorAvailable.ts new file mode 100644 index 00000000..97cff64d --- /dev/null +++ b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/webauthn/useIsWebAuthnPlatformAuthenticatorAvailable.ts @@ -0,0 +1,48 @@ +/* + * 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 { useEffect, useState } from 'react'; + +/** + * Returns whether the device exposes a user-verifying platform authenticator (Touch ID, + * Windows Hello, Android biometrics, …). + */ +export function useIsWebAuthnPlatformAuthenticatorAvailable(): boolean | undefined { + const [available, setAvailable] = useState(); + + useEffect(() => { + let cancelled = false; + + if (isWebAuthnPlatformAuthenticatorApiAvailable) { + void resolveAvailability().then(value => { + if (!cancelled) { + setAvailable(value); + } + }); + } + + return () => { + cancelled = true; + }; + }, []); + + return available; +} + +const isWebAuthnPlatformAuthenticatorApiAvailable = + typeof PublicKeyCredential === 'function' && 'isUserVerifyingPlatformAuthenticatorAvailable' in PublicKeyCredential; + +const resolveAvailability = (): Promise => { + if (!isWebAuthnPlatformAuthenticatorApiAvailable) { + return Promise.resolve(false); + } + return PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); +}; diff --git a/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/webauthn/utils.ts b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/webauthn/utils.ts new file mode 100644 index 00000000..6dfe0203 --- /dev/null +++ b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/webauthn/utils.ts @@ -0,0 +1,138 @@ +/* + * 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 { + HAAPI_ACTION_CLIENT_OPERATIONS, + HAAPI_ACTION_TYPES, + HaapiAction, + HaapiClientOperationAction, + HaapiWebAuthnAnyDeviceRegistrationAction, + HaapiWebAuthnAuthenticationClientOperationAction, + HaapiWebAuthnPasskeysRegistrationAction, + HaapiWebAuthnRegistrationClientOperationAction, +} from '../../../../../data-access/types/haapi-action.types'; + +const WEBAUTHN_PLATFORM_LABEL = 'This device'; +const WEBAUTHN_CROSS_PLATFORM_LABEL = 'Another device'; + +/** + * When the server returned both `platformCredentialCreationOptions` and + * `crossPlatformCredentialCreationOptions` (any-device mode of the `webauthn` authenticator), + * splits the action into two sibling actions — one per credential type, each with + * `model.arguments` narrowed to its single creation-options key and `title` suffixed with the + * matching English label (e.g. `"Register new device (This device)"`). Default action + * rendering produces one button per emitted action. + * + * Single-option (any-device platform-only / cross-platform-only) and passkeys-mode actions + * pass through unchanged. + * + * Throws if an any-device-mode action carries no creation options at all (malformed HAAPI). + */ +export function splitWebAuthnRegistrationAction( + action: HaapiWebAuthnRegistrationClientOperationAction +): HaapiClientOperationAction[] { + if (isAnyDeviceWebAuthnRegistrationAction(action)) { + const args = action.model.arguments; + + const platformAction: HaapiClientOperationAction[] = args.platformCredentialCreationOptions + ? [ + { + ...action, + title: composeTitle(action.title, WEBAUTHN_PLATFORM_LABEL), + model: { + ...action.model, + arguments: { platformCredentialCreationOptions: args.platformCredentialCreationOptions }, + }, + }, + ] + : []; + + const crossPlatformAction: HaapiClientOperationAction[] = args.crossPlatformCredentialCreationOptions + ? [ + { + ...action, + title: composeTitle(action.title, WEBAUTHN_CROSS_PLATFORM_LABEL), + model: { + ...action.model, + arguments: { crossPlatformCredentialCreationOptions: args.crossPlatformCredentialCreationOptions }, + }, + }, + ] + : []; + + const webAuthActions = [...platformAction, ...crossPlatformAction]; + + if (webAuthActions.length === 0) { + throw new Error('webauthn-registration action has no credential creation options'); + } + + return webAuthActions; + } + + return [action]; +} + +const composeTitle = (originalTitle: string | undefined, label: string): string => + originalTitle ? `${originalTitle} (${label})` : label; + +export const isWebAuthnRegistrationClientOperation = ( + action: HaapiAction +): action is HaapiWebAuthnRegistrationClientOperationAction => { + return ( + action.template === HAAPI_ACTION_TYPES.CLIENT_OPERATION && + action.model.name === HAAPI_ACTION_CLIENT_OPERATIONS.WEBAUTHN_REGISTRATION + ); +}; + +export const isWebAuthnAuthenticationClientOperation = ( + action: HaapiAction +): action is HaapiWebAuthnAuthenticationClientOperationAction => { + return ( + action.template === HAAPI_ACTION_TYPES.CLIENT_OPERATION && + action.model.name === HAAPI_ACTION_CLIENT_OPERATIONS.WEBAUTHN_AUTHENTICATION + ); +}; + +export const isWebAuthnClientOperationAction = (action: HaapiAction): boolean => + isWebAuthnRegistrationClientOperation(action) || isWebAuthnAuthenticationClientOperation(action); + +export function isPlatformOnlyAnyDeviceWebAuthnRegistrationAction(action: HaapiAction): boolean { + if (!isWebAuthnRegistrationClientOperation(action) || !isAnyDeviceWebAuthnRegistrationAction(action)) { + return false; + } + + const args = action.model.arguments; + + return ( + args.platformCredentialCreationOptions !== undefined && args.crossPlatformCredentialCreationOptions === undefined + ); +} + +/** + * Passkeys-mode (`passkey` authenticator or `webauthn` in passkeys-mode) — the server collapses + * platform vs cross-platform into a single option. Continue payload key: `credential`. + */ +export function isPasskeysWebAuthnRegistrationAction( + action: HaapiWebAuthnRegistrationClientOperationAction +): action is HaapiWebAuthnPasskeysRegistrationAction { + return 'credentialCreationOptions' in action.model.arguments; +} + +/** + * Any-device-mode (`webauthn` authenticator) — the server offers one or both of the platform / + * cross-platform options for the user to pick from. Continue payload keys: `platformCredential` + * and/or `crossPlatformCredential`. + */ +export function isAnyDeviceWebAuthnRegistrationAction( + action: HaapiWebAuthnRegistrationClientOperationAction +): action is HaapiWebAuthnAnyDeviceRegistrationAction { + return !isPasskeysWebAuthnRegistrationAction(action); +} 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 new file mode 100644 index 00000000..0edf72b5 --- /dev/null +++ b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/webauthn/webauthn.ts @@ -0,0 +1,149 @@ +/* + * 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 { + HAAPI_WEBAUTHN_REGISTRATION_SELECTED_OPTION, + HaapiWebAuthnAuthenticationClientOperationAction, + HaapiWebAuthnRegistrationClientOperationAction, +} from '../../../../../data-access/types/haapi-action.types'; +import { HaapiFetchFormAction } from '../../../../../data-access/types/haapi-fetch.types'; +import { isAnyDeviceWebAuthnRegistrationAction, isPasskeysWebAuthnRegistrationAction } from './utils'; + +const WEBAUTHN_API_NOT_SUPPORTED_ERROR_MESSAGE = 'WebAuthn API is not supported in this browser'; + +export function isWebAuthnApiSupported(): boolean { + return ( + typeof PublicKeyCredential === 'function' && + typeof PublicKeyCredential.parseCreationOptionsFromJSON === 'function' && + typeof PublicKeyCredential.parseRequestOptionsFromJSON === 'function' + ); +} + +/** + * Executes the `webauthn-registration` ceremony: prompts the browser for a new public-key + * credential and returns the HAAPI continue-action with the credential serialised under the + * payload key matching the option the server offered (`credential` / `platformCredential` / + * `crossPlatformCredential` (`HAAPI_WEBAUTHN_REGISTRATION_SELECTED_OPTION`)). + */ +export async function runWebAuthnRegistration( + action: HaapiWebAuthnRegistrationClientOperationAction, + abortSignal: AbortSignal +): Promise { + if (!isWebAuthnApiSupported()) { + throw new Error(WEBAUTHN_API_NOT_SUPPORTED_ERROR_MESSAGE); + } + + const selectedOption = getWebAuthnRegistrationSelectedOption(action); + const credential = await createWebAuthnRegistrationCredential(action, abortSignal); + + return { + action: action.model.continueActions[0], + payload: { [selectedOption]: credential.toJSON() as unknown }, + }; +} + +/** + * Executes the `webauthn-authentication` ceremony: prompts the browser for an existing + * public-key credential and returns the HAAPI continue-action with the credential serialised + * under the `credential` payload key. + */ +export async function runWebAuthnAuthentication( + action: HaapiWebAuthnAuthenticationClientOperationAction, + abortSignal: AbortSignal +): Promise { + if (!isWebAuthnApiSupported()) { + throw new Error(WEBAUTHN_API_NOT_SUPPORTED_ERROR_MESSAGE); + } + + const credential = await getWebAuthnAuthenticationCredential(action, abortSignal); + + return { + action: action.model.continueActions[0], + payload: { credential: credential.toJSON() as unknown }, + }; +} + +async function createWebAuthnRegistrationCredential( + action: HaapiWebAuthnRegistrationClientOperationAction, + abortSignal: AbortSignal +): Promise { + const creationOptions = getWebAuthnRegistrationCreationOptions(action); + const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(creationOptions); + const credential = (await navigator.credentials.create({ + publicKey, + signal: abortSignal, + })) as PublicKeyCredential | null; + + if (credential === null) { + throw new Error('Could not create credential'); + } + + return credential; +} + +async function getWebAuthnAuthenticationCredential( + action: HaapiWebAuthnAuthenticationClientOperationAction, + abortSignal: AbortSignal +): Promise { + const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON( + action.model.arguments.credentialRequestOptions.publicKey + ); + const credential = (await navigator.credentials.get({ + publicKey, + signal: abortSignal, + })) as PublicKeyCredential | null; + + if (credential === null) { + throw new Error('Could not get credential'); + } + + return credential; +} + +function getWebAuthnRegistrationSelectedOption( + action: HaapiWebAuthnRegistrationClientOperationAction +): HAAPI_WEBAUTHN_REGISTRATION_SELECTED_OPTION { + if (isPasskeysWebAuthnRegistrationAction(action)) { + 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; + } + } + + throw new Error('webauthn-registration action has no credential creation options'); +} + +function getWebAuthnRegistrationCreationOptions( + action: HaapiWebAuthnRegistrationClientOperationAction +): PublicKeyCredentialCreationOptionsJSON { + if (isPasskeysWebAuthnRegistrationAction(action)) { + 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; + } + } + + throw new Error('webauthn-registration action has no credential creation options'); +} diff --git a/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/useIsClientOperationAvailable.ts b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/useIsClientOperationAvailable.ts new file mode 100644 index 00000000..bb78d62c --- /dev/null +++ b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/useIsClientOperationAvailable.ts @@ -0,0 +1,30 @@ +/* + * 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 { HaapiStepperClientOperationAction } from '../../stepper/haapi-stepper.types'; +import { + isPlatformOnlyAnyDeviceWebAuthnRegistrationAction, + isWebAuthnApiSupported, + isWebAuthnClientOperationAction, + useIsWebAuthnPlatformAuthenticatorAvailable, +} from './operations/webauthn'; + +export function useIsClientOperationAvailable(action: HaapiStepperClientOperationAction): boolean { + const isPlatformAuthenticatorAvailable = useIsWebAuthnPlatformAuthenticatorAvailable(); + + if (isWebAuthnClientOperationAction(action) && !isWebAuthnApiSupported()) { + return false; + } + if (isPlatformOnlyAnyDeviceWebAuthnRegistrationAction(action) && isPlatformAuthenticatorAvailable === false) { + return false; + } + return true; +} diff --git a/src/login-web-app/src/haapi-stepper/feature/actions/selector/HaapiStepperSelectorUI.tsx b/src/login-web-app/src/haapi-stepper/feature/actions/selector/HaapiStepperSelectorUI.tsx index 2ed5756e..dd5a8804 100644 --- a/src/login-web-app/src/haapi-stepper/feature/actions/selector/HaapiStepperSelectorUI.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/actions/selector/HaapiStepperSelectorUI.tsx @@ -35,9 +35,9 @@ interface HaapiStepperSelectorUIProps { * ```tsx * function HaapiComponentExample() { * const { currentStep, nextStep } = useHaapiStepper(); - * const selectorAction = currentStep?.dataHelpers.selectorActions?.[0]; + * const selectorAction = currentStep?.dataHelpers.actions?.selector?.[0]; * - * return {selectorAction && } + * return selectorAction && ; * } * * diff --git a/src/login-web-app/src/haapi-stepper/feature/index.ts b/src/login-web-app/src/haapi-stepper/feature/index.ts index e164f98c..eae6e6bc 100644 --- a/src/login-web-app/src/haapi-stepper/feature/index.ts +++ b/src/login-web-app/src/haapi-stepper/feature/index.ts @@ -20,10 +20,9 @@ export * from './stepper/data-formatters/polling-step'; export * from './stepper/data-formatters/problem-step'; export * from './steps/HaapiStepperStepUI'; - export * from './actions/form/HaapiStepperFormUI'; export * from './actions/form/HaapiStepperFormValidationErrorInputWrapper'; export * from './actions/form/HaapiStepperFormHook'; -export * from './actions/client-operation/HaapiStepperClientOperationUI'; -export * from './actions/client-operation/client-operations'; export * from './actions/selector/HaapiStepperSelectorUI'; +export * from './actions/client-operation/HaapiStepperClientOperationUI'; +export * from './actions/client-operation/operations/client-operations'; 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 1b04f134..8252853f 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 @@ -732,11 +732,9 @@ vi.mock('../../util/useThrowErrorToAppErrorBoundary', () => ({ })); const mockOpenBankIdApp = vi.hoisted(() => vi.fn()); -vi.mock('../actions/client-operation/openBankIdApp', () => { - return { - openBankIdApp: mockOpenBankIdApp, - }; -}); +vi.mock('../actions/client-operation/operations/bankid/open-bankid-app', () => ({ + openBankIdApp: mockOpenBankIdApp, +})); const mockHaapiFetchStep = (step: HAAPI_STEPS | HAAPI_PROBLEM_STEPS, config: Record = {}) => { const stepMock = getStepMock(step, config); 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 840fac7b..bf4fbe93 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 @@ -20,7 +20,7 @@ import { } from '../../data-access/types/haapi-step.types'; import { HaapiStepperContext } from './HaapiStepperContext'; -import { isClientOperation, performClientOperation } from '../actions/client-operation/client-operations'; +import { isClientOperation, performClientOperation } from '../actions/client-operation/operations/client-operations'; import { formatContinueSameStepData } from './data-formatters/continue-same-step'; import { handlePollingStep } from './data-formatters/polling-step'; import { formatErrorStepData } from './data-formatters/problem-step'; @@ -131,15 +131,15 @@ type SetCurrentStepAndUpdateHistoryFn = ( * return
Error: {error.app.title}
; * } * - * const { formActions, selectorActions, clientOperationActions, links } = currentStep.dataHelpers || {}; + * const { actions, links } = currentStep.dataHelpers; * * return ( * <> - * {formActions?.map((action) => ())} - * {selectorActions?.map(action => )} - * {clientOperationActions?.map((action) => ( ))} - * {links?.map(link => ( - * * ))} @@ -170,26 +170,26 @@ type SetCurrentStepAndUpdateHistoryFn = ( * return
Error: {error.app.title}
; * } * - * const { formActions, clientOperationActions, links } = currentStep.dataHelpers || {}; + * const { actions, links } = currentStep.dataHelpers; * * return ( *
*

Step: {currentStep.type}

- * {formActions?.map((action) => ( - *
+ * {actions?.form.map(action => ( + *
*
{action.title}
* *
* ))} - * {clientOperationActions?.map((action) => ( - * * ))} - * {links?.map((link) => ( - * * ))} diff --git a/src/login-web-app/src/haapi-stepper/feature/stepper/data-formatters/format-next-step-data.ts b/src/login-web-app/src/haapi-stepper/feature/stepper/data-formatters/format-next-step-data.ts index e277c700..622e8ba3 100644 --- a/src/login-web-app/src/haapi-stepper/feature/stepper/data-formatters/format-next-step-data.ts +++ b/src/login-web-app/src/haapi-stepper/feature/stepper/data-formatters/format-next-step-data.ts @@ -29,6 +29,10 @@ import { HaapiStepperStep, HaapiStepperUserMessage, } from '../haapi-stepper.types'; +import { + isWebAuthnRegistrationClientOperation, + splitWebAuthnRegistrationAction, +} from '../../actions/client-operation/operations/webauthn'; export function formatNextStepData( step: T @@ -49,7 +53,8 @@ export function formatNextStepData addActionDataHelpers(action, step)); + const actions = getNextStepActions(step.actions); + const actionsWithDataHelpers = actions.map(action => addActionDataHelpers(action, step)); const actionsWithDataHelpersMap = buildActionsMap(actionsWithDataHelpers); return { @@ -61,6 +66,12 @@ export function formatNextStepData + isWebAuthnRegistrationClientOperation(action) ? splitWebAuthnRegistrationAction(action) : [action] + ); +} + function addActionDataHelpers( action: HaapiAction, step: HaapiActionStep | HaapiCompletedStep | HaapiStepperStep diff --git a/src/login-web-app/src/haapi-stepper/feature/stepper/data-formatters/polling-step.ts b/src/login-web-app/src/haapi-stepper/feature/stepper/data-formatters/polling-step.ts index f9f806cc..935865b6 100644 --- a/src/login-web-app/src/haapi-stepper/feature/stepper/data-formatters/polling-step.ts +++ b/src/login-web-app/src/haapi-stepper/feature/stepper/data-formatters/polling-step.ts @@ -9,8 +9,7 @@ import type { import { HaapiStepperConfig } from '../HaapiStepper'; import { formatNextStepData } from './format-next-step-data'; import { HAAPI_FORM_ACTION_KINDS } from '../../../data-access/types/haapi-action.types'; -import { isBankIdClientOperation } from '../../actions/client-operation/client-operations'; -import { openBankIdApp } from '../../actions/client-operation/openBankIdApp'; +import { isBankIdClientOperation, openBankIdApp } from '../../actions/client-operation/operations/bankid'; import { HaapiStepperPollingStep } from '../haapi-stepper.types'; export function handlePollingStep( diff --git a/src/login-web-app/src/haapi-stepper/ui/actions/defaultHaapiStepperActionElementFactory.tsx b/src/login-web-app/src/haapi-stepper/ui/actions/defaultHaapiStepperActionElementFactory.tsx index fba4d32f..a9abab02 100644 --- a/src/login-web-app/src/haapi-stepper/ui/actions/defaultHaapiStepperActionElementFactory.tsx +++ b/src/login-web-app/src/haapi-stepper/ui/actions/defaultHaapiStepperActionElementFactory.tsx @@ -39,9 +39,7 @@ export default function defaultHaapiStepperActionElementFactory( ); case HAAPI_ACTION_TYPES.CLIENT_OPERATION: - return ( - - ); + return ; case HAAPI_ACTION_TYPES.SELECTOR: return ; diff --git a/src/login-web-app/src/haapi-stepper/util/tests/mocks.ts b/src/login-web-app/src/haapi-stepper/util/tests/mocks.ts index 7a5739e6..94c43a64 100644 --- a/src/login-web-app/src/haapi-stepper/util/tests/mocks.ts +++ b/src/login-web-app/src/haapi-stepper/util/tests/mocks.ts @@ -1,6 +1,11 @@ import { MEDIA_TYPES } from '../../../shared/util/types/media.types'; import { HAAPI_STEPPER_ELEMENT_TYPES, HAAPI_STEPS } from '../../data-access/types/haapi-step.types'; -import { HAAPI_ACTION_TYPES, HAAPI_ACTION_CLIENT_OPERATIONS } from '../../data-access/types/haapi-action.types'; +import { + HAAPI_ACTION_CLIENT_OPERATIONS, + HAAPI_ACTION_TYPES, + HAAPI_FORM_ACTION_KINDS, + HaapiClientOperationAction, +} from '../../data-access/types/haapi-action.types'; import { HAAPI_FORM_FIELDS, HTTP_METHODS } from '../../data-access/types/haapi-form.types'; import type { HaapiStepperStep, @@ -139,3 +144,93 @@ export const defaultStepperAPI: HaapiStepperAPI = { history: [], nextStep: mockNextStep, }; + +// ============================================================================ +// Client-operation action factories +// ============================================================================ + +export const externalBrowserFlowActionTitle = 'Continue in browser'; +export const bankIdActionTitle = 'Open BankID'; +export const webAuthnRegistrationActionTitle = 'Register a passkey'; +export const webAuthnAnyDeviceActionTitle = 'Register device'; +export const webAuthnPlatformOnlyAnyDeviceActionTitle = 'Register device (This device)'; + +const continueAction = createMockFormAction({ + kind: HAAPI_FORM_ACTION_KINDS.CONTINUE, + title: 'Continue', +}); + +const WEBAUTHN_PUBLIC_KEY = { + challenge: 'c', + rp: { name: 'r' }, + user: { id: 'u', name: 'n', displayName: 'd' }, + pubKeyCredParams: [], +} as never; + +export const createMockExternalBrowserFlowAction = ( + overrides: Partial = {} +): HaapiStepperClientOperationAction => + createMockClientOperationAction({ + title: externalBrowserFlowActionTitle, + model: { + name: HAAPI_ACTION_CLIENT_OPERATIONS.EXTERNAL_BROWSER_FLOW, + arguments: { href: '/external-browser' }, + continueActions: [continueAction], + }, + ...overrides, + }); + +export const createMockBankIdAction = ( + overrides: Partial = {} +): HaapiStepperClientOperationAction => + createMockClientOperationAction({ + title: bankIdActionTitle, + kind: 'bankid', + model: { + name: HAAPI_ACTION_CLIENT_OPERATIONS.BANKID, + arguments: { href: '/bankid', autoStartToken: 'token' }, + continueActions: [continueAction], + }, + ...overrides, + }); + +export const createMockWebAuthnRegistrationAction = ( + overrides: Partial = {} +): HaapiStepperClientOperationAction => + createMockClientOperationAction({ + title: webAuthnRegistrationActionTitle, + kind: 'device-register', + template: HAAPI_ACTION_TYPES.CLIENT_OPERATION, + model: { + name: HAAPI_ACTION_CLIENT_OPERATIONS.WEBAUTHN_REGISTRATION, + arguments: { credentialCreationOptions: { publicKey: WEBAUTHN_PUBLIC_KEY } }, + continueActions: [continueAction], + }, + ...overrides, + }); + +export const createMockWebAuthnAnyDeviceBothOptionsAction = (): HaapiClientOperationAction => ({ + template: HAAPI_ACTION_TYPES.CLIENT_OPERATION, + kind: 'device-register', + title: webAuthnAnyDeviceActionTitle, + model: { + name: HAAPI_ACTION_CLIENT_OPERATIONS.WEBAUTHN_REGISTRATION, + arguments: { + platformCredentialCreationOptions: { publicKey: WEBAUTHN_PUBLIC_KEY }, + crossPlatformCredentialCreationOptions: { publicKey: WEBAUTHN_PUBLIC_KEY }, + }, + continueActions: [continueAction], + }, +}); + +export const createMockWebAuthnPlatformOnlyAnyDeviceAction = (): HaapiStepperClientOperationAction => + createMockClientOperationAction({ + title: webAuthnPlatformOnlyAnyDeviceActionTitle, + kind: 'device-register', + template: HAAPI_ACTION_TYPES.CLIENT_OPERATION, + model: { + name: HAAPI_ACTION_CLIENT_OPERATIONS.WEBAUTHN_REGISTRATION, + arguments: { platformCredentialCreationOptions: { publicKey: WEBAUTHN_PUBLIC_KEY } }, + continueActions: [continueAction], + }, + });