From 032044a788869cced7dff9a08befa972a4adf02c Mon Sep 17 00:00:00 2001 From: AJ Ancheta <7781450+ancheetah@users.noreply.github.com> Date: Tue, 27 May 2025 14:24:25 -0400 Subject: [PATCH 1/4] chore(device-client): update device client --- packages/device-client/README.md | 7 +- .../src/lib/device.store.test.ts | 435 ++++++++++++++++++ .../device-client/src/lib/device.store.ts | 299 ++++++++---- .../src/lib/mock-data/msw-mock-data.ts | 172 +++++++ .../device-client/src/lib/services/index.ts | 98 ++-- ...-device.types.ts => bound-device.types.ts} | 6 +- packages/device-client/src/lib/types/index.ts | 3 +- .../device-client/src/lib/types/oath.types.ts | 27 +- .../src/lib/types/profile-device.types.ts | 64 +++ .../src/lib/types/push-device.types.ts | 22 +- .../src/lib/types/webauthn.types.ts | 13 - packages/device-client/src/types.ts | 7 + packages/device-client/tsconfig.lib.json | 8 +- packages/device-client/tsconfig.spec.json | 7 +- packages/device-client/vite.config.ts | 1 - 15 files changed, 1007 insertions(+), 162 deletions(-) create mode 100644 packages/device-client/src/lib/device.store.test.ts create mode 100644 packages/device-client/src/lib/mock-data/msw-mock-data.ts rename packages/device-client/src/lib/types/{binding-device.types.ts => bound-device.types.ts} (84%) create mode 100644 packages/device-client/src/lib/types/profile-device.types.ts create mode 100644 packages/device-client/src/types.ts diff --git a/packages/device-client/README.md b/packages/device-client/README.md index ff60e5c6c9..d6ff9ece54 100644 --- a/packages/device-client/README.md +++ b/packages/device-client/README.md @@ -44,9 +44,11 @@ const config: ConfigOptions = { }, realmPath: '/your-realm-path', }; +``` If there is no realmPath or you wish to override the value, you can do so in the api call itself where you pass in the query. +``` const apiClient = deviceClient(config); ``` @@ -205,7 +207,10 @@ apiClient.webauthn ### Bound Devices Management Example -```typescript const bindingQuery: BindingDeviceQuery = { /* your query parameters */ }; +```typescript +const bindingQuery: BindingDeviceQuery = { + /* your query parameters */ +}; apiClient.boundDevices .get(bindingQuery) .then((response) => { diff --git a/packages/device-client/src/lib/device.store.test.ts b/packages/device-client/src/lib/device.store.test.ts new file mode 100644 index 0000000000..ce2a0085ca --- /dev/null +++ b/packages/device-client/src/lib/device.store.test.ts @@ -0,0 +1,435 @@ +/* + * 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 { afterEach, afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; +import { deviceClient } from './device.store.js'; + +import { + MOCK_PUSH_DEVICES, + MOCK_BINDING_DEVICES, + MOCK_OATH_DEVICES, + MOCK_DELETED_OATH_DEVICE, + MOCK_WEBAUTHN_DEVICES, + MOCK_DEVICE_PROFILE_SUCCESS, +} from './mock-data/msw-mock-data.js'; + +// Create handlers +export const handlers = [ + // OATH Devices + http.get('*/json/realms/:realm/users/:userId/devices/2fa/oath', ({ params }) => { + if (params['realm'] === 'fake-realm') { + return HttpResponse.json({ error: 'bad realm' }, { status: 401 }); + } + if (params['userId'] === 'bad-user') { + return HttpResponse.json({ error: 'bad user' }, { status: 401 }); + } + return HttpResponse.json(MOCK_OATH_DEVICES); + }), + + http.delete('*/json/realms/:realm/users/:userId/devices/2fa/oath/:uuid', ({ params }) => { + if (params['realm'] === 'fake-realm') { + return HttpResponse.json({ error: 'bad realm' }, { status: 401 }); + } + if (params['userId'] === 'bad-user') { + return HttpResponse.json({ error: 'bad user' }, { status: 401 }); + } + return HttpResponse.json(MOCK_DELETED_OATH_DEVICE); + }), + + // Push Devices + http.get('*/json/realms/:realm/users/:userId/devices/2fa/push', ({ params }) => { + if (params['realm'] === 'fake-realm') { + return HttpResponse.json({ error: 'bad realm' }, { status: 401 }); + } + if (params['userId'] === 'bad-user') { + return HttpResponse.json({ error: 'bad user' }, { status: 401 }); + } + return HttpResponse.json({ result: MOCK_PUSH_DEVICES }); + }), + + http.delete('*/json/realms/:realm/users/:userId/devices/2fa/push/:uuid', ({ params }) => { + if (params['realm'] === 'fake-realm') { + return HttpResponse.json({ error: 'bad realm' }, { status: 401 }); + } + if (params['userId'] === 'bad-user') { + return HttpResponse.json({ error: 'bad user' }, { status: 401 }); + } + if (params['uuid'] === 'bad-uuid') { + return HttpResponse.json({ error: 'bad uuid' }, { status: 401 }); + } + return HttpResponse.json(MOCK_PUSH_DEVICES[0]); + }), + + // WebAuthn Devices + http.get('*/json/realms/:realm/users/:userId/devices/2fa/webauthn', ({ params }) => { + if (params['realm'] === 'fake-realm') { + return HttpResponse.json({ error: 'bad realm' }, { status: 401 }); + } + if (params['userId'] === 'bad-user') { + return HttpResponse.json({ error: 'bad user' }, { status: 401 }); + } + return HttpResponse.json({ result: MOCK_WEBAUTHN_DEVICES }); + }), + + http.put('*/json/realms/:realm/users/:userId/devices/2fa/webauthn/:uuid', ({ params }) => { + if (params['userId'] === 'bad-uuid') { + return HttpResponse.json({ error: 'bad uuid' }, { status: 401 }); + } + if (params['realm'] === 'fake-realm') { + return HttpResponse.json({ error: 'bad realm' }, { status: 401 }); + } + if (params['userId'] === 'bad-user') { + return HttpResponse.json({ error: 'bad user' }, { status: 401 }); + } + return HttpResponse.json({ + ...MOCK_WEBAUTHN_DEVICES.result[0], + deviceName: 'Updated WebAuthn Device', + }); + }), + + http.delete('*/json/realms/:realm/users/:userId/devices/2fa/webauthn/:uuid', ({ params }) => { + if (params['realm'] === 'fake-realm') { + return HttpResponse.json({ error: 'bad realm' }, { status: 401 }); + } + if (params['userId'] === 'bad-user') { + return HttpResponse.json({ error: 'bad user' }, { status: 401 }); + } + if (params['uuid'] === 'bad-uuid') { + return HttpResponse.json({ error: 'bad-uuid' }, { status: 401 }); + } + return HttpResponse.json(MOCK_WEBAUTHN_DEVICES.result[0]); + }), + + // Binding Devices + http.get('*/json/realms/:realm/users/:userId/devices/2fa/binding', ({ params }) => { + if (params['realm'] === 'fake-realm') { + return HttpResponse.json({ error: 'bad realm' }, { status: 401 }); + } + if (params['userId'] === 'bad-user') { + return HttpResponse.json({ error: 'bad user' }, { status: 401 }); + } + return HttpResponse.json({ result: MOCK_BINDING_DEVICES }); + }), + + http.put( + '*/json/realms/root/realms/:realm/users/:userId/devices/2fa/binding/:uuid', + ({ params }) => { + if (params['realm'] === 'fake-realm') { + return HttpResponse.json({ error: 'bad realm' }, { status: 401 }); + } + if (params['userId'] === 'bad-user') { + return HttpResponse.json({ error: 'bad user' }, { status: 401 }); + } + if (params['userId'] === 'bad-uuid') { + return HttpResponse.json({ error: 'bad user' }, { status: 401 }); + } + return HttpResponse.json({ + ...MOCK_BINDING_DEVICES.result[0], + deviceName: 'Updated Binding Device', + }); + }, + ), + + http.delete( + '*/json/realms/root/realms/:realm/users/:userId/devices/2fa/binding/:uuid', + ({ params }) => { + if (params['realm'] === 'fake-realm') { + return HttpResponse.json({ error: 'bad realm' }, { status: 401 }); + } + if (params['userId'] === 'bad-user') { + return HttpResponse.json({ error: 'bad user' }, { status: 401 }); + } + if (params['userId'] === 'bad-uuid') { + return HttpResponse.json({ error: 'bad uuid' }, { status: 401 }); + } + return HttpResponse.json({ result: MOCK_BINDING_DEVICES.result[0] }); + }, + ), + + // profile devices + http.get('*/json/realms/:realm/users/:userId/devices/profile', ({ params }) => { + if (params['realm'] === 'fake-realm') { + return HttpResponse.json({ error: 'bad realm' }, { status: 401 }); + } + if (params['userId'] === 'bad-user') { + return HttpResponse.json({ error: 'bad user' }, { status: 401 }); + } + return HttpResponse.json({ result: MOCK_DEVICE_PROFILE_SUCCESS }); + }), + http.put('*/json/realms/:realm/users/:userId/devices/profile/:uuid', ({ params }) => { + if (params['realm'] === 'fake-realm') { + return HttpResponse.json({ error: 'bad realm' }, { status: 401 }); + } + if (params['userId'] === 'bad-user') { + return HttpResponse.json({ error: 'bad user' }, { status: 401 }); + } + return HttpResponse.json({ + ...MOCK_DEVICE_PROFILE_SUCCESS.result[0], + alias: 'new-name', + }); + }), + http.delete('*/json/realms/:realm/users/:userId/devices/profile/:uuid', ({ params }) => { + if (params['realm'] === 'fake-realm') { + return HttpResponse.json({ error: 'bad realm' }, { status: 401 }); + } + if (params['userId'] === 'bad-user') { + return HttpResponse.json({ error: 'bad user' }, { status: 401 }); + } + if (params['userId'] === 'bad-uuid') { + return HttpResponse.json({ error: 'bad uuid' }, { status: 401 }); + } + return HttpResponse.json(MOCK_DEVICE_PROFILE_SUCCESS.result[0]); + }), +]; + +export const server = setupServer(...handlers); + +// Establish API mocking before all tests. +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); + +// Reset any request handlers that we may add during the tests, +// so they don't affect other tests. +afterEach(() => server.resetHandlers()); + +// Clean up after the tests are finished. +afterAll(() => server.close()); + +describe('Device Client Store', () => { + const config = { + serverConfig: { + baseUrl: 'https://api.example.com', + }, + realmPath: 'test-realm', + }; + + describe('OATH Device Management', () => { + const client = deviceClient(config); + + it('should fetch OATH devices', async () => { + const result = await client.oath.get({ + userId: 'test-user', + }); + + expect(result).toEqual(MOCK_OATH_DEVICES.result); + }); + + it('should delete OATH device', async () => { + const result = await client.oath.delete({ + userId: 'test-user', + device: { + deviceManagementStatus: false, + _rev: '1221312', + uuid: 'oath-uuid-1', + deviceName: 'Test OATH Device', + _id: 'test-id', + createdDate: 1705555555555, + lastAccessDate: 1705555555555, + }, + }); + + expect(result).toEqual(MOCK_DELETED_OATH_DEVICE); + }); + it('should return error obj if a user does not exist', async () => { + const badClient = deviceClient(config); + const result = await badClient.oath.get({ + userId: 'bad-user', + }); + expect(result).toStrictEqual({ error: new Error('response did not contain data') }); + }); + it('should return undefined if a realm does not exist', async () => { + const badConfig = { ...config, realmPath: 'fake-realm' }; + const badClient = deviceClient(badConfig); + const result = await badClient.oath.get({ + userId: 'test-user', + }); + expect(result).toStrictEqual({ error: new Error('response did not contain data') }); + }); + }); + + describe('Push Device Management', () => { + const client = deviceClient(config); + + it('should fetch push devices', async () => { + const result = await client.push.get({ + userId: 'test-user', + }); + + expect(result).toEqual(MOCK_PUSH_DEVICES); + }); + + it('should delete push device', async () => { + const result = await client.push.delete({ + userId: 'test-user', + device: MOCK_PUSH_DEVICES[0], + }); + expect(result).toEqual(MOCK_PUSH_DEVICES[0]); + }); + it('should fail with a bad uuid', async () => { + const client = deviceClient(config); + const result1 = await client.push.delete({ + userId: 'test-user', + device: { ...MOCK_PUSH_DEVICES[0], uuid: 'bad-uuid' }, + }); + + expect(result1).toStrictEqual({ error: new Error('response did not contain data') }); + }); + it('should fail with a bad userId', async () => { + const badConfig = { ...config, realmPath: 'bad-realm' }; + const badClient = deviceClient(badConfig); + const result1 = await badClient.push.delete({ + userId: 'bad-user', + device: MOCK_PUSH_DEVICES[0], + }); + const result2 = await badClient.push.get({ userId: 'bad-user' }); + + expect(result1).toStrictEqual({ error: new Error('response did not contain data') }); + expect(result2).toStrictEqual({ error: new Error('response did not contain data') }); + }); + it('should return error if a uuid does not exist', async () => { + const badClient = deviceClient(config); + const result = await badClient.push.delete({ + userId: 'user', + device: { ...MOCK_PUSH_DEVICES[0], uuid: 'bad-uuid' }, + }); + expect(result).toStrictEqual({ error: new Error('response did not contain data') }); + }); + it('should return undefined if a user does not exist', async () => { + const badClient = deviceClient(config); + const result = await badClient.push.get({ + userId: 'bad-user', + }); + expect(result).toStrictEqual({ error: new Error('response did not contain data') }); + }); + it('should return undefined if a realm does not exist', async () => { + const badConfig = { ...config, realmPath: 'fake-realm' }; + const badClient = deviceClient(badConfig); + const result = await badClient.push.get({ + userId: 'test-user', + }); + expect(result).toStrictEqual({ error: new Error('response did not contain data') }); + }); + }); + // + describe('WebAuthn Device Management', () => { + const client = deviceClient(config); + + it('should fetch webauthn devices', async () => { + const result = await client.webAuthn.get({ + userId: 'test-user', + }); + + expect(result).toEqual(MOCK_WEBAUTHN_DEVICES); + }); + + it('should update webauthn device name', async () => { + const mockDevice = MOCK_WEBAUTHN_DEVICES.result[0]; + const result = await client.webAuthn.update({ + userId: 'test-user', + device: { + _id: mockDevice._id, + _rev: mockDevice._rev, + uuid: mockDevice.uuid, + deviceName: 'Updated WebAuthn Device', + credentialId: mockDevice.credentialId, + createdDate: mockDevice.createdDate, + lastAccessDate: mockDevice.lastAccessDate, + deviceManagementStatus: mockDevice.deviceManagementStatus, + }, + }); + + expect(result).toEqual({ + ...mockDevice, + deviceName: 'Updated WebAuthn Device', + }); + }); + it('should error when deleting webauthn device with invalid uuid', async () => { + const mockDevice = MOCK_WEBAUTHN_DEVICES.result[0]; + const result = await client.webAuthn.delete({ + userId: 'test-user', + device: { + ...mockDevice, + uuid: 'bad-uuid', + }, + }); + + expect(result).toStrictEqual({ error: new Error('response did not contain data') }); + }); + + it('should delete webauthn device', async () => { + const mockDevice = MOCK_WEBAUTHN_DEVICES.result[0]; + const result = await client.webAuthn.delete({ + userId: 'test-user', + device: mockDevice, + }); + + expect(result).toEqual(mockDevice); + }); + }); + // + describe('Bound Device Management', () => { + const client = deviceClient(config); + const mockDevice = MOCK_BINDING_DEVICES.result[0]; + + it('should fetch bound devices', async () => { + const result = await client.bound.get({ + userId: 'test-user', + ...mockDevice, + }); + + expect(result).toEqual(MOCK_BINDING_DEVICES); + }); + + it('should update bound device name', async () => { + const result = await client.bound.update({ + userId: 'test-user', + device: mockDevice, + }); + + expect(result).toEqual({ + ...mockDevice, + deviceName: 'Updated Binding Device', + }); + }); + + it('should delete bound device', async () => { + const result = await client.bound.delete({ + userId: 'test-user', + device: mockDevice, + }); + + expect(result).toEqual(mockDevice); + }); + }); + describe('Profile Device', () => { + const client = deviceClient(config); + + it('should fetch device profiles', async () => { + const result = await client.profile.get({ userId: 'test-user', realm: 'test-realm' }); + + expect(result).toEqual(MOCK_DEVICE_PROFILE_SUCCESS); + }); + it('should update device profiles', async () => { + const result = await client.profile.update({ + userId: 'test-user', + realm: 'test-realm', + device: MOCK_DEVICE_PROFILE_SUCCESS.result[0], + }); + + expect(result).toEqual({ ...MOCK_DEVICE_PROFILE_SUCCESS.result[0], alias: 'new-name' }); + }); + it('should delete device profiles', async () => { + const result = await client.profile.delete({ + userId: 'hello', + realm: 'alpha', + device: MOCK_DEVICE_PROFILE_SUCCESS.result[0], + }); + + expect(result).toEqual({ ...MOCK_DEVICE_PROFILE_SUCCESS.result[0] }); + }); + }); +}); diff --git a/packages/device-client/src/lib/device.store.ts b/packages/device-client/src/lib/device.store.ts index 1fc4a84e4b..7c5aea28f0 100644 --- a/packages/device-client/src/lib/device.store.ts +++ b/packages/device-client/src/lib/device.store.ts @@ -7,10 +7,20 @@ import { type ConfigOptions } from '@forgerock/javascript-sdk'; import { configureStore } from '@reduxjs/toolkit'; import { deviceService } from './services/index.js'; -import { DeleteOathQuery, OathDevice, RetrieveOathQuery } from './types/oath.types.js'; -import { DeleteDeviceQuery, PushDeviceQuery } from './types/push-device.types.js'; -import { WebAuthnBody, WebAuthnQuery, WebAuthnQueryWithUUID } from './types/webauthn.types.js'; -import { BindingDeviceQuery } from './types/binding-device.types.js'; +import { DeletedOathDevice, OathDevice, RetrieveOathQuery } from './types/oath.types.js'; +import { + DeleteDeviceQuery, + DeletedPushDevice, + PushDevice, + PushDeviceQuery, +} from './types/push-device.types.js'; +import { UpdatedWebAuthnDevice, WebAuthnDevice, WebAuthnQuery } from './types/webauthn.types.js'; +import { BoundDeviceQuery, Device, GetBoundDevicesQuery } from './types/bound-device.types.js'; +import { + GetProfileDevices, + ProfileDevice, + ProfileDevicesQuery, +} from './types/profile-device.types.js'; export const deviceClient = (config: ConfigOptions) => { const { middleware, reducerPath, reducer, endpoints } = deviceService({ @@ -43,16 +53,20 @@ export const deviceClient = (config: ConfigOptions) => { * @async * @function get * @param {RetrieveOathQuery} query - The query used to retrieve Oath devices. - * @returns {Promise} - A promise that resolves to the retrieved data or undefined if the response is not valid. + * @returns {Promise} - A promise that resolves to the retrieved data or an error object if the response is not valid. */ - get: async function (query: RetrieveOathQuery) { - const response = await store.dispatch(endpoints.getOAthDevices.initiate(query)); + get: async function (query: RetrieveOathQuery): Promise { + try { + const response = await store.dispatch(endpoints.getOAthDevices.initiate(query)); - if (!response || !response.data) { - return undefined; - } + if (!response || !response.data || !response.data.result) { + throw new Error('response did not contain data'); + } - return response.data; + return response.data.result; + } catch (error) { + return { error }; + } }, /** @@ -61,16 +75,22 @@ export const deviceClient = (config: ConfigOptions) => { * @async * @function delete * @param {DeleteOathQuery & OathDevice} query - The query and device information used to delete the Oath device. - * @returns {Promise} - A promise that resolves to the response data or undefined if the response is not valid. + * @returns {Promise} - A promise that resolves to the response data or an error object if the response is not valid. */ - delete: async function (query: DeleteOathQuery & OathDevice) { - const response = await store.dispatch(endpoints.deleteOathDevice.initiate(query)); - - if (!response || !response.data) { - return undefined; + delete: async function ( + query: RetrieveOathQuery & { device: OathDevice }, + ): Promise { + try { + const response = await store.dispatch(endpoints.deleteOathDevice.initiate(query)); + + if (!response || !response.data) { + throw new Error('response did not contain data'); + } + + return response.data; + } catch (error) { + return { error }; } - - return response.data; }, }, @@ -86,16 +106,20 @@ export const deviceClient = (config: ConfigOptions) => { * @async * @function get * @param {PushDeviceQuery} query - The query used to retrieve Push devices. - * @returns {Promise} - A promise that resolves to the retrieved data or undefined if the response is not valid. + * @returns {Promise} - A promise that resolves to the retrieved data or an error object if the response is not valid. */ - get: async function (query: PushDeviceQuery) { - const response = await store.dispatch(endpoints.getPushDevices.initiate(query)); + get: async function (query: PushDeviceQuery): Promise { + try { + const response = await store.dispatch(endpoints.getPushDevices.initiate(query)); - if (!response || !response.data) { - return undefined; - } + if (!response || !response.data || !response.data.result) { + throw new Error('response did not contain data'); + } - return response.data; + return response.data.result; + } catch (error) { + return { error }; + } }, /** @@ -104,16 +128,22 @@ export const deviceClient = (config: ConfigOptions) => { * @async * @function delete * @param {DeleteDeviceQuery} query - The query used to delete the Push device. - * @returns {Promise} - A promise that resolves to the response data or undefined if the response is not valid. + * @returns {Promise} - A promise that resolves to the response data or an error object if the response is not valid. */ - delete: async function (query: DeleteDeviceQuery) { - const response = await store.dispatch(endpoints.deletePushDevice.initiate(query)); - - if (!response || !response.data) { - return undefined; + delete: async function ( + query: DeleteDeviceQuery, + ): Promise { + try { + const response = await store.dispatch(endpoints.deletePushDevice.initiate(query)); + + if (!response || !response.data) { + throw new Error('response did not contain data'); + } + + return response.data; + } catch (error) { + return { error }; } - - return response.data; }, }, @@ -122,23 +152,27 @@ export const deviceClient = (config: ConfigOptions) => { * * @type {WebAuthnManagement} */ - webauthn: { + webAuthn: { /** * Retrieves WebAuthn devices based on the specified query. * * @async * @function get * @param {WebAuthnQuery} query - The query used to retrieve WebAuthn devices. - * @returns {Promise} - A promise that resolves to the retrieved data or undefined if the response is not valid. + * @returns {Promise} - A promise that resolves to the retrieved data or an error object if the response is not valid. */ - get: async function (query: WebAuthnQuery) { - const response = await store.dispatch(endpoints.getWebAuthnDevices.initiate(query)); + get: async function (query: WebAuthnQuery): Promise { + try { + const response = await store.dispatch(endpoints.getWebAuthnDevices.initiate(query)); - if (!response || !response.data) { - return undefined; - } + if (!response || !response.data || !response.data.result) { + throw new Error('response did not contain data'); + } - return response.data; + return response.data.result; + } catch (error) { + return { error }; + } }, /** @@ -146,17 +180,23 @@ export const deviceClient = (config: ConfigOptions) => { * * @async * @function update - * @param {WebAuthnQueryWithUUID & WebAuthnBody} query - The query and body used to update the WebAuthn device name. - * @returns {Promise} - A promise that resolves to the response data or undefined if the response is not valid. + * @param {WebAuthnQueryWithUUID & { device: WebAuthnBody } } query - The query and body used to update the WebAuthn device name. + * @returns {Promise} - A promise that resolves to the response data or an error object if the response is not valid. */ - update: async function (query: WebAuthnQueryWithUUID & WebAuthnBody) { - const response = await store.dispatch(endpoints.updateWebAuthnDeviceName.initiate(query)); - - if (!response || !response.data) { - return undefined; + update: async function ( + query: WebAuthnQuery & { device: WebAuthnDevice }, + ): Promise { + try { + const response = await store.dispatch(endpoints.updateWebAuthnDeviceName.initiate(query)); + + if (!response || !response.data) { + throw new Error('response did not contain data'); + } + + return response.data; + } catch (error) { + return { error }; } - - return response.data; }, /** @@ -164,17 +204,23 @@ export const deviceClient = (config: ConfigOptions) => { * * @async * @function delete - * @param {WebAuthnQueryWithUUID & WebAuthnBody} query - The query and body used to delete the WebAuthn device. - * @returns {Promise} - A promise that resolves to the response data or undefined if the response is not valid. + * @param {WebAuthnQueryWithUUID & { device: WebAuthnBody } } query - The query and body used to delete the WebAuthn device. + * @returns {Promise} - A promise that resolves to the response data or an error object if the response is not valid. */ - delete: async function (query: WebAuthnQueryWithUUID & WebAuthnBody) { - const response = await store.dispatch(endpoints.deleteWebAuthnDeviceName.initiate(query)); - - if (!response || !response.data) { - return undefined; + delete: async function ( + query: WebAuthnQuery & { device: WebAuthnDevice | UpdatedWebAuthnDevice }, + ): Promise { + try { + const response = await store.dispatch(endpoints.deleteWebAuthnDeviceName.initiate(query)); + + if (!response || !response.data) { + throw new Error('response did not contain data'); + } + + return response.data; + } catch (error) { + return { error }; } - - return response.data; }, }, @@ -183,23 +229,27 @@ export const deviceClient = (config: ConfigOptions) => { * * @type {BoundDevicesManagement} */ - boundDevices: { + bound: { /** * Retrieves bound devices based on the specified query. * * @async * @function get - * @param {BindingDeviceQuery} query - The query used to retrieve bound devices. - * @returns {Promise} - A promise that resolves to the retrieved data or undefined if the response is not valid. + * @param {BoundDeviceQuery} query - The query used to retrieve bound devices. + * @returns {Promise} - A promise that resolves to the retrieved data or an error object if the response is not valid. */ - get: async function (query: BindingDeviceQuery) { - const response = await store.dispatch(endpoints.getBoundDevices.initiate(query)); + get: async function (query: GetBoundDevicesQuery): Promise { + try { + const response = await store.dispatch(endpoints.getBoundDevices.initiate(query)); - if (!response || !response.data) { - return undefined; - } + if (!response || !response.data || !response.data.result) { + throw new Error('response did not contain data'); + } - return response.data; + return response.data.result; + } catch (error) { + return { error }; + } }, /** @@ -207,17 +257,21 @@ export const deviceClient = (config: ConfigOptions) => { * * @async * @function delete - * @param {BindingDeviceQuery} query - The query used to delete the bound device. - * @returns {Promise} - A promise that resolves to the response data or undefined if the response is not valid. + * @param {BoundDeviceQuery} query - The query used to delete the bound device. + * @returns {Promise} - A promise that resolves to the response data or an error object if the response is not valid. */ - delete: async function (query: BindingDeviceQuery) { - const response = await store.dispatch(endpoints.deleteBindingDevice.initiate(query)); + delete: async function (query: BoundDeviceQuery): Promise { + try { + const response = await store.dispatch(endpoints.deleteBoundDevice.initiate(query)); - if (!response || !response.data) { - return undefined; - } + if (!response || !response.data || !response.data.result) { + throw new Error('response did not contain data'); + } - return response.data; + return response.data.result; + } catch (error) { + return { error }; + } }, /** @@ -225,17 +279,92 @@ export const deviceClient = (config: ConfigOptions) => { * * @async * @function update - * @param {BindingDeviceQuery} query - The query used to update the bound device name. - * @returns {Promise} - A promise that resolves to the response data or undefined if the response is not valid. + * @param {BoundDeviceQuery} query - The query used to update the bound device name. + * @returns {Promise} - A promise that resolves to the response data or an error object if the response is not valid. */ - update: async function (query: BindingDeviceQuery) { - const response = await store.dispatch(endpoints.updateBindingDeviceName.initiate(query)); + update: async function (query: BoundDeviceQuery): Promise { + try { + const response = await store.dispatch(endpoints.updateBoundDevice.initiate(query)); - if (!response || !response.data) { - return undefined; - } + if (!response || !response.data) { + throw new Error('response did not contain data'); + } - return response.data; + return response.data; + } catch (error) { + return { error }; + } + }, + }, + profile: { + /** + * Get profile devices + * + * @async + * @function update + * @param {GetProfileDevice} query - The query used to get profile devices + * @returns {Promise} - A promise that resolves to the response data or an error object if the response is not valid. + */ + get: async function ( + query: GetProfileDevices, + ): Promise { + try { + const response = await store.dispatch(endpoints.getDeviceProfiles.initiate(query)); + + if (!response || !response.data || !response.data.result) { + throw new Error('response did not contain data'); + } + + return response.data.result; + } catch (error) { + return { error }; + } + }, + /** + * Get profile devices + * + * @async + * @function update + * @param {ProfileDevicesQuery} query - The query used to update a profile device + * @returns {Promise} - A promise that resolves to the response data or or an error object if the response is not valid. + */ + update: async function ( + query: ProfileDevicesQuery, + ): Promise { + try { + const response = await store.dispatch(endpoints.updateDeviceProfile.initiate(query)); + + if (!response || !response.data) { + throw new Error('response did not contain data'); + } + + return response.data; + } catch (error) { + return { error }; + } + }, + /** + * Get profile devices + * + * @async + * @function update + * @param {ProfileDevicesQuery} query - The query used to update a profile device + * @returns {Promise} - A promise that resolves to the response data or an error object if the response is not valid. + */ + delete: async function ( + query: ProfileDevicesQuery, + ): Promise { + try { + const response = await store.dispatch(endpoints.deleteDeviceProfile.initiate(query)); + + if (!response || !response.data) { + throw new Error('response did not contain data'); + } + + return response.data; + } catch (error) { + return { error }; + } }, }, }; diff --git a/packages/device-client/src/lib/mock-data/msw-mock-data.ts b/packages/device-client/src/lib/mock-data/msw-mock-data.ts new file mode 100644 index 0000000000..aee59a19c8 --- /dev/null +++ b/packages/device-client/src/lib/mock-data/msw-mock-data.ts @@ -0,0 +1,172 @@ +/* + * 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 { GeneralResponse } from '../services/index.js'; +import type { + OathResponse, + DeletedOathDevice, + DeviceResponse, + ProfileDevice, + PushDevice, + WebAuthnDevice, +} from '../types'; + +// Mock data +export const MOCK_OATH_DEVICES: OathResponse = { + pagedResultsCookie: 'cookie', + remainingPagedResults: -1, + resultCount: 2, + totalPagedResults: 2, + totalPagedResultsPolicy: 'string', + result: [ + { + _id: 'oath-1', + _rev: '1-oath', + createdDate: 1705555555555, + lastAccessDate: 1705555555555, + deviceName: 'Test OATH Device', + uuid: 'oath-uuid-1', + deviceManagementStatus: true, + }, + ], +}; + +export const MOCK_DELETED_OATH_DEVICE: DeletedOathDevice = { + _id: 'oath-1', + _rev: '1-oath', + uuid: 'oath-uuid-1', + recoveryCodes: ['code1', 'code2'], + createdDate: 1705555555555, + lastAccessDate: 1705555555555, + sharedSecret: 'secret123', + deviceName: 'Test OATH Device', + lastLogin: 1705555555555, + counter: 0, + checksumDigit: true, + truncationOffset: 0, + clockDriftSeconds: 0, +}; + +export const MOCK_PUSH_DEVICES: PushDevice[] = [ + { + _id: 'push-1', + _rev: '1-push', + createdDate: 1705555555555, + lastAccessDate: 1705555555555, + deviceName: 'Test Push Device', + uuid: 'push-uuid-1', + deviceManagementStatus: true, + }, +]; + +export const MOCK_WEBAUTHN_DEVICES: GeneralResponse = { + result: [ + { + _id: 'webauthn-1', + _rev: '1-webauthn', + createdDate: 1705555555555, + lastAccessDate: 1705555555555, + credentialId: 'credential-1', + deviceName: 'Test WebAuthn Device', + uuid: 'webauthn-uuid-1', + deviceManagementStatus: true, + }, + ], + resultCount: 1, + pagedResultsCookie: null, + totalPagedResultsPolicy: 'NONE', + totalPagedResults: -1, + remainingPagedResults: -1, +}; + +export const MOCK_BINDING_DEVICES: DeviceResponse = { + result: [ + { + _id: 'binding-1', + _rev: '1-binding', + createdDate: 1705555555555, + lastAccessDate: 1705555555555, + deviceId: 'device-1', + deviceName: 'Test Binding Device', + uuid: 'binding-uuid-1', + recoveryCodes: ['123456', '789012'], + key: { + kty: 'RSA', + kid: 'key-1', + use: 'sig', + alg: 'RS256', + n: 'mock-n', + e: 'mock-e', + }, + deviceManagementStatus: true, + }, + ], + resultCount: 1, + pagedResultsCookie: null, + totalPagedResultsPolicy: 'NONE', + totalPagedResults: -1, + remainingPagedResults: -1, +}; + +export const MOCK_DEVICE_PROFILE_SUCCESS: GeneralResponse = { + result: [ + { + _id: 'ce0677ca57da8b38-5bfaa23e9a8ddc7899638da7cccbfe6a8879b6cf', + _rev: '755317638', + identifier: 'ce0677ca57da8b38-5bfaa23e9a8ddc7899638da7cccbfe6a8879b6cf', + metadata: { + platform: { + platform: 'Android', + version: 34, + device: 'emu64a', + deviceName: 'sdk_gphone64_arm64', + model: 'sdk_gphone64_arm64', + brand: 'google', + locale: 'en_US', + timeZone: 'America/Vancouver', + jailBreakScore: 0, + }, + hardware: { + hardware: 'ranchu', + manufacturer: 'Google', + storage: 5939, + memory: 2981, + cpu: 4, + display: { + width: 1440, + height: 2678, + orientation: 1, + }, + camera: { + numberOfCameras: 2, + }, + }, + browser: { + userAgent: + 'Mozilla/5.0 (Linux; Android 14; sdk_gphone64_arm64 Build/UPB4.230623.005; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/128.0.6613.127 Mobile Safari/537.36', + }, + bluetooth: { + supported: true, + }, + network: { + connected: true, + }, + telephony: { + networkCountryIso: 'us', + carrierName: 'T-Mobile', + }, + }, + lastSelectedDate: 1727110785783, + alias: 'test', + recoveryCodes: [], + }, + ], + resultCount: 1, + pagedResultsCookie: null, + totalPagedResultsPolicy: 'NONE', + totalPagedResults: -1, + remainingPagedResults: -1, +}; diff --git a/packages/device-client/src/lib/services/index.ts b/packages/device-client/src/lib/services/index.ts index fc4f5a8400..7a4ae32235 100644 --- a/packages/device-client/src/lib/services/index.ts +++ b/packages/device-client/src/lib/services/index.ts @@ -6,28 +6,34 @@ */ import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query'; import { - DeletedOAthDevice, - DeleteOathQuery, + DeletedOathDevice, OathDevice, - OAthResponse, + OathResponse, RetrieveOathQuery, } from '../types/oath.types.js'; import { DeleteDeviceQuery, + DeletedPushDevice, PushDevice, PushDeviceQuery, - PushDevicesResponse, } from '../types/push-device.types.js'; -import { BindingDeviceQuery, Device, DeviceResponse } from '../types/binding-device.types.js'; +import { BoundDeviceQuery, Device, GetBoundDevicesQuery } from '../types/bound-device.types.js'; +import { UpdatedWebAuthnDevice, WebAuthnDevice, WebAuthnQuery } from '../types/webauthn.types.js'; import { - UpdatedWebAuthnDevice, - WebAuthnBody, - WebAuthnDevice, - WebAuthnDevicesResponse, - WebAuthnQuery, - WebAuthnQueryWithUUID, -} from '../types/webauthn.types.js'; + ProfileDevice, + GetProfileDevices, + ProfileDevicesQuery, +} from '../types/profile-device.types.js'; + +export interface GeneralResponse { + pagedResultsCookie: string | null; + remainingPagedResults: number; + resultCount: number; + totalPagedResults: number; + totalPagedResultsPolicy: string; + result: T; +} export const deviceService = ({ baseUrl, realmPath }: { baseUrl: string; realmPath: string }) => createApi({ @@ -39,81 +45,103 @@ export const deviceService = ({ baseUrl, realmPath }: { baseUrl: string; realmPa headers.set('Accept', 'application/json'); headers.set('x-requested-with', 'forgerock-sdk'); headers.set('x-requested-platform', 'javascript'); + return headers; }, baseUrl, }), endpoints: (builder) => ({ // oath endpoints - getOAthDevices: builder.query({ + getOAthDevices: builder.query({ query: ({ realm = realmPath, userId }) => `json/realms/${realm}/users/${userId}/devices/2fa/oath?_queryFilter=true`, }), - deleteOathDevice: builder.mutation({ - query: ({ realm = realmPath, userId, uuid, ...body }) => ({ + deleteOathDevice: builder.mutation< + DeletedOathDevice, + RetrieveOathQuery & { device: OathDevice } + >({ + query: ({ realm = realmPath, userId, device }) => ({ method: 'DELETE', - url: `json/realms/${realm}/users/${userId}/devices/2fa/oath/${uuid}`, - body: { uuid, ...body }, + url: `json/realms/${realm}/users/${userId}/devices/2fa/oath/${device.uuid}`, + body: device, }), }), // push device - getPushDevices: builder.query({ + getPushDevices: builder.query, PushDeviceQuery>({ query: ({ realm = realmPath, userId }) => `/json/realms/${realm}/users/${userId}/devices/2fa/push?_queryFilter=true`, }), - deletePushDevice: builder.mutation({ - query: ({ realm = realmPath, userId, uuid }) => ({ - url: `/json/realms/${realm}/users/${userId}/devices/2fa/push/${uuid}`, + deletePushDevice: builder.mutation({ + query: ({ realm = realmPath, userId, device }) => ({ + url: `/json/realms/${realm}/users/${userId}/devices/2fa/push/${device.uuid}`, method: 'DELETE', body: {}, }), }), // webauthn devices - getWebAuthnDevices: builder.query({ + getWebAuthnDevices: builder.query, WebAuthnQuery>({ query: ({ realm = realmPath, userId }) => `/json/realms/${realm}/users/${userId}/devices/2fa/webauthn?_queryFilter=true`, }), updateWebAuthnDeviceName: builder.mutation< UpdatedWebAuthnDevice, - WebAuthnQueryWithUUID & WebAuthnBody + WebAuthnQuery & { device: WebAuthnDevice } >({ - query: ({ realm = realmPath, userId, ...device }) => ({ + query: ({ realm = realmPath, userId, device }) => ({ url: `/json/realms/${realm}/users/${userId}/devices/2fa/webauthn/${device.uuid}`, method: 'PUT', - body: device satisfies WebAuthnBody, + body: device, }), }), deleteWebAuthnDeviceName: builder.mutation< - WebAuthnDevice, - WebAuthnQueryWithUUID & WebAuthnBody + UpdatedWebAuthnDevice, + WebAuthnQuery & { device: UpdatedWebAuthnDevice | WebAuthnDevice } >({ - query: ({ realm = realmPath, userId, ...device }) => ({ + query: ({ realm = realmPath, userId, device }) => ({ url: `/json/realms/${realm}/users/${userId}/devices/2fa/webauthn/${device.uuid}`, method: 'DELETE', - body: device satisfies WebAuthnBody, + body: device, }), }), - getBoundDevices: builder.mutation({ + getBoundDevices: builder.mutation, GetBoundDevicesQuery>({ query: ({ realm = realmPath, userId }) => `/json/realms/${realm}/users/${userId}/devices/2fa/binding?_queryFilter=true`, }), - updateBindingDeviceName: builder.mutation({ - query: ({ realm = realmPath, userId, ...device }) => ({ + updateBoundDevice: builder.mutation({ + query: ({ realm = realmPath, userId, device }) => ({ url: `/json/realms/root/realms/${realm}/users/${userId}/devices/2fa/binding/${device.uuid}`, method: 'PUT', - body: device satisfies Device, + body: device, }), }), - deleteBindingDevice: builder.mutation({ - query: ({ realm = realmPath, userId, ...device }) => ({ + deleteBoundDevice: builder.mutation, BoundDeviceQuery>({ + query: ({ realm = realmPath, userId, device }) => ({ url: `/json/realms/root/realms/${realm}/users/${userId}/devices/2fa/binding/${device.uuid}`, method: 'DELETE', body: device satisfies Device, }), }), + getDeviceProfiles: builder.query, GetProfileDevices>({ + query: ({ realm = realmPath, userId }) => + `json/realms/${realm}/users/${userId}/devices/profile?_queryFilter=true`, + }), + updateDeviceProfile: builder.mutation>({ + query: ({ realm = realmPath, userId, device }) => ({ + url: `json/realms/${realm}/users/${userId}/devices/profile/${device.identifier}`, + method: 'PUT', + body: device, + }), + }), + deleteDeviceProfile: builder.mutation({ + query: ({ realm = realmPath, userId, device }) => ({ + url: `json/realms/${realm}/users/${userId}/devices/profile/${device.identifier}`, + method: 'DELETE', + body: device, + }), + }), }), }); diff --git a/packages/device-client/src/lib/types/binding-device.types.ts b/packages/device-client/src/lib/types/bound-device.types.ts similarity index 84% rename from packages/device-client/src/lib/types/binding-device.types.ts rename to packages/device-client/src/lib/types/bound-device.types.ts index 73a04c4e98..cbb0c13943 100644 --- a/packages/device-client/src/lib/types/binding-device.types.ts +++ b/packages/device-client/src/lib/types/bound-device.types.ts @@ -4,10 +4,11 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ -export type BindingDeviceQuery = { +export type GetBoundDevicesQuery = { userId: string; realm?: string; -} & Device; +}; +export type BoundDeviceQuery = GetBoundDevicesQuery & { device: Device }; export type DeviceResponse = { result: Device[]; @@ -26,6 +27,7 @@ export type Device = { deviceId: string; deviceName: string; uuid: string; + recoveryCodes: string[]; key: { kty: string; kid: string; diff --git a/packages/device-client/src/lib/types/index.ts b/packages/device-client/src/lib/types/index.ts index a5bd473fa4..33a54ff60d 100644 --- a/packages/device-client/src/lib/types/index.ts +++ b/packages/device-client/src/lib/types/index.ts @@ -6,6 +6,7 @@ */ export * from './oath.types.js'; export * from './webauthn.types.js'; +export * from './profile-device.types.js'; export * from './push-device.types.js'; -export * from './binding-device.types.js'; +export * from './bound-device.types.js'; export * from './updateDeviceProfile.types.js'; diff --git a/packages/device-client/src/lib/types/oath.types.ts b/packages/device-client/src/lib/types/oath.types.ts index be5df5398c..02d96b6bf6 100644 --- a/packages/device-client/src/lib/types/oath.types.ts +++ b/packages/device-client/src/lib/types/oath.types.ts @@ -5,11 +5,13 @@ * of the MIT license. See the LICENSE file for details. */ export type OathDevice = { - id: string; + _id: string; + deviceManagementStatus: boolean; deviceName: string; uuid: string; - createdDate: Date; - lastAccessDate: Date; + createdDate: number; + lastAccessDate: number; + _rev: string; }; export type DeleteOathQuery = { @@ -23,17 +25,16 @@ export type RetrieveOathQuery = { userId: string; }; -export type OAthResponse = { - _id: string; - _rev: string; - createdDate: number; - lastAccessDate: number; - deviceName: string; - uuid: string; - deviceManagementStatus: boolean; -}[]; +export type OathResponse = { + pagedResultsCookie: string | null; + remainingPagedResults: number; + resultCount: number; + totalPagedResults: number; + totalPagedResultsPolicy: string; + result: OathDevice[]; +}; -export type DeletedOAthDevice = { +export type DeletedOathDevice = { _id: string; _rev: string; uuid: string; diff --git a/packages/device-client/src/lib/types/profile-device.types.ts b/packages/device-client/src/lib/types/profile-device.types.ts new file mode 100644 index 0000000000..f3687bc4c2 --- /dev/null +++ b/packages/device-client/src/lib/types/profile-device.types.ts @@ -0,0 +1,64 @@ +/* + * 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. + */ +export interface GetProfileDevices { + realm: string; + userId: string; +} + +export interface ProfileDevicesQuery extends GetProfileDevices { + device: ProfileDevice; +} + +export type ProfileDevice = { + _id: string; + _rev: string; + identifier: string; + metadata: { + platform: { + platform: string; + version: number; + device: string; + deviceName: string; + model: string; + brand: string; + locale: string; + timeZone: string; + jailBreakScore: number; + }; + hardware: { + hardware: string; + manufacturer: string; + storage: number; + memory: number; + cpu: number; + display: { + width: number; + height: number; + orientation: number; + }; + camera: { + numberOfCameras: number; + }; + }; + browser: { + userAgent: string; + }; + bluetooth: { + supported: boolean; + }; + network: { + connected: boolean; + }; + telephony: { + networkCountryIso: string; + carrierName: string; + }; + }; + lastSelectedDate: number; + alias: string; + recoveryCodes: string[]; +}; diff --git a/packages/device-client/src/lib/types/push-device.types.ts b/packages/device-client/src/lib/types/push-device.types.ts index b5fc857ea0..64bcb4043c 100644 --- a/packages/device-client/src/lib/types/push-device.types.ts +++ b/packages/device-client/src/lib/types/push-device.types.ts @@ -20,7 +20,7 @@ export type PushDeviceBody = { export type DeleteDeviceQuery = { realm?: string; userId: string; - uuid: string; + device: PushDevice; }; export type DeviceInfoResponse = { @@ -81,9 +81,6 @@ export type DeviceInfo = { alias: string; recoveryCodes: string[]; }; - -export type PushDevicesResponse = PushDevice[]; - export type PushDevice = { _id: string; _rev: string; @@ -93,3 +90,20 @@ export type PushDevice = { uuid: string; deviceManagementStatus: boolean; }; + +export interface DeletedPushDevice { + communicationId: string; + communicationType: string; + createdDate: number; + deviceId: string; + deviceMechanismUID: string; + deviceName: string; + deviceType: string; + issuer: string; + lastAccessDate: number; + recoveryCodes: string[]; + sharedSecret: string; + uuid: string; + _id: string; + _rev: string; +} diff --git a/packages/device-client/src/lib/types/webauthn.types.ts b/packages/device-client/src/lib/types/webauthn.types.ts index 51dad83698..551921d726 100644 --- a/packages/device-client/src/lib/types/webauthn.types.ts +++ b/packages/device-client/src/lib/types/webauthn.types.ts @@ -9,10 +9,6 @@ export type WebAuthnQuery = { userId: string; }; -export type WebAuthnQueryWithUUID = { - uuid: string; -} & WebAuthnQuery; - export type WebAuthnBody = { id: string; deviceName: string; @@ -22,15 +18,6 @@ export type WebAuthnBody = { lastAccessDate: number; }; -export type WebAuthnDevicesResponse = { - result: WebAuthnDevice[]; - resultCount: number; - pagedResultsCookie: null; - totalPagedResultsPolicy: 'NONE'; - totalPagedResults: -1; - remainingPagedResults: -1; -}; - export type WebAuthnDevice = { _id: string; _rev: string; diff --git a/packages/device-client/src/types.ts b/packages/device-client/src/types.ts new file mode 100644 index 0000000000..c6eea56feb --- /dev/null +++ b/packages/device-client/src/types.ts @@ -0,0 +1,7 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All right reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ +export * from './lib/types/index.js'; diff --git a/packages/device-client/tsconfig.lib.json b/packages/device-client/tsconfig.lib.json index 706ef39a3d..c61ea183a1 100644 --- a/packages/device-client/tsconfig.lib.json +++ b/packages/device-client/tsconfig.lib.json @@ -1,9 +1,9 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "module": "NodeNext", - "moduleResolution": "NodeNext", - "target": "ES2022", + "module": "ES2020", + "moduleResolution": "Bundler", + "target": "ES2020", "outDir": "./dist", "declaration": true, "declarationMap": true, @@ -12,7 +12,7 @@ "composite": true, "lib": ["es2022", "dom", "dom.iterable"] }, - "include": ["src/**/*.ts"], + "include": ["src/**/*.ts", "src/**/*.*.ts"], "exclude": [ "vite.config.ts", "src/**/*.spec.ts", diff --git a/packages/device-client/tsconfig.spec.json b/packages/device-client/tsconfig.spec.json index bfa1969edd..2c6bd0f3a6 100644 --- a/packages/device-client/tsconfig.spec.json +++ b/packages/device-client/tsconfig.spec.json @@ -2,8 +2,8 @@ "extends": "./tsconfig.json", "compilerOptions": { "composite": true, - "module": "NodeNext", - "moduleResolution": "NodeNext", + "module": "ES2020", + "moduleResolution": "bundler", "target": "ES2022", "outDir": "../../dist/out-tsc", "types": ["vitest/globals", "vitest/importMeta", "vite/client", "node"] @@ -19,6 +19,7 @@ "src/**/*.spec.js", "src/**/*.test.jsx", "src/**/*.spec.jsx", - "src/**/*.d.ts" + "src/**/*.d.ts", + "src/**/*.ts" ] } diff --git a/packages/device-client/vite.config.ts b/packages/device-client/vite.config.ts index ff9e523881..1720079a7f 100644 --- a/packages/device-client/vite.config.ts +++ b/packages/device-client/vite.config.ts @@ -11,7 +11,6 @@ export default defineConfig({ watch: false, reporters: ['default'], globals: true, - setupFiles: ['./vitest.setup.ts'], passWithNoTests: true, coverage: { enabled: Boolean(process.env['CI']), From b0e387a02fc7d3471a1ad0687c4390245e381065 Mon Sep 17 00:00:00 2001 From: AJ Ancheta <7781450+ancheetah@users.noreply.github.com> Date: Wed, 4 Jun 2025 11:49:50 -0400 Subject: [PATCH 2/4] test(device-client): add e2e tests --- .changeset/config.json | 1 + e2e/device-client-app/.gitignore | 24 ++ e2e/device-client-app/eslint.config.mjs | 25 ++ e2e/device-client-app/package.json | 22 ++ e2e/device-client-app/public/typescript.svg | 1 + e2e/device-client-app/public/vite.svg | 1 + .../src/_callback/index.html | 8 + e2e/device-client-app/src/autoscript.ts | 154 ++++++++++++ .../src/device-binding/index.html | 9 + .../src/device-binding/main.ts | 76 ++++++ .../src/device-profile/index.html | 9 + .../src/device-profile/main.ts | 76 ++++++ e2e/device-client-app/src/index.html | 29 +++ e2e/device-client-app/src/index.ts | 10 + e2e/device-client-app/src/oath/index.html | 9 + e2e/device-client-app/src/oath/main.ts | 64 +++++ e2e/device-client-app/src/push/index.html | 9 + e2e/device-client-app/src/push/main.ts | 63 +++++ e2e/device-client-app/src/style.css | 100 ++++++++ e2e/device-client-app/src/types.ts | 3 + e2e/device-client-app/src/vite-env.d.ts | 1 + e2e/device-client-app/src/webauthn/index.html | 9 + e2e/device-client-app/src/webauthn/main.ts | 76 ++++++ e2e/device-client-app/tsconfig.app.json | 32 +++ e2e/device-client-app/tsconfig.json | 13 + e2e/device-client-app/vite.config.ts | 56 +++++ packages/device-client/eslint.config.mjs | 10 +- packages/device-client/package.json | 10 +- .../src/lib/device.store.test.ts | 223 +++--------------- .../src/lib/device.store.test.utils.ts | 188 +++++++++++++++ .../device-client/src/lib/device.store.ts | 78 +++--- .../src/lib/device.store.utils.ts | 15 ++ ...{msw-mock-data.ts => device.store.mock.ts} | 2 +- packages/device-client/tsconfig.lib.json | 1 + packages/device-client/tsconfig.spec.json | 4 +- packages/device-client/vite.config.ts | 1 + pnpm-lock.yaml | 25 +- tsconfig.json | 3 + 38 files changed, 1186 insertions(+), 254 deletions(-) create mode 100644 e2e/device-client-app/.gitignore create mode 100644 e2e/device-client-app/eslint.config.mjs create mode 100644 e2e/device-client-app/package.json create mode 100644 e2e/device-client-app/public/typescript.svg create mode 100644 e2e/device-client-app/public/vite.svg create mode 100644 e2e/device-client-app/src/_callback/index.html create mode 100644 e2e/device-client-app/src/autoscript.ts create mode 100644 e2e/device-client-app/src/device-binding/index.html create mode 100644 e2e/device-client-app/src/device-binding/main.ts create mode 100644 e2e/device-client-app/src/device-profile/index.html create mode 100644 e2e/device-client-app/src/device-profile/main.ts create mode 100644 e2e/device-client-app/src/index.html create mode 100644 e2e/device-client-app/src/index.ts create mode 100644 e2e/device-client-app/src/oath/index.html create mode 100644 e2e/device-client-app/src/oath/main.ts create mode 100644 e2e/device-client-app/src/push/index.html create mode 100644 e2e/device-client-app/src/push/main.ts create mode 100644 e2e/device-client-app/src/style.css create mode 100644 e2e/device-client-app/src/types.ts create mode 100644 e2e/device-client-app/src/vite-env.d.ts create mode 100644 e2e/device-client-app/src/webauthn/index.html create mode 100644 e2e/device-client-app/src/webauthn/main.ts create mode 100644 e2e/device-client-app/tsconfig.app.json create mode 100644 e2e/device-client-app/tsconfig.json create mode 100644 e2e/device-client-app/vite.config.ts create mode 100644 packages/device-client/src/lib/device.store.test.utils.ts create mode 100644 packages/device-client/src/lib/device.store.utils.ts rename packages/device-client/src/lib/mock-data/{msw-mock-data.ts => device.store.mock.ts} (99%) diff --git a/.changeset/config.json b/.changeset/config.json index 2be9970bfe..e382cdd043 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -14,6 +14,7 @@ "updateInternalDependencies": "patch", "ignore": [ "@forgerock/device-client", + "@forgerock/device-client-app", "@forgerock/davinci-app", "@forgerock/davinci-suites", "@forgerock/mock-api-v2", diff --git a/e2e/device-client-app/.gitignore b/e2e/device-client-app/.gitignore new file mode 100644 index 0000000000..a547bf36d8 --- /dev/null +++ b/e2e/device-client-app/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/e2e/device-client-app/eslint.config.mjs b/e2e/device-client-app/eslint.config.mjs new file mode 100644 index 0000000000..ef30ce27f4 --- /dev/null +++ b/e2e/device-client-app/eslint.config.mjs @@ -0,0 +1,25 @@ +import baseConfig from '../../eslint.config.mjs'; + +export default [ + { + ignores: [ + 'node_modules', + '*.md', + 'LICENSE', + '.swcrc', + '.babelrc', + '.env*', + '.bin', + 'dist', + '.eslintignore', + '**/*.html', + '**/*.svg', + '**/*.css', + 'public', + '*.json', + '*.d.ts', + '.gitignore', + ], + }, + ...baseConfig, +]; diff --git a/e2e/device-client-app/package.json b/e2e/device-client-app/package.json new file mode 100644 index 0000000000..178b92f4c0 --- /dev/null +++ b/e2e/device-client-app/package.json @@ -0,0 +1,22 @@ +{ + "name": "@forgerock/device-client-app", + "version": "0.0.0", + "private": true, + "scripts": { + "build": "pnpm nx nxBuild", + "lint": "pnpm nx nxLint", + "preview": "pnpm nx nxPreview", + "serve": "pnpm nx nxServe" + }, + "dependencies": { + "@forgerock/device-client": "workspace:*", + "@forgerock/javascript-sdk": "4.7.0", + "effect": "^3.12.7" + }, + "nx": { + "tags": ["scope:e2e"] + }, + "devDependencies": { + "@effect/language-service": "^0.2.0" + } +} diff --git a/e2e/device-client-app/public/typescript.svg b/e2e/device-client-app/public/typescript.svg new file mode 100644 index 0000000000..d91c910cc3 --- /dev/null +++ b/e2e/device-client-app/public/typescript.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/e2e/device-client-app/public/vite.svg b/e2e/device-client-app/public/vite.svg new file mode 100644 index 0000000000..e7b8dfb1b2 --- /dev/null +++ b/e2e/device-client-app/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/e2e/device-client-app/src/_callback/index.html b/e2e/device-client-app/src/_callback/index.html new file mode 100644 index 0000000000..9662116d79 --- /dev/null +++ b/e2e/device-client-app/src/_callback/index.html @@ -0,0 +1,8 @@ + + + + Logged In | E2E Test | Ping Identity JavaScript SDK + + + + diff --git a/e2e/device-client-app/src/autoscript.ts b/e2e/device-client-app/src/autoscript.ts new file mode 100644 index 0000000000..00653be9f2 --- /dev/null +++ b/e2e/device-client-app/src/autoscript.ts @@ -0,0 +1,154 @@ +/* + * + * Copyright © 2025 Ping Identity Corporation. All right reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + * + */ + +import { + FRAuth, + CallbackType, + NameCallback, + TokenManager, + SessionManager, + Config, + PasswordCallback, +} from '@forgerock/javascript-sdk'; +import { deviceClient } from '@forgerock/device-client'; +import { Effect } from 'effect'; +import { DeviceClient } from './types.js'; + +/** + * @function autoscript + * @description Steps through an authentication journey to test device management + * @param {function} handleDevice A function that manages the device through the device client + * @returns {Effect.Effect} An effect to run the test + */ +export const autoscript = ( + handleDevice: (client: DeviceClient) => Effect.Effect, +) => + Effect.gen(function* () { + const url = new URL(window.location.href); + const amUrl = url.searchParams.get('amUrl') || 'https://openam-sdks.forgeblocks.com/am'; + const realmPath = url.searchParams.get('realmPath') || 'alpha'; + const platformHeader = url.searchParams.get('platformHeader') === 'true' ? true : false; + const tree = url.searchParams.get('tree') || 'selfservice'; + + /** + * Make sure this `un` is a real user + * this is a manual test and requires a real tenant and a real user + * that has devices. + */ + const un = url.searchParams.get('un') || 'devicetestuser'; + const pw = url.searchParams.get('pw') || 'password'; + + // Configure the SDK + yield* Effect.try({ + try: () => { + Config.set({ + middleware: [ + (req, action, next) => { + switch (action.type) { + case 'START_AUTHENTICATE': + if ( + action.payload.type === 'service' && + typeof action.payload.tree === 'string' + ) { + console.log('Starting authentication with service'); + } + break; + case 'AUTHENTICATE': + if ( + action.payload.type === 'service' && + typeof action.payload.tree === 'string' + ) { + console.log('Continuing authentication with service'); + } + break; + } + next(); + }, + ], + platformHeader, + realmPath, + tree, + clientId: 'WebOAuthClient', + scope: 'profile email me.read openid', + redirectUri: `${window.location.origin}/src/_callback/index.html`, + serverConfig: { + baseUrl: amUrl, + timeout: 3000, + }, + }); + console.log('Configured the SDK'); + }, + catch: (err) => new Error(`SDK configuration failed: ${err}`), + }); + + // Log out any user before starting auth journey + yield* Effect.tryPromise({ + try: () => SessionManager.logout(), + catch: (err) => new Error(`Logout failed: ${err}`), + }); + + // Start the authentication journey + const step = yield* Effect.tryPromise({ + try: () => FRAuth.start(), + catch: (err) => new Error(`Authentication start failed: ${err}`), + }); + + // Login with username/password + yield* Effect.tryPromise({ + try: () => { + if (step.type !== 'Step') { + return Promise.reject( + new Error('Expected a step, but received a login success or failure.'), + ); + } + + console.log('Set values on auth tree callbacks'); + step.getCallbackOfType(CallbackType.NameCallback).setName(un); + step.getCallbackOfType(CallbackType.PasswordCallback).setPassword(pw); + return FRAuth.next(step); + }, + catch: (err) => new Error(`Login failed: ${err}`), + }); + + // Get tokens + yield* Effect.tryPromise({ + try: () => TokenManager.getTokens(), + catch: (err) => new Error(`Failed to get tokens: ${err}`), + }); + + // Create a device client + const client = yield* Effect.sync(() => { + return deviceClient({ + realmPath, + tree, + clientId: 'WebOAuthClient', + scope: 'profile email me.read openid', + serverConfig: { + baseUrl: amUrl, + timeout: 3000, + }, + }); + }); + + // Test the device + yield* handleDevice(client); + + // Finish autoscript + yield* Effect.sync(() => { + document.body.innerHTML = `

Test script complete

`; + }); + + return 'Test script complete'; + }); + +// Display error message +export const handleError = (err: unknown) => { + console.error(err); + document.body.innerHTML = `

Test script failed: ${err}

`; +}; diff --git a/e2e/device-client-app/src/device-binding/index.html b/e2e/device-client-app/src/device-binding/index.html new file mode 100644 index 0000000000..ace4e44aa5 --- /dev/null +++ b/e2e/device-client-app/src/device-binding/index.html @@ -0,0 +1,9 @@ + + + + E2E Test | Ping Identity JavaScript SDK + + + + + diff --git a/e2e/device-client-app/src/device-binding/main.ts b/e2e/device-client-app/src/device-binding/main.ts new file mode 100644 index 0000000000..d130e4986e --- /dev/null +++ b/e2e/device-client-app/src/device-binding/main.ts @@ -0,0 +1,76 @@ +/* + * + * Copyright © 2025 Ping Identity Corporation. All right reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + * + */ + +import { UserManager } from '@forgerock/javascript-sdk'; +import { autoscript, handleError } from '../autoscript.js'; +import { DeviceClient } from '../types.js'; +import { Device } from '@forgerock/device-client/types'; +import { Effect } from 'effect'; + +/** + * @function handleDeviceBinding + * @description Handles device binding management operations such as getting, updating, and deleting devices + * @param {DeviceClient} client A device client instance from the JS SDK + * @returns {Effect.Effect} An Effect that performs device binding management operations + */ +function handleDeviceBinding(client: DeviceClient): Effect.Effect { + return Effect.gen(function* () { + const user = yield* Effect.tryPromise({ + try: () => UserManager.getCurrentUser(), + catch: (err) => new Error(`Failed to get current user: ${err}`), + }); + + const query = { + userId: (user as Record).sub, + realm: 'alpha', + }; + + const deviceArr = yield* Effect.promise(() => client.bound.get(query)); + console.log('GET devices', deviceArr); + + if (Array.isArray(deviceArr)) { + const [device] = deviceArr; + + if (!device) { + yield* Effect.fail(new Error('No device to delete')); + } + console.log('device', device); + + const updatedDevice = yield* Effect.promise(() => + client.bound.update({ + ...query, + device: { ...device, deviceName: 'UpdatedDeviceName' }, + }), + ); + + if ('error' in updatedDevice) { + yield* Effect.fail(new Error(`Failed to update device: ${updatedDevice.error}`)); + } + console.log('updated device', updatedDevice); + + const deletedDevice = yield* Effect.promise(() => + client.bound.delete({ + ...query, + device: updatedDevice as Device, + }), + ); + + if (deletedDevice !== null && deletedDevice.error) { + yield* Effect.fail(new Error(`Failed to delete device: ${deletedDevice.error}`)); + } + + console.log('deleted', deletedDevice); + } else { + yield* Effect.fail(new Error(`Failed to get devices: ${deviceArr.error}`)); + } + }); +} + +// Execute the device test +Effect.runPromise(autoscript(handleDeviceBinding)).then(console.log, handleError); diff --git a/e2e/device-client-app/src/device-profile/index.html b/e2e/device-client-app/src/device-profile/index.html new file mode 100644 index 0000000000..ace4e44aa5 --- /dev/null +++ b/e2e/device-client-app/src/device-profile/index.html @@ -0,0 +1,9 @@ + + + + E2E Test | Ping Identity JavaScript SDK + + + + + diff --git a/e2e/device-client-app/src/device-profile/main.ts b/e2e/device-client-app/src/device-profile/main.ts new file mode 100644 index 0000000000..649e451135 --- /dev/null +++ b/e2e/device-client-app/src/device-profile/main.ts @@ -0,0 +1,76 @@ +/* + * + * Copyright © 2025 Ping Identity Corporation. All right reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + * + */ + +import { UserManager } from '@forgerock/javascript-sdk'; +import { autoscript, handleError } from '../autoscript.js'; +import { DeviceClient } from '../types.js'; +import { ProfileDevice } from '@forgerock/device-client/types'; +import { Effect } from 'effect'; + +/** + * @function handleDeviceProfile + * @description Handles device profile management operations such as getting, updating, and deleting devices + * @param {DeviceClient} client A device client instance from the JS SDK + * @returns {Effect.Effect} An Effect that performs device profile management operations + */ +function handleDeviceProfile(client: DeviceClient): Effect.Effect { + return Effect.gen(function* () { + const user = yield* Effect.tryPromise({ + try: () => UserManager.getCurrentUser(), + catch: (err) => new Error(`Failed to get current user: ${err}`), + }); + + const query = { + userId: (user as Record).sub, + realm: 'alpha', + }; + + const profileArr = yield* Effect.promise(() => client.profile.get(query)); + console.log('GET devices', profileArr); + + if (Array.isArray(profileArr)) { + const [profile] = profileArr; + + if (!profile) { + yield* Effect.fail(new Error('No profile to delete')); + } + console.log('profile', profile); + + const updatedProfile = yield* Effect.promise(() => + client.profile.update({ + ...query, + device: { ...profile, alias: 'UpdatedDeviceName' }, + }), + ); + + if ('error' in updatedProfile) { + yield* Effect.fail(new Error(`Failed to update device: ${updatedProfile.error}`)); + } + console.log('updated device', updatedProfile); + + const deletedProfile = yield* Effect.promise(() => + client.profile.delete({ + ...query, + device: updatedProfile as ProfileDevice, + }), + ); + + if (deletedProfile !== null && deletedProfile.error) { + yield* Effect.fail(new Error(`Failed to delete device: ${deletedProfile.error}`)); + } + + console.log('deleted', deletedProfile); + } else { + yield* Effect.fail(new Error(`Failed to get devices: ${profileArr.error}`)); + } + }); +} + +// Execute the device test +Effect.runPromise(autoscript(handleDeviceProfile)).then(console.log, handleError); diff --git a/e2e/device-client-app/src/index.html b/e2e/device-client-app/src/index.html new file mode 100644 index 0000000000..a7dc55dd7d --- /dev/null +++ b/e2e/device-client-app/src/index.html @@ -0,0 +1,29 @@ + + + + + + + Device Client E2E Test Index | Ping Identity JavaScript SDK + + +
+ + + + + + +

Click on the Vite and TypeScript logos to learn more

+

Device Client E2E Test Index | Ping Identity JavaScript SDK

+ +
+ + + diff --git a/e2e/device-client-app/src/index.ts b/e2e/device-client-app/src/index.ts new file mode 100644 index 0000000000..df64de15b1 --- /dev/null +++ b/e2e/device-client-app/src/index.ts @@ -0,0 +1,10 @@ +/* + * + * Copyright © 2025 Ping Identity Corporation. All right reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + * + */ + +import './style.css'; diff --git a/e2e/device-client-app/src/oath/index.html b/e2e/device-client-app/src/oath/index.html new file mode 100644 index 0000000000..ace4e44aa5 --- /dev/null +++ b/e2e/device-client-app/src/oath/index.html @@ -0,0 +1,9 @@ + + + + E2E Test | Ping Identity JavaScript SDK + + + + + diff --git a/e2e/device-client-app/src/oath/main.ts b/e2e/device-client-app/src/oath/main.ts new file mode 100644 index 0000000000..cc5c0e8a71 --- /dev/null +++ b/e2e/device-client-app/src/oath/main.ts @@ -0,0 +1,64 @@ +/* + * + * Copyright © 2025 Ping Identity Corporation. All right reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + * + */ + +import { UserManager } from '@forgerock/javascript-sdk'; +import { autoscript, handleError } from '../autoscript.js'; +import { DeviceClient } from '../types.js'; +import { Effect } from 'effect'; + +/** + * @function handleOath + * @description Handles OATH device management operations such as getting and deleting devices + * @param {DeviceClient} client A device client instance from the JS SDK + * @returns {Effect.Effect} An Effect that performs OATH device management operations + */ +function handleOath(client: DeviceClient): Effect.Effect { + return Effect.gen(function* () { + const user = yield* Effect.tryPromise({ + try: () => UserManager.getCurrentUser(), + catch: (err) => new Error(`Failed to get current user: ${err}`), + }); + console.log('user', user); + + const query = { + userId: (user as Record).sub, + realm: 'alpha', + }; + + const deviceArr = yield* Effect.promise(() => client.oath.get(query)); + console.log('GET devices', deviceArr); + + if (Array.isArray(deviceArr)) { + const [device] = deviceArr; + + if (!device) { + yield* Effect.fail(new Error('No device to delete')); + } + console.log('device', device); + + const deletedDevice = yield* Effect.promise(() => + client.oath.delete({ + ...query, + device, + }), + ); + + if (deletedDevice !== null && deletedDevice.error) { + yield* Effect.fail(new Error(`Failed to delete device: ${deletedDevice.error}`)); + } + + console.log('deleted', deletedDevice); + } else { + yield* Effect.fail(new Error(`Failed to get devices: ${deviceArr.error}`)); + } + }); +} + +// Execute the device test +Effect.runPromise(autoscript(handleOath)).then(console.log, handleError); diff --git a/e2e/device-client-app/src/push/index.html b/e2e/device-client-app/src/push/index.html new file mode 100644 index 0000000000..ace4e44aa5 --- /dev/null +++ b/e2e/device-client-app/src/push/index.html @@ -0,0 +1,9 @@ + + + + E2E Test | Ping Identity JavaScript SDK + + + + + diff --git a/e2e/device-client-app/src/push/main.ts b/e2e/device-client-app/src/push/main.ts new file mode 100644 index 0000000000..69201707e9 --- /dev/null +++ b/e2e/device-client-app/src/push/main.ts @@ -0,0 +1,63 @@ +/* + * + * Copyright © 2025 Ping Identity Corporation. All right reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + * + */ + +import { UserManager } from '@forgerock/javascript-sdk'; +import { autoscript, handleError } from '../autoscript.js'; +import { DeviceClient } from '../types.js'; +import { Effect } from 'effect'; + +/** + * @function handlePush + * @description Handles PUSH device management operations such as getting and deleting devices + * @param {DeviceClient} client A device client instance from the JS SDK + * @returns {Effect.Effect} An Effect that performs PUSH device management operations + */ +function handlePush(client: DeviceClient): Effect.Effect { + return Effect.gen(function* () { + const user = yield* Effect.tryPromise({ + try: () => UserManager.getCurrentUser(), + catch: (err) => new Error(`Failed to get current user: ${err}`), + }); + + const query = { + userId: (user as Record).sub, + realm: 'alpha', + }; + + const deviceArr = yield* Effect.promise(() => client.push.get(query)); + console.log('GET devices', deviceArr); + + if (Array.isArray(deviceArr)) { + const [device] = deviceArr; + + if (!device) { + yield* Effect.fail(new Error('No device to delete')); + } + console.log('device', device); + + const deletedDevice = yield* Effect.promise(() => + client.push.delete({ + ...query, + device, + }), + ); + + if (deletedDevice !== null && deletedDevice.error) { + yield* Effect.fail(new Error(`Failed to delete device: ${deletedDevice.error}`)); + } + + console.log('deleted', deletedDevice); + } else { + yield* Effect.fail(new Error(`Failed to get devices: ${deviceArr.error}`)); + } + }); +} + +// Execute the device test +Effect.runPromise(autoscript(handlePush)).then(console.log, handleError); diff --git a/e2e/device-client-app/src/style.css b/e2e/device-client-app/src/style.css new file mode 100644 index 0000000000..b371f42085 --- /dev/null +++ b/e2e/device-client-app/src/style.css @@ -0,0 +1,100 @@ +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +#nav > a { + display: block; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +#app { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.vanilla:hover { + filter: drop-shadow(0 0 2em #3178c6aa); +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/e2e/device-client-app/src/types.ts b/e2e/device-client-app/src/types.ts new file mode 100644 index 0000000000..94692f2581 --- /dev/null +++ b/e2e/device-client-app/src/types.ts @@ -0,0 +1,3 @@ +import { deviceClient } from '@forgerock/device-client'; + +export type DeviceClient = ReturnType; diff --git a/e2e/device-client-app/src/vite-env.d.ts b/e2e/device-client-app/src/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/e2e/device-client-app/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/e2e/device-client-app/src/webauthn/index.html b/e2e/device-client-app/src/webauthn/index.html new file mode 100644 index 0000000000..ace4e44aa5 --- /dev/null +++ b/e2e/device-client-app/src/webauthn/index.html @@ -0,0 +1,9 @@ + + + + E2E Test | Ping Identity JavaScript SDK + + + + + diff --git a/e2e/device-client-app/src/webauthn/main.ts b/e2e/device-client-app/src/webauthn/main.ts new file mode 100644 index 0000000000..63c64c482f --- /dev/null +++ b/e2e/device-client-app/src/webauthn/main.ts @@ -0,0 +1,76 @@ +/* + * + * Copyright © 2025 Ping Identity Corporation. All right reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + * + */ + +import { UserManager } from '@forgerock/javascript-sdk'; +import { autoscript, handleError } from '../autoscript.js'; +import { DeviceClient } from '../types.js'; +import { UpdatedWebAuthnDevice } from '@forgerock/device-client/types'; +import { Effect } from 'effect'; + +/** + * @function handleWebAuthN + * @description Handles WebAuthN device management operations such as getting, updating, and deleting devices + * @param {DeviceClient} client A device client instance from the JS SDK + * @returns {Effect.Effect} An Effect that performs WebAuthN device management operations + */ +function handleWebAuthN(client: DeviceClient): Effect.Effect { + return Effect.gen(function* () { + const user = yield* Effect.tryPromise({ + try: () => UserManager.getCurrentUser(), + catch: (err) => new Error(`Failed to get current user: ${err}`), + }); + + const query = { + userId: (user as Record).sub, + realm: 'alpha', + }; + + const deviceArr = yield* Effect.promise(() => client.webAuthn.get(query)); + console.log('GET devices', deviceArr); + + if (Array.isArray(deviceArr)) { + const [device] = deviceArr; + + if (!device) { + yield* Effect.fail(new Error('No device to delete')); + } + console.log('device', device); + + const updatedDevice = yield* Effect.promise(() => + client.webAuthn.update({ + ...query, + device: { ...device, deviceName: 'UpdatedDeviceName' }, + }), + ); + + if ('error' in updatedDevice) { + yield* Effect.fail(new Error(`Failed to update device: ${updatedDevice.error}`)); + } + console.log('updated device', updatedDevice); + + const deletedDevice = yield* Effect.promise(() => + client.webAuthn.delete({ + ...query, + device: updatedDevice as UpdatedWebAuthnDevice, + }), + ); + + if (deletedDevice !== null && deletedDevice.error) { + yield* Effect.fail(new Error(`Failed to delete device: ${deletedDevice.error}`)); + } + + console.log('deleted', deletedDevice); + } else { + yield* Effect.fail(new Error(`Failed to get devices: ${deviceArr.error}`)); + } + }); +} + +// Execute the device test +Effect.runPromise(autoscript(handleWebAuthN)).then(console.log, handleError); diff --git a/e2e/device-client-app/tsconfig.app.json b/e2e/device-client-app/tsconfig.app.json new file mode 100644 index 0000000000..e68f53f5ba --- /dev/null +++ b/e2e/device-client-app/tsconfig.app.json @@ -0,0 +1,32 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "types": ["node"], + "rootDir": "src", + "target": "esnext", + "module": "esnext", + "moduleResolution": "bundler", + "tsBuildInfoFile": "dist/tsconfig.app.tsbuildinfo", + "plugins": [ + { + "name": "@effect/language-service" + } + ] + }, + "exclude": [ + "out-tsc", + "dist", + "src/**/*.spec.ts", + "src/**/*.test.ts", + "eslint.config.js", + "eslint.config.cjs", + "eslint.config.mjs" + ], + "include": ["src/**/*.ts"], + "references": [ + { + "path": "../../packages/device-client/tsconfig.lib.json" + } + ] +} diff --git a/e2e/device-client-app/tsconfig.json b/e2e/device-client-app/tsconfig.json new file mode 100644 index 0000000000..301fbe928b --- /dev/null +++ b/e2e/device-client-app/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "../../packages/device-client" + }, + { + "path": "./tsconfig.app.json" + } + ] +} diff --git a/e2e/device-client-app/vite.config.ts b/e2e/device-client-app/vite.config.ts new file mode 100644 index 0000000000..f54af97750 --- /dev/null +++ b/e2e/device-client-app/vite.config.ts @@ -0,0 +1,56 @@ +/// +import { defineConfig } from 'vite'; +import * as path from 'path'; + +const pages = ['oath', 'push', 'webauthn', 'device-binding', 'device-profile']; + +export default defineConfig(() => ({ + root: __dirname + '/src', + cacheDir: '../../node_modules/.vite/e2e/device-client-app', + publicDir: __dirname + '/public', + server: { + cors: true, + port: 8443, + host: 'localhost', + headers: { + 'Access-Control-Allow-Credentials': 'true', + 'Access-Control-Allow-Origin': 'null', + 'Access-Control-Allow-Headers': 'x-authorize-middleware', + }, + }, + preview: { + port: 8443, + host: 'localhost', + headers: { + 'Access-Control-Allow-Origin': 'http://localhost:8443', + }, + }, + plugins: [], + // Uncomment this if you are using workers. + // worker: { + // plugins: [ nxViteTsPaths() ], + // }, + build: { + outDir: __dirname + '/dist', + emptyOutDir: true, + reportCompressedSize: true, + rollupOptions: { + input: { + main: path.resolve(__dirname + '/src', 'index.html'), + ...pages.reduce( + (acc, page) => { + acc[page as keyof typeof pages] = path.resolve( + __dirname + '/src', + `${page}/index.html`, + ); + return acc; + }, + {} as Record, + ), + }, + output: { + entryFileNames: '[name]/main.js', + }, + }, + }, +})); diff --git a/packages/device-client/eslint.config.mjs b/packages/device-client/eslint.config.mjs index 7cbfa2e3e4..dd48071231 100644 --- a/packages/device-client/eslint.config.mjs +++ b/packages/device-client/eslint.config.mjs @@ -1,14 +1,5 @@ -import { FlatCompat } from '@eslint/eslintrc'; -import { dirname } from 'path'; -import { fileURLToPath } from 'url'; -import js from '@eslint/js'; import baseConfig from '../../eslint.config.mjs'; -const compat = new FlatCompat({ - baseDirectory: dirname(fileURLToPath(import.meta.url)), - recommendedConfig: js.configs.recommended, -}); - export default [ { ignores: ['**/dist'], @@ -39,6 +30,7 @@ export default [ '{projectRoot}/eslint.config.{js,cjs,mjs}', '{projectRoot}/vite.config.{js,ts,mjs,mts}', ], + ignoredDependencies: ['msw'], }, ], }, diff --git a/packages/device-client/package.json b/packages/device-client/package.json index 3c4d7a2350..47571cfb10 100644 --- a/packages/device-client/package.json +++ b/packages/device-client/package.json @@ -11,13 +11,13 @@ "sideEffects": false, "type": "module", "exports": { - ".": "./dist/index.js", + ".": "./dist/src/index.js", "./package.json": "./package.json", - "./types": "./dist/lib/types/index.d.ts" + "./types": "./dist/src/lib/types/index.d.ts" }, - "main": "./dist/index.cjs", - "module": "./dist/index.js", - "typings": "./dist/index.d.ts", + "main": "./dist/src/index.js", + "module": "./dist/src/index.js", + "typings": "./dist/src/index.d.ts", "files": ["./dist"], "scripts": { "build": "pnpm nx nxBuild", diff --git a/packages/device-client/src/lib/device.store.test.ts b/packages/device-client/src/lib/device.store.test.ts index ce2a0085ca..1d89feb436 100644 --- a/packages/device-client/src/lib/device.store.test.ts +++ b/packages/device-client/src/lib/device.store.test.ts @@ -5,187 +5,17 @@ * of the MIT license. See the LICENSE file for details. */ import { afterEach, afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; import { deviceClient } from './device.store.js'; +import { handlers } from './device.store.test.utils.js'; import { MOCK_PUSH_DEVICES, MOCK_BINDING_DEVICES, MOCK_OATH_DEVICES, - MOCK_DELETED_OATH_DEVICE, MOCK_WEBAUTHN_DEVICES, MOCK_DEVICE_PROFILE_SUCCESS, -} from './mock-data/msw-mock-data.js'; - -// Create handlers -export const handlers = [ - // OATH Devices - http.get('*/json/realms/:realm/users/:userId/devices/2fa/oath', ({ params }) => { - if (params['realm'] === 'fake-realm') { - return HttpResponse.json({ error: 'bad realm' }, { status: 401 }); - } - if (params['userId'] === 'bad-user') { - return HttpResponse.json({ error: 'bad user' }, { status: 401 }); - } - return HttpResponse.json(MOCK_OATH_DEVICES); - }), - - http.delete('*/json/realms/:realm/users/:userId/devices/2fa/oath/:uuid', ({ params }) => { - if (params['realm'] === 'fake-realm') { - return HttpResponse.json({ error: 'bad realm' }, { status: 401 }); - } - if (params['userId'] === 'bad-user') { - return HttpResponse.json({ error: 'bad user' }, { status: 401 }); - } - return HttpResponse.json(MOCK_DELETED_OATH_DEVICE); - }), - - // Push Devices - http.get('*/json/realms/:realm/users/:userId/devices/2fa/push', ({ params }) => { - if (params['realm'] === 'fake-realm') { - return HttpResponse.json({ error: 'bad realm' }, { status: 401 }); - } - if (params['userId'] === 'bad-user') { - return HttpResponse.json({ error: 'bad user' }, { status: 401 }); - } - return HttpResponse.json({ result: MOCK_PUSH_DEVICES }); - }), - - http.delete('*/json/realms/:realm/users/:userId/devices/2fa/push/:uuid', ({ params }) => { - if (params['realm'] === 'fake-realm') { - return HttpResponse.json({ error: 'bad realm' }, { status: 401 }); - } - if (params['userId'] === 'bad-user') { - return HttpResponse.json({ error: 'bad user' }, { status: 401 }); - } - if (params['uuid'] === 'bad-uuid') { - return HttpResponse.json({ error: 'bad uuid' }, { status: 401 }); - } - return HttpResponse.json(MOCK_PUSH_DEVICES[0]); - }), - - // WebAuthn Devices - http.get('*/json/realms/:realm/users/:userId/devices/2fa/webauthn', ({ params }) => { - if (params['realm'] === 'fake-realm') { - return HttpResponse.json({ error: 'bad realm' }, { status: 401 }); - } - if (params['userId'] === 'bad-user') { - return HttpResponse.json({ error: 'bad user' }, { status: 401 }); - } - return HttpResponse.json({ result: MOCK_WEBAUTHN_DEVICES }); - }), - - http.put('*/json/realms/:realm/users/:userId/devices/2fa/webauthn/:uuid', ({ params }) => { - if (params['userId'] === 'bad-uuid') { - return HttpResponse.json({ error: 'bad uuid' }, { status: 401 }); - } - if (params['realm'] === 'fake-realm') { - return HttpResponse.json({ error: 'bad realm' }, { status: 401 }); - } - if (params['userId'] === 'bad-user') { - return HttpResponse.json({ error: 'bad user' }, { status: 401 }); - } - return HttpResponse.json({ - ...MOCK_WEBAUTHN_DEVICES.result[0], - deviceName: 'Updated WebAuthn Device', - }); - }), - - http.delete('*/json/realms/:realm/users/:userId/devices/2fa/webauthn/:uuid', ({ params }) => { - if (params['realm'] === 'fake-realm') { - return HttpResponse.json({ error: 'bad realm' }, { status: 401 }); - } - if (params['userId'] === 'bad-user') { - return HttpResponse.json({ error: 'bad user' }, { status: 401 }); - } - if (params['uuid'] === 'bad-uuid') { - return HttpResponse.json({ error: 'bad-uuid' }, { status: 401 }); - } - return HttpResponse.json(MOCK_WEBAUTHN_DEVICES.result[0]); - }), - - // Binding Devices - http.get('*/json/realms/:realm/users/:userId/devices/2fa/binding', ({ params }) => { - if (params['realm'] === 'fake-realm') { - return HttpResponse.json({ error: 'bad realm' }, { status: 401 }); - } - if (params['userId'] === 'bad-user') { - return HttpResponse.json({ error: 'bad user' }, { status: 401 }); - } - return HttpResponse.json({ result: MOCK_BINDING_DEVICES }); - }), - - http.put( - '*/json/realms/root/realms/:realm/users/:userId/devices/2fa/binding/:uuid', - ({ params }) => { - if (params['realm'] === 'fake-realm') { - return HttpResponse.json({ error: 'bad realm' }, { status: 401 }); - } - if (params['userId'] === 'bad-user') { - return HttpResponse.json({ error: 'bad user' }, { status: 401 }); - } - if (params['userId'] === 'bad-uuid') { - return HttpResponse.json({ error: 'bad user' }, { status: 401 }); - } - return HttpResponse.json({ - ...MOCK_BINDING_DEVICES.result[0], - deviceName: 'Updated Binding Device', - }); - }, - ), - - http.delete( - '*/json/realms/root/realms/:realm/users/:userId/devices/2fa/binding/:uuid', - ({ params }) => { - if (params['realm'] === 'fake-realm') { - return HttpResponse.json({ error: 'bad realm' }, { status: 401 }); - } - if (params['userId'] === 'bad-user') { - return HttpResponse.json({ error: 'bad user' }, { status: 401 }); - } - if (params['userId'] === 'bad-uuid') { - return HttpResponse.json({ error: 'bad uuid' }, { status: 401 }); - } - return HttpResponse.json({ result: MOCK_BINDING_DEVICES.result[0] }); - }, - ), - - // profile devices - http.get('*/json/realms/:realm/users/:userId/devices/profile', ({ params }) => { - if (params['realm'] === 'fake-realm') { - return HttpResponse.json({ error: 'bad realm' }, { status: 401 }); - } - if (params['userId'] === 'bad-user') { - return HttpResponse.json({ error: 'bad user' }, { status: 401 }); - } - return HttpResponse.json({ result: MOCK_DEVICE_PROFILE_SUCCESS }); - }), - http.put('*/json/realms/:realm/users/:userId/devices/profile/:uuid', ({ params }) => { - if (params['realm'] === 'fake-realm') { - return HttpResponse.json({ error: 'bad realm' }, { status: 401 }); - } - if (params['userId'] === 'bad-user') { - return HttpResponse.json({ error: 'bad user' }, { status: 401 }); - } - return HttpResponse.json({ - ...MOCK_DEVICE_PROFILE_SUCCESS.result[0], - alias: 'new-name', - }); - }), - http.delete('*/json/realms/:realm/users/:userId/devices/profile/:uuid', ({ params }) => { - if (params['realm'] === 'fake-realm') { - return HttpResponse.json({ error: 'bad realm' }, { status: 401 }); - } - if (params['userId'] === 'bad-user') { - return HttpResponse.json({ error: 'bad user' }, { status: 401 }); - } - if (params['userId'] === 'bad-uuid') { - return HttpResponse.json({ error: 'bad uuid' }, { status: 401 }); - } - return HttpResponse.json(MOCK_DEVICE_PROFILE_SUCCESS.result[0]); - }), -]; +} from './mock-data/device.store.mock.js'; export const server = setupServer(...handlers); @@ -232,8 +62,9 @@ describe('Device Client Store', () => { }, }); - expect(result).toEqual(MOCK_DELETED_OATH_DEVICE); + expect(result).toEqual(null); }); + it('should return error obj if a user does not exist', async () => { const badClient = deviceClient(config); const result = await badClient.oath.get({ @@ -241,7 +72,8 @@ describe('Device Client Store', () => { }); expect(result).toStrictEqual({ error: new Error('response did not contain data') }); }); - it('should return undefined if a realm does not exist', async () => { + + it('should return error obj if a realm does not exist', async () => { const badConfig = { ...config, realmPath: 'fake-realm' }; const badClient = deviceClient(badConfig); const result = await badClient.oath.get({ @@ -267,8 +99,9 @@ describe('Device Client Store', () => { userId: 'test-user', device: MOCK_PUSH_DEVICES[0], }); - expect(result).toEqual(MOCK_PUSH_DEVICES[0]); + expect(result).toEqual(null); }); + it('should fail with a bad uuid', async () => { const client = deviceClient(config); const result1 = await client.push.delete({ @@ -276,8 +109,11 @@ describe('Device Client Store', () => { device: { ...MOCK_PUSH_DEVICES[0], uuid: 'bad-uuid' }, }); - expect(result1).toStrictEqual({ error: new Error('response did not contain data') }); + expect(result1).toEqual({ + error: expect.objectContaining({ message: expect.stringContaining('bad uuid') }), + }); }); + it('should fail with a bad userId', async () => { const badConfig = { ...config, realmPath: 'bad-realm' }; const badClient = deviceClient(badConfig); @@ -287,25 +123,32 @@ describe('Device Client Store', () => { }); const result2 = await badClient.push.get({ userId: 'bad-user' }); - expect(result1).toStrictEqual({ error: new Error('response did not contain data') }); + expect(result1).toEqual({ + error: expect.objectContaining({ message: expect.stringContaining('bad user') }), + }); expect(result2).toStrictEqual({ error: new Error('response did not contain data') }); }); - it('should return error if a uuid does not exist', async () => { + + it('should return error obj if a uuid does not exist', async () => { const badClient = deviceClient(config); const result = await badClient.push.delete({ userId: 'user', device: { ...MOCK_PUSH_DEVICES[0], uuid: 'bad-uuid' }, }); - expect(result).toStrictEqual({ error: new Error('response did not contain data') }); + expect(result).toEqual({ + error: expect.objectContaining({ message: expect.stringContaining('bad uuid') }), + }); }); - it('should return undefined if a user does not exist', async () => { + + it('should return error obj if a user does not exist', async () => { const badClient = deviceClient(config); const result = await badClient.push.get({ userId: 'bad-user', }); expect(result).toStrictEqual({ error: new Error('response did not contain data') }); }); - it('should return undefined if a realm does not exist', async () => { + + it('should return error obj if a realm does not exist', async () => { const badConfig = { ...config, realmPath: 'fake-realm' }; const badClient = deviceClient(badConfig); const result = await badClient.push.get({ @@ -314,7 +157,7 @@ describe('Device Client Store', () => { expect(result).toStrictEqual({ error: new Error('response did not contain data') }); }); }); - // + describe('WebAuthn Device Management', () => { const client = deviceClient(config); @@ -347,6 +190,7 @@ describe('Device Client Store', () => { deviceName: 'Updated WebAuthn Device', }); }); + it('should error when deleting webauthn device with invalid uuid', async () => { const mockDevice = MOCK_WEBAUTHN_DEVICES.result[0]; const result = await client.webAuthn.delete({ @@ -357,7 +201,9 @@ describe('Device Client Store', () => { }, }); - expect(result).toStrictEqual({ error: new Error('response did not contain data') }); + expect(result).toEqual({ + error: expect.objectContaining({ message: expect.stringContaining('bad uuid') }), + }); }); it('should delete webauthn device', async () => { @@ -367,10 +213,10 @@ describe('Device Client Store', () => { device: mockDevice, }); - expect(result).toEqual(mockDevice); + expect(result).toEqual(null); }); }); - // + describe('Bound Device Management', () => { const client = deviceClient(config); const mockDevice = MOCK_BINDING_DEVICES.result[0]; @@ -402,9 +248,10 @@ describe('Device Client Store', () => { device: mockDevice, }); - expect(result).toEqual(mockDevice); + expect(result).toEqual(null); }); }); + describe('Profile Device', () => { const client = deviceClient(config); @@ -413,6 +260,7 @@ describe('Device Client Store', () => { expect(result).toEqual(MOCK_DEVICE_PROFILE_SUCCESS); }); + it('should update device profiles', async () => { const result = await client.profile.update({ userId: 'test-user', @@ -422,6 +270,7 @@ describe('Device Client Store', () => { expect(result).toEqual({ ...MOCK_DEVICE_PROFILE_SUCCESS.result[0], alias: 'new-name' }); }); + it('should delete device profiles', async () => { const result = await client.profile.delete({ userId: 'hello', @@ -429,7 +278,7 @@ describe('Device Client Store', () => { device: MOCK_DEVICE_PROFILE_SUCCESS.result[0], }); - expect(result).toEqual({ ...MOCK_DEVICE_PROFILE_SUCCESS.result[0] }); + expect(result).toEqual(null); }); }); }); diff --git a/packages/device-client/src/lib/device.store.test.utils.ts b/packages/device-client/src/lib/device.store.test.utils.ts new file mode 100644 index 0000000000..32e9c3a08a --- /dev/null +++ b/packages/device-client/src/lib/device.store.test.utils.ts @@ -0,0 +1,188 @@ +/* + * 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 { http, HttpResponse } from 'msw'; + +import { + MOCK_PUSH_DEVICES, + MOCK_BINDING_DEVICES, + MOCK_OATH_DEVICES, + MOCK_DELETED_OATH_DEVICE, + MOCK_WEBAUTHN_DEVICES, + MOCK_DEVICE_PROFILE_SUCCESS, +} from './mock-data/device.store.mock.js'; + +// Create mock service worker handlers +export const handlers = [ + // OATH Devices + http.get('*/json/realms/:realm/users/:userId/devices/2fa/oath', ({ params }) => { + if (params['realm'] === 'fake-realm') { + return HttpResponse.json({ error: 'bad realm' }, { status: 401 }); + } + if (params['userId'] === 'bad-user') { + return HttpResponse.json({ error: 'bad user' }, { status: 401 }); + } + return HttpResponse.json(MOCK_OATH_DEVICES); + }), + + http.delete('*/json/realms/:realm/users/:userId/devices/2fa/oath/:uuid', ({ params }) => { + if (params['realm'] === 'fake-realm') { + return HttpResponse.json({ error: 'bad realm' }, { status: 401 }); + } + if (params['userId'] === 'bad-user') { + return HttpResponse.json({ error: 'bad user' }, { status: 401 }); + } + return HttpResponse.json(MOCK_DELETED_OATH_DEVICE); + }), + + // Push Devices + http.get('*/json/realms/:realm/users/:userId/devices/2fa/push', ({ params }) => { + if (params['realm'] === 'fake-realm') { + return HttpResponse.json({ error: 'bad realm' }, { status: 401 }); + } + if (params['userId'] === 'bad-user') { + return HttpResponse.json({ error: 'bad user' }, { status: 401 }); + } + return HttpResponse.json({ result: MOCK_PUSH_DEVICES }); + }), + + http.delete('*/json/realms/:realm/users/:userId/devices/2fa/push/:uuid', ({ params }) => { + if (params['realm'] === 'fake-realm') { + return HttpResponse.json({ error: 'bad realm' }, { status: 401 }); + } + if (params['userId'] === 'bad-user') { + return HttpResponse.json({ error: 'bad user' }, { status: 401 }); + } + if (params['uuid'] === 'bad-uuid') { + return HttpResponse.json({ error: 'bad uuid' }, { status: 401 }); + } + return HttpResponse.json(MOCK_PUSH_DEVICES[0]); + }), + + // WebAuthn Devices + http.get('*/json/realms/:realm/users/:userId/devices/2fa/webauthn', ({ params }) => { + if (params['realm'] === 'fake-realm') { + return HttpResponse.json({ error: 'bad realm' }, { status: 401 }); + } + if (params['userId'] === 'bad-user') { + return HttpResponse.json({ error: 'bad user' }, { status: 401 }); + } + return HttpResponse.json({ result: MOCK_WEBAUTHN_DEVICES }); + }), + + http.put('*/json/realms/:realm/users/:userId/devices/2fa/webauthn/:uuid', ({ params }) => { + if (params['userId'] === 'bad-uuid') { + return HttpResponse.json({ error: 'bad uuid' }, { status: 401 }); + } + if (params['realm'] === 'fake-realm') { + return HttpResponse.json({ error: 'bad realm' }, { status: 401 }); + } + if (params['userId'] === 'bad-user') { + return HttpResponse.json({ error: 'bad user' }, { status: 401 }); + } + return HttpResponse.json({ + ...MOCK_WEBAUTHN_DEVICES.result[0], + deviceName: 'Updated WebAuthn Device', + }); + }), + + http.delete('*/json/realms/:realm/users/:userId/devices/2fa/webauthn/:uuid', ({ params }) => { + if (params['realm'] === 'fake-realm') { + return HttpResponse.json({ error: 'bad realm' }, { status: 401 }); + } + if (params['userId'] === 'bad-user') { + return HttpResponse.json({ error: 'bad user' }, { status: 401 }); + } + if (params['uuid'] === 'bad-uuid') { + return HttpResponse.json({ error: 'bad uuid' }, { status: 401 }); + } + return HttpResponse.json(MOCK_WEBAUTHN_DEVICES.result[0]); + }), + + // Binding Devices + http.get('*/json/realms/:realm/users/:userId/devices/2fa/binding', ({ params }) => { + if (params['realm'] === 'fake-realm') { + return HttpResponse.json({ error: 'bad realm' }, { status: 401 }); + } + if (params['userId'] === 'bad-user') { + return HttpResponse.json({ error: 'bad user' }, { status: 401 }); + } + return HttpResponse.json({ result: MOCK_BINDING_DEVICES }); + }), + + http.put( + '*/json/realms/root/realms/:realm/users/:userId/devices/2fa/binding/:uuid', + ({ params }) => { + if (params['realm'] === 'fake-realm') { + return HttpResponse.json({ error: 'bad realm' }, { status: 401 }); + } + if (params['userId'] === 'bad-user') { + return HttpResponse.json({ error: 'bad user' }, { status: 401 }); + } + if (params['userId'] === 'bad-uuid') { + return HttpResponse.json({ error: 'bad user' }, { status: 401 }); + } + return HttpResponse.json({ + ...MOCK_BINDING_DEVICES.result[0], + deviceName: 'Updated Binding Device', + }); + }, + ), + + http.delete( + '*/json/realms/root/realms/:realm/users/:userId/devices/2fa/binding/:uuid', + ({ params }) => { + if (params['realm'] === 'fake-realm') { + return HttpResponse.json({ error: 'bad realm' }, { status: 401 }); + } + if (params['userId'] === 'bad-user') { + return HttpResponse.json({ error: 'bad user' }, { status: 401 }); + } + if (params['userId'] === 'bad-uuid') { + return HttpResponse.json({ error: 'bad uuid' }, { status: 401 }); + } + return HttpResponse.json({ result: MOCK_BINDING_DEVICES.result[0] }); + }, + ), + + // profile devices + http.get('*/json/realms/:realm/users/:userId/devices/profile', ({ params }) => { + if (params['realm'] === 'fake-realm') { + return HttpResponse.json({ error: 'bad realm' }, { status: 401 }); + } + if (params['userId'] === 'bad-user') { + return HttpResponse.json({ error: 'bad user' }, { status: 401 }); + } + return HttpResponse.json({ result: MOCK_DEVICE_PROFILE_SUCCESS }); + }), + + http.put('*/json/realms/:realm/users/:userId/devices/profile/:uuid', ({ params }) => { + if (params['realm'] === 'fake-realm') { + return HttpResponse.json({ error: 'bad realm' }, { status: 401 }); + } + if (params['userId'] === 'bad-user') { + return HttpResponse.json({ error: 'bad user' }, { status: 401 }); + } + return HttpResponse.json({ + ...MOCK_DEVICE_PROFILE_SUCCESS.result[0], + alias: 'new-name', + }); + }), + + http.delete('*/json/realms/:realm/users/:userId/devices/profile/:uuid', ({ params }) => { + if (params['realm'] === 'fake-realm') { + return HttpResponse.json({ error: 'bad realm' }, { status: 401 }); + } + if (params['userId'] === 'bad-user') { + return HttpResponse.json({ error: 'bad user' }, { status: 401 }); + } + if (params['userId'] === 'bad-uuid') { + return HttpResponse.json({ error: 'bad uuid' }, { status: 401 }); + } + return HttpResponse.json(MOCK_DEVICE_PROFILE_SUCCESS.result[0]); + }), +]; diff --git a/packages/device-client/src/lib/device.store.ts b/packages/device-client/src/lib/device.store.ts index 7c5aea28f0..f773fb4b33 100644 --- a/packages/device-client/src/lib/device.store.ts +++ b/packages/device-client/src/lib/device.store.ts @@ -7,13 +7,8 @@ import { type ConfigOptions } from '@forgerock/javascript-sdk'; import { configureStore } from '@reduxjs/toolkit'; import { deviceService } from './services/index.js'; -import { DeletedOathDevice, OathDevice, RetrieveOathQuery } from './types/oath.types.js'; -import { - DeleteDeviceQuery, - DeletedPushDevice, - PushDevice, - PushDeviceQuery, -} from './types/push-device.types.js'; +import { OathDevice, RetrieveOathQuery } from './types/oath.types.js'; +import { DeleteDeviceQuery, PushDevice, PushDeviceQuery } from './types/push-device.types.js'; import { UpdatedWebAuthnDevice, WebAuthnDevice, WebAuthnQuery } from './types/webauthn.types.js'; import { BoundDeviceQuery, Device, GetBoundDevicesQuery } from './types/bound-device.types.js'; import { @@ -21,6 +16,7 @@ import { ProfileDevice, ProfileDevicesQuery, } from './types/profile-device.types.js'; +import { handleError } from './device.store.utils.js'; export const deviceClient = (config: ConfigOptions) => { const { middleware, reducerPath, reducer, endpoints } = deviceService({ @@ -53,7 +49,7 @@ export const deviceClient = (config: ConfigOptions) => { * @async * @function get * @param {RetrieveOathQuery} query - The query used to retrieve Oath devices. - * @returns {Promise} - A promise that resolves to the retrieved data or an error object if the response is not valid. + * @returns {Promise} - A promise that resolves to the retrieved data or an error object if the response is not valid. */ get: async function (query: RetrieveOathQuery): Promise { try { @@ -75,19 +71,19 @@ export const deviceClient = (config: ConfigOptions) => { * @async * @function delete * @param {DeleteOathQuery & OathDevice} query - The query and device information used to delete the Oath device. - * @returns {Promise} - A promise that resolves to the response data or an error object if the response is not valid. + * @returns {Promise} - A promise that resolves to the response data or an error object if the response is not valid. */ delete: async function ( query: RetrieveOathQuery & { device: OathDevice }, - ): Promise { + ): Promise { try { - const response = await store.dispatch(endpoints.deleteOathDevice.initiate(query)); + const { error } = await store.dispatch(endpoints.deleteOathDevice.initiate(query)); - if (!response || !response.data) { - throw new Error('response did not contain data'); + if (error) { + handleError(error, 'Failed to delete device: '); } - return response.data; + return null; } catch (error) { return { error }; } @@ -128,19 +124,17 @@ export const deviceClient = (config: ConfigOptions) => { * @async * @function delete * @param {DeleteDeviceQuery} query - The query used to delete the Push device. - * @returns {Promise} - A promise that resolves to the response data or an error object if the response is not valid. + * @returns {Promise} - A promise that resolves to the response data or an error object if the response is not valid. */ - delete: async function ( - query: DeleteDeviceQuery, - ): Promise { + delete: async function (query: DeleteDeviceQuery): Promise { try { - const response = await store.dispatch(endpoints.deletePushDevice.initiate(query)); + const { error } = await store.dispatch(endpoints.deletePushDevice.initiate(query)); - if (!response || !response.data) { - throw new Error('response did not contain data'); + if (error) { + handleError(error, 'Failed to delete device: '); } - return response.data; + return null; } catch (error) { return { error }; } @@ -205,19 +199,21 @@ export const deviceClient = (config: ConfigOptions) => { * @async * @function delete * @param {WebAuthnQueryWithUUID & { device: WebAuthnBody } } query - The query and body used to delete the WebAuthn device. - * @returns {Promise} - A promise that resolves to the response data or an error object if the response is not valid. + * @returns {Promise} - A promise that resolves to the response data or an error object if the response is not valid. */ delete: async function ( query: WebAuthnQuery & { device: WebAuthnDevice | UpdatedWebAuthnDevice }, - ): Promise { + ): Promise { try { - const response = await store.dispatch(endpoints.deleteWebAuthnDeviceName.initiate(query)); + const { error } = await store.dispatch( + endpoints.deleteWebAuthnDeviceName.initiate(query), + ); - if (!response || !response.data) { - throw new Error('response did not contain data'); + if (error) { + handleError(error, 'Failed to delete device: '); } - return response.data; + return null; } catch (error) { return { error }; } @@ -258,17 +254,17 @@ export const deviceClient = (config: ConfigOptions) => { * @async * @function delete * @param {BoundDeviceQuery} query - The query used to delete the bound device. - * @returns {Promise} - A promise that resolves to the response data or an error object if the response is not valid. + * @returns {Promise} - A promise that resolves to the response data or an error object if the response is not valid. */ - delete: async function (query: BoundDeviceQuery): Promise { + delete: async function (query: BoundDeviceQuery): Promise { try { - const response = await store.dispatch(endpoints.deleteBoundDevice.initiate(query)); + const { error } = await store.dispatch(endpoints.deleteBoundDevice.initiate(query)); - if (!response || !response.data || !response.data.result) { - throw new Error('response did not contain data'); + if (error) { + handleError(error, 'Failed to delete device: '); } - return response.data.result; + return null; } catch (error) { return { error }; } @@ -349,19 +345,17 @@ export const deviceClient = (config: ConfigOptions) => { * @async * @function update * @param {ProfileDevicesQuery} query - The query used to update a profile device - * @returns {Promise} - A promise that resolves to the response data or an error object if the response is not valid. + * @returns {Promise} - A promise that resolves to the response data or an error object if the response is not valid. */ - delete: async function ( - query: ProfileDevicesQuery, - ): Promise { + delete: async function (query: ProfileDevicesQuery): Promise { try { - const response = await store.dispatch(endpoints.deleteDeviceProfile.initiate(query)); + const { error } = await store.dispatch(endpoints.deleteDeviceProfile.initiate(query)); - if (!response || !response.data) { - throw new Error('response did not contain data'); + if (error) { + handleError(error, 'Failed to delete device profile: '); } - return response.data; + return null; } catch (error) { return { error }; } diff --git a/packages/device-client/src/lib/device.store.utils.ts b/packages/device-client/src/lib/device.store.utils.ts new file mode 100644 index 0000000000..7e62f5e0c2 --- /dev/null +++ b/packages/device-client/src/lib/device.store.utils.ts @@ -0,0 +1,15 @@ +import { SerializedError } from '@reduxjs/toolkit'; +import { FetchBaseQueryError } from '@reduxjs/toolkit/query'; + +export function handleError(error: FetchBaseQueryError | SerializedError, message?: string) { + /** + * Handle an RTK Query error after narrowing to either FetchBaseQueryError or SerializedError + * https://redux-toolkit.js.org/rtk-query/usage-with-typescript#type-safe-error-handling + */ + if ('status' in error) { + const errMsg = 'error' in error ? error.error : JSON.stringify(error.data); + throw new Error(`${message ?? ''}${errMsg}`); + } + + throw new Error(`${message ?? ''}${error.message}`); +} diff --git a/packages/device-client/src/lib/mock-data/msw-mock-data.ts b/packages/device-client/src/lib/mock-data/device.store.mock.ts similarity index 99% rename from packages/device-client/src/lib/mock-data/msw-mock-data.ts rename to packages/device-client/src/lib/mock-data/device.store.mock.ts index aee59a19c8..3c53c7bfd8 100644 --- a/packages/device-client/src/lib/mock-data/msw-mock-data.ts +++ b/packages/device-client/src/lib/mock-data/device.store.mock.ts @@ -12,7 +12,7 @@ import type { ProfileDevice, PushDevice, WebAuthnDevice, -} from '../types'; +} from '../types/index.js'; // Mock data export const MOCK_OATH_DEVICES: OathResponse = { diff --git a/packages/device-client/tsconfig.lib.json b/packages/device-client/tsconfig.lib.json index c61ea183a1..c0610b334d 100644 --- a/packages/device-client/tsconfig.lib.json +++ b/packages/device-client/tsconfig.lib.json @@ -17,6 +17,7 @@ "vite.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts", + "src/**/*.test.utils.ts", "src/lib/mock-data/*" ] } diff --git a/packages/device-client/tsconfig.spec.json b/packages/device-client/tsconfig.spec.json index 2c6bd0f3a6..0554f18a6d 100644 --- a/packages/device-client/tsconfig.spec.json +++ b/packages/device-client/tsconfig.spec.json @@ -20,6 +20,8 @@ "src/**/*.test.jsx", "src/**/*.spec.jsx", "src/**/*.d.ts", - "src/**/*.ts" + "src/**/*.ts", + "src/**/*.test.utils.ts", + "src/lib/mock-data/*" ] } diff --git a/packages/device-client/vite.config.ts b/packages/device-client/vite.config.ts index 1720079a7f..30eeb5ca57 100644 --- a/packages/device-client/vite.config.ts +++ b/packages/device-client/vite.config.ts @@ -27,5 +27,6 @@ export default defineConfig({ }, environment: 'jsdom', include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + exclude: ['src/**/*.test.utils.ts'], }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 08ac9357a3..ad8f04d6c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,15 +4,6 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false -catalogs: - default: - '@reduxjs/toolkit': - specifier: ^2.8.2 - version: 2.8.2 - immer: - specifier: ^10.1.1 - version: 10.1.1 - importers: .: @@ -242,6 +233,22 @@ importers: e2e/davinci-suites: {} + e2e/device-client-app: + dependencies: + '@forgerock/device-client': + specifier: workspace:* + version: link:../../packages/device-client + '@forgerock/javascript-sdk': + specifier: 4.7.0 + version: 4.7.0 + effect: + specifier: ^3.12.7 + version: 3.14.14 + devDependencies: + '@effect/language-service': + specifier: ^0.2.0 + version: 0.2.0 + e2e/mock-api-v2: dependencies: '@effect/language-service': diff --git a/tsconfig.json b/tsconfig.json index 63a20e88dd..6e5375fc51 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,6 +28,9 @@ { "path": "./e2e/protect-suites" }, + { + "path": "./e2e/device-client-app" + }, { "path": "./packages/davinci-client" }, From 353f1fdcc514cbf38ddf2d58dfde856a93966ed0 Mon Sep 17 00:00:00 2001 From: ryanbas21 Date: Wed, 11 Jun 2025 11:36:28 -0600 Subject: [PATCH 3/4] chore: refactoring-work --- .github/instructions/nx.instructions.md | 39 ++++ e2e/device-client-app/package.json | 2 +- e2e/device-client-app/src/autoscript.ts | 195 +++++++----------- .../src/device-binding/main.ts | 91 ++++---- .../src/device-profile/main.ts | 17 +- e2e/device-client-app/src/oath/main.ts | 108 +++++----- e2e/device-client-app/src/push/main.ts | 126 +++++------ .../src/service/device-client.ts | 54 +++++ .../src/util-effects/index.ts | 50 +++++ e2e/device-client-app/src/webauthn/main.ts | 128 ++++++------ package.json | 1 - pnpm-lock.yaml | 9 + 12 files changed, 456 insertions(+), 364 deletions(-) create mode 100644 .github/instructions/nx.instructions.md create mode 100644 e2e/device-client-app/src/service/device-client.ts create mode 100644 e2e/device-client-app/src/util-effects/index.ts diff --git a/.github/instructions/nx.instructions.md b/.github/instructions/nx.instructions.md new file mode 100644 index 0000000000..eb224c349b --- /dev/null +++ b/.github/instructions/nx.instructions.md @@ -0,0 +1,39 @@ +--- +applyTo: '**' +--- + +// This file is automatically generated by Nx Console + +You are in an nx workspace using Nx 21.0.3 and pnpm as the package manager. + +You have access to the Nx MCP server and the tools it provides. Use them. Follow these guidelines in order to best help the user: + +# General Guidelines +- When answering questions, use the nx_workspace tool first to gain an understanding of the workspace architecture +- For questions around nx configuration, best practices or if you're unsure, use the nx_docs tool to get relevant, up-to-date docs!! Always use this instead of assuming things about nx configuration +- If the user needs help with an Nx configuration or project graph error, use the 'nx_workspace' tool to get any errors +- To help answer questions about the workspace structure or simply help with demonstrating how tasks depend on each other, use the 'nx_visualize_graph' tool + +# Generation Guidelines +If the user wants to generate something, use the following flow: + +- learn about the nx workspace and any specifics the user needs by using the 'nx_workspace' tool and the 'nx_project_details' tool if applicable +- get the available generators using the 'nx_generators' tool +- decide which generator to use. If no generators seem relevant, check the 'nx_available_plugins' tool to see if the user could install a plugin to help them +- get generator details using the 'nx_generator_schema' tool +- you may use the 'nx_docs' tool to learn more about a specific generator or technology if you're unsure +- decide which options to provide in order to best complete the user's request. Don't make any assumptions and keep the options minimalistic +- open the generator UI using the 'nx_open_generate_ui' tool +- wait for the user to finish the generator +- read the generator log file using the 'nx_read_generator_log' tool +- use the information provided in the log file to answer the user's question or continue with what they were doing + + +# CI Error Guidelines +If the user wants help with fixing an error in their CI pipeline, use the following flow: +- Retrieve the list of current CI Pipeline Executions (CIPEs) using the 'nx_cloud_cipe_details' tool +- If there are any errors, use the 'nx_cloud_fix_cipe_failure' tool to retrieve the logs for a specific task +- Use the task logs to see what's wrong and help the user fix their problem. Use the appropriate tools if necessary +- Make sure that the problem is fixed by running the task that you passed into the 'nx_cloud_fix_cipe_failure' tool + + diff --git a/e2e/device-client-app/package.json b/e2e/device-client-app/package.json index 178b92f4c0..21818ec78a 100644 --- a/e2e/device-client-app/package.json +++ b/e2e/device-client-app/package.json @@ -17,6 +17,6 @@ "tags": ["scope:e2e"] }, "devDependencies": { - "@effect/language-service": "^0.2.0" + "@effect/language-service": "^0.20.0" } } diff --git a/e2e/device-client-app/src/autoscript.ts b/e2e/device-client-app/src/autoscript.ts index 00653be9f2..766b4be9dc 100644 --- a/e2e/device-client-app/src/autoscript.ts +++ b/e2e/device-client-app/src/autoscript.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ /* * * Copyright © 2025 Ping Identity Corporation. All right reserved. @@ -8,146 +9,96 @@ */ import { - FRAuth, CallbackType, - NameCallback, - TokenManager, - SessionManager, Config, + FRLoginFailure, + FRLoginSuccess, + FRStep, + NameCallback, PasswordCallback, } from '@forgerock/javascript-sdk'; -import { deviceClient } from '@forgerock/device-client'; import { Effect } from 'effect'; -import { DeviceClient } from './types.js'; +import { start, logout, checkFRStep, callNext, getTokens } from './util-effects/index.js'; +import { deviceClient } from '@forgerock/device-client'; +const checkForLoginSuccess = (step: FRStep | FRLoginSuccess | FRLoginFailure) => { + if (step.type === 'LoginSuccess') { + return Effect.succeed(step); + } else if (step.type === 'LoginFailure') { + return Effect.fail(new Error(`Login failed`)); + } else { + return Effect.fail( + new Error(`Unexpected step, expected to be in a LoginSuccess but got ${step.type}`), + ); + } +}; /** * @function autoscript * @description Steps through an authentication journey to test device management * @param {function} handleDevice A function that manages the device through the device client * @returns {Effect.Effect} An effect to run the test */ -export const autoscript = ( - handleDevice: (client: DeviceClient) => Effect.Effect, -) => - Effect.gen(function* () { - const url = new URL(window.location.href); - const amUrl = url.searchParams.get('amUrl') || 'https://openam-sdks.forgeblocks.com/am'; - const realmPath = url.searchParams.get('realmPath') || 'alpha'; - const platformHeader = url.searchParams.get('platformHeader') === 'true' ? true : false; - const tree = url.searchParams.get('tree') || 'selfservice'; +export const LoginAndGetClient = Effect.gen(function* () { + /** + * Make sure this `un` is a real user + * this is a manual test and requires a real tenant and a real user + * that has devices. + */ + const url = new URL(window.location.href); + const un = url.searchParams.get('un') || 'devicetestuser'; + const pw = url.searchParams.get('pw') || 'password'; + const amUrl = url.searchParams.get('amUrl') || 'https://openam-sdks.forgeblocks.com/am'; + const realmPath = url.searchParams.get('realmPath') || 'alpha'; + const platformHeader = url.searchParams.get('platformHeader') === 'true' ? true : false; + const tree = url.searchParams.get('tree') || 'selfservice'; - /** - * Make sure this `un` is a real user - * this is a manual test and requires a real tenant and a real user - * that has devices. - */ - const un = url.searchParams.get('un') || 'devicetestuser'; - const pw = url.searchParams.get('pw') || 'password'; + const config = { + realmPath, + tree, + clientId: 'WebOAuthClient', + scope: 'profile email me.read openid', + serverConfig: { + baseUrl: amUrl, + timeout: 3000, + }, + }; - // Configure the SDK - yield* Effect.try({ - try: () => { - Config.set({ - middleware: [ - (req, action, next) => { - switch (action.type) { - case 'START_AUTHENTICATE': - if ( - action.payload.type === 'service' && - typeof action.payload.tree === 'string' - ) { - console.log('Starting authentication with service'); - } - break; - case 'AUTHENTICATE': - if ( - action.payload.type === 'service' && - typeof action.payload.tree === 'string' - ) { - console.log('Continuing authentication with service'); - } - break; - } - next(); - }, - ], - platformHeader, - realmPath, - tree, - clientId: 'WebOAuthClient', - scope: 'profile email me.read openid', - redirectUri: `${window.location.origin}/src/_callback/index.html`, - serverConfig: { - baseUrl: amUrl, - timeout: 3000, - }, - }); - console.log('Configured the SDK'); + yield* Effect.try(() => + Config.set({ + platformHeader, + realmPath, + tree, + clientId: 'WebOAuthClient', + scope: 'profile email me.read openid', + redirectUri: `${window.location.origin}/src/_callback/index.html`, + serverConfig: { + baseUrl: amUrl, + timeout: 3000, }, - catch: (err) => new Error(`SDK configuration failed: ${err}`), - }); - - // Log out any user before starting auth journey - yield* Effect.tryPromise({ - try: () => SessionManager.logout(), - catch: (err) => new Error(`Logout failed: ${err}`), - }); - - // Start the authentication journey - const step = yield* Effect.tryPromise({ - try: () => FRAuth.start(), - catch: (err) => new Error(`Authentication start failed: ${err}`), - }); + }), + ); + yield* logout; - // Login with username/password - yield* Effect.tryPromise({ - try: () => { - if (step.type !== 'Step') { - return Promise.reject( - new Error('Expected a step, but received a login success or failure.'), - ); - } - - console.log('Set values on auth tree callbacks'); - step.getCallbackOfType(CallbackType.NameCallback).setName(un); - step.getCallbackOfType(CallbackType.PasswordCallback).setPassword(pw); - return FRAuth.next(step); - }, - catch: (err) => new Error(`Login failed: ${err}`), - }); - - // Get tokens - yield* Effect.tryPromise({ - try: () => TokenManager.getTokens(), - catch: (err) => new Error(`Failed to get tokens: ${err}`), - }); - - // Create a device client - const client = yield* Effect.sync(() => { - return deviceClient({ - realmPath, - tree, - clientId: 'WebOAuthClient', - scope: 'profile email me.read openid', - serverConfig: { - baseUrl: amUrl, - timeout: 3000, - }, - }); - }); - - // Test the device - yield* handleDevice(client); - - // Finish autoscript - yield* Effect.sync(() => { - document.body.innerHTML = `

Test script complete

`; - }); + yield* start.pipe( + Effect.flatMap((step) => checkFRStep(step)), + Effect.map((step) => { + step.getCallbackOfType(CallbackType.NameCallback).setName(un); + step.getCallbackOfType(CallbackType.PasswordCallback).setPassword(pw); + return step; + }), + Effect.flatMap((step) => callNext(step)), + /** + * Don't explicitly need this but if the journey changes + * maybe we dont get a LoginSuccess + */ + Effect.flatMap((step) => checkForLoginSuccess(step)), + Effect.flatMap(() => getTokens), + ); - return 'Test script complete'; - }); + const client = deviceClient(config); + return client; +}); -// Display error message export const handleError = (err: unknown) => { console.error(err); document.body.innerHTML = `

Test script failed: ${err}

`; diff --git a/e2e/device-client-app/src/device-binding/main.ts b/e2e/device-client-app/src/device-binding/main.ts index d130e4986e..8f6d1bd276 100644 --- a/e2e/device-client-app/src/device-binding/main.ts +++ b/e2e/device-client-app/src/device-binding/main.ts @@ -7,11 +7,10 @@ * */ -import { UserManager } from '@forgerock/javascript-sdk'; -import { autoscript, handleError } from '../autoscript.js'; -import { DeviceClient } from '../types.js'; import { Device } from '@forgerock/device-client/types'; -import { Effect } from 'effect'; +import { Console, Effect } from 'effect'; +import { LoginAndGetClient, handleError } from '../autoscript.js'; +import { getUser } from '../util-effects/index.js'; /** * @function handleDeviceBinding @@ -19,58 +18,52 @@ import { Effect } from 'effect'; * @param {DeviceClient} client A device client instance from the JS SDK * @returns {Effect.Effect} An Effect that performs device binding management operations */ -function handleDeviceBinding(client: DeviceClient): Effect.Effect { - return Effect.gen(function* () { - const user = yield* Effect.tryPromise({ - try: () => UserManager.getCurrentUser(), - catch: (err) => new Error(`Failed to get current user: ${err}`), - }); - const query = { - userId: (user as Record).sub, - realm: 'alpha', - }; +const deviceBinding = Effect.gen(function* () { + const client = yield* LoginAndGetClient; + const user = yield* getUser; + const query = { + userId: user.sub, + realm: 'alpha', + }; - const deviceArr = yield* Effect.promise(() => client.bound.get(query)); - console.log('GET devices', deviceArr); + const deviceArr = yield* Effect.promise(() => client.bound.get(query)); - if (Array.isArray(deviceArr)) { - const [device] = deviceArr; + if (!deviceArr || 'error' in deviceArr) { + yield* Console.log('No devices found or error occurred', deviceArr); + return yield* Effect.fail(new Error('No devices found or error occurred')); + } + yield* Console.log('GET devices', deviceArr); - if (!device) { - yield* Effect.fail(new Error('No device to delete')); - } - console.log('device', device); + const [device] = deviceArr; - const updatedDevice = yield* Effect.promise(() => - client.bound.update({ - ...query, - device: { ...device, deviceName: 'UpdatedDeviceName' }, - }), - ); + yield* Console.log('device', device); - if ('error' in updatedDevice) { - yield* Effect.fail(new Error(`Failed to update device: ${updatedDevice.error}`)); - } - console.log('updated device', updatedDevice); + const updatedDevice = yield* Effect.promise(() => + client.bound.update({ + ...query, + device: { ...device, deviceName: 'UpdatedDeviceName' }, + }), + ); - const deletedDevice = yield* Effect.promise(() => - client.bound.delete({ - ...query, - device: updatedDevice as Device, - }), - ); + if ('error' in updatedDevice) { + return yield* Effect.fail(new Error(`Failed to update device: ${updatedDevice.error}`)); + } - if (deletedDevice !== null && deletedDevice.error) { - yield* Effect.fail(new Error(`Failed to delete device: ${deletedDevice.error}`)); - } + yield* Console.log('updated device', updatedDevice); - console.log('deleted', deletedDevice); - } else { - yield* Effect.fail(new Error(`Failed to get devices: ${deviceArr.error}`)); - } - }); -} + const deletedDevice = yield* Effect.promise(() => + client.bound.delete({ + ...query, + device: updatedDevice as Device, + }), + ); -// Execute the device test -Effect.runPromise(autoscript(handleDeviceBinding)).then(console.log, handleError); + if (deletedDevice !== null && deletedDevice.error) { + return yield* Effect.fail(new Error(`Failed to delete device: ${deletedDevice.error}`)); + } + + yield* Console.log('deleted', deletedDevice); +}); + +Effect.runPromise(deviceBinding).then(console.log).catch(handleError); diff --git a/e2e/device-client-app/src/device-profile/main.ts b/e2e/device-client-app/src/device-profile/main.ts index 649e451135..23529b7436 100644 --- a/e2e/device-client-app/src/device-profile/main.ts +++ b/e2e/device-client-app/src/device-profile/main.ts @@ -7,11 +7,10 @@ * */ -import { UserManager } from '@forgerock/javascript-sdk'; -import { autoscript, handleError } from '../autoscript.js'; -import { DeviceClient } from '../types.js'; +import { handleError, LoginAndGetClient } from '../autoscript.js'; import { ProfileDevice } from '@forgerock/device-client/types'; import { Effect } from 'effect'; +import { getUser } from '../util-effects/index.js'; /** * @function handleDeviceProfile @@ -19,12 +18,10 @@ import { Effect } from 'effect'; * @param {DeviceClient} client A device client instance from the JS SDK * @returns {Effect.Effect} An Effect that performs device profile management operations */ -function handleDeviceProfile(client: DeviceClient): Effect.Effect { - return Effect.gen(function* () { - const user = yield* Effect.tryPromise({ - try: () => UserManager.getCurrentUser(), - catch: (err) => new Error(`Failed to get current user: ${err}`), - }); +const handleDeviceProfile = Effect.gen(function* () { + const client = yield* LoginAndGetClient + const user = yield* getUser; + const query = { userId: (user as Record).sub, @@ -73,4 +70,4 @@ function handleDeviceProfile(client: DeviceClient): Effect.Effect} An Effect that performs OATH device management operations - */ -function handleOath(client: DeviceClient): Effect.Effect { - return Effect.gen(function* () { - const user = yield* Effect.tryPromise({ - try: () => UserManager.getCurrentUser(), - catch: (err) => new Error(`Failed to get current user: ${err}`), - }); - console.log('user', user); +// /** +// * @function handleOath +// * @description Handles OATH device management operations such as getting and deleting devices +// * @param {DeviceClient} client A device client instance from the JS SDK +// * @returns {Effect.Effect} An Effect that performs OATH device management operations +// */ +// function handleOath(client: DeviceClient): Effect.Effect { +// return Effect.gen(function* () { +// const user = yield* Effect.tryPromise({ +// try: () => UserManager.getCurrentUser(), +// catch: (err) => new Error(`Failed to get current user: ${err}`), +// }); +// console.log('user', user); - const query = { - userId: (user as Record).sub, - realm: 'alpha', - }; +// const query = { +// userId: (user as Record).sub, +// realm: 'alpha', +// }; - const deviceArr = yield* Effect.promise(() => client.oath.get(query)); - console.log('GET devices', deviceArr); +// const deviceArr = yield* Effect.promise(() => client.oath.get(query)); +// console.log('GET devices', deviceArr); - if (Array.isArray(deviceArr)) { - const [device] = deviceArr; +// if (Array.isArray(deviceArr)) { +// const [device] = deviceArr; - if (!device) { - yield* Effect.fail(new Error('No device to delete')); - } - console.log('device', device); +// if (!device) { +// yield* Effect.fail(new Error('No device to delete')); +// } +// console.log('device', device); - const deletedDevice = yield* Effect.promise(() => - client.oath.delete({ - ...query, - device, - }), - ); +// const deletedDevice = yield* Effect.promise(() => +// client.oath.delete({ +// ...query, +// device, +// }), +// ); - if (deletedDevice !== null && deletedDevice.error) { - yield* Effect.fail(new Error(`Failed to delete device: ${deletedDevice.error}`)); - } +// if (deletedDevice !== null && deletedDevice.error) { +// yield* Effect.fail(new Error(`Failed to delete device: ${deletedDevice.error}`)); +// } - console.log('deleted', deletedDevice); - } else { - yield* Effect.fail(new Error(`Failed to get devices: ${deviceArr.error}`)); - } - }); -} +// console.log('deleted', deletedDevice); +// } else { +// yield* Effect.fail(new Error(`Failed to get devices: ${deviceArr.error}`)); +// } +// }); +// } -// Execute the device test -Effect.runPromise(autoscript(handleOath)).then(console.log, handleError); +// // Execute the device test +// Effect.runPromise(autoscript(handleOath)).then(console.log, handleError); diff --git a/e2e/device-client-app/src/push/main.ts b/e2e/device-client-app/src/push/main.ts index 69201707e9..2964fd18e6 100644 --- a/e2e/device-client-app/src/push/main.ts +++ b/e2e/device-client-app/src/push/main.ts @@ -1,63 +1,63 @@ -/* - * - * Copyright © 2025 Ping Identity Corporation. All right reserved. - * - * This software may be modified and distributed under the terms - * of the MIT license. See the LICENSE file for details. - * - */ - -import { UserManager } from '@forgerock/javascript-sdk'; -import { autoscript, handleError } from '../autoscript.js'; -import { DeviceClient } from '../types.js'; -import { Effect } from 'effect'; - -/** - * @function handlePush - * @description Handles PUSH device management operations such as getting and deleting devices - * @param {DeviceClient} client A device client instance from the JS SDK - * @returns {Effect.Effect} An Effect that performs PUSH device management operations - */ -function handlePush(client: DeviceClient): Effect.Effect { - return Effect.gen(function* () { - const user = yield* Effect.tryPromise({ - try: () => UserManager.getCurrentUser(), - catch: (err) => new Error(`Failed to get current user: ${err}`), - }); - - const query = { - userId: (user as Record).sub, - realm: 'alpha', - }; - - const deviceArr = yield* Effect.promise(() => client.push.get(query)); - console.log('GET devices', deviceArr); - - if (Array.isArray(deviceArr)) { - const [device] = deviceArr; - - if (!device) { - yield* Effect.fail(new Error('No device to delete')); - } - console.log('device', device); - - const deletedDevice = yield* Effect.promise(() => - client.push.delete({ - ...query, - device, - }), - ); - - if (deletedDevice !== null && deletedDevice.error) { - yield* Effect.fail(new Error(`Failed to delete device: ${deletedDevice.error}`)); - } - - console.log('deleted', deletedDevice); - } else { - yield* Effect.fail(new Error(`Failed to get devices: ${deviceArr.error}`)); - } - }); -} - -// Execute the device test -Effect.runPromise(autoscript(handlePush)).then(console.log, handleError); +// /* +// * +// * Copyright © 2025 Ping Identity Corporation. All right reserved. +// * +// * This software may be modified and distributed under the terms +// * of the MIT license. See the LICENSE file for details. +// * +// */ + +// import { UserManager } from '@forgerock/javascript-sdk'; +// import { autoscript, handleError } from '../autoscript.js'; +// import { DeviceClient } from '../types.js'; +// import { Effect } from 'effect'; + +// /** +// * @function handlePush +// * @description Handles PUSH device management operations such as getting and deleting devices +// * @param {DeviceClient} client A device client instance from the JS SDK +// * @returns {Effect.Effect} An Effect that performs PUSH device management operations +// */ +// function handlePush(client: DeviceClient): Effect.Effect { +// return Effect.gen(function* () { +// const user = yield* Effect.tryPromise({ +// try: () => UserManager.getCurrentUser(), +// catch: (err) => new Error(`Failed to get current user: ${err}`), +// }); + +// const query = { +// userId: (user as Record).sub, +// realm: 'alpha', +// }; + +// const deviceArr = yield* Effect.promise(() => client.push.get(query)); +// console.log('GET devices', deviceArr); + +// if (Array.isArray(deviceArr)) { +// const [device] = deviceArr; + +// if (!device) { +// yield* Effect.fail(new Error('No device to delete')); +// } +// console.log('device', device); + +// const deletedDevice = yield* Effect.promise(() => +// client.push.delete({ +// ...query, +// device, +// }), +// ); + +// if (deletedDevice !== null && deletedDevice.error) { +// yield* Effect.fail(new Error(`Failed to delete device: ${deletedDevice.error}`)); +// } + +// console.log('deleted', deletedDevice); +// } else { +// yield* Effect.fail(new Error(`Failed to get devices: ${deviceArr.error}`)); +// } +// }); +// } + +// // Execute the device test +// Effect.runPromise(autoscript(handlePush)).then(console.log, handleError); diff --git a/e2e/device-client-app/src/service/device-client.ts b/e2e/device-client-app/src/service/device-client.ts new file mode 100644 index 0000000000..2e1f9f6c1b --- /dev/null +++ b/e2e/device-client-app/src/service/device-client.ts @@ -0,0 +1,54 @@ +import { deviceClient } from '@forgerock/device-client'; +import { Config } from '@forgerock/javascript-sdk'; +import { Context, Effect, Layer } from 'effect'; + +const url = new URL(window.location.href); +const amUrl = url.searchParams.get('amUrl') || 'https://openam-sdks.forgeblocks.com/am'; +const realmPath = url.searchParams.get('realmPath') || 'alpha'; +const platformHeader = url.searchParams.get('platformHeader') === 'true' ? true : false; +const tree = url.searchParams.get('tree') || 'selfservice'; + +export class DeviceClient extends Context.Tag('DeviceClient')< + DeviceClient, + ReturnType +>() {} + +const config = { + realmPath, + tree, + clientId: 'WebOAuthClient', + scope: 'profile email me.read openid', + serverConfig: { + baseUrl: amUrl, + timeout: 3000, + }, +}; + +export const DeviceClientLive = Layer.succeed( + DeviceClient, + DeviceClient.of({ + ...deviceClient(config), + }), +); + +export class SDKConfig extends Context.Tag('SDKConfig')() {} + +export const SDKConfigLive = Layer.scoped( + SDKConfig, + Effect.gen(function* () { + yield* Effect.try(() => + Config.set({ + platformHeader, + realmPath, + tree, + clientId: 'WebOAuthClient', + scope: 'profile email me.read openid', + redirectUri: `${window.location.origin}/src/_callback/index.html`, + serverConfig: { + baseUrl: amUrl, + timeout: 3000, + }, + }), + ); + }), +); diff --git a/e2e/device-client-app/src/util-effects/index.ts b/e2e/device-client-app/src/util-effects/index.ts new file mode 100644 index 0000000000..8c1a6ad9e6 --- /dev/null +++ b/e2e/device-client-app/src/util-effects/index.ts @@ -0,0 +1,50 @@ +import { + FRAuth, + FRLoginFailure, + FRLoginSuccess, + FRStep, + SessionManager, + TokenManager, + UserManager, +} from '@forgerock/javascript-sdk'; +import { Console, Effect } from 'effect'; + +export const logout = Effect.ignore( + Effect.tryPromise({ + try: () => SessionManager.logout(), + catch: (err) => new Error(`Logout failed: ${err}`), + }), +); + +export const start = Effect.tryPromise({ + try: () => FRAuth.start(), + catch: (err) => new Error(`Authentication start failed: ${err}`), +}).pipe(Effect.tap((step) => Console.log('Called start', step))); + +export const checkFRStep = (step: FRStep | FRLoginFailure | FRLoginSuccess) => + Effect.try({ + try: () => { + if (step.type == 'LoginSuccess' || step.type == 'LoginFailure') { + throw new Error(`Unexpected step type: ${step.type}`); + } else { + return step; + } + }, + catch: (err) => new Error(`Failed to start authentication: ${err}`), + }); + +export const callNext = (step: FRStep) => + Effect.tryPromise({ + try: () => FRAuth.next(step), + catch: (err) => new Error(`Failed to proceed to next step: ${err}`), + }).pipe(Effect.tap((step) => Console.log('Got next step', step))); + +export const getTokens = Effect.tryPromise({ + try: () => TokenManager.getTokens(), + catch: (err) => new Error(`Failed to get tokens: ${err}`), +}).pipe(Effect.tap((tokens) => Console.log('Got Tokens', tokens))); + +export const getUser = Effect.tryPromise({ + try: () => UserManager.getCurrentUser() as Promise>, + catch: (err) => new Error(`Failed to get current user: ${err}`), +}); diff --git a/e2e/device-client-app/src/webauthn/main.ts b/e2e/device-client-app/src/webauthn/main.ts index 63c64c482f..5c1ad91af7 100644 --- a/e2e/device-client-app/src/webauthn/main.ts +++ b/e2e/device-client-app/src/webauthn/main.ts @@ -1,76 +1,76 @@ -/* - * - * Copyright © 2025 Ping Identity Corporation. All right reserved. - * - * This software may be modified and distributed under the terms - * of the MIT license. See the LICENSE file for details. - * - */ +// /* +// * +// * Copyright © 2025 Ping Identity Corporation. All right reserved. +// * +// * This software may be modified and distributed under the terms +// * of the MIT license. See the LICENSE file for details. +// * +// */ -import { UserManager } from '@forgerock/javascript-sdk'; -import { autoscript, handleError } from '../autoscript.js'; -import { DeviceClient } from '../types.js'; -import { UpdatedWebAuthnDevice } from '@forgerock/device-client/types'; -import { Effect } from 'effect'; +// import { UserManager } from '@forgerock/javascript-sdk'; +// import { autoscript, handleError } from '../autoscript.js'; +// import { DeviceClient } from '../types.js'; +// import { UpdatedWebAuthnDevice } from '@forgerock/device-client/types'; +// import { Effect } from 'effect'; -/** - * @function handleWebAuthN - * @description Handles WebAuthN device management operations such as getting, updating, and deleting devices - * @param {DeviceClient} client A device client instance from the JS SDK - * @returns {Effect.Effect} An Effect that performs WebAuthN device management operations - */ -function handleWebAuthN(client: DeviceClient): Effect.Effect { - return Effect.gen(function* () { - const user = yield* Effect.tryPromise({ - try: () => UserManager.getCurrentUser(), - catch: (err) => new Error(`Failed to get current user: ${err}`), - }); +// /** +// * @function handleWebAuthN +// * @description Handles WebAuthN device management operations such as getting, updating, and deleting devices +// * @param {DeviceClient} client A device client instance from the JS SDK +// * @returns {Effect.Effect} An Effect that performs WebAuthN device management operations +// */ +// function handleWebAuthN(client: DeviceClient): Effect.Effect { +// return Effect.gen(function* () { +// const user = yield* Effect.tryPromise({ +// try: () => UserManager.getCurrentUser(), +// catch: (err) => new Error(`Failed to get current user: ${err}`), +// }); - const query = { - userId: (user as Record).sub, - realm: 'alpha', - }; +// const query = { +// userId: (user as Record).sub, +// realm: 'alpha', +// }; - const deviceArr = yield* Effect.promise(() => client.webAuthn.get(query)); - console.log('GET devices', deviceArr); +// const deviceArr = yield* Effect.promise(() => client.webAuthn.get(query)); +// console.log('GET devices', deviceArr); - if (Array.isArray(deviceArr)) { - const [device] = deviceArr; +// if (Array.isArray(deviceArr)) { +// const [device] = deviceArr; - if (!device) { - yield* Effect.fail(new Error('No device to delete')); - } - console.log('device', device); +// if (!device) { +// yield* Effect.fail(new Error('No device to delete')); +// } +// console.log('device', device); - const updatedDevice = yield* Effect.promise(() => - client.webAuthn.update({ - ...query, - device: { ...device, deviceName: 'UpdatedDeviceName' }, - }), - ); +// const updatedDevice = yield* Effect.promise(() => +// client.webAuthn.update({ +// ...query, +// device: { ...device, deviceName: 'UpdatedDeviceName' }, +// }), +// ); - if ('error' in updatedDevice) { - yield* Effect.fail(new Error(`Failed to update device: ${updatedDevice.error}`)); - } - console.log('updated device', updatedDevice); +// if ('error' in updatedDevice) { +// yield* Effect.fail(new Error(`Failed to update device: ${updatedDevice.error}`)); +// } +// console.log('updated device', updatedDevice); - const deletedDevice = yield* Effect.promise(() => - client.webAuthn.delete({ - ...query, - device: updatedDevice as UpdatedWebAuthnDevice, - }), - ); +// const deletedDevice = yield* Effect.promise(() => +// client.webAuthn.delete({ +// ...query, +// device: updatedDevice as UpdatedWebAuthnDevice, +// }), +// ); - if (deletedDevice !== null && deletedDevice.error) { - yield* Effect.fail(new Error(`Failed to delete device: ${deletedDevice.error}`)); - } +// if (deletedDevice !== null && deletedDevice.error) { +// yield* Effect.fail(new Error(`Failed to delete device: ${deletedDevice.error}`)); +// } - console.log('deleted', deletedDevice); - } else { - yield* Effect.fail(new Error(`Failed to get devices: ${deviceArr.error}`)); - } - }); -} +// console.log('deleted', deletedDevice); +// } else { +// yield* Effect.fail(new Error(`Failed to get devices: ${deviceArr.error}`)); +// } +// }); +// } -// Execute the device test -Effect.runPromise(autoscript(handleWebAuthN)).then(console.log, handleError); +// // Execute the device test +// Effect.runPromise(autoscript(handleWebAuthN)).then(console.log, handleError); diff --git a/package.json b/package.json index cfa4502162..c4915cc630 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,6 @@ "conventional-changelog-conventionalcommits": "^8.0.0", "cz-conventional-changelog": "^3.3.0", "cz-git": "^1.6.1", - "effect": "^3.12.7", "eslint": "^9.8.0", "eslint-config-prettier": "10.1.5", "eslint-plugin-import": "2.31.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad8f04d6c1..74be401297 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,15 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +catalogs: + default: + '@reduxjs/toolkit': + specifier: ^2.8.2 + version: 2.8.2 + immer: + specifier: ^10.1.1 + version: 10.1.1 + importers: .: From a3f4d67e6495964def3b95c8064aec5f23ecb22b Mon Sep 17 00:00:00 2001 From: AJ Ancheta <7781450+ancheetah@users.noreply.github.com> Date: Wed, 11 Jun 2025 15:09:12 -0400 Subject: [PATCH 4/4] chore(device-client): finish refactor --- .github/instructions/nx.instructions.md | 39 ----- .../src/device-binding/main.ts | 17 +-- .../src/device-profile/main.ts | 91 +++++------- e2e/device-client-app/src/oath/main.ts | 111 ++++++-------- e2e/device-client-app/src/push/main.ts | 110 ++++++-------- .../src/service/device-client.ts | 54 ------- .../src/util-effects/index.ts | 50 ------- .../src/{autoscript.ts => utils/index.ts} | 82 +++++++---- e2e/device-client-app/src/webauthn/main.ts | 136 ++++++++---------- .../device-client/src/lib/device.store.ts | 2 +- .../device-client/src/lib/services/index.ts | 2 +- pnpm-lock.yaml | 14 +- 12 files changed, 265 insertions(+), 443 deletions(-) delete mode 100644 .github/instructions/nx.instructions.md delete mode 100644 e2e/device-client-app/src/service/device-client.ts delete mode 100644 e2e/device-client-app/src/util-effects/index.ts rename e2e/device-client-app/src/{autoscript.ts => utils/index.ts} (61%) diff --git a/.github/instructions/nx.instructions.md b/.github/instructions/nx.instructions.md deleted file mode 100644 index eb224c349b..0000000000 --- a/.github/instructions/nx.instructions.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -applyTo: '**' ---- - -// This file is automatically generated by Nx Console - -You are in an nx workspace using Nx 21.0.3 and pnpm as the package manager. - -You have access to the Nx MCP server and the tools it provides. Use them. Follow these guidelines in order to best help the user: - -# General Guidelines -- When answering questions, use the nx_workspace tool first to gain an understanding of the workspace architecture -- For questions around nx configuration, best practices or if you're unsure, use the nx_docs tool to get relevant, up-to-date docs!! Always use this instead of assuming things about nx configuration -- If the user needs help with an Nx configuration or project graph error, use the 'nx_workspace' tool to get any errors -- To help answer questions about the workspace structure or simply help with demonstrating how tasks depend on each other, use the 'nx_visualize_graph' tool - -# Generation Guidelines -If the user wants to generate something, use the following flow: - -- learn about the nx workspace and any specifics the user needs by using the 'nx_workspace' tool and the 'nx_project_details' tool if applicable -- get the available generators using the 'nx_generators' tool -- decide which generator to use. If no generators seem relevant, check the 'nx_available_plugins' tool to see if the user could install a plugin to help them -- get generator details using the 'nx_generator_schema' tool -- you may use the 'nx_docs' tool to learn more about a specific generator or technology if you're unsure -- decide which options to provide in order to best complete the user's request. Don't make any assumptions and keep the options minimalistic -- open the generator UI using the 'nx_open_generate_ui' tool -- wait for the user to finish the generator -- read the generator log file using the 'nx_read_generator_log' tool -- use the information provided in the log file to answer the user's question or continue with what they were doing - - -# CI Error Guidelines -If the user wants help with fixing an error in their CI pipeline, use the following flow: -- Retrieve the list of current CI Pipeline Executions (CIPEs) using the 'nx_cloud_cipe_details' tool -- If there are any errors, use the 'nx_cloud_fix_cipe_failure' tool to retrieve the logs for a specific task -- Use the task logs to see what's wrong and help the user fix their problem. Use the appropriate tools if necessary -- Make sure that the problem is fixed by running the task that you passed into the 'nx_cloud_fix_cipe_failure' tool - - diff --git a/e2e/device-client-app/src/device-binding/main.ts b/e2e/device-client-app/src/device-binding/main.ts index 8f6d1bd276..9dd9f5f70c 100644 --- a/e2e/device-client-app/src/device-binding/main.ts +++ b/e2e/device-client-app/src/device-binding/main.ts @@ -7,17 +7,8 @@ * */ -import { Device } from '@forgerock/device-client/types'; import { Console, Effect } from 'effect'; -import { LoginAndGetClient, handleError } from '../autoscript.js'; -import { getUser } from '../util-effects/index.js'; - -/** - * @function handleDeviceBinding - * @description Handles device binding management operations such as getting, updating, and deleting devices - * @param {DeviceClient} client A device client instance from the JS SDK - * @returns {Effect.Effect} An Effect that performs device binding management operations - */ +import { getUser, LoginAndGetClient, handleError, handleSuccess } from '../utils/index.js'; const deviceBinding = Effect.gen(function* () { const client = yield* LoginAndGetClient; @@ -29,7 +20,7 @@ const deviceBinding = Effect.gen(function* () { const deviceArr = yield* Effect.promise(() => client.bound.get(query)); - if (!deviceArr || 'error' in deviceArr) { + if ((Array.isArray(deviceArr) && !deviceArr.length) || 'error' in deviceArr) { yield* Console.log('No devices found or error occurred', deviceArr); return yield* Effect.fail(new Error('No devices found or error occurred')); } @@ -55,7 +46,7 @@ const deviceBinding = Effect.gen(function* () { const deletedDevice = yield* Effect.promise(() => client.bound.delete({ ...query, - device: updatedDevice as Device, + device: updatedDevice, }), ); @@ -66,4 +57,4 @@ const deviceBinding = Effect.gen(function* () { yield* Console.log('deleted', deletedDevice); }); -Effect.runPromise(deviceBinding).then(console.log).catch(handleError); +Effect.runPromise(deviceBinding).then(handleSuccess).catch(handleError); diff --git a/e2e/device-client-app/src/device-profile/main.ts b/e2e/device-client-app/src/device-profile/main.ts index 23529b7436..e455442b88 100644 --- a/e2e/device-client-app/src/device-profile/main.ts +++ b/e2e/device-client-app/src/device-profile/main.ts @@ -7,67 +7,54 @@ * */ -import { handleError, LoginAndGetClient } from '../autoscript.js'; -import { ProfileDevice } from '@forgerock/device-client/types'; -import { Effect } from 'effect'; -import { getUser } from '../util-effects/index.js'; +import { Console, Effect } from 'effect'; +import { getUser, LoginAndGetClient, handleError, handleSuccess } from '../utils/index.js'; -/** - * @function handleDeviceProfile - * @description Handles device profile management operations such as getting, updating, and deleting devices - * @param {DeviceClient} client A device client instance from the JS SDK - * @returns {Effect.Effect} An Effect that performs device profile management operations - */ -const handleDeviceProfile = Effect.gen(function* () { - const client = yield* LoginAndGetClient - const user = yield* getUser; +const deviceProfiling = Effect.gen(function* () { + const client = yield* LoginAndGetClient; + const user = yield* getUser; + const query = { + userId: user.sub, + realm: 'alpha', + }; + const deviceArr = yield* Effect.promise(() => client.profile.get(query)); - const query = { - userId: (user as Record).sub, - realm: 'alpha', - }; + if ((Array.isArray(deviceArr) && !deviceArr.length) || 'error' in deviceArr) { + yield* Console.log('No devices found or error occurred', deviceArr); + return yield* Effect.fail(new Error('No devices found or error occurred')); + } + yield* Console.log('GET devices', deviceArr); - const profileArr = yield* Effect.promise(() => client.profile.get(query)); - console.log('GET devices', profileArr); + const [device] = deviceArr; - if (Array.isArray(profileArr)) { - const [profile] = profileArr; + yield* Console.log('device', device); - if (!profile) { - yield* Effect.fail(new Error('No profile to delete')); - } - console.log('profile', profile); + const updatedDevice = yield* Effect.promise(() => + client.profile.update({ + ...query, + device: { ...device, alias: 'UpdatedDeviceName' }, + }), + ); - const updatedProfile = yield* Effect.promise(() => - client.profile.update({ - ...query, - device: { ...profile, alias: 'UpdatedDeviceName' }, - }), - ); + if ('error' in updatedDevice) { + return yield* Effect.fail(new Error(`Failed to update device: ${updatedDevice.error}`)); + } - if ('error' in updatedProfile) { - yield* Effect.fail(new Error(`Failed to update device: ${updatedProfile.error}`)); - } - console.log('updated device', updatedProfile); + yield* Console.log('updated device', updatedDevice); - const deletedProfile = yield* Effect.promise(() => - client.profile.delete({ - ...query, - device: updatedProfile as ProfileDevice, - }), - ); + const deletedDevice = yield* Effect.promise(() => + client.profile.delete({ + ...query, + device: updatedDevice, + }), + ); - if (deletedProfile !== null && deletedProfile.error) { - yield* Effect.fail(new Error(`Failed to delete device: ${deletedProfile.error}`)); - } + if (deletedDevice !== null && deletedDevice.error) { + return yield* Effect.fail(new Error(`Failed to delete device: ${deletedDevice.error}`)); + } - console.log('deleted', deletedProfile); - } else { - yield* Effect.fail(new Error(`Failed to get devices: ${profileArr.error}`)); - } - }); -} + yield* Console.log('deleted', deletedDevice); +}); -// Execute the device test -Effect.runPromise(handleDeviceProfile).then(console.log, handleError); +Effect.runPromise(deviceProfiling).then(handleSuccess).catch(handleError); diff --git a/e2e/device-client-app/src/oath/main.ts b/e2e/device-client-app/src/oath/main.ts index 3d6899acd6..04e3758e8b 100644 --- a/e2e/device-client-app/src/oath/main.ts +++ b/e2e/device-client-app/src/oath/main.ts @@ -1,64 +1,47 @@ -// /* -// * -// * Copyright © 2025 Ping Identity Corporation. All right reserved. -// * -// * This software may be modified and distributed under the terms -// * of the MIT license. See the LICENSE file for details. -// * -// */ - -// import { UserManager } from '@forgerock/javascript-sdk'; -// import { autoscript, handleError } from '../autoscript.js'; -// import { DeviceClient } from '../types.js'; -// import { Effect } from 'effect'; - -// /** -// * @function handleOath -// * @description Handles OATH device management operations such as getting and deleting devices -// * @param {DeviceClient} client A device client instance from the JS SDK -// * @returns {Effect.Effect} An Effect that performs OATH device management operations -// */ -// function handleOath(client: DeviceClient): Effect.Effect { -// return Effect.gen(function* () { -// const user = yield* Effect.tryPromise({ -// try: () => UserManager.getCurrentUser(), -// catch: (err) => new Error(`Failed to get current user: ${err}`), -// }); -// console.log('user', user); - -// const query = { -// userId: (user as Record).sub, -// realm: 'alpha', -// }; - -// const deviceArr = yield* Effect.promise(() => client.oath.get(query)); -// console.log('GET devices', deviceArr); - -// if (Array.isArray(deviceArr)) { -// const [device] = deviceArr; - -// if (!device) { -// yield* Effect.fail(new Error('No device to delete')); -// } -// console.log('device', device); - -// const deletedDevice = yield* Effect.promise(() => -// client.oath.delete({ -// ...query, -// device, -// }), -// ); - -// if (deletedDevice !== null && deletedDevice.error) { -// yield* Effect.fail(new Error(`Failed to delete device: ${deletedDevice.error}`)); -// } - -// console.log('deleted', deletedDevice); -// } else { -// yield* Effect.fail(new Error(`Failed to get devices: ${deviceArr.error}`)); -// } -// }); -// } - -// // Execute the device test -// Effect.runPromise(autoscript(handleOath)).then(console.log, handleError); +/* + * + * Copyright © 2025 Ping Identity Corporation. All right reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + * + */ + +import { Console, Effect } from 'effect'; +import { getUser, LoginAndGetClient, handleError, handleSuccess } from '../utils/index.js'; + +const oath = Effect.gen(function* () { + const client = yield* LoginAndGetClient; + const user = yield* getUser; + const query = { + userId: user.sub, + realm: 'alpha', + }; + + const deviceArr = yield* Effect.promise(() => client.oath.get(query)); + + if ((Array.isArray(deviceArr) && !deviceArr.length) || 'error' in deviceArr) { + yield* Console.log('No devices found or error occurred', deviceArr); + return yield* Effect.fail(new Error('No devices found or error occurred')); + } + yield* Console.log('GET devices', deviceArr); + + const [device] = deviceArr; + + yield* Console.log('device', device); + + const deletedDevice = yield* Effect.promise(() => + client.oath.delete({ + ...query, + device, + }), + ); + + if (deletedDevice !== null && deletedDevice.error) { + return yield* Effect.fail(new Error(`Failed to delete device: ${deletedDevice.error}`)); + } + + yield* Console.log('deleted', deletedDevice); +}); + +Effect.runPromise(oath).then(handleSuccess).catch(handleError); diff --git a/e2e/device-client-app/src/push/main.ts b/e2e/device-client-app/src/push/main.ts index 2964fd18e6..97f47f0331 100644 --- a/e2e/device-client-app/src/push/main.ts +++ b/e2e/device-client-app/src/push/main.ts @@ -1,63 +1,47 @@ -// /* -// * -// * Copyright © 2025 Ping Identity Corporation. All right reserved. -// * -// * This software may be modified and distributed under the terms -// * of the MIT license. See the LICENSE file for details. -// * -// */ - -// import { UserManager } from '@forgerock/javascript-sdk'; -// import { autoscript, handleError } from '../autoscript.js'; -// import { DeviceClient } from '../types.js'; -// import { Effect } from 'effect'; - -// /** -// * @function handlePush -// * @description Handles PUSH device management operations such as getting and deleting devices -// * @param {DeviceClient} client A device client instance from the JS SDK -// * @returns {Effect.Effect} An Effect that performs PUSH device management operations -// */ -// function handlePush(client: DeviceClient): Effect.Effect { -// return Effect.gen(function* () { -// const user = yield* Effect.tryPromise({ -// try: () => UserManager.getCurrentUser(), -// catch: (err) => new Error(`Failed to get current user: ${err}`), -// }); - -// const query = { -// userId: (user as Record).sub, -// realm: 'alpha', -// }; - -// const deviceArr = yield* Effect.promise(() => client.push.get(query)); -// console.log('GET devices', deviceArr); - -// if (Array.isArray(deviceArr)) { -// const [device] = deviceArr; - -// if (!device) { -// yield* Effect.fail(new Error('No device to delete')); -// } -// console.log('device', device); - -// const deletedDevice = yield* Effect.promise(() => -// client.push.delete({ -// ...query, -// device, -// }), -// ); - -// if (deletedDevice !== null && deletedDevice.error) { -// yield* Effect.fail(new Error(`Failed to delete device: ${deletedDevice.error}`)); -// } - -// console.log('deleted', deletedDevice); -// } else { -// yield* Effect.fail(new Error(`Failed to get devices: ${deviceArr.error}`)); -// } -// }); -// } - -// // Execute the device test -// Effect.runPromise(autoscript(handlePush)).then(console.log, handleError); +/* + * + * Copyright © 2025 Ping Identity Corporation. All right reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + * + */ + +import { Console, Effect } from 'effect'; +import { getUser, LoginAndGetClient, handleError, handleSuccess } from '../utils/index.js'; + +const push = Effect.gen(function* () { + const client = yield* LoginAndGetClient; + const user = yield* getUser; + const query = { + userId: user.sub, + realm: 'alpha', + }; + + const deviceArr = yield* Effect.promise(() => client.push.get(query)); + + if ((Array.isArray(deviceArr) && !deviceArr.length) || 'error' in deviceArr) { + yield* Console.log('No devices found or error occurred', deviceArr); + return yield* Effect.fail(new Error('No devices found or error occurred')); + } + yield* Console.log('GET devices', deviceArr); + + const [device] = deviceArr; + + yield* Console.log('device', device); + + const deletedDevice = yield* Effect.promise(() => + client.push.delete({ + ...query, + device, + }), + ); + + if (deletedDevice !== null && deletedDevice.error) { + return yield* Effect.fail(new Error(`Failed to delete device: ${deletedDevice.error}`)); + } + + yield* Console.log('deleted', deletedDevice); +}); + +Effect.runPromise(push).then(handleSuccess).catch(handleError); diff --git a/e2e/device-client-app/src/service/device-client.ts b/e2e/device-client-app/src/service/device-client.ts deleted file mode 100644 index 2e1f9f6c1b..0000000000 --- a/e2e/device-client-app/src/service/device-client.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { deviceClient } from '@forgerock/device-client'; -import { Config } from '@forgerock/javascript-sdk'; -import { Context, Effect, Layer } from 'effect'; - -const url = new URL(window.location.href); -const amUrl = url.searchParams.get('amUrl') || 'https://openam-sdks.forgeblocks.com/am'; -const realmPath = url.searchParams.get('realmPath') || 'alpha'; -const platformHeader = url.searchParams.get('platformHeader') === 'true' ? true : false; -const tree = url.searchParams.get('tree') || 'selfservice'; - -export class DeviceClient extends Context.Tag('DeviceClient')< - DeviceClient, - ReturnType ->() {} - -const config = { - realmPath, - tree, - clientId: 'WebOAuthClient', - scope: 'profile email me.read openid', - serverConfig: { - baseUrl: amUrl, - timeout: 3000, - }, -}; - -export const DeviceClientLive = Layer.succeed( - DeviceClient, - DeviceClient.of({ - ...deviceClient(config), - }), -); - -export class SDKConfig extends Context.Tag('SDKConfig')() {} - -export const SDKConfigLive = Layer.scoped( - SDKConfig, - Effect.gen(function* () { - yield* Effect.try(() => - Config.set({ - platformHeader, - realmPath, - tree, - clientId: 'WebOAuthClient', - scope: 'profile email me.read openid', - redirectUri: `${window.location.origin}/src/_callback/index.html`, - serverConfig: { - baseUrl: amUrl, - timeout: 3000, - }, - }), - ); - }), -); diff --git a/e2e/device-client-app/src/util-effects/index.ts b/e2e/device-client-app/src/util-effects/index.ts deleted file mode 100644 index 8c1a6ad9e6..0000000000 --- a/e2e/device-client-app/src/util-effects/index.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { - FRAuth, - FRLoginFailure, - FRLoginSuccess, - FRStep, - SessionManager, - TokenManager, - UserManager, -} from '@forgerock/javascript-sdk'; -import { Console, Effect } from 'effect'; - -export const logout = Effect.ignore( - Effect.tryPromise({ - try: () => SessionManager.logout(), - catch: (err) => new Error(`Logout failed: ${err}`), - }), -); - -export const start = Effect.tryPromise({ - try: () => FRAuth.start(), - catch: (err) => new Error(`Authentication start failed: ${err}`), -}).pipe(Effect.tap((step) => Console.log('Called start', step))); - -export const checkFRStep = (step: FRStep | FRLoginFailure | FRLoginSuccess) => - Effect.try({ - try: () => { - if (step.type == 'LoginSuccess' || step.type == 'LoginFailure') { - throw new Error(`Unexpected step type: ${step.type}`); - } else { - return step; - } - }, - catch: (err) => new Error(`Failed to start authentication: ${err}`), - }); - -export const callNext = (step: FRStep) => - Effect.tryPromise({ - try: () => FRAuth.next(step), - catch: (err) => new Error(`Failed to proceed to next step: ${err}`), - }).pipe(Effect.tap((step) => Console.log('Got next step', step))); - -export const getTokens = Effect.tryPromise({ - try: () => TokenManager.getTokens(), - catch: (err) => new Error(`Failed to get tokens: ${err}`), -}).pipe(Effect.tap((tokens) => Console.log('Got Tokens', tokens))); - -export const getUser = Effect.tryPromise({ - try: () => UserManager.getCurrentUser() as Promise>, - catch: (err) => new Error(`Failed to get current user: ${err}`), -}); diff --git a/e2e/device-client-app/src/autoscript.ts b/e2e/device-client-app/src/utils/index.ts similarity index 61% rename from e2e/device-client-app/src/autoscript.ts rename to e2e/device-client-app/src/utils/index.ts index 766b4be9dc..a89e05fc85 100644 --- a/e2e/device-client-app/src/autoscript.ts +++ b/e2e/device-client-app/src/utils/index.ts @@ -1,25 +1,53 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ -/* - * - * Copyright © 2025 Ping Identity Corporation. All right reserved. - * - * This software may be modified and distributed under the terms - * of the MIT license. See the LICENSE file for details. - * - */ - +import { deviceClient } from '@forgerock/device-client'; import { CallbackType, Config, + FRAuth, FRLoginFailure, FRLoginSuccess, FRStep, NameCallback, PasswordCallback, + SessionManager, + TokenManager, + UserManager, } from '@forgerock/javascript-sdk'; -import { Effect } from 'effect'; -import { start, logout, checkFRStep, callNext, getTokens } from './util-effects/index.js'; -import { deviceClient } from '@forgerock/device-client'; +import { Console, Effect } from 'effect'; + +const logout = Effect.ignore( + Effect.tryPromise({ + try: () => SessionManager.logout(), + catch: (err) => new Error(`Logout failed: ${err}`), + }), +); + +const start = Effect.tryPromise({ + try: () => FRAuth.start(), + catch: (err) => new Error(`Authentication start failed: ${err}`), +}).pipe(Effect.tap((step) => Console.log('Called start', step))); + +const checkFRStep = (step: FRStep | FRLoginFailure | FRLoginSuccess) => + Effect.try({ + try: () => { + if (step.type == 'LoginSuccess' || step.type == 'LoginFailure') { + throw new Error(`Unexpected step type: ${step.type}`); + } else { + return step; + } + }, + catch: (err) => new Error(`Failed to start authentication: ${err}`), + }); + +const callNext = (step: FRStep) => + Effect.tryPromise({ + try: () => FRAuth.next(step), + catch: (err) => new Error(`Failed to proceed to next step: ${err}`), + }).pipe(Effect.tap((step) => Console.log('Got next step', step))); + +const getTokens = Effect.tryPromise({ + try: () => TokenManager.getTokens(), + catch: (err) => new Error(`Failed to get tokens: ${err}`), +}).pipe(Effect.tap((tokens) => Console.log('Got Tokens', tokens))); const checkForLoginSuccess = (step: FRStep | FRLoginSuccess | FRLoginFailure) => { if (step.type === 'LoginSuccess') { @@ -32,25 +60,21 @@ const checkForLoginSuccess = (step: FRStep | FRLoginSuccess | FRLoginFailure) => ); } }; -/** - * @function autoscript - * @description Steps through an authentication journey to test device management - * @param {function} handleDevice A function that manages the device through the device client - * @returns {Effect.Effect} An effect to run the test - */ + export const LoginAndGetClient = Effect.gen(function* () { + const url = new URL(window.location.href); + const amUrl = url.searchParams.get('amUrl') || 'https://openam-sdks.forgeblocks.com/am'; + const realmPath = url.searchParams.get('realmPath') || 'alpha'; + const platformHeader = url.searchParams.get('platformHeader') === 'true' ? true : false; + const tree = url.searchParams.get('tree') || 'selfservice'; + /** * Make sure this `un` is a real user * this is a manual test and requires a real tenant and a real user * that has devices. */ - const url = new URL(window.location.href); const un = url.searchParams.get('un') || 'devicetestuser'; const pw = url.searchParams.get('pw') || 'password'; - const amUrl = url.searchParams.get('amUrl') || 'https://openam-sdks.forgeblocks.com/am'; - const realmPath = url.searchParams.get('realmPath') || 'alpha'; - const platformHeader = url.searchParams.get('platformHeader') === 'true' ? true : false; - const tree = url.searchParams.get('tree') || 'selfservice'; const config = { realmPath, @@ -99,7 +123,17 @@ export const LoginAndGetClient = Effect.gen(function* () { return client; }); +export const getUser = Effect.tryPromise({ + try: () => UserManager.getCurrentUser() as Promise>, + catch: (err) => new Error(`Failed to get current user: ${err}`), +}); + export const handleError = (err: unknown) => { console.error(err); document.body.innerHTML = `

Test script failed: ${err}

`; }; + +export const handleSuccess = () => { + console.log('Test script complete'); + document.body.innerHTML = `

Test script complete

`; +}; diff --git a/e2e/device-client-app/src/webauthn/main.ts b/e2e/device-client-app/src/webauthn/main.ts index 5c1ad91af7..89bdb4c941 100644 --- a/e2e/device-client-app/src/webauthn/main.ts +++ b/e2e/device-client-app/src/webauthn/main.ts @@ -1,76 +1,60 @@ -// /* -// * -// * Copyright © 2025 Ping Identity Corporation. All right reserved. -// * -// * This software may be modified and distributed under the terms -// * of the MIT license. See the LICENSE file for details. -// * -// */ - -// import { UserManager } from '@forgerock/javascript-sdk'; -// import { autoscript, handleError } from '../autoscript.js'; -// import { DeviceClient } from '../types.js'; -// import { UpdatedWebAuthnDevice } from '@forgerock/device-client/types'; -// import { Effect } from 'effect'; - -// /** -// * @function handleWebAuthN -// * @description Handles WebAuthN device management operations such as getting, updating, and deleting devices -// * @param {DeviceClient} client A device client instance from the JS SDK -// * @returns {Effect.Effect} An Effect that performs WebAuthN device management operations -// */ -// function handleWebAuthN(client: DeviceClient): Effect.Effect { -// return Effect.gen(function* () { -// const user = yield* Effect.tryPromise({ -// try: () => UserManager.getCurrentUser(), -// catch: (err) => new Error(`Failed to get current user: ${err}`), -// }); - -// const query = { -// userId: (user as Record).sub, -// realm: 'alpha', -// }; - -// const deviceArr = yield* Effect.promise(() => client.webAuthn.get(query)); -// console.log('GET devices', deviceArr); - -// if (Array.isArray(deviceArr)) { -// const [device] = deviceArr; - -// if (!device) { -// yield* Effect.fail(new Error('No device to delete')); -// } -// console.log('device', device); - -// const updatedDevice = yield* Effect.promise(() => -// client.webAuthn.update({ -// ...query, -// device: { ...device, deviceName: 'UpdatedDeviceName' }, -// }), -// ); - -// if ('error' in updatedDevice) { -// yield* Effect.fail(new Error(`Failed to update device: ${updatedDevice.error}`)); -// } -// console.log('updated device', updatedDevice); - -// const deletedDevice = yield* Effect.promise(() => -// client.webAuthn.delete({ -// ...query, -// device: updatedDevice as UpdatedWebAuthnDevice, -// }), -// ); - -// if (deletedDevice !== null && deletedDevice.error) { -// yield* Effect.fail(new Error(`Failed to delete device: ${deletedDevice.error}`)); -// } - -// console.log('deleted', deletedDevice); -// } else { -// yield* Effect.fail(new Error(`Failed to get devices: ${deviceArr.error}`)); -// } -// }); -// } - -// // Execute the device test -// Effect.runPromise(autoscript(handleWebAuthN)).then(console.log, handleError); +/* + * + * Copyright © 2025 Ping Identity Corporation. All right reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + * + */ + +import { Console, Effect } from 'effect'; +import { getUser, LoginAndGetClient, handleError, handleSuccess } from '../utils/index.js'; + +const webauthn = Effect.gen(function* () { + const client = yield* LoginAndGetClient; + const user = yield* getUser; + const query = { + userId: user.sub, + realm: 'alpha', + }; + + const deviceArr = yield* Effect.promise(() => client.webAuthn.get(query)); + + if ((Array.isArray(deviceArr) && !deviceArr.length) || 'error' in deviceArr) { + yield* Console.log('No devices found or error occurred', deviceArr); + return yield* Effect.fail(new Error('No devices found or error occurred')); + } + yield* Console.log('GET devices', deviceArr); + + const [device] = deviceArr; + + yield* Console.log('device', device); + + const updatedDevice = yield* Effect.promise(() => + client.webAuthn.update({ + ...query, + device: { ...device, deviceName: 'UpdatedDeviceName' }, + }), + ); + + if ('error' in updatedDevice) { + return yield* Effect.fail(new Error(`Failed to update device: ${updatedDevice.error}`)); + } + + yield* Console.log('updated device', updatedDevice); + + const deletedDevice = yield* Effect.promise(() => + client.webAuthn.delete({ + ...query, + device: updatedDevice, + }), + ); + + if (deletedDevice !== null && deletedDevice.error) { + return yield* Effect.fail(new Error(`Failed to delete device: ${deletedDevice.error}`)); + } + + yield* Console.log('deleted', deletedDevice); +}); + +Effect.runPromise(webauthn).then(handleSuccess).catch(handleError); diff --git a/packages/device-client/src/lib/device.store.ts b/packages/device-client/src/lib/device.store.ts index f773fb4b33..02e37bfcb7 100644 --- a/packages/device-client/src/lib/device.store.ts +++ b/packages/device-client/src/lib/device.store.ts @@ -53,7 +53,7 @@ export const deviceClient = (config: ConfigOptions) => { */ get: async function (query: RetrieveOathQuery): Promise { try { - const response = await store.dispatch(endpoints.getOAthDevices.initiate(query)); + const response = await store.dispatch(endpoints.getOathDevices.initiate(query)); if (!response || !response.data || !response.data.result) { throw new Error('response did not contain data'); diff --git a/packages/device-client/src/lib/services/index.ts b/packages/device-client/src/lib/services/index.ts index 7a4ae32235..fd7be4981d 100644 --- a/packages/device-client/src/lib/services/index.ts +++ b/packages/device-client/src/lib/services/index.ts @@ -52,7 +52,7 @@ export const deviceService = ({ baseUrl, realmPath }: { baseUrl: string; realmPa }), endpoints: (builder) => ({ // oath endpoints - getOAthDevices: builder.query({ + getOathDevices: builder.query({ query: ({ realm = realmPath, userId }) => `json/realms/${realm}/users/${userId}/devices/2fa/oath?_queryFilter=true`, }), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 74be401297..c969a6bdc1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -137,9 +137,6 @@ importers: cz-git: specifier: ^1.6.1 version: 1.11.1 - effect: - specifier: ^3.12.7 - version: 3.16.0 eslint: specifier: ^9.8.0 version: 9.27.0(jiti@2.4.2) @@ -252,11 +249,11 @@ importers: version: 4.7.0 effect: specifier: ^3.12.7 - version: 3.14.14 + version: 3.16.0 devDependencies: '@effect/language-service': - specifier: ^0.2.0 - version: 0.2.0 + specifier: ^0.20.0 + version: 0.20.1 e2e/mock-api-v2: dependencies: @@ -1230,6 +1227,9 @@ packages: '@effect/language-service@0.2.0': resolution: {integrity: sha512-DoK41yKGyQv79o0ca8gxEogMlt+IphXkdCXwgenbQjH1BXKD7tJAr0+VsDhblycQcvQ39f1l9NZN9CBqjM9ALA==} + '@effect/language-service@0.20.1': + resolution: {integrity: sha512-AgFazqxD2rlE0mc8V03BZw1XKghfOv9rrvR0M2xBv5haT4jHw5j07UK+Ln+dyeGmvrVXUT3a8Uc3pEkRJb+XHw==} + '@effect/platform-node-shared@0.8.26': resolution: {integrity: sha512-c7yYFvQwse5ar8JZitBM1fTGAQGfBQUqMRKVxKYux4GDMKw6oaZ8g7eQf9PRpMCxIdBMZlPilIablSJ0DtoPVQ==} peerDependencies: @@ -8404,6 +8404,8 @@ snapshots: '@effect/language-service@0.2.0': {} + '@effect/language-service@0.20.1': {} + '@effect/platform-node-shared@0.8.26(@effect/platform@0.58.27(@effect/schema@0.68.27(effect@3.16.0))(effect@3.16.0))(effect@3.16.0)': dependencies: '@effect/platform': 0.58.27(@effect/schema@0.68.27(effect@3.16.0))(effect@3.16.0)