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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ 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
* * encap-auto-activation (deprecated)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, right. This was removed in server version 11.0. Since the LWA will released after that, I suggest removing this mention here. The HAAPI docs will be updated at some point.

* * webauthn-registration
* * webauthn-authentication
*/
Expand Down Expand Up @@ -260,14 +260,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
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void> => {
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);
};
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@
export * from './webauthn';
export * from './utils';
export * from './useIsWebAuthnPlatformAuthenticatorAvailable';
export * from './auto-start';
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
}
Expand All @@ -36,13 +37,3 @@ export function useIsWebAuthnPlatformAuthenticatorAvailable(): boolean | undefin

return available;
}

const isWebAuthnPlatformAuthenticatorApiAvailable =
typeof PublicKeyCredential === 'function' && 'isUserVerifyingPlatformAuthenticatorAvailable' in PublicKeyCredential;

const resolveAvailability = (): Promise<boolean> => {
if (!isWebAuthnPlatformAuthenticatorApiAvailable) {
return Promise.resolve(false);
}
return PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
};
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<boolean> => {
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');
};
Original file line number Diff line number Diff line change
@@ -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),
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading