|
5 | 5 | * of the MIT license. See the LICENSE file for details. |
6 | 6 | */ |
7 | 7 | import { configureStore } from '@reduxjs/toolkit'; |
| 8 | +import { Micro } from 'effect'; |
| 9 | +import { exitIsSuccess, exitIsFail } from 'effect/Micro'; |
8 | 10 |
|
9 | 11 | import type { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-middleware'; |
10 | 12 | import type { logger as loggerFn } from '@forgerock/sdk-logger'; |
| 13 | +import { isGenericError } from '@forgerock/sdk-utilities'; |
11 | 14 |
|
12 | 15 | import { configSlice } from './config.slice.js'; |
13 | 16 | import { nodeSlice } from './node.slice.js'; |
14 | 17 | import { davinciApi } from './davinci.api.js'; |
15 | 18 | import { ErrorNode, ContinueNode, StartNode, SuccessNode } from '../types.js'; |
16 | 19 | import { wellknownApi } from './wellknown.api.js'; |
17 | | -import { InternalErrorResponse } from './client.types.js'; |
| 20 | +import { InternalErrorResponse, PollingStatus, PollingStatusType } from './client.types.js'; |
| 21 | +import { PollingCollector } from './collector.types.js'; |
18 | 22 |
|
19 | 23 | export function createClientStore<ActionType extends ActionTypes>({ |
20 | 24 | requestMiddleware, |
@@ -75,3 +79,185 @@ export interface RootStateWithNode<T extends ErrorNode | ContinueNode | StartNod |
75 | 79 | } |
76 | 80 |
|
77 | 81 | export type AppDispatch = ReturnType<ReturnType<ClientStore>['dispatch']>; |
| 82 | + |
| 83 | +export async function handleChallengePolling({ |
| 84 | + collector, |
| 85 | + challenge, |
| 86 | + store, |
| 87 | + log, |
| 88 | +}: { |
| 89 | + collector: PollingCollector; |
| 90 | + challenge: string; |
| 91 | + store: ReturnType<ClientStore>; |
| 92 | + log: ReturnType<typeof loggerFn>; |
| 93 | +}): Promise<PollingStatusType | InternalErrorResponse> { |
| 94 | + if (!challenge) { |
| 95 | + log.error('No challenge found on collector for poll operation'); |
| 96 | + return { |
| 97 | + error: { |
| 98 | + message: 'No challenge found on collector for poll operation', |
| 99 | + type: 'state_error', |
| 100 | + }, |
| 101 | + type: 'internal_error', |
| 102 | + }; |
| 103 | + } |
| 104 | + |
| 105 | + const rootState: RootState = store.getState(); |
| 106 | + const serverSlice = nodeSlice.selectors.selectServer(rootState); |
| 107 | + |
| 108 | + if (serverSlice === null) { |
| 109 | + log.error('No server info found for poll operation'); |
| 110 | + return { |
| 111 | + error: { |
| 112 | + message: 'No server info found for poll operation', |
| 113 | + type: 'state_error', |
| 114 | + }, |
| 115 | + type: 'internal_error', |
| 116 | + }; |
| 117 | + } |
| 118 | + |
| 119 | + if (isGenericError(serverSlice)) { |
| 120 | + log.error(serverSlice.message ?? serverSlice.error); |
| 121 | + return { |
| 122 | + error: { |
| 123 | + message: serverSlice.message ?? 'Failed to retrieve server info for poll operation', |
| 124 | + type: 'internal_error', |
| 125 | + }, |
| 126 | + type: 'internal_error', |
| 127 | + }; |
| 128 | + } |
| 129 | + |
| 130 | + if (serverSlice.status !== 'continue') { |
| 131 | + return { |
| 132 | + error: { |
| 133 | + message: 'Not in a continue node state, must be in a continue node to use poll method', |
| 134 | + type: 'state_error', |
| 135 | + }, |
| 136 | + } as InternalErrorResponse; |
| 137 | + } |
| 138 | + |
| 139 | + // Construct the challenge polling endpoint |
| 140 | + const links = serverSlice._links; |
| 141 | + if (!links || !('self' in links) || !('href' in links['self']) || !links['self'].href) { |
| 142 | + return { |
| 143 | + error: { |
| 144 | + message: 'No self link found in server info for challenge polling operation', |
| 145 | + type: 'internal_error', |
| 146 | + }, |
| 147 | + } as InternalErrorResponse; |
| 148 | + } |
| 149 | + |
| 150 | + const selfUrl = links['self'].href; |
| 151 | + const url = new URL(selfUrl); |
| 152 | + const baseUrl = url.origin; |
| 153 | + const paths = url.pathname.split('/'); |
| 154 | + const envId = paths[1]; |
| 155 | + |
| 156 | + if (!baseUrl || !envId) { |
| 157 | + return { |
| 158 | + error: { |
| 159 | + message: |
| 160 | + 'Failed to construct challenge polling endpoint. Requires host and environment ID.', |
| 161 | + type: 'parse_error', |
| 162 | + }, |
| 163 | + } as InternalErrorResponse; |
| 164 | + } |
| 165 | + |
| 166 | + const interactionId = serverSlice.interactionId; |
| 167 | + if (!interactionId) { |
| 168 | + return { |
| 169 | + error: { |
| 170 | + message: 'Missing interactionId in server info for challenge polling', |
| 171 | + type: 'internal_error', |
| 172 | + }, |
| 173 | + } as InternalErrorResponse; |
| 174 | + } |
| 175 | + |
| 176 | + const challengeEndpoint = `${baseUrl}/${envId}/davinci/user/credentials/challenge/${challenge}/status`; |
| 177 | + |
| 178 | + // Start challenge polling |
| 179 | + let retriesLeft = collector.output.config.pollRetries; |
| 180 | + const pollInterval = collector.output.config.pollInterval; |
| 181 | + |
| 182 | + const queryµ = Micro.promise(() => { |
| 183 | + retriesLeft--; |
| 184 | + console.log('retries left: ', retriesLeft); |
| 185 | + return store.dispatch( |
| 186 | + davinciApi.endpoints.poll.initiate({ |
| 187 | + challengeEndpoint, |
| 188 | + interactionId, |
| 189 | + }), |
| 190 | + ); |
| 191 | + }); |
| 192 | + |
| 193 | + const challengePollµ = Micro.repeat(queryµ, { |
| 194 | + while: ({ data, error }) => |
| 195 | + retriesLeft > 0 && !error && !(data as Record<string, unknown>)['isChallengeComplete'], |
| 196 | + schedule: Micro.scheduleSpaced(pollInterval), |
| 197 | + }).pipe( |
| 198 | + Micro.flatMap(({ data, error }) => { |
| 199 | + const pollResponse = data as Record<string, unknown>; |
| 200 | + |
| 201 | + // Check for any errors and return the appropriate status |
| 202 | + if (error) { |
| 203 | + // SerializedError |
| 204 | + // let message = 'An error occurred while challenge polling'; |
| 205 | + // if ('message' in error && error.message) { |
| 206 | + // message = error.message; |
| 207 | + // } |
| 208 | + |
| 209 | + // FetchBaseQueryError |
| 210 | + let status: number | string = 'unknown'; |
| 211 | + if ('status' in error) { |
| 212 | + status = error.status; |
| 213 | + |
| 214 | + const errorDetails = error.data as Record<string, unknown>; |
| 215 | + const serviceName = errorDetails['serviceName']; |
| 216 | + |
| 217 | + // Check for an expired challenge |
| 218 | + if (status === 400 && serviceName && serviceName === 'challengeExpired') { |
| 219 | + log.debug('Challenge expired for polling'); |
| 220 | + return Micro.succeed(PollingStatus.Expired); |
| 221 | + } else { |
| 222 | + // If we're here there is some other type of network error and status != 200 |
| 223 | + // e.g. A bad challenge can return a httpStatus of 400 with code 4019 |
| 224 | + log.debug('Network error occurred during polling'); |
| 225 | + return Micro.succeed(PollingStatus.Error); |
| 226 | + } |
| 227 | + } |
| 228 | + } |
| 229 | + |
| 230 | + // If a successful response is recieved it can be either a timeout or true success |
| 231 | + if (pollResponse['isChallengeComplete'] === true) { |
| 232 | + return Micro.succeed(PollingStatus.Complete); |
| 233 | + } else if (retriesLeft <= 0 && !pollResponse['isChallengeComplete']) { |
| 234 | + return Micro.succeed(PollingStatus.TimedOut); |
| 235 | + } |
| 236 | + |
| 237 | + // Just in case no polling status was determined |
| 238 | + return Micro.fail({ |
| 239 | + error: { |
| 240 | + message: 'Unknown error occurred during polling', |
| 241 | + type: 'unknown_error', |
| 242 | + }, |
| 243 | + type: 'internal_error', |
| 244 | + } as InternalErrorResponse); |
| 245 | + }), |
| 246 | + ); |
| 247 | + |
| 248 | + const result = await Micro.runPromiseExit(challengePollµ); |
| 249 | + |
| 250 | + if (exitIsSuccess(result)) { |
| 251 | + return result.value; |
| 252 | + } else if (exitIsFail(result)) { |
| 253 | + return result.cause.error; |
| 254 | + } else { |
| 255 | + return { |
| 256 | + error: { |
| 257 | + message: result.cause.message, |
| 258 | + type: 'unknown_error', |
| 259 | + }, |
| 260 | + type: 'internal_error', |
| 261 | + }; |
| 262 | + } |
| 263 | +} |
0 commit comments