diff --git a/.changeset/sparkly-dragons-lay.md b/.changeset/sparkly-dragons-lay.md new file mode 100644 index 0000000000..9ca9ef42a7 --- /dev/null +++ b/.changeset/sparkly-dragons-lay.md @@ -0,0 +1,6 @@ +--- +'@forgerock/davinci-client': minor +'@forgerock/sdk-types': minor +--- + +Adds FIDO feature module to `@forgerock/davinci-client` package diff --git a/package.json b/package.json index 8a0c839a80..437aba8674 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "ci:release": "pnpm publish -r --no-git-checks && changeset tag", "ci:version": "changeset version && pnpm install --no-frozen-lockfile && pnpm nx format:write --uncommitted", "circular-dep-check": "madge --circular .", - "clean": "shx rm -rf ./{coverage,dist,docs,node_modules,tmp}/ ./{packages,e2e}/*/{dist,node_modules}/ && git clean -fX -e \"!.env*,nx-cloud.env\" -e \"!**/GEMINI.md\"", + "clean": "shx rm -rf ./{coverage,dist,docs,node_modules,tmp}/ ./{packages,e2e}/*/{dist,node_modules}/ ./e2e/node_modules/ && git clean -fX -e \"!.env*,nx-cloud.env\" -e \"!**/GEMINI.md\"", "commit": "git cz", "commitlint": "commitlint --edit", "create-package": "nx g @nx/js:library", diff --git a/packages/davinci-client/package.json b/packages/davinci-client/package.json index b3788b512b..6f11d62767 100644 --- a/packages/davinci-client/package.json +++ b/packages/davinci-client/package.json @@ -29,6 +29,7 @@ "@forgerock/sdk-types": "workspace:*", "@forgerock/storage": "workspace:*", "@reduxjs/toolkit": "catalog:", + "effect": "catalog:effect", "immer": "catalog:" }, "devDependencies": { diff --git a/packages/davinci-client/src/index.ts b/packages/davinci-client/src/index.ts index 597ca50dc1..4349671075 100644 --- a/packages/davinci-client/src/index.ts +++ b/packages/davinci-client/src/index.ts @@ -5,5 +5,6 @@ * of the MIT license. See the LICENSE file for details. */ import { davinci } from './lib/client.store.js'; +import { fido } from './lib/fido/fido.js'; -export { davinci }; +export { davinci, fido }; diff --git a/packages/davinci-client/src/lib/client.store.ts b/packages/davinci-client/src/lib/client.store.ts index 67bac062ad..aeee826647 100644 --- a/packages/davinci-client/src/lib/client.store.ts +++ b/packages/davinci-client/src/lib/client.store.ts @@ -45,7 +45,7 @@ import type { Validator, } from './client.types.js'; import { returnValidator } from './collector.utils.js'; -import { ContinueNode, StartNode } from './node.types.js'; +import type { ContinueNode, StartNode } from './node.types.js'; /** * Create a client function that returns a set of methods diff --git a/packages/davinci-client/src/lib/client.types.test-d.ts b/packages/davinci-client/src/lib/client.types.test-d.ts index 8e96656572..5f8190c6bf 100644 --- a/packages/davinci-client/src/lib/client.types.test-d.ts +++ b/packages/davinci-client/src/lib/client.types.test-d.ts @@ -11,7 +11,11 @@ import type { GenericError } from '@forgerock/sdk-types'; import type { InitFlow, InternalErrorResponse, Updater } from './client.types.js'; import type { ErrorNode, FailureNode, ContinueNode, StartNode, SuccessNode } from './node.types.js'; -import type { PhoneNumberInputValue } from './collector.types.js'; +import type { + FidoAuthenticationInputValue, + FidoRegistrationInputValue, + PhoneNumberInputValue, +} from './collector.types.js'; describe('Client Types', () => { it('should allow function returning error', async () => { @@ -170,7 +174,12 @@ describe('Client Types', () => { describe('Updater', () => { it('should accept string value and optional index', () => { const updater: Updater = ( - value: string | string[] | boolean | PhoneNumberInputValue, + value: + | string + | string[] + | PhoneNumberInputValue + | FidoRegistrationInputValue + | FidoAuthenticationInputValue, index?: number, ) => { return { @@ -178,7 +187,15 @@ describe('Updater', () => { type: 'internal_error', }; }; - expectTypeOf(updater).parameter(0).toEqualTypeOf(); + expectTypeOf(updater) + .parameter(0) + .toEqualTypeOf< + | string + | string[] + | PhoneNumberInputValue + | FidoRegistrationInputValue + | FidoAuthenticationInputValue + >(); expectTypeOf(updater).parameter(1).toBeNullable(); expectTypeOf(updater).parameter(1).toBeNullable(); }); diff --git a/packages/davinci-client/src/lib/client.types.ts b/packages/davinci-client/src/lib/client.types.ts index c562baadda..8372b073c2 100644 --- a/packages/davinci-client/src/lib/client.types.ts +++ b/packages/davinci-client/src/lib/client.types.ts @@ -6,7 +6,11 @@ */ import type { GenericError } from '@forgerock/sdk-types'; -import type { PhoneNumberInputValue } from './collector.types.js'; +import type { + FidoRegistrationInputValue, + FidoAuthenticationInputValue, + PhoneNumberInputValue, +} from './collector.types.js'; import type { ErrorNode, FailureNode, ContinueNode, StartNode, SuccessNode } from './node.types.js'; export type FlowNode = ContinueNode | ErrorNode | StartNode | SuccessNode | FailureNode; @@ -19,7 +23,12 @@ export interface InternalErrorResponse { export type InitFlow = () => Promise; export type Updater = ( - value: string | string[] | PhoneNumberInputValue, + value: + | string + | string[] + | PhoneNumberInputValue + | FidoRegistrationInputValue + | FidoAuthenticationInputValue, index?: number, ) => InternalErrorResponse | null; export type Validator = (value: string) => diff --git a/packages/davinci-client/src/lib/collector.types.ts b/packages/davinci-client/src/lib/collector.types.ts index f05273b3c5..bce4061b7e 100644 --- a/packages/davinci-client/src/lib/collector.types.ts +++ b/packages/davinci-client/src/lib/collector.types.ts @@ -5,6 +5,8 @@ * of the MIT license. See the LICENSE file for details. */ +import type { FidoAuthenticationOptions, FidoRegistrationOptions } from './davinci.types.js'; + /** ********************************************************************* * SINGLE-VALUE COLLECTORS */ @@ -302,14 +304,6 @@ export interface PhoneNumberOutputValue { phoneNumber?: string; } -export interface FidoRegistrationInputValue { - attestationValue?: PublicKeyCredential; -} - -export interface FidoAuthenticationInputValue { - assertionValue?: PublicKeyCredential; -} - export interface ObjectOptionsCollectorWithStringValue< T extends ObjectValueCollectorTypes, V = string, @@ -544,6 +538,51 @@ export type UnknownCollector = { * @interface AutoCollector - Represents a collector that collects a value programmatically without user intervention. */ +export interface ProtectOutputValue { + behavioralDataCollection: boolean; + universalDeviceIdentification: boolean; +} + +export interface AttestationValue + extends Omit { + rawId: string; + response: { + // https://developer.mozilla.org/en-US/docs/Web/API/AuthenticatorAttestationResponse + clientDataJSON: string; + attestationObject: string; + }; +} +export interface FidoRegistrationInputValue { + attestationValue?: AttestationValue; +} + +export interface FidoRegistrationOutputValue { + publicKeyCredentialCreationOptions: FidoRegistrationOptions; + action: 'REGISTER'; + trigger: string; +} + +export interface AssertionValue + extends Omit { + rawId: string; + response: { + // https://developer.mozilla.org/en-US/docs/Web/API/AuthenticatorAssertionResponse + clientDataJSON: string; + authenticatorData: string; + signature: string; + userHandle: string | null; + }; +} +export interface FidoAuthenticationInputValue { + assertionValue?: AssertionValue; +} + +export interface FidoAuthenticationOutputValue { + publicKeyCredentialRequestOptions: FidoAuthenticationOptions; + action: 'AUTHENTICATE'; + trigger: string; +} + export type AutoCollectorCategories = 'SingleValueAutoCollector' | 'ObjectValueAutoCollector'; export type SingleValueAutoCollectorTypes = 'SingleValueAutoCollector' | 'ProtectCollector'; export type ObjectValueAutoCollectorTypes = @@ -556,6 +595,7 @@ export interface AutoCollector< C extends AutoCollectorCategories, T extends AutoCollectorTypes, IV = string, + OV = Record, > { category: C; error: string | null; @@ -571,24 +611,27 @@ export interface AutoCollector< output: { key: string; type: string; - config: Record; + config: OV; }; } export type ProtectCollector = AutoCollector< 'SingleValueAutoCollector', 'ProtectCollector', - string + string, + ProtectOutputValue >; export type FidoRegistrationCollector = AutoCollector< 'ObjectValueAutoCollector', 'FidoRegistrationCollector', - FidoRegistrationInputValue + FidoRegistrationInputValue, + FidoRegistrationOutputValue >; export type FidoAuthenticationCollector = AutoCollector< 'ObjectValueAutoCollector', 'FidoAuthenticationCollector', - FidoAuthenticationInputValue + FidoAuthenticationInputValue, + FidoAuthenticationOutputValue >; export type SingleValueAutoCollector = AutoCollector< 'SingleValueAutoCollector', diff --git a/packages/davinci-client/src/lib/davinci.types.ts b/packages/davinci-client/src/lib/davinci.types.ts index 3de18e800c..1d249953f2 100644 --- a/packages/davinci-client/src/lib/davinci.types.ts +++ b/packages/davinci-client/src/lib/davinci.types.ts @@ -161,13 +161,25 @@ export type ProtectField = { }; export interface FidoRegistrationOptions - extends Omit { + extends Omit< + PublicKeyCredentialCreationOptions, + 'challenge' | 'user' | 'pubKeyCredParams' | 'excludeCredentials' + > { challenge: number[]; user: { id: number[]; name: string; displayName: string; }; + pubKeyCredParams: { + alg: string | number; + type: PublicKeyCredentialType; + }[]; + excludeCredentials?: { + id: number[]; + transports?: AuthenticatorTransport[]; + type: PublicKeyCredentialType; + }[]; } export type FidoRegistrationField = { diff --git a/packages/davinci-client/src/lib/davinci.utils.ts b/packages/davinci-client/src/lib/davinci.utils.ts index 474c20a460..a742708178 100644 --- a/packages/davinci-client/src/lib/davinci.utils.ts +++ b/packages/davinci-client/src/lib/davinci.utils.ts @@ -44,7 +44,8 @@ export function transformSubmitRequest( collector.category === 'SingleValueCollector' || collector.category === 'ValidatedSingleValueCollector' || collector.category === 'ObjectValueCollector' || - collector.category === 'SingleValueAutoCollector', + collector.category === 'SingleValueAutoCollector' || + collector.category === 'ObjectValueAutoCollector', ); const formData = collectors?.reduce<{ diff --git a/packages/davinci-client/src/lib/fido/README.md b/packages/davinci-client/src/lib/fido/README.md new file mode 100644 index 0000000000..bb1e1cd1b8 --- /dev/null +++ b/packages/davinci-client/src/lib/fido/README.md @@ -0,0 +1,70 @@ +# FIDO Module for DaVinci + +## Overview + +The `fido` API provides an interface for registering and authenticating with the WebAuthn API and transforming data to and from DaVinci. These methods transform options from DaVinci into WebAuthn compatible options, then call `navigator.credentials.create` or `navigator.credentials.get`, and finally transform the output of the WebAuthn API into a valid payload to send back to DaVinci. + +## Installation and Initialization + +The `fido` module is exported as a member of the `@forgerock/davinci-client` package and is intended to be used alongside the `davinciClient` to progress through a flow. To install the necessary dependencies, run: + +```bash +npm install @forgerock/davinci-client --save +``` + +After installing, import and initialize the clients: + +```typescript +import { davinci, fido } from '@forgerock/davinci-client'; + +const davinciClient = await davinci({ config }); +const fidoApi = fido(); +``` + +## API methods + +### Registration + +**register(options: FidoRegistrationOptions) => Promise** + +Creates a keypair and returns a public key credential formatted for DaVinci or an error + +### Authentication + +**authenticate: (options: FidoAuthenticationOptions) => Promise** + +Creates an assertion to send to DaVinci for authentication + +## Example Usage + +### Registration Example + +```typescript +if (collector.type === 'FidoRegistrationCollector') { + const credentialOptions = collector.output.config.publicKeyCredentialCreationOptions; + const publicKeyCredential = await fidoApi.register(credentialOptions); + if ('error' in publicKeyCredential) { + // Handle error + } else { + // Update the FidoRegistrationCollector with the credential + const updater = davinciClient.update(collector); + updater(publicKeyCredential); + } +} +``` + +### Authentication Example + +```typescript +if (collector.type === 'FidoAuthenticationCollector') { + const credentialOptions = collector.output.config.publicKeyCredentialRequestOptions; + const assertion = await fidoApi.authenticate(credentialOptions); + if ('error' in assertion) { + // Handle error + } else { + // Update the FidoAuthenticationCollector with the credential + const updater = davinciClient.update(collector); + updater(assertion); + } +} +``` diff --git a/packages/davinci-client/src/lib/fido/fido.ts b/packages/davinci-client/src/lib/fido/fido.ts new file mode 100644 index 0000000000..3207343622 --- /dev/null +++ b/packages/davinci-client/src/lib/fido/fido.ts @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ +import { Micro } from 'effect'; +import { exitIsFail, exitIsSuccess } from 'effect/Micro'; +import { + transformAssertion, + transformAuthenticationOptions, + transformPublicKeyCredential, + transformRegistrationOptions, +} from './fido.utils.js'; + +import type { GenericError } from '@forgerock/sdk-types'; +import type { + FidoAuthenticationInputValue, + FidoRegistrationInputValue, +} from '../collector.types.js'; +import type { FidoAuthenticationOptions, FidoRegistrationOptions } from '../davinci.types.js'; + +export interface FidoClient { + /** + * Create a keypair and get the public key credential to send back to DaVinci for registration + * @function register + * @param { FidoRegistrationOptions } options - DaVinci FIDO registration options + * @returns { Promise } - The formatted credential for DaVinci or an error + */ + register: ( + options: FidoRegistrationOptions, + ) => Promise; + /** + * Get an assertion to send back to DaVinci for authentication + * @function authenticate + * @param { FidoAuthenticationOptions } options - DaVinci FIDO authentication options + * @returns { Promise } - The formatted assertion for DaVinci or an error + */ + authenticate: ( + options: FidoAuthenticationOptions, + ) => Promise; +} + +/** + * A client function that returns a set of methods for transforming DaVinci data and + * interacting with the WebAuthn API for registration and authentication + * @function fido + * @returns {FidoClient} - A set of methods for FIDO registration and authentication + */ +export function fido(): FidoClient { + return { + /** + * Call WebAuthn API to create keypair and get public key credential + */ + register: async function register( + options: FidoRegistrationOptions, + ): Promise { + const createCredentialµ = Micro.sync(() => transformRegistrationOptions(options)).pipe( + Micro.flatMap((publicKeyCredentialCreationOptions) => + Micro.tryPromise({ + try: () => + navigator.credentials.create({ + publicKey: publicKeyCredentialCreationOptions, + }), + catch: (error) => { + console.error('Failed to create keypair: ', error); + return { + error: 'registration_error', + message: 'FIDO registration failed', + type: 'fido_error', + } as GenericError; + }, + }), + ), + Micro.flatMap((credential) => { + if (!credential) { + return Micro.fail({ + error: 'registration_error', + message: 'FIDO registration failed: No credential returned', + type: 'fido_error', + } as GenericError); + } else { + const formattedCredential = transformPublicKeyCredential( + credential as PublicKeyCredential, + ); + return Micro.succeed(formattedCredential); + } + }), + ); + + const result = await Micro.runPromiseExit(createCredentialµ); + + if (exitIsSuccess(result)) { + return result.value; + } else if (exitIsFail(result)) { + return result.cause.error; + } else { + return { + error: 'fido_registration_error', + message: result.cause.message, + type: 'unknown_error', + }; + } + }, + /** + * Call WebAuthn API to get assertion + */ + authenticate: async function authenticate( + options: FidoAuthenticationOptions, + ): Promise { + const getAssertionµ = Micro.sync(() => transformAuthenticationOptions(options)).pipe( + Micro.flatMap((publicKeyCredentialRequestOptions) => + Micro.tryPromise({ + try: () => + navigator.credentials.get({ + publicKey: publicKeyCredentialRequestOptions, + }), + catch: (error) => { + console.error('Failed to authenticate: ', error); + return { + error: 'authentication_error', + message: 'FIDO authentication failed', + type: 'fido_error', + } as GenericError; + }, + }), + ), + Micro.flatMap((assertion) => { + if (!assertion) { + return Micro.fail({ + error: 'authentication_error', + message: 'FIDO authentication failed: No credential returned', + type: 'fido_error', + } as GenericError); + } else { + const formattedAssertion = transformAssertion(assertion as PublicKeyCredential); + return Micro.succeed(formattedAssertion); + } + }), + ); + + const result = await Micro.runPromiseExit(getAssertionµ); + + if (exitIsSuccess(result)) { + return result.value; + } else if (exitIsFail(result)) { + return result.cause.error; + } else { + return { + error: 'fido_authentication_error', + message: result.cause.message, + type: 'unknown_error', + }; + } + }, + }; +} diff --git a/packages/davinci-client/src/lib/fido/fido.utils.test.ts b/packages/davinci-client/src/lib/fido/fido.utils.test.ts new file mode 100644 index 0000000000..5470cfeff1 --- /dev/null +++ b/packages/davinci-client/src/lib/fido/fido.utils.test.ts @@ -0,0 +1,210 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ +import { describe, it, expect } from 'vitest'; +import { + transformAssertion, + transformAuthenticationOptions, + transformPublicKeyCredential, + transformRegistrationOptions, +} from './fido.utils'; + +import type { FidoAuthenticationOptions, FidoRegistrationOptions } from '../davinci.types'; +import type { FidoAuthenticationInputValue, FidoRegistrationInputValue } from '../collector.types'; + +function base64ToArrayBuffer(base64: string): ArrayBuffer { + const binaryString = atob(base64); + const len = binaryString.length; + const bytes = new Uint8Array(len); + + for (let i = 0; i < len; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + + return bytes.buffer; +} + +describe('FIDO registration utilities', () => { + it('transformRegistrationOptions should return PublicKeyCredentialCreationOptions', () => { + const mockOptions: FidoRegistrationOptions = { + rp: { + id: 'test.pi.scrd.run', + name: 'RP Name', + }, + user: { + id: [85, -28, 50, 85, -49, -56, 102, 100, 9, 62, -115], + displayName: 'test@example.com', + name: 'First Last', + }, + challenge: [-91, -70, -33, -14, -28, -114, -111, -49, 47, 0, 96], + pubKeyCredParams: [ + { + type: 'public-key', + alg: '-7', + }, + { + type: 'public-key', + alg: '-37', + }, + { + type: 'public-key', + alg: '-257', + }, + ], + timeout: 120000, + excludeCredentials: [ + { + type: 'public-key', + id: [112, -100, -3, 40, 111, -93, -68, 90, -18, -14, -11, -109, -93, 4, -115, -105], + }, + { + type: 'public-key', + id: [78, -23, 71, -75, 64, 25, -108, -125, -2, -57, 104, 52, 14, -14, -116, 26, 56], + }, + ], + authenticatorSelection: { + residentKey: 'required', + requireResidentKey: true, + userVerification: 'required', + }, + attestation: 'none', + extensions: { + credProps: true, + hmacCreateSecret: true, + }, + }; + + const result = transformRegistrationOptions(mockOptions); + assertType(result); + + expect(result.rp).toEqual(mockOptions.rp); + expect(result.timeout).toBe(mockOptions.timeout); + expect(result.authenticatorSelection).toEqual(mockOptions.authenticatorSelection); + expect(result.attestation).toBe(mockOptions.attestation); + expect(result.extensions).toEqual(mockOptions.extensions); + + // Check that standard arrays were converted to ArrayBuffers + expect(result.challenge).toBeInstanceOf(ArrayBuffer); + expect(result.user.id).toBeInstanceOf(ArrayBuffer); + expect(result.excludeCredentials).toBeDefined(); + expect(result.excludeCredentials).toHaveLength(2); + expectTypeOf(result.excludeCredentials).toEqualTypeOf< + PublicKeyCredentialDescriptor[] | undefined + >(); + + // Check that pubKeyCredParams.alg values were converted to numbers + expectTypeOf(result.pubKeyCredParams).toEqualTypeOf(); + }); + + it('transformPublicKeyCredential should return FidoRegistrationInputValue', () => { + const rawIdBase64 = 'SGVsbG8sIFdvcmxkIQ=='; + const clientDataJSONBase64 = 'Y2xpZW50RGF0YUpTT04='; + const attestationObjectBase64 = 'YXR0ZXN0YXRpb25PYmplY3Q='; + + const mockCredential = { + id: 'MdD7ErRoxf5RBBCm6ODs5g', + rawId: base64ToArrayBuffer(rawIdBase64), + type: 'public-key', + authenticatorAttachment: 'platform', + response: { + clientDataJSON: base64ToArrayBuffer(clientDataJSONBase64), + attestationObject: base64ToArrayBuffer(attestationObjectBase64), + }, + }; + + const result = transformPublicKeyCredential(mockCredential as unknown as PublicKeyCredential); + const attestationValue = result.attestationValue; + + assertType(result); + expect(attestationValue).toBeDefined(); + + expect(attestationValue?.id).toBe(mockCredential.id); + expect(attestationValue?.type).toBe(mockCredential.type); + expect(attestationValue?.authenticatorAttachment).toBe(mockCredential.authenticatorAttachment); + expect(attestationValue?.rawId).toBe(rawIdBase64); + expect(attestationValue?.response.clientDataJSON).toBe(clientDataJSONBase64); + expect(attestationValue?.response.attestationObject).toBe(attestationObjectBase64); + }); +}); + +describe('FIDO authentication utilities', () => { + it('transformAuthenticationOptions should return PublicKeyCredentialRequestOptions', () => { + const mockOptions: FidoAuthenticationOptions = { + challenge: [-91, -70, -33, -14, -28, -114, -111, -49, 47, 0, 96], + timeout: 120000, + rpId: 'test.pi.scrd.run', + allowCredentials: [ + { + type: 'public-key', + id: [112, -100, -3, 40, 111, -93, -68, 90, -18, -14, -11, -109, -93, 4, -115, -105], + transports: ['internal'], + }, + { + type: 'public-key', + id: [78, -23, 71, -75, 64, 25, -108, -125, -2, -57, 104, 52, 14, -14, -116, 26, 56], + transports: ['internal'], + }, + ], + userVerification: 'required', + extensions: { + credProps: true, + hmacCreateSecret: true, + }, + }; + + const result = transformAuthenticationOptions(mockOptions); + assertType(result); + + expect(result.timeout).toBe(mockOptions.timeout); + expect(result.rpId).toBe(mockOptions.rpId); + expect(result.userVerification).toBe(mockOptions.userVerification); + expect(result.extensions).toEqual(mockOptions.extensions); + + // Check that standard arrays were converted to ArrayBuffers + expect(result.challenge).toBeInstanceOf(ArrayBuffer); + expect(result.allowCredentials).toBeDefined(); + expect(result.allowCredentials).toHaveLength(2); + expectTypeOf(result.allowCredentials).toEqualTypeOf< + PublicKeyCredentialDescriptor[] | undefined + >(); + }); + + it('transformAssertion should return FidoAuthenticationInputValue', () => { + const rawIdBase64 = 'SGVsbG8sIFdvcmxkIQ=='; + const clientDataJSONBase64 = 'Y2xpZW50RGF0YUpTT04='; + const authenticatorDataBase64 = 'YXV0aGVudGljYXRvckRhdGE='; + const signatureBase64 = 'c2lnbmF0dXJl'; + const userHandleBase64 = 'dXNlckhhbmRsZQ=='; + + const mockCredential = { + id: 'MdD7ErRoxf5RBBCm6ODs5g', + rawId: base64ToArrayBuffer(rawIdBase64), + type: 'public-key', + authenticatorAttachment: 'platform', + response: { + clientDataJSON: base64ToArrayBuffer(clientDataJSONBase64), + authenticatorData: base64ToArrayBuffer(authenticatorDataBase64), + signature: base64ToArrayBuffer(signatureBase64), + userHandle: base64ToArrayBuffer(userHandleBase64), + }, + }; + + const result = transformAssertion(mockCredential as unknown as PublicKeyCredential); + const assertionValue = result.assertionValue; + + assertType(result); + expect(assertionValue).toBeDefined(); + + expect(assertionValue?.id).toBe(mockCredential.id); + expect(assertionValue?.type).toBe(mockCredential.type); + expect(assertionValue?.authenticatorAttachment).toBe(mockCredential.authenticatorAttachment); + expect(assertionValue?.rawId).toBe(rawIdBase64); + expect(assertionValue?.response.clientDataJSON).toBe(clientDataJSONBase64); + expect(assertionValue?.response.authenticatorData).toBe(authenticatorDataBase64); + expect(assertionValue?.response.signature).toBe(signatureBase64); + expect(assertionValue?.response.userHandle).toBe(userHandleBase64); + }); +}); diff --git a/packages/davinci-client/src/lib/fido/fido.utils.ts b/packages/davinci-client/src/lib/fido/fido.utils.ts new file mode 100644 index 0000000000..304fc60ca8 --- /dev/null +++ b/packages/davinci-client/src/lib/fido/fido.utils.ts @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ +import type { + FidoAuthenticationInputValue, + FidoRegistrationInputValue, +} from '../collector.types.js'; +import type { FidoAuthenticationOptions, FidoRegistrationOptions } from '../davinci.types.js'; + +function convertArrayToBuffer(arr: number[]): ArrayBuffer { + return new Int8Array(arr).buffer; +} + +function convertBufferToBase64(buffer: ArrayBuffer): string { + const byteArray = new Uint8Array(buffer); + let binaryString = ''; + for (let i = 0; i < byteArray.byteLength; i++) { + binaryString += String.fromCharCode(byteArray[i]); + } + return btoa(binaryString); +} + +/** + * Convert DaVinci registration options to PublicKeyCredentialCreationOptions + * @function transformRegistrationOptions + * @param { FidoRegistrationOptions } options - DaVinci FIDO registration options + * @returns { PublicKeyCredentialCreationOptions } - WebAuthn API compatible registration options + */ +export function transformRegistrationOptions( + options: FidoRegistrationOptions, +): PublicKeyCredentialCreationOptions { + const pubKeyCredParams = options.pubKeyCredParams.map((param) => ({ + type: param.type, + alg: typeof param.alg === 'string' ? parseInt(param.alg, 10) : param.alg, + })); + const excludeCredentials = options.excludeCredentials?.map((param) => ({ + type: param.type, + id: convertArrayToBuffer(param.id), + transports: param.transports, + })); + + return { + ...options, + challenge: convertArrayToBuffer(options.challenge), + user: { + ...options.user, + id: convertArrayToBuffer(options.user.id), + }, + pubKeyCredParams, + excludeCredentials, + }; +} + +/** + * Format the credential to send back to DaVinci for registration + * @function transformPublicKeyCredential + * @param { PublicKeyCredential } credential - The credential returned from navigator.credentials.create() + * @returns { FidoRegistrationInputValue } - The formatted credential for registering with DaVinci + */ +export function transformPublicKeyCredential( + credential: PublicKeyCredential, +): FidoRegistrationInputValue { + const credentialResponse = credential.response as AuthenticatorAttestationResponse; + const clientDataJSON = convertBufferToBase64(credentialResponse.clientDataJSON); + const attestationObject = convertBufferToBase64(credentialResponse.attestationObject); + const rawId = convertBufferToBase64(credential.rawId); + + return { + attestationValue: { + ...credential, + id: credential.id, + rawId, + type: credential.type, + authenticatorAttachment: credential.authenticatorAttachment, + response: { + ...credentialResponse, + clientDataJSON, + attestationObject, + }, + }, + }; +} + +/** + * Convert DaVinci authentication options to PublicKeyCredentialRequestOptions + * @function transformAuthenticationOptions + * @param { FidoAuthenticationOptions } options - DaVinci FIDO authentication options + * @returns { PublicKeyCredentialRequestOptions } - WebAuthn API compatible authentication options + */ +export function transformAuthenticationOptions( + options: FidoAuthenticationOptions, +): PublicKeyCredentialRequestOptions { + const allowCredentials = options.allowCredentials?.map((param) => ({ + id: convertArrayToBuffer(param.id), + type: param.type, + transports: param.transports, + })); + const challenge = convertArrayToBuffer(options.challenge); + + return { + ...options, + challenge, + allowCredentials, + }; +} + +/** + * Format the assertion to send back to DaVinci for authentication + * @function transformAssertion + * @param { PublicKeyCredential } credential - The credential returned from navigator.credentials.get() + * @returns { FidoAuthenticationInputValue } - The formatted credential for authenticating with DaVinci + */ +export function transformAssertion(credential: PublicKeyCredential): FidoAuthenticationInputValue { + const credentialResponse = credential.response as AuthenticatorAssertionResponse; + const clientDataJSON = convertBufferToBase64(credentialResponse.clientDataJSON); + const authenticatorData = convertBufferToBase64(credentialResponse.authenticatorData); + const signature = convertBufferToBase64(credentialResponse.signature); + const userHandle = credentialResponse.userHandle + ? convertBufferToBase64(credentialResponse.userHandle) + : null; + const rawId = convertBufferToBase64(credential.rawId); + + return { + assertionValue: { + ...credential, + id: credential.id, + rawId, + type: credential.type, + response: { + ...credentialResponse, + clientDataJSON, + authenticatorData, + signature, + userHandle, + }, + }, + }; +} diff --git a/packages/davinci-client/src/types.ts b/packages/davinci-client/src/types.ts index d6a01a6c46..e6e7ec90c5 100644 --- a/packages/davinci-client/src/types.ts +++ b/packages/davinci-client/src/types.ts @@ -6,6 +6,7 @@ import 'immer'; // Side-effect needed only for getting types in workspace import type { RequestMiddleware } from '@forgerock/sdk-request-middleware'; +import type { FidoClient } from './lib/fido/fido.js'; import type * as collectors from './lib/collector.types.js'; import type * as config from './lib/config.types.js'; import type * as nodes from './lib/node.types.js'; @@ -54,3 +55,4 @@ export type FidoAuthenticationCollector = collectors.FidoAuthenticationCollector export type InternalErrorResponse = client.InternalErrorResponse; export type { RequestMiddleware }; +export type { FidoClient }; diff --git a/packages/sdk-types/src/lib/error.types.ts b/packages/sdk-types/src/lib/error.types.ts index 0d64896a91..0659cd5e0d 100644 --- a/packages/sdk-types/src/lib/error.types.ts +++ b/packages/sdk-types/src/lib/error.types.ts @@ -13,6 +13,7 @@ export interface GenericError { | 'argument_error' | 'auth_error' | 'davinci_error' + | 'fido_error' | 'exchange_error' | 'internal_error' | 'network_error' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f94380ed68..010611e4cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -380,6 +380,9 @@ importers: '@reduxjs/toolkit': specifier: 'catalog:' version: 2.10.1 + effect: + specifier: catalog:effect + version: 3.19.3 immer: specifier: 'catalog:' version: 10.2.0