diff --git a/e2e/davinci-app/components/polling.ts b/e2e/davinci-app/components/polling.ts new file mode 100644 index 0000000000..d20caf9f61 --- /dev/null +++ b/e2e/davinci-app/components/polling.ts @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ +import type { + PollingCollector, + PollingStatus, + InternalErrorResponse, + Updater, + ContinueNode, +} from '@forgerock/davinci-client/types'; + +export default function pollingComponent( + formEl: HTMLFormElement, + collector: PollingCollector, + poll: ( + collector: PollingCollector, + ) => Promise, + updater: Updater, + submitForm: () => Promise, +) { + const button = document.createElement('button'); + button.type = 'button'; + button.value = collector.output.key; + button.innerHTML = 'Start polling'; + formEl.appendChild(button); + + button.onclick = async () => { + const p = document.createElement('p'); + p.innerText = 'Polling...'; + formEl?.appendChild(p); + + // TODO: support continue polling + const status = await poll(collector); + if (typeof status !== 'string' && 'error' in status) { + console.error(status.error?.message); + + const errEl = document.createElement('p'); + errEl.innerText = 'Polling error: ' + status.error?.message; + formEl?.appendChild(errEl); + return; + } + + const result = updater(status); + if (result && 'error' in result) { + console.error(result.error.message); + + const errEl = document.createElement('p'); + errEl.innerText = 'Polling error: ' + result.error.message; + formEl?.appendChild(errEl); + return; + } + + const resultEl = document.createElement('p'); + resultEl.innerText = 'Polling result: ' + JSON.stringify(status, null, 2); + formEl?.appendChild(resultEl); + + await submitForm(); + }; +} diff --git a/e2e/davinci-app/main.ts b/e2e/davinci-app/main.ts index e1048cd37a..53a57e277a 100644 --- a/e2e/davinci-app/main.ts +++ b/e2e/davinci-app/main.ts @@ -32,6 +32,7 @@ import multiValueComponent from './components/multi-value.js'; import labelComponent from './components/label.js'; import objectValueComponent from './components/object-value.js'; import fidoComponent from './components/fido.js'; +import pollingComponent from './components/polling.js'; const loggerFn = { error: () => { @@ -262,6 +263,14 @@ const urlParams = new URLSearchParams(window.location.search); davinciClient.update(collector), // Returns an update function for this collector submitForm, ); + } else if (collector.type === 'PollingCollector') { + pollingComponent( + formEl, // You can ignore this; it's just for rendering + collector, // This is the plain object of the collector + davinciClient.poll, // Returns a poll function + davinciClient.update(collector), // Returns an update function for this collector + submitForm, + ); } else if (collector.type === 'FlowCollector') { flowLinkComponent( formEl, // You can ignore this; it's just for rendering diff --git a/package.json b/package.json index 50662075de..c224d5a7e3 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ }, "author": "ForgeRock", "scripts": { - "build": "nx affected --target=build", + "build": "nx sync && nx affected --target=build", "changeset": "changeset", "ci:release": "pnpm nx run-many -t build --no-agents && pnpm publish -r --no-git-checks && changeset tag", "ci:version": "changeset version && pnpm install --no-frozen-lockfile && pnpm nx format:write --uncommitted", diff --git a/packages/davinci-client/src/lib/client.store.ts b/packages/davinci-client/src/lib/client.store.ts index aabbff90b1..d85e53c6b4 100644 --- a/packages/davinci-client/src/lib/client.store.ts +++ b/packages/davinci-client/src/lib/client.store.ts @@ -11,7 +11,13 @@ import { CustomLogger, logger as loggerFn, LogLevel } from '@forgerock/sdk-logge import { createStorage } from '@forgerock/storage'; import { isGenericError, createWellknownError } from '@forgerock/sdk-utilities'; -import { createClientStore, handleUpdateValidateError, RootState } from './client.store.utils.js'; +import { + createClientStore, + handleChallengePolling, + handleContinuePolling, + handleUpdateValidateError, + RootState, +} from './client.store.utils.js'; import { nodeSlice } from './node.slice.js'; import { davinciApi } from './davinci.api.js'; import { configSlice } from './config.slice.js'; @@ -34,6 +40,7 @@ import type { ObjectValueCollectors, PhoneNumberInputValue, AutoCollectors, + PollingCollector, MultiValueCollectors, FidoRegistrationInputValue, FidoAuthenticationInputValue, @@ -44,6 +51,7 @@ import type { NodeStates, Updater, Validator, + PollingStatus, } from './client.types.js'; import { returnValidator } from './collector.utils.js'; import type { ContinueNode, StartNode } from './node.types.js'; @@ -404,6 +412,68 @@ export async function davinci({ return returnValidator(collectorToUpdate); }, + /** + * @method: poll - Poll for updates for a polling collector + * @returns {Promise} - Returns a promise that resolves to + * a polling status or error for challenge polling. Returns a promise that resolves to the next node or + * an error for continue polling + */ + poll: async ( + collector: PollingCollector, + ): Promise => { + try { + if (collector.type !== 'PollingCollector') { + log.error('Collector provided to poll is not a PollingCollector'); + return { + error: { + message: 'Collector provided to poll is not a PollingCollector', + type: 'argument_error', + }, + type: 'internal_error', + }; + } + + const pollChallengeStatus = collector.output.config.pollChallengeStatus; + const challenge = collector.output.config.challenge; + + if (challenge && pollChallengeStatus === true) { + // Challenge Polling + return await handleChallengePolling({ + collector, + challenge, + store, + log, + }); + } else if (!challenge && !pollChallengeStatus) { + // Continue polling + return await handleContinuePolling({ + collector, + store, + log, + }); + } else { + log.error('Invalid polling collector configuration'); + return { + error: { + message: 'Invalid polling collector configuration', + type: 'internal_error', + }, + type: 'internal_error', + }; + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + log.error(errorMessage); + return { + error: { + message: errorMessage || 'An unexpected error occurred during poll operation', + type: 'internal_error', + }, + type: 'internal_error', + }; + } + }, + /** * @method client - Selector to get the node.client from state * @returns {Node.client} - the client property from the current node diff --git a/packages/davinci-client/src/lib/client.store.utils.ts b/packages/davinci-client/src/lib/client.store.utils.ts index f46e25642b..d0d7ce3aa7 100644 --- a/packages/davinci-client/src/lib/client.store.utils.ts +++ b/packages/davinci-client/src/lib/client.store.utils.ts @@ -5,16 +5,20 @@ * of the MIT license. See the LICENSE file for details. */ import { configureStore } from '@reduxjs/toolkit'; +import { Micro } from 'effect'; +import { exitIsSuccess, exitIsFail } from 'effect/Micro'; import type { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-middleware'; import type { logger as loggerFn } from '@forgerock/sdk-logger'; +import { isGenericError } from '@forgerock/sdk-utilities'; import { configSlice } from './config.slice.js'; import { nodeSlice } from './node.slice.js'; import { davinciApi } from './davinci.api.js'; import { ErrorNode, ContinueNode, StartNode, SuccessNode } from '../types.js'; import { wellknownApi } from './wellknown.api.js'; -import { InternalErrorResponse } from './client.types.js'; +import type { InternalErrorResponse, PollingStatus } from './client.types.js'; +import { PollingCollector } from './collector.types.js'; export function createClientStore({ requestMiddleware, @@ -75,3 +79,384 @@ export interface RootStateWithNode['dispatch']>; + +export async function handleChallengePolling({ + collector, + challenge, + store, + log, +}: { + collector: PollingCollector; + challenge: string; + store: ReturnType; + log: ReturnType; +}): Promise { + if (!challenge) { + log.error('No challenge found on collector for poll operation'); + return { + error: { + message: 'No challenge found on collector for poll operation', + type: 'state_error', + }, + type: 'internal_error', + }; + } + + const rootState: RootState = store.getState(); + const serverSlice = nodeSlice.selectors.selectServer(rootState); + + if (serverSlice === null) { + log.error('No server info found for poll operation'); + return { + error: { + message: 'No server info found for poll operation', + type: 'state_error', + }, + type: 'internal_error', + }; + } + + if (isGenericError(serverSlice)) { + log.error(serverSlice.message ?? serverSlice.error); + return { + error: { + message: serverSlice.message ?? 'Failed to retrieve server info for poll operation', + type: 'internal_error', + }, + type: 'internal_error', + }; + } + + if (serverSlice.status !== 'continue') { + return { + error: { + message: 'Not in a continue node state, must be in a continue node to use poll method', + type: 'state_error', + }, + } as InternalErrorResponse; + } + + // Construct the challenge polling endpoint + const links = serverSlice._links; + if (!links || !('self' in links) || !('href' in links['self']) || !links['self'].href) { + return { + error: { + message: 'No self link found in server info for challenge polling operation', + type: 'internal_error', + }, + } as InternalErrorResponse; + } + + const selfUrl = links['self'].href; + const url = new URL(selfUrl); + const baseUrl = url.origin; + const paths = url.pathname.split('/'); + const envId = paths[1]; + + if (!baseUrl || !envId) { + return { + error: { + message: + 'Failed to construct challenge polling endpoint. Requires host and environment ID.', + type: 'parse_error', + }, + } as InternalErrorResponse; + } + + const interactionId = serverSlice.interactionId; + if (!interactionId) { + return { + error: { + message: 'Missing interactionId in server info for challenge polling', + type: 'internal_error', + }, + } as InternalErrorResponse; + } + + const challengeEndpoint = `${baseUrl}/${envId}/davinci/user/credentials/challenge/${challenge}/status`; + + // Start challenge polling + let retriesLeft = collector.output.config.pollRetries ?? 60; + const pollInterval = collector.output.config.pollInterval ?? 2000; // miliseconds + + const queryµ = Micro.promise(() => { + retriesLeft--; + return store.dispatch( + davinciApi.endpoints.poll.initiate({ + endpoint: challengeEndpoint, + interactionId, + mode: 'challenge', + }), + ); + }); + + const challengePollµ = Micro.repeat(queryµ, { + while: ({ data, error }) => + retriesLeft > 0 && !error && !(data as Record)['isChallengeComplete'], + schedule: Micro.scheduleSpaced(pollInterval), + }).pipe( + Micro.flatMap(({ data, error }) => { + const pollResponse = data as Record; + + // Check for any errors and return the appropriate status + if (error) { + // SerializedError + let message = 'An unknown error occurred while challenge polling'; + if ('message' in error && error.message) { + message = error.message; + return Micro.fail({ + error: { + message, + type: 'unknown_error', + }, + type: 'internal_error', + } as InternalErrorResponse); + } + + // FetchBaseQueryError + let status: number | string = 'unknown'; + if ('status' in error) { + status = error.status; + + const errorDetails = error.data as Record; + const serviceName = errorDetails['serviceName']; + + // Check for an expired challenge + if (status === 400 && serviceName && serviceName === 'challengeExpired') { + log.debug('Challenge expired for polling'); + return Micro.succeed('expired' as PollingStatus); + } else { + // If we're here there is some other type of network error and status != 200 + // e.g. A bad challenge can return a httpStatus of 400 with code 4019 + log.debug('Network error occurred during polling'); + return Micro.succeed('error' as PollingStatus); + } + } + } + + // If a successful response is recieved it can be either a timeout or true success + if (pollResponse['isChallengeComplete'] === true) { + const pollStatus = pollResponse['status']; + if (!pollStatus) { + return Micro.succeed('error' as PollingStatus); + } else { + return Micro.succeed(pollStatus as PollingStatus); + } + } else if (retriesLeft <= 0 && !pollResponse['isChallengeComplete']) { + return Micro.succeed('timedOut' as PollingStatus); + } + + // Just in case no polling status was determined + return Micro.fail({ + error: { + message: 'Unknown error occurred during polling', + type: 'unknown_error', + }, + type: 'internal_error', + } as InternalErrorResponse); + }), + ); + + const result = await Micro.runPromiseExit(challengePollµ); + + if (exitIsSuccess(result)) { + return result.value; + } else if (exitIsFail(result)) { + return result.cause.error; + } else { + return { + error: { + message: result.cause.message, + type: 'unknown_error', + }, + type: 'internal_error', + }; + } +} + +export async function handleContinuePolling({ + collector, + store, + log, +}: { + collector: PollingCollector; + store: ReturnType; + log: ReturnType; +}): Promise { + const rootState: RootState = store.getState(); + const serverSlice = nodeSlice.selectors.selectServer(rootState); + + if (serverSlice === null) { + log.error('No server info found for poll operation'); + return { + error: { + message: 'No server info found for poll operation', + type: 'state_error', + }, + type: 'internal_error', + }; + } + + if (isGenericError(serverSlice)) { + log.error(serverSlice.message ?? serverSlice.error); + return { + error: { + message: serverSlice.message ?? 'Failed to retrieve server info for poll operation', + type: 'internal_error', + }, + type: 'internal_error', + }; + } + + if (serverSlice.status !== 'continue') { + return { + error: { + message: 'Not in a continue node state, must be in a continue node to use poll method', + type: 'state_error', + }, + } as InternalErrorResponse; + } + + // Get the continue polling endpoint + const links = serverSlice._links; + if (!links || !('next' in links) || !('href' in links['next']) || !links['next'].href) { + return { + error: { + message: 'No next link found in server info for continue polling operation', + type: 'internal_error', + }, + } as InternalErrorResponse; + } + + const nextUrl = links['next'].href; + + const interactionId = serverSlice.interactionId; + if (!interactionId) { + return { + error: { + message: 'Missing interactionId in server info for challenge polling', + type: 'internal_error', + }, + } as InternalErrorResponse; + } + + // Start continue polling + let retriesLeft = collector.output.config.pollRetries ?? 60; + const pollInterval = collector.output.config.pollInterval ?? 2000; // miliseconds + + const updateµ = Micro.try({ + try: () => { + // Update the polling collector input value to 'continue' or 'timedOut'. We will call + // continue polling endpoint (_links.next.href) with this data in queryµ + if (retriesLeft > 0) { + return store.dispatch( + nodeSlice.actions.update({ id: collector.id, value: 'continue' as PollingStatus }), + ); + } else { + return store.dispatch( + nodeSlice.actions.update({ id: collector.id, value: 'timedOut' as PollingStatus }), + ); + } + }, + catch: (err) => { + const errorMessage = err instanceof Error ? err.message : String(err); + return { + type: 'internal_error', + error: { message: errorMessage, type: 'internal_error' }, + } as InternalErrorResponse; + }, + }); + + const queryµ = Micro.promise(() => { + retriesLeft--; + return store.dispatch( + davinciApi.endpoints.poll.initiate({ + endpoint: nextUrl, + interactionId, + mode: 'continue', + }), + ); + }); + + const repeatµ = updateµ.pipe(Micro.flatMap(() => queryµ)); + + const continuePollµ = Micro.repeat(repeatµ, { + while: ({ data, error }) => + retriesLeft > 0 && + !error && + ['rewindStateToLastRenderedUI', 'rewindStateToSpecificRenderedUI'].includes( + (data as Record)['eventName'] as string, + ), + schedule: Micro.scheduleSpaced(pollInterval), + }).pipe( + Micro.flatMap(({ data, error }) => { + const pollResponse = data as Record; + console.log('Continue poll response', pollResponse); + + if (error) { + // SerializedError + let message = 'An unknown error occurred while challenge polling'; + if ('message' in error && error.message) { + message = error.message; + return Micro.fail({ + error: { + message, + type: 'unknown_error', + }, + type: 'internal_error', + } as InternalErrorResponse); + } + + // FetchBaseQueryError + if ('status' in error) { + return Micro.fail({ + error: { + message: 'An unknown error occured during continue polling', + type: 'unknown_error', + }, + type: 'internal_error', + } as InternalErrorResponse); + } + } + + if (retriesLeft <= 0) { + // Note: when retries are exhausted, DaVinci currently returns another continue polling response instead of an error + return Micro.fail({ + error: { + message: 'Continue polling timed out', + type: 'internal_error', + }, + type: 'internal_error', + } as InternalErrorResponse); + } + + // TODO: If there are still retries left but we did not find a rewind event then polling succeeded and the next node was returned + // return the next node here + + // Just in case no polling status was determined + return Micro.fail({ + error: { + message: 'Unknown error occurred during continue polling', + type: 'unknown_error', + }, + type: 'internal_error', + } as InternalErrorResponse); + }), + ); + + const result = await Micro.runPromiseExit(continuePollµ); + + if (exitIsSuccess(result)) { + return result.value; + } else if (exitIsFail(result)) { + return result.cause.error; + } else { + return { + error: { + message: result.cause.message, + type: 'unknown_error', + }, + type: 'internal_error', + }; + } +} diff --git a/packages/davinci-client/src/lib/client.types.ts b/packages/davinci-client/src/lib/client.types.ts index 5d82027918..5a686eb6fc 100644 --- a/packages/davinci-client/src/lib/client.types.ts +++ b/packages/davinci-client/src/lib/client.types.ts @@ -91,3 +91,6 @@ export type Validator = (value: string) => }; export type NodeStates = StartNode | ContinueNode | ErrorNode | SuccessNode | FailureNode; + +export type PollingStatusComplete = 'approved' | 'denied' | 'continue' | string; +export type PollingStatus = PollingStatusComplete | 'expired' | 'timedOut' | 'error'; diff --git a/packages/davinci-client/src/lib/collector.types.ts b/packages/davinci-client/src/lib/collector.types.ts index bce4061b7e..56618b00a6 100644 --- a/packages/davinci-client/src/lib/collector.types.ts +++ b/packages/davinci-client/src/lib/collector.types.ts @@ -583,8 +583,18 @@ export interface FidoAuthenticationOutputValue { trigger: string; } +export interface PollingOutputValue { + pollInterval: number; + pollRetries: number; + pollChallengeStatus?: boolean; + challenge?: string; +} + export type AutoCollectorCategories = 'SingleValueAutoCollector' | 'ObjectValueAutoCollector'; -export type SingleValueAutoCollectorTypes = 'SingleValueAutoCollector' | 'ProtectCollector'; +export type SingleValueAutoCollectorTypes = + | 'SingleValueAutoCollector' + | 'ProtectCollector' + | 'PollingCollector'; export type ObjectValueAutoCollectorTypes = | 'ObjectValueAutoCollector' | 'FidoRegistrationCollector' @@ -633,6 +643,12 @@ export type FidoAuthenticationCollector = AutoCollector< FidoAuthenticationInputValue, FidoAuthenticationOutputValue >; +export type PollingCollector = AutoCollector< + 'SingleValueAutoCollector', + 'PollingCollector', + string, + PollingOutputValue +>; export type SingleValueAutoCollector = AutoCollector< 'SingleValueAutoCollector', 'SingleValueAutoCollector', @@ -648,6 +664,7 @@ export type AutoCollectors = | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector + | PollingCollector | SingleValueAutoCollector | ObjectValueAutoCollector; @@ -660,14 +677,16 @@ export type AutoCollectors = */ export type InferAutoCollectorType = T extends 'ProtectCollector' ? ProtectCollector - : T extends 'FidoRegistrationCollector' - ? FidoRegistrationCollector - : T extends 'FidoAuthenticationCollector' - ? FidoAuthenticationCollector - : T extends 'ObjectValueAutoCollector' - ? ObjectValueAutoCollector - : /** - * At this point, we have not passed in a collector type - * so we can return a SingleValueAutoCollector - **/ - SingleValueAutoCollector; + : T extends 'PollingCollector' + ? PollingCollector + : T extends 'FidoRegistrationCollector' + ? FidoRegistrationCollector + : T extends 'FidoAuthenticationCollector' + ? FidoAuthenticationCollector + : T extends 'ObjectValueAutoCollector' + ? ObjectValueAutoCollector + : /** + * At this point, we have not passed in a collector type + * so we can return a SingleValueAutoCollector + **/ + SingleValueAutoCollector; diff --git a/packages/davinci-client/src/lib/collector.utils.test.ts b/packages/davinci-client/src/lib/collector.utils.test.ts index a50925d4d0..94a6a2c3a2 100644 --- a/packages/davinci-client/src/lib/collector.utils.test.ts +++ b/packages/davinci-client/src/lib/collector.utils.test.ts @@ -31,6 +31,7 @@ import type { FidoRegistrationField, PhoneNumberField, ProtectField, + PollingField, ReadOnlyField, RedirectField, StandardField, @@ -839,6 +840,40 @@ describe('returnSingleValueAutoCollector', () => { }, }); }); + + it('should create a valid PollingCollector', () => { + const mockField: PollingField = { + type: 'POLLING', + key: 'polling-key', + pollInterval: 2000, + pollRetries: 20, + pollChallengeStatus: true, + challenge: 'hlMtnk2RsPtnlYs2n1IiS9qhTZQLK-AOHNAo8-F3eY0', + }; + const result = returnSingleValueAutoCollector(mockField, 1, 'PollingCollector'); + expect(result).toEqual({ + category: 'SingleValueAutoCollector', + error: null, + type: 'PollingCollector', + id: 'polling-key-1', + name: 'polling-key', + input: { + key: mockField.key, + value: '', + type: mockField.type, + }, + output: { + key: mockField.key, + type: mockField.type, + config: { + pollInterval: 2000, + pollRetries: 20, + pollChallengeStatus: true, + challenge: 'hlMtnk2RsPtnlYs2n1IiS9qhTZQLK-AOHNAo8-F3eY0', + }, + }, + }); + }); }); describe('returnObjectValueAutoCollector', () => { diff --git a/packages/davinci-client/src/lib/collector.utils.ts b/packages/davinci-client/src/lib/collector.utils.ts index 68ac1c27e8..ab486fd965 100644 --- a/packages/davinci-client/src/lib/collector.utils.ts +++ b/packages/davinci-client/src/lib/collector.utils.ts @@ -38,6 +38,7 @@ import type { MultiSelectField, PhoneNumberField, ProtectField, + PollingField, ReadOnlyField, RedirectField, SingleSelectField, @@ -271,7 +272,7 @@ export function returnSingleValueCollector< * @returns {AutoCollector} The constructed AutoCollector object. */ export function returnSingleValueAutoCollector< - Field extends ProtectField, + Field extends ProtectField | PollingField, CollectorType extends SingleValueAutoCollectorTypes = 'SingleValueAutoCollector', >(field: Field, idx: number, collectorType: CollectorType) { let error = ''; @@ -282,7 +283,7 @@ export function returnSingleValueAutoCollector< error = `${error}Type is not found in the field object. `; } - if (collectorType === 'ProtectCollector') { + if (collectorType === 'ProtectCollector' && field.type === 'PROTECT') { return { category: 'SingleValueAutoCollector', error: error || null, @@ -303,6 +304,31 @@ export function returnSingleValueAutoCollector< }, }, } as InferAutoCollectorType<'ProtectCollector'>; + } else if (collectorType === 'PollingCollector' && field.type === 'POLLING') { + return { + category: 'SingleValueAutoCollector', + error: error || null, + type: collectorType, + id: `${field?.key}-${idx}`, + name: field.key, + input: { + key: field.key, + value: '', + type: field.type, + }, + output: { + key: field.key, + type: field.type, + config: { + pollInterval: field.pollInterval, + pollRetries: field.pollRetries, + ...(field.pollChallengeStatus !== undefined && { + pollChallengeStatus: field.pollChallengeStatus, + }), + ...(field.challenge && { challenge: field.challenge }), + }, + }, + } as InferAutoCollectorType<'PollingCollector'>; } else { return { category: 'SingleValueAutoCollector', @@ -440,6 +466,16 @@ export function returnProtectCollector(field: ProtectField, idx: number) { return returnSingleValueAutoCollector(field, idx, 'ProtectCollector'); } +/** + * @function returnPollingCollector - Creates a PollingCollector object based on the provided field and index. + * @param {DaVinciField} field - The field object containing key, label, type, and links. + * @param {number} idx - The index to be used in the id of the PollingCollector. + * @returns {PollingCollector} The constructed PollingCollector object. + */ +export function returnPollingCollector(field: PollingField, idx: number) { + return returnSingleValueAutoCollector(field, idx, 'PollingCollector'); +} + /** * @function returnFidoRegistrationCollector - Creates a FidoRegistrationCollector object based on the provided field and index. * @param {DaVinciField} field - The field object containing key, label, type, and links. diff --git a/packages/davinci-client/src/lib/davinci.api.ts b/packages/davinci-client/src/lib/davinci.api.ts index 8f47cc2f84..1df49441d9 100644 --- a/packages/davinci-client/src/lib/davinci.api.ts +++ b/packages/davinci-client/src/lib/davinci.api.ts @@ -58,10 +58,12 @@ export const davinciApi = createApi({ reducerPath: 'davinci', // TODO: implement extraOptions for request interceptors: https://stackoverflow.com/a/77569083 & https://stackoverflow.com/a/65129117 baseQuery: fetchBaseQuery({ - prepareHeaders: (headers) => { + prepareHeaders: (headers, { endpoint }) => { headers.set('Accept', 'application/json'); - headers.set('x-requested-with', 'ping-sdk'); - headers.set('x-requested-platform', 'javascript'); + if (endpoint !== 'poll') { + headers.set('x-requested-with', 'ping-sdk'); + headers.set('x-requested-platform', 'javascript'); + } return headers; }, }), @@ -348,6 +350,10 @@ export const davinciApi = createApi({ handleResponse(cacheEntry, api.dispatch, response?.status || 0, logger); }, }), + + /** + * @method resume - method for resuming a social login DaVinci flow + */ resume: builder.query({ async queryFn({ serverInfo, continueToken }, api, _c, baseQuery) { const { requestMiddleware, logger } = api.extra as Extras; @@ -422,5 +428,48 @@ export const davinciApi = createApi({ handleResponse(cacheEntry, api.dispatch, response?.status || 0, logger); }, }), + + /** + * @method poll - method for polling in a DaVinci flow + */ + /** + * The poll endpoint differs from others in that it does not use onQueryStarted. This allows + * us to use the response from onQueryFn, while avoiding updating the node state with the poll + * response which causes the node to lose the initial collectors state when polling started. + */ + poll: builder.mutation< + unknown, + { endpoint: string; interactionId: string; mode: 'challenge' | 'continue' } + >({ + async queryFn({ endpoint, interactionId, mode }, api, _c, baseQuery) { + const state = api.getState() as RootStateWithNode; + const { requestMiddleware, logger } = api.extra as Extras; + + let requestBody = {}; + if (mode === 'continue') { + requestBody = transformSubmitRequest(state.node, logger); + } else if (mode !== 'challenge') { + logger.error('Invalid polling mode, defaulting to challenge mode'); + } + + const request: FetchArgs = { + url: endpoint, + credentials: 'include', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + interactionId, + }, + body: JSON.stringify(requestBody), + }; + + logger.debug('Davinci API request', request); + const response: BaseQueryResponse = initQuery(request, 'poll') + .applyMiddleware(requestMiddleware) + .applyQuery(async (req: FetchArgs) => await baseQuery(req)); + + return response; + }, + }), }), }); diff --git a/packages/davinci-client/src/lib/davinci.types.ts b/packages/davinci-client/src/lib/davinci.types.ts index 1d249953f2..0f7c33f5e7 100644 --- a/packages/davinci-client/src/lib/davinci.types.ts +++ b/packages/davinci-client/src/lib/davinci.types.ts @@ -19,7 +19,7 @@ export interface DaVinciRequest { eventName: string; interactionId: string; parameters: { - eventType: 'submit' | 'action'; + eventType: 'submit' | 'action' | 'polling'; data: { actionKey: string; formData?: Record; @@ -212,6 +212,15 @@ export type FidoAuthenticationField = { required: boolean; }; +export type PollingField = { + type: 'POLLING'; + key: string; + pollInterval: number; + pollRetries: number; + pollChallengeStatus: boolean; + challenge: string; +}; + export type UnknownField = Record; export type ComplexValueFields = @@ -219,7 +228,8 @@ export type ComplexValueFields = | DeviceRegistrationField | PhoneNumberField | FidoRegistrationField - | FidoAuthenticationField; + | FidoAuthenticationField + | PollingField; export type MultiValueFields = MultiSelectField; export type ReadOnlyFields = ReadOnlyField; export type RedirectFields = RedirectField; diff --git a/packages/davinci-client/src/lib/davinci.utils.ts b/packages/davinci-client/src/lib/davinci.utils.ts index a742708178..f406ca258f 100644 --- a/packages/davinci-client/src/lib/davinci.utils.ts +++ b/packages/davinci-client/src/lib/davinci.utils.ts @@ -62,6 +62,12 @@ export function transformSubmitRequest( acc[collector.input.key] = collector.input.value; return acc; }, {}); + + // Set eventType based on PollingCollector presence + const eventType = collectors?.some((collector) => collector.type === 'PollingCollector') + ? 'polling' + : 'submit'; + logger.debug('Transforming submit request', { node, formData }); return { @@ -69,7 +75,7 @@ export function transformSubmitRequest( eventName: node.server.eventName || '', interactionId: node.server.interactionId || '', parameters: { - eventType: 'submit', + eventType, data: { actionKey: node.client?.action || '', formData: formData || {}, diff --git a/packages/davinci-client/src/lib/node.reducer.ts b/packages/davinci-client/src/lib/node.reducer.ts index 63bf2e36ec..43112ad3ff 100644 --- a/packages/davinci-client/src/lib/node.reducer.ts +++ b/packages/davinci-client/src/lib/node.reducer.ts @@ -25,6 +25,7 @@ import { returnObjectSelectCollector, returnObjectValueCollector, returnProtectCollector, + returnPollingCollector, returnUnknownCollector, returnFidoRegistrationCollector, returnFidoAuthenticationCollector, @@ -49,6 +50,7 @@ import type { PhoneNumberOutputValue, UnknownCollector, ProtectCollector, + PollingCollector, FidoRegistrationCollector, FidoAuthenticationCollector, FidoAuthenticationInputValue, @@ -96,6 +98,7 @@ const initialCollectorValues: ( | ValidatedTextCollector | UnknownCollector | ProtectCollector + | PollingCollector | FidoRegistrationCollector | FidoAuthenticationCollector )[] = []; @@ -179,6 +182,10 @@ export const nodeCollectorReducer = createReducer(initialCollectorValues, (build case 'PROTECT': { return returnProtectCollector(field, idx); } + case 'POLLING': { + // No data to send + return returnPollingCollector(field, idx); + } case 'FIDO2': { if (field.action === 'REGISTER') { return returnFidoRegistrationCollector(field, idx); @@ -225,7 +232,7 @@ export const nodeCollectorReducer = createReducer(initialCollectorValues, (build if ( collector.category === 'SingleValueCollector' || collector.category === 'ValidatedSingleValueCollector' || - collector.type === 'ProtectCollector' + collector.category === 'SingleValueAutoCollector' ) { if (typeof action.payload.value !== 'string') { throw new Error('Value argument must be a string'); diff --git a/packages/davinci-client/src/lib/node.slice.ts b/packages/davinci-client/src/lib/node.slice.ts index ca80e20669..b92e7e2364 100644 --- a/packages/davinci-client/src/lib/node.slice.ts +++ b/packages/davinci-client/src/lib/node.slice.ts @@ -110,10 +110,10 @@ export const nodeSlice = createSlice({ }, /** - * @method failure - Method for creating an error node + * @method failure - Method for creating a failure node * @param {Object} state - The current state of the slice * @param {PayloadAction} action - The action to be dispatched - * @returns {FailureNode} - The error node + * @returns {FailureNode} - The failure node */ failure( state, diff --git a/packages/davinci-client/src/lib/node.types.test-d.ts b/packages/davinci-client/src/lib/node.types.test-d.ts index de5f5b67f0..a9b16534ea 100644 --- a/packages/davinci-client/src/lib/node.types.test-d.ts +++ b/packages/davinci-client/src/lib/node.types.test-d.ts @@ -33,6 +33,7 @@ import { PhoneNumberCollector, UnknownCollector, ProtectCollector, + PollingCollector, FidoRegistrationCollector, FidoAuthenticationCollector, } from './collector.types.js'; @@ -234,6 +235,7 @@ describe('Node Types', () => { | SingleSelectCollector | ValidatedTextCollector | ProtectCollector + | PollingCollector | FidoRegistrationCollector | FidoAuthenticationCollector | UnknownCollector diff --git a/packages/davinci-client/src/lib/node.types.ts b/packages/davinci-client/src/lib/node.types.ts index 2bcc57a5f2..77581cdfac 100644 --- a/packages/davinci-client/src/lib/node.types.ts +++ b/packages/davinci-client/src/lib/node.types.ts @@ -22,6 +22,7 @@ import type { ValidatedTextCollector, PhoneNumberCollector, ProtectCollector, + PollingCollector, UnknownCollector, FidoRegistrationCollector, FidoAuthenticationCollector, @@ -44,6 +45,7 @@ export type Collectors = | ReadOnlyCollector | ValidatedTextCollector | ProtectCollector + | PollingCollector | FidoRegistrationCollector | FidoAuthenticationCollector | UnknownCollector; diff --git a/packages/davinci-client/src/types.ts b/packages/davinci-client/src/types.ts index 691be0679e..256ec95fec 100644 --- a/packages/davinci-client/src/types.ts +++ b/packages/davinci-client/src/types.ts @@ -50,9 +50,13 @@ export type DeviceRegistrationCollector = collectors.DeviceRegistrationCollector export type DeviceAuthenticationCollector = collectors.DeviceAuthenticationCollector; export type PhoneNumberCollector = collectors.PhoneNumberCollector; export type ProtectCollector = collectors.ProtectCollector; +export type PollingCollector = collectors.PollingCollector; export type FidoRegistrationCollector = collectors.FidoRegistrationCollector; export type FidoAuthenticationCollector = collectors.FidoAuthenticationCollector; +export type PollingStatusComplete = client.PollingStatusComplete; +export type PollingStatus = client.PollingStatus; + export type InternalErrorResponse = client.InternalErrorResponse; export type { RequestMiddleware, ActionTypes } from '@forgerock/sdk-request-middleware'; export type { FidoClient }; diff --git a/packages/sdk-effects/sdk-request-middleware/src/lib/request-mware.derived.ts b/packages/sdk-effects/sdk-request-middleware/src/lib/request-mware.derived.ts index 695ccb951b..8de893dfe9 100644 --- a/packages/sdk-effects/sdk-request-middleware/src/lib/request-mware.derived.ts +++ b/packages/sdk-effects/sdk-request-middleware/src/lib/request-mware.derived.ts @@ -19,6 +19,7 @@ export const actionTypes = { error: 'DAVINCI_ERROR', failure: 'DAVINCI_FAILURE', resume: 'DAVINCI_RESUME', + poll: 'DAVINCI_POLL', // OIDC authorize: 'AUTHORIZE',