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 82e1d8da..91de9f79 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 @@ -99,10 +99,6 @@ export enum HAAPI_ACTION_CLIENT_OPERATIONS { BANKID = 'bankid', WEBAUTHN_AUTHENTICATION = 'webauthn-authentication', WEBAUTHN_REGISTRATION = 'webauthn-registration', - /* - * @deprecated - */ - ENCAP_AUTO_ACTIVATION = 'encap-auto-activation', } /** @@ -131,7 +127,6 @@ export interface HaapiBaseClientOperationModel { * Other operations that the Curity Identity Server ships with (and may be supported by fully compliant clients) are: * * * bankid - * * encap-auto-activation * * webauthn-registration * * webauthn-authentication */ @@ -260,14 +255,28 @@ export interface HaapiWebAuthnAuthenticationClientOperationAction extends HaapiC export interface HaapiWebAuthnAuthenticationClientOperationModel extends HaapiBaseClientOperationModel { name: HAAPI_ACTION_CLIENT_OPERATIONS.WEBAUTHN_AUTHENTICATION; - arguments: { - credentialRequestOptions: { - publicKey: PublicKeyCredentialRequestOptionsJSON; - }; - }; + arguments: HaapiWebAuthnAuthenticationArgs; continueActions: [HaapiFormAction]; } +/** + * Unlike registration, the authentication step emits a SINGLE action that carries the ceremony + * spec together with optional descriptor lists used to scope which credentials the assertion will + * accept. The browser's WebAuthn API handles credential selection inside the single ceremony, so + * there is no client-side action selection for authentication. + * + * See https://curity.io/docs/haapi-data-model/latest/webauthn-authentication-step.html + */ +export interface HaapiWebAuthnAuthenticationArgs { + credentialRequestOptions: HaapiPublicKeyCredentialRequestOptions; + platformCredentials?: PublicKeyCredentialDescriptorJSON[]; + crossPlatformCredentials?: PublicKeyCredentialDescriptorJSON[]; +} + +export interface HaapiPublicKeyCredentialRequestOptions { + publicKey: PublicKeyCredentialRequestOptionsJSON; +} + /** * Client operation BankId action */ diff --git a/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/webauthn/auto-start.ts b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/webauthn/auto-start.ts new file mode 100644 index 00000000..628747fe --- /dev/null +++ b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/webauthn/auto-start.ts @@ -0,0 +1,51 @@ +/* + * 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 type { HaapiStepperNextStep, HaapiStepperStep } from '../../../../stepper/haapi-stepper.types'; +import { + isPlatformOnlyAnyDeviceWebAuthnRegistrationAction, + isWebAuthnApiSupported, + isWebAuthnClientOperationAction, + isWebAuthnPlatformAuthenticatorAvailable, + requiresWebAuthnUserInteraction, +} from './utils'; + +export const manageWebAuthnAutoStart = async ( + step: HaapiStepperStep, + nextStep: HaapiStepperNextStep +): Promise => { + if (!isWebAuthnApiSupported() || requiresWebAuthnUserInteraction()) { + return; + } + + const clientOperationActions = step.dataHelpers.actions?.clientOperation ?? []; + const webAuthnActions = clientOperationActions.filter(isWebAuthnClientOperationAction); + + /* + * Multi-option registration steps (any-device with both platform + cross-platform) + * are split into two actions by `splitWebAuthnRegistrationAction` upstream, so they + * won't trigger auto-start. + */ + if (webAuthnActions.length !== 1) { + return; + } + + const action = webAuthnActions[0]; + + if (isPlatformOnlyAnyDeviceWebAuthnRegistrationAction(action)) { + const webAuthnPlatformAuthenticatorAvailable = await isWebAuthnPlatformAuthenticatorAvailable(); + if (!webAuthnPlatformAuthenticatorAvailable) { + return; + } + } + + nextStep(action); +}; 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 index 1e3fa2fe..96f05835 100644 --- 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 @@ -12,3 +12,4 @@ export * from './webauthn'; export * from './utils'; export * from './useIsWebAuthnPlatformAuthenticatorAvailable'; +export * from './auto-start'; 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 index 97cff64d..976c81c3 100644 --- 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 @@ -10,6 +10,7 @@ */ import { useEffect, useState } from 'react'; +import { isWebAuthnPlatformAuthenticatorApiAvailable, isWebAuthnPlatformAuthenticatorAvailable } from './utils'; /** * Returns whether the device exposes a user-verifying platform authenticator (Touch ID, @@ -21,8 +22,8 @@ export function useIsWebAuthnPlatformAuthenticatorAvailable(): boolean | undefin useEffect(() => { let cancelled = false; - if (isWebAuthnPlatformAuthenticatorApiAvailable) { - void resolveAvailability().then(value => { + if (isWebAuthnPlatformAuthenticatorApiAvailable()) { + void isWebAuthnPlatformAuthenticatorAvailable().then(value => { if (!cancelled) { setAvailable(value); } @@ -36,13 +37,3 @@ export function useIsWebAuthnPlatformAuthenticatorAvailable(): boolean | undefin 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 index 6dfe0203..0cb8adcc 100644 --- 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 @@ -19,6 +19,7 @@ import { HaapiWebAuthnPasskeysRegistrationAction, HaapiWebAuthnRegistrationClientOperationAction, } from '../../../../../data-access/types/haapi-action.types'; +import type { HaapiStepperStep } from '../../../../stepper/haapi-stepper.types'; const WEBAUTHN_PLATFORM_LABEL = 'This device'; const WEBAUTHN_CROSS_PLATFORM_LABEL = 'Another device'; @@ -136,3 +137,32 @@ export function isAnyDeviceWebAuthnRegistrationAction( ): action is HaapiWebAuthnAnyDeviceRegistrationAction { return !isPasskeysWebAuthnRegistrationAction(action); } + +export const isWebAuthnStep = (step: HaapiStepperStep): boolean => { + const clientOperationActions = step.dataHelpers.actions?.clientOperation ?? []; + return clientOperationActions.some(isWebAuthnClientOperationAction); +}; + +export const isWebAuthnApiSupported = (): boolean => + typeof PublicKeyCredential === 'function' && + typeof PublicKeyCredential.parseCreationOptionsFromJSON === 'function' && + typeof PublicKeyCredential.parseRequestOptionsFromJSON === 'function'; + +export const isWebAuthnPlatformAuthenticatorApiAvailable = (): boolean => + typeof PublicKeyCredential === 'function' && 'isUserVerifyingPlatformAuthenticatorAvailable' in PublicKeyCredential; + +export const isWebAuthnPlatformAuthenticatorAvailable = (): Promise => { + if (!isWebAuthnPlatformAuthenticatorApiAvailable()) { + return Promise.resolve(false); + } + return PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); +}; + +/** + * Safari browser (non-Chromium) blocks auto-initiated `navigator.credentials.{create,get}` calls + * when the document isn't focused. + */ +export const requiresWebAuthnUserInteraction = (): boolean => { + const userAgent = navigator.userAgent; + return userAgent.includes('Safari') && !userAgent.includes('Chrom'); +}; 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 new file mode 100644 index 00000000..b25a39a1 --- /dev/null +++ b/src/login-web-app/src/haapi-stepper/feature/actions/client-operation/operations/webauthn/webauthn.spec.ts @@ -0,0 +1,184 @@ +/* + * 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 { runWebAuthnAuthentication, runWebAuthnRegistration } from './webauthn'; +import { + createMockWebAuthnAuthenticationAction, + createMockWebAuthnCrossPlatformOnlyAnyDeviceAction, + createMockWebAuthnPlatformOnlyAnyDeviceAction, + createMockWebAuthnRegistrationAction, +} from '../../../../../util/tests/mocks'; + +describe('webauthn', () => { + const abortSignal = new AbortController().signal; + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('PublicKeyCredential', stubPublicKeyCredential()); + installNavigatorCredentials(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + restoreNavigatorCredentials(); + }); + + 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', + }); + }); + + 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('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); + + 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 }, + }); + }); + }); + + describe('any-device', () => { + it('platform-only: parses platformCredentialCreationOptions, creates a credential, and returns a continuation under "platformCredential"', async () => { + const parsedOptions = { challenge: 'platform' }; + const credentialJSON = { id: 'platform-cred', type: 'public-key' }; + + mockParseCreationOptionsFromJSON.mockReturnValue(parsedOptions); + mockCredentialsCreate.mockResolvedValue(mockCredential(credentialJSON)); + + const action = createMockWebAuthnPlatformOnlyAnyDeviceAction(); + const result = await runWebAuthnRegistration(action, abortSignal); + + expect(mockParseCreationOptionsFromJSON).toHaveBeenCalledWith( + action.model.arguments.platformCredentialCreationOptions?.publicKey + ); + expect(mockCredentialsCreate).toHaveBeenCalledWith({ publicKey: parsedOptions, signal: abortSignal }); + expect(result).toEqual({ + action: action.model.continueActions[0], + payload: { platformCredential: credentialJSON }, + }); + }); + + it('cross-platform-only: parses crossPlatformCredentialCreationOptions, creates a credential, and returns a continuation under "crossPlatformCredential"', async () => { + const parsedOptions = { challenge: 'cross-platform' }; + const credentialJSON = { id: 'cross-platform-cred', type: 'public-key' }; + + mockParseCreationOptionsFromJSON.mockReturnValue(parsedOptions); + mockCredentialsCreate.mockResolvedValue(mockCredential(credentialJSON)); + + const action = createMockWebAuthnCrossPlatformOnlyAnyDeviceAction(); + const result = await runWebAuthnRegistration(action, abortSignal); + + 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('runWebAuthnAuthentication', () => { + it('throws when WebAuthn API is not supported', async () => { + vi.unstubAllGlobals(); + const action = createMockWebAuthnAuthenticationAction(); + + await expect(runWebAuthnAuthentication(action, abortSignal)).rejects.toMatchObject({ + message: 'WebAuthn API is not supported in this browser', + }); + }); + + it('throws when navigator.credentials.get returns null', async () => { + mockCredentialsGet.mockResolvedValue(null); + const action = createMockWebAuthnAuthenticationAction(); + + await expect(runWebAuthnAuthentication(action, abortSignal)).rejects.toMatchObject({ + message: 'Could not get credential', + }); + }); + + 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' }; + + mockParseRequestOptionsFromJSON.mockReturnValue(parsedOptions); + mockCredentialsGet.mockResolvedValue(mockCredential(credentialJSON)); + + const action = createMockWebAuthnAuthenticationAction(); + const result = await runWebAuthnAuthentication(action, abortSignal); + + expect(mockParseRequestOptionsFromJSON).toHaveBeenCalledWith( + action.model.arguments.credentialRequestOptions.publicKey + ); + expect(mockCredentialsGet).toHaveBeenCalledWith({ publicKey: parsedOptions, signal: abortSignal }); + expect(result).toEqual({ + action: action.model.continueActions[0], + payload: { credential: credentialJSON }, + }); + }); + }); +}); + +const mockParseCreationOptionsFromJSON = vi.fn(); +const mockParseRequestOptionsFromJSON = vi.fn(); +const mockCredentialsCreate = vi.fn(); +const mockCredentialsGet = vi.fn(); + +const stubPublicKeyCredential = () => + Object.assign(vi.fn(), { + parseCreationOptionsFromJSON: mockParseCreationOptionsFromJSON, + parseRequestOptionsFromJSON: mockParseRequestOptionsFromJSON, + }); + +const installNavigatorCredentials = () => { + Object.defineProperty(navigator, 'credentials', { + configurable: true, + value: { create: mockCredentialsCreate, get: mockCredentialsGet }, + }); +}; + +const restoreNavigatorCredentials = () => { + Reflect.deleteProperty(navigator, 'credentials'); +}; + +const mockCredential = (toJSONResult: unknown = { id: 'cred-id', type: 'public-key' }) => ({ + toJSON: vi.fn(() => toJSONResult), +}); 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 0edf72b5..1706e9fb 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,18 +15,14 @@ import { HaapiWebAuthnRegistrationClientOperationAction, } from '../../../../../data-access/types/haapi-action.types'; import { HaapiFetchFormAction } from '../../../../../data-access/types/haapi-fetch.types'; -import { isAnyDeviceWebAuthnRegistrationAction, isPasskeysWebAuthnRegistrationAction } from './utils'; +import { + isAnyDeviceWebAuthnRegistrationAction, + isPasskeysWebAuthnRegistrationAction, + isWebAuthnApiSupported, +} 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 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 8252853f..d738d7ff 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 @@ -11,7 +11,11 @@ import { render, screen, waitFor } from '@testing-library/react'; import { HaapiStepper } from './HaapiStepper'; import { HAAPI_POLLING_STATUS, HAAPI_PROBLEM_STEPS, HAAPI_STEPS } from '../../data-access/types/haapi-step.types'; -import { HAAPI_ACTION_TYPES, HAAPI_FORM_ACTION_KINDS } from '../../data-access/types/haapi-action.types'; +import { + HAAPI_ACTION_CLIENT_OPERATIONS, + HAAPI_ACTION_TYPES, + HAAPI_FORM_ACTION_KINDS, +} from '../../data-access/types/haapi-action.types'; import { HTTP_METHODS } from '../../data-access/types/haapi-form.types'; import { MEDIA_TYPES } from '../../../shared/util/types/media.types'; import { @@ -30,6 +34,14 @@ import { useHaapiStepper } from './HaapiStepperHook'; import type { HaapiStepperHistoryEntry, HaapiStepperNextStepAction } from './haapi-stepper.types'; import { HaapiStepperActionStep, HaapiStepperFormAction } from './haapi-stepper.types'; import type { BootstrapConfiguration } from '../../data-access/bootstrap-configuration'; +import { + createMockWebAuthnAnyDeviceBothOptionsAction, + createMockWebAuthnAuthenticationAction, + createMockWebAuthnCrossPlatformOnlyAnyDeviceAction, + createMockWebAuthnPlatformOnlyAnyDeviceAction, + createMockWebAuthnRegistrationAction, +} from '../../util/tests/mocks'; +import type { HaapiClientOperationAction } from '../../data-access/types/haapi-action.types'; describe('HaapiStepper', () => { const initialStepType = HAAPI_STEPS.AUTHENTICATION; @@ -174,6 +186,7 @@ describe('HaapiStepper', () => { }); }); }); + describe('Polling Step', () => { beforeEach(() => { vi.useFakeTimers(); @@ -372,6 +385,247 @@ describe('HaapiStepper', () => { }); }); + describe('Authentication / Registration Step', () => { + describe('WebAuthn Auto-Start', () => { + beforeEach(() => { + mockRunWebAuthnRegistration.mockResolvedValue(null); + mockRunWebAuthnAuthentication.mockResolvedValue(null); + vi.stubGlobal('PublicKeyCredential', stubPublicKeyCredential()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + 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('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); + }); + }); + }); + + describe('WebAuthn Authentication', () => { + it('should auto-start a single WebAuthn authentication action', async () => { + mockHaapiFetchWebAuthnStep(HAAPI_STEPS.AUTHENTICATION, createMockWebAuthnAuthenticationAction()); + + render( + + + + ); + + await waitFor(() => { + expect(mockRunWebAuthnAuthentication).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 () => { + 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('Completed With Success Step', () => { const authorizationResponseUrl = completedWithSuccessStep.links?.find( link => link.rel === 'authorization-response' @@ -736,6 +990,86 @@ vi.mock('../actions/client-operation/operations/bankid/open-bankid-app', () => ( openBankIdApp: mockOpenBankIdApp, })); +const { mockRunWebAuthnRegistration, mockRunWebAuthnAuthentication } = vi.hoisted(() => ({ + mockRunWebAuthnRegistration: vi.fn(), + mockRunWebAuthnAuthentication: vi.fn(), +})); +vi.mock('../actions/client-operation/operations/webauthn', async () => { + const actual = await vi.importActual( + '../actions/client-operation/operations/webauthn' + ); + return { + ...actual, + runWebAuthnRegistration: mockRunWebAuthnRegistration, + runWebAuthnAuthentication: mockRunWebAuthnAuthentication, + }; +}); + +/** + * Stand-in for the static `PublicKeyCredential` interface used by the WebAuthn + * auto-start tests — enough for `isWebAuthnApiSupported()` and + * `isWebAuthnPlatformAuthenticatorAvailable()` to resolve. jsdom doesn't expose it. + */ +const stubPublicKeyCredential = ({ platformAuthenticatorAvailable = true } = {}) => + Object.assign(vi.fn(), { + parseCreationOptionsFromJSON: vi.fn(), + parseRequestOptionsFromJSON: vi.fn(), + isUserVerifyingPlatformAuthenticatorAvailable: vi.fn(() => Promise.resolve(platformAuthenticatorAvailable)), + }); + +/** + * Shadows `navigator.userAgent` with the given string for the duration of a test, exercising + * the `requiresWebAuthnUserInteraction` Safari-vs-Chromium UA sniff. + */ +const stubUserAgent = (userAgent: string): void => { + Object.defineProperty(navigator, 'userAgent', { configurable: true, value: userAgent }); +}; + +/** + * Replaces the next pending `mockHaapiFetch` response with an authentication or + * registration step containing the given action — used by the WebAuthn auto-start + * tests to stand up a step shape that's not in `getStepMock`. + */ +const firstCallAction = (mock: ReturnType): HaapiClientOperationAction => { + const [action] = mock.mock.calls[0] as [HaapiClientOperationAction]; + return action; +}; + +/** + * Replaces any pending `mockHaapiFetch` response with an authentication or registration + * step containing the given action — used by the WebAuthn auto-start tests to stand up a + * step shape that's not in `getStepMock`. + * + * Resets queued responses (including the default initial-step from the outer `beforeEach`) + * so the stepper sees only this WebAuthn step on its initial fetch. + */ +const mockHaapiFetchWebAuthnStep = ( + stepType: HAAPI_STEPS.AUTHENTICATION | HAAPI_STEPS.REGISTRATION, + action: HaapiClientOperationAction +) => { + const stepMock = { + type: stepType, + actions: [action], + metadata: { templateArea: 'lwa-test', viewName: 'test' }, + }; + mockHaapiFetch.mockReset(); + mockHaapiFetch.mockImplementationOnce( + () => + new Promise(resolve => + setTimeout( + () => + resolve({ + headers: { + get: (name: string) => (name === 'Content-Type' ? MEDIA_TYPES.AUTH : null), + }, + json: () => Promise.resolve(stepMock), + }), + 0 + ) + ) + ); +}; + 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 bf4fbe93..54a597f6 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 @@ -42,16 +42,19 @@ import type { } from './haapi-stepper.types'; import { useThrowErrorToAppErrorBoundary } from '../../util/useThrowErrorToAppErrorBoundary'; import { useRefCallback } from '../../util/useRefCallBack'; +import { handleAuthenticationOrRegistrationStep } from './step-handlers/authentication-or-registration-step'; const DEFAULT_CONFIG: Required = { pollingInterval: 3000, bankIdAutostart: true, + webAuthnAutostart: true, redirectOnAuthenticationCompletedWithSuccess: true, }; export interface HaapiStepperConfig { pollingInterval: number; bankIdAutostart: boolean; + webAuthnAutostart: boolean; redirectOnAuthenticationCompletedWithSuccess: boolean; } @@ -409,6 +412,8 @@ async function processHaapiNextStep( case HAAPI_STEPS.AUTHENTICATION: case HAAPI_STEPS.REGISTRATION: + return handleAuthenticationOrRegistrationStep(nextStepResponse, nextStep, config); + case HAAPI_STEPS.USER_CONSENT: case HAAPI_STEPS.CONSENTOR: case HAAPI_PROBLEM_STEPS.COMPLETED_WITH_ERROR: diff --git a/src/login-web-app/src/haapi-stepper/feature/stepper/haapi-stepper.types.ts b/src/login-web-app/src/haapi-stepper/feature/stepper/haapi-stepper.types.ts index c6adb712..ebfc8b87 100644 --- a/src/login-web-app/src/haapi-stepper/feature/stepper/haapi-stepper.types.ts +++ b/src/login-web-app/src/haapi-stepper/feature/stepper/haapi-stepper.types.ts @@ -5,6 +5,10 @@ import { HaapiClientOperationAction, HaapiFormAction, HaapiSelectorAction, + HaapiWebAuthnAnyDeviceRegistrationAction, + HaapiWebAuthnAuthenticationClientOperationAction, + HaapiWebAuthnPasskeysRegistrationAction, + HaapiWebAuthnRegistrationClientOperationAction, } from '../../data-access/types/haapi-action.types'; import { HAAPI_STEPPER_ELEMENT_TYPES, @@ -105,6 +109,14 @@ export type HaapiStepperClientOperationAction = HaapiClientOperationAction & /** Polling session remaining time in seconds before the session expires. */ maxWaitRemainingTime?: number; }; +export type HaapiStepperWebAuthnRegistrationClientOperationAction = HaapiStepperClientOperationAction & + HaapiWebAuthnRegistrationClientOperationAction; +export type HaapiStepperWebAuthnPasskeysRegistrationAction = HaapiStepperClientOperationAction & + HaapiWebAuthnPasskeysRegistrationAction; +export type HaapiStepperWebAuthnAnyDeviceRegistrationAction = HaapiStepperClientOperationAction & + HaapiWebAuthnAnyDeviceRegistrationAction; +export type HaapiStepperWebAuthnAuthenticationClientOperationAction = HaapiStepperClientOperationAction & + HaapiWebAuthnAuthenticationClientOperationAction; /* * STEP MESSAGE TYPINGS diff --git a/src/login-web-app/src/haapi-stepper/feature/stepper/step-handlers/authentication-or-registration-step.ts b/src/login-web-app/src/haapi-stepper/feature/stepper/step-handlers/authentication-or-registration-step.ts new file mode 100644 index 00000000..76908e33 --- /dev/null +++ b/src/login-web-app/src/haapi-stepper/feature/stepper/step-handlers/authentication-or-registration-step.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 type { HaapiAuthenticationStep, HaapiRegistrationStep } from '../../../data-access/types/haapi-step.types'; +import { formatNextStepData } from '../data-formatters/format-next-step-data'; +import type { HaapiStepperConfig } from '../HaapiStepper'; +import type { HaapiStepperNextStep, HaapiStepperStep } from '../haapi-stepper.types'; +import { isWebAuthnStep, manageWebAuthnAutoStart } from '../../actions/client-operation/operations/webauthn'; + +export function handleAuthenticationOrRegistrationStep( + step: HaapiAuthenticationStep | HaapiRegistrationStep, + nextStep: HaapiStepperNextStep, + config: HaapiStepperConfig +): { nextStepData: HaapiStepperStep } { + const nextStepData = formatNextStepData(step); + + if (config.webAuthnAutostart && isWebAuthnStep(nextStepData)) { + void manageWebAuthnAutoStart(nextStepData, nextStep); + } + + return { nextStepData }; +} 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 94c43a64..1a1a959a 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 @@ -4,7 +4,6 @@ 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 { @@ -15,6 +14,9 @@ import type { HaapiStepperUserMessage, HaapiStepperLink, HaapiStepperAPI, + HaapiStepperWebAuthnAnyDeviceRegistrationAction, + HaapiStepperWebAuthnAuthenticationClientOperationAction, + HaapiStepperWebAuthnPasskeysRegistrationAction, } from '../../feature/stepper/haapi-stepper.types'; import { formatNextStepData } from '../../feature/stepper/data-formatters/format-next-step-data'; @@ -154,6 +156,8 @@ export const bankIdActionTitle = 'Open BankID'; export const webAuthnRegistrationActionTitle = 'Register a passkey'; export const webAuthnAnyDeviceActionTitle = 'Register device'; export const webAuthnPlatformOnlyAnyDeviceActionTitle = 'Register device (This device)'; +export const webAuthnCrossPlatformOnlyAnyDeviceActionTitle = 'Register device (Another device)'; +export const webAuthnAuthenticationActionTitle = 'Use passkey'; const continueAction = createMockFormAction({ kind: HAAPI_FORM_ACTION_KINDS.CONTINUE, @@ -194,43 +198,56 @@ export const createMockBankIdAction = ( ...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, - }); +const PUBLIC_KEY = { publicKey: WEBAUTHN_PUBLIC_KEY }; -export const createMockWebAuthnAnyDeviceBothOptionsAction = (): HaapiClientOperationAction => ({ +const webAuthnActionMetadata = { + type: HAAPI_STEPPER_ELEMENT_TYPES.ACTION, + subtype: HAAPI_ACTION_TYPES.CLIENT_OPERATION, template: HAAPI_ACTION_TYPES.CLIENT_OPERATION, - kind: 'device-register', - title: webAuthnAnyDeviceActionTitle, - model: { +} as const; + +const createMockWebAuthnAction = (title: string, kind: K, model: M) => ({ + ...webAuthnActionMetadata, + id: crypto.randomUUID(), + title, + kind, + model, +}); + +export const createMockWebAuthnRegistrationAction = (): HaapiStepperWebAuthnPasskeysRegistrationAction => + createMockWebAuthnAction(webAuthnRegistrationActionTitle, 'device-register', { + name: HAAPI_ACTION_CLIENT_OPERATIONS.WEBAUTHN_REGISTRATION, + arguments: { credentialCreationOptions: PUBLIC_KEY }, + continueActions: [continueAction], + }); + +export const createMockWebAuthnAnyDeviceBothOptionsAction = (): HaapiStepperWebAuthnAnyDeviceRegistrationAction => + createMockWebAuthnAction(webAuthnAnyDeviceActionTitle, 'device-register', { name: HAAPI_ACTION_CLIENT_OPERATIONS.WEBAUTHN_REGISTRATION, arguments: { - platformCredentialCreationOptions: { publicKey: WEBAUTHN_PUBLIC_KEY }, - crossPlatformCredentialCreationOptions: { publicKey: WEBAUTHN_PUBLIC_KEY }, + platformCredentialCreationOptions: PUBLIC_KEY, + crossPlatformCredentialCreationOptions: 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], - }, +export const createMockWebAuthnPlatformOnlyAnyDeviceAction = (): HaapiStepperWebAuthnAnyDeviceRegistrationAction => + createMockWebAuthnAction(webAuthnPlatformOnlyAnyDeviceActionTitle, 'device-register', { + name: HAAPI_ACTION_CLIENT_OPERATIONS.WEBAUTHN_REGISTRATION, + arguments: { platformCredentialCreationOptions: PUBLIC_KEY }, + continueActions: [continueAction], + }); + +export const createMockWebAuthnCrossPlatformOnlyAnyDeviceAction = (): HaapiStepperWebAuthnAnyDeviceRegistrationAction => + createMockWebAuthnAction(webAuthnCrossPlatformOnlyAnyDeviceActionTitle, 'device-register', { + name: HAAPI_ACTION_CLIENT_OPERATIONS.WEBAUTHN_REGISTRATION, + arguments: { crossPlatformCredentialCreationOptions: PUBLIC_KEY }, + continueActions: [continueAction], + }); + +export const createMockWebAuthnAuthenticationAction = (): HaapiStepperWebAuthnAuthenticationClientOperationAction => + createMockWebAuthnAction(webAuthnAuthenticationActionTitle, 'device-authn', { + name: HAAPI_ACTION_CLIENT_OPERATIONS.WEBAUTHN_AUTHENTICATION, + arguments: { credentialRequestOptions: PUBLIC_KEY }, + continueActions: [continueAction], });