diff --git a/.changeset/public-cougars-sneeze.md b/.changeset/public-cougars-sneeze.md new file mode 100644 index 000000000..37beb6363 --- /dev/null +++ b/.changeset/public-cougars-sneeze.md @@ -0,0 +1,8 @@ +--- +'@forgerock/oidc-client': minor +'@forgerock/storage': patch +'@forgerock/davinci-client': patch +'@forgerock/sdk-types': patch +--- + +Implement OIDC logout and user info request; includes type updates and global error type diff --git a/e2e/oidc-app/index.html b/e2e/oidc-app/index.html index 1aa8d8309..e235e069a 100644 --- a/e2e/oidc-app/index.html +++ b/e2e/oidc-app/index.html @@ -10,7 +10,8 @@ @@ -19,6 +20,7 @@

Welcome

+ diff --git a/e2e/oidc-app/src/main.ts b/e2e/oidc-app/src/main.ts index 3cf87daaf..9a43cae2a 100644 --- a/e2e/oidc-app/src/main.ts +++ b/e2e/oidc-app/src/main.ts @@ -54,11 +54,22 @@ async function app() { } else { console.log('Token Exchange Response:', tokenResponse); document.getElementById('logout')!.style.display = 'block'; + document.getElementById('userinfo')!.style.display = 'block'; document.getElementById('login')!.style.display = 'none'; } } }); + document.getElementById('userinfo')?.addEventListener('click', async () => { + const userInfo = await oidcClient.user.info(); + + if ('error' in userInfo) { + console.error('User Info Error:', userInfo); + } else { + console.log('User Info:', userInfo); + } + }); + document.getElementById('logout')?.addEventListener('click', async () => { const response = await oidcClient.user.logout(); @@ -67,6 +78,7 @@ async function app() { } else { console.log('Logout successful'); document.getElementById('logout')!.style.display = 'none'; + document.getElementById('userinfo')!.style.display = 'none'; document.getElementById('login')!.style.display = 'block'; } }); diff --git a/packages/davinci-client/src/lib/client.types.test-d.ts b/packages/davinci-client/src/lib/client.types.test-d.ts index f9b3a0dbb..8e9665657 100644 --- a/packages/davinci-client/src/lib/client.types.test-d.ts +++ b/packages/davinci-client/src/lib/client.types.test-d.ts @@ -6,10 +6,12 @@ */ /* eslint-disable @typescript-eslint/no-unused-vars */ import { describe, expectTypeOf, it } from 'vitest'; + +import type { GenericError } from '@forgerock/sdk-types'; + import type { InitFlow, InternalErrorResponse, Updater } from './client.types.js'; -import type { GenericError } from './error.types.js'; import type { ErrorNode, FailureNode, ContinueNode, StartNode, SuccessNode } from './node.types.js'; -import { PhoneNumberInputValue } from './collector.types.js'; +import type { PhoneNumberInputValue } from './collector.types.js'; describe('Client Types', () => { it('should allow function returning error', async () => { @@ -189,8 +191,10 @@ describe('Updater', () => { const withoutError: Updater = () => null; - expectTypeOf(withError).returns.toMatchTypeOf<{ error: GenericError } | null>(); - expectTypeOf(withoutError).returns.toMatchTypeOf<{ error: GenericError } | null>(); + expectTypeOf(withError).returns.toMatchTypeOf<{ error: Omit } | null>(); + expectTypeOf(withoutError).returns.toMatchTypeOf<{ + error: Omit; + } | null>(); // Test both are valid Updater types expectTypeOf(withError).toMatchTypeOf(); diff --git a/packages/davinci-client/src/lib/client.types.ts b/packages/davinci-client/src/lib/client.types.ts index 491e2d19b..c562baadd 100644 --- a/packages/davinci-client/src/lib/client.types.ts +++ b/packages/davinci-client/src/lib/client.types.ts @@ -4,14 +4,15 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ -import { PhoneNumberInputValue } from './collector.types.js'; -import { GenericError } from './error.types.js'; -import { ErrorNode, FailureNode, ContinueNode, StartNode, SuccessNode } from './node.types.js'; +import type { GenericError } from '@forgerock/sdk-types'; + +import type { PhoneNumberInputValue } from './collector.types.js'; +import type { ErrorNode, FailureNode, ContinueNode, StartNode, SuccessNode } from './node.types.js'; export type FlowNode = ContinueNode | ErrorNode | StartNode | SuccessNode | FailureNode; export interface InternalErrorResponse { - error: GenericError; + error: Omit & { message: string }; type: 'internal_error'; } diff --git a/packages/davinci-client/src/lib/error.types.test-d.ts b/packages/davinci-client/src/lib/error.types.test-d.ts index 354c85e6f..e87a1ece9 100644 --- a/packages/davinci-client/src/lib/error.types.test-d.ts +++ b/packages/davinci-client/src/lib/error.types.test-d.ts @@ -6,11 +6,12 @@ */ /* eslint-disable @typescript-eslint/no-unused-vars */ import { describe, expect, it } from 'vitest'; -import type { GenericError } from './error.types.js'; + +import type { GenericError } from '@forgerock/sdk-types'; describe('GenericError type', () => { it('should allow valid error objects', () => { - const validErrors: GenericError[] = [ + const validErrors: Omit[] = [ { message: 'Something went wrong', type: 'unknown_error', @@ -48,8 +49,7 @@ describe('GenericError type', () => { // This test is just for TypeScript compilation validation it('should enforce required properties', () => { - // @ts-expect-error - message is required - const missingMessage: GenericError = { + const missingMessage: Omit = { type: 'unknown_error', }; diff --git a/packages/davinci-client/src/lib/error.types.ts b/packages/davinci-client/src/lib/error.types.ts deleted file mode 100644 index 4cb9183dc..000000000 --- a/packages/davinci-client/src/lib/error.types.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * 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 GenericError { - code?: string | number; - message: string; - type: - | 'argument_error' - | 'davinci_error' - | 'internal_error' - | 'network_error' - | 'state_error' - | 'unknown_error'; -} diff --git a/packages/davinci-client/src/lib/node.slice.ts b/packages/davinci-client/src/lib/node.slice.ts index d6afb5c8f..ca80e2066 100644 --- a/packages/davinci-client/src/lib/node.slice.ts +++ b/packages/davinci-client/src/lib/node.slice.ts @@ -19,8 +19,8 @@ import { getCollectorErrors } from './node.utils.js'; * Import the types */ import type { Draft, PayloadAction } from '@reduxjs/toolkit'; + import type { SubmitCollector } from './collector.types.js'; -import type { GenericError } from './error.types.js'; import type { DavinciErrorResponse, DaVinciFailureResponse, @@ -294,7 +294,7 @@ export const nodeSlice = createSlice({ code: 'unknown', type: 'state_error', message: `\`collectors\` are only available on nodes with \`status\` of ${CONTINUE_STATUS} or ${ERROR_STATUS}`, - } as GenericError, + } as const, state: [], }; }, @@ -310,7 +310,7 @@ export const nodeSlice = createSlice({ code: 'unknown', type: 'state_error', message: `\`collectors\` are only available on nodes with \`status\` of ${CONTINUE_STATUS} or ${ERROR_STATUS}`, - } as GenericError, + } as const, state: null, }; }, @@ -329,7 +329,7 @@ export const nodeSlice = createSlice({ code: 'unknown', type: 'state_error', message: `\`errorCollectors\` are only available on nodes with \`status\` of ${ERROR_STATUS}`, - } as GenericError, + } as const, state: [], }; }, diff --git a/packages/davinci-client/src/lib/node.types.ts b/packages/davinci-client/src/lib/node.types.ts index 0ebac0f15..c6de407dd 100644 --- a/packages/davinci-client/src/lib/node.types.ts +++ b/packages/davinci-client/src/lib/node.types.ts @@ -4,6 +4,8 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ +import { GenericError } from '@forgerock/sdk-types'; + import type { FlowCollector, PasswordCollector, @@ -23,7 +25,6 @@ import type { UnknownCollector, } from './collector.types.js'; import type { Links } from './davinci.types.js'; -import { GenericError } from './error.types.js'; export type Collectors = | FlowCollector @@ -74,9 +75,10 @@ export interface ContinueNode { status: 'continue'; } -export interface DaVinciError extends GenericError { +export interface DaVinciError extends Omit { collectors?: CollectorErrors[]; internalHttpStatus?: number; + message: string; status: 'error' | 'failure' | 'unknown'; } diff --git a/packages/oidc-client/src/lib/authorize.request.ts b/packages/oidc-client/src/lib/authorize.request.ts index ba2e1e813..ea3222e47 100644 --- a/packages/oidc-client/src/lib/authorize.request.ts +++ b/packages/oidc-client/src/lib/authorize.request.ts @@ -21,6 +21,15 @@ import type { WellKnownResponse } from '@forgerock/sdk-types'; import type { OidcConfig } from './config.types.js'; import { AuthorizeErrorResponse, AuthorizeSuccessResponse } from './authorize.request.types.js'; +/** + * @function authorizeµ + * @description Creates an authorization URL for the OIDC client. + * @param {WellKnownResponse} wellknown - The well-known configuration for the OIDC server. + * @param {OidcConfig} config - The OIDC client configuration. + * @param {CustomLogger} log - The logger instance for logging debug information. + * @param {GetAuthorizationUrlOptions} options - Optional parameters for the authorization request. + * @returns {Micro.Micro} - A micro effect that resolves to the authorization response. + */ export async function authorizeµ( wellknown: WellKnownResponse, config: OidcConfig, @@ -37,13 +46,18 @@ export async function authorizeµ( * If we support the pi.flow field, this means we are using a PingOne server. * PingOne servers do not support redirection through iframes because they * set iframe's to DENY. + * + * We do not use RTK Query for this because we don't want caching, or store + * updates, and want the request to be made similar to the iframe method below. + * + * This returns a Micro that resolves to the parsed response JSON. */ return authorizeFetchµ(url).pipe( Micro.flatMap( (response): Micro.Micro => { - if ('authorizeResponse' in response) { - log.debug('Received authorize response', response.authorizeResponse); - return Micro.succeed(response.authorizeResponse); + if ('code' in response) { + log.debug('Received code in response', response); + return Micro.succeed(response); } log.error('Error in authorize response', response); // For redirection, we need to remore `pi.flow` from the options @@ -57,6 +71,9 @@ export async function authorizeµ( /** * If the response mode is not pi.flow, then we are likely using a traditional * redirect based server supporting iframes. An example would be PingAM. + * + * This returns a Micro that's either the success URL parameters or error URL + * parameters. */ return authorizeIframeµ(url, config).pipe( Micro.flatMap( diff --git a/packages/oidc-client/src/lib/authorize.request.utils.ts b/packages/oidc-client/src/lib/authorize.request.utils.ts index 34f804602..5e6eadd33 100644 --- a/packages/oidc-client/src/lib/authorize.request.utils.ts +++ b/packages/oidc-client/src/lib/authorize.request.utils.ts @@ -7,7 +7,7 @@ import { createAuthorizeUrl, GetAuthorizationUrlOptions } from '@forgerock/sdk-oidc'; import { Micro } from 'effect'; -import { iFrameManager } from '@forgerock/iframe-manager'; +import { iFrameManager, ResolvedParams } from '@forgerock/iframe-manager'; import type { WellKnownResponse } from '@forgerock/sdk-types'; @@ -17,16 +17,52 @@ import type { } from './authorize.request.types.js'; import type { OidcConfig } from './config.types.js'; -export function authorizeFetchµ(url: string) { +/** + * @function authorizeFetchµ + * @description Fetches the authorization response from the given URL. + * @param {string} url - The URL to fetch the authorization response from. + * @returns {Micro.Micro} - A micro effect that resolves to the authorization response. + */ +export function authorizeFetchµ( + url: string, +): Micro.Micro { return Micro.tryPromise({ try: async () => { const response = await fetch(url, { method: 'POST', credentials: 'include', }); - return (await response.json()) as + const resJson = (await response.json()) as | { authorizeResponse: AuthorizeSuccessResponse } - | AuthorizeErrorResponse; + | unknown; + + if (!resJson || typeof resJson !== 'object') { + return { + error: 'Authorization Network Failure', + error_description: 'Failed to fetch authorization response', + type: 'auth_error', + }; + } + + if ('authorizeResponse' in resJson) { + // Return authorizeResponse as it contains the code and state + return resJson.authorizeResponse as AuthorizeSuccessResponse; + } else if ('details' in resJson && resJson.details && Array.isArray(resJson.details)) { + const details = resJson.details[0] as { code: string; message: string }; + // Return error response + return { + error: details.code || 'Unknown_Error', + error_description: details.message || 'An error occurred during authorization', + type: 'auth_error', + }; + } + + // Unrecognized response format + return { + error: 'Authorization Network Failure', + error_description: 'Unexpected response format from authorization endpoint', + type: 'auth_error', + }; }, catch: (err) => { let message = 'Error fetching authorization URL'; @@ -43,7 +79,17 @@ export function authorizeFetchµ(url: string) { }); } -export function authorizeIframeµ(url: string, config: OidcConfig) { +/** + * @function authorizeIframeµ + * @description Fetches the authorization response from the given URL using an iframe. + * @param {string} url - The authorization URL to be used for the iframe. + * @param {OidcConfig} config - The OIDC client configuration. + * @returns {Micro.Micro} + */ +export function authorizeIframeµ( + url: string, + config: OidcConfig, +): Micro.Micro { return Micro.tryPromise({ try: () => { const params = iFrameManager().getParamsByRedirect({ @@ -75,11 +121,20 @@ export function authorizeIframeµ(url: string, config: OidcConfig) { type BuildAuthorizationData = [string, OidcConfig, GetAuthorizationUrlOptions]; export type OptionalAuthorizeOptions = Partial; + +/** + * @function buildAuthorizeOptionsµ + * @description Builds the authorization options for the OIDC client. + * @param {WellKnownResponse} wellknown - The well-known configuration for the OIDC server. + * @param {OidcConfig} config - The OIDC client configuration. + * @param {OptionalAuthorizeOptions} options - Optional parameters for the authorization request. + * @returns {Micro.Micro} + */ export function buildAuthorizeOptionsµ( wellknown: WellKnownResponse, config: OidcConfig, options?: OptionalAuthorizeOptions, -) { +): Micro.Micro { const isPiFlow = wellknown.response_modes_supported?.includes('pi.flow'); return Micro.sync( (): BuildAuthorizationData => [ @@ -97,12 +152,21 @@ export function buildAuthorizeOptionsµ( ); } +/** + * @function createAuthorizeErrorµ + * @description Creates an error response with new Authorize URL for the authorization request. + * @param { error: string; error_description: string } res - The error response from the authorization request. + * @param {WellKnownResponse} wellknown- The well-known configuration for the OIDC server. + * @param { OidcConfig } config- The OIDC client configuration. + * @param { GetAuthorizationUrlOptions } options- Optional parameters for the authorization request. + * @returns { Micro.Micro } + */ export function createAuthorizeErrorµ( res: { error: string; error_description: string }, wellknown: WellKnownResponse, config: OidcConfig, options: GetAuthorizationUrlOptions, -) { +): Micro.Micro { return Micro.tryPromise({ try: () => createAuthorizeUrl(wellknown.authorization_endpoint, { @@ -131,13 +195,21 @@ export function createAuthorizeErrorµ( ); } +/** + * @function createAuthorizeUrlµ + * @description Creates an authorization URL and related options/config for the Authorize request. + * @param {string} path - The path to the authorization endpoint. + * @param { OidcConfig } config - The OIDC client configuration. + * @param { GetAuthorizationUrlOptions } options - Optional parameters for the authorization request. + * @returns { Micro.Micro<[string, OidcConfig, GetAuthorizationUrlOptions], AuthorizeErrorResponse, never> } + */ export function createAuthorizeUrlµ( path: string, config: OidcConfig, options: GetAuthorizationUrlOptions, -) { +): Micro.Micro<[string, OidcConfig, GetAuthorizationUrlOptions], AuthorizeErrorResponse, never> { return Micro.tryPromise({ - try: async (): Promise<[string, OidcConfig, GetAuthorizationUrlOptions]> => [ + try: async () => [ await createAuthorizeUrl(path, { ...options, prompt: 'none', diff --git a/packages/oidc-client/src/lib/client.store.ts b/packages/oidc-client/src/lib/client.store.ts index 1b193b298..177bc08de 100644 --- a/packages/oidc-client/src/lib/client.store.ts +++ b/packages/oidc-client/src/lib/client.store.ts @@ -11,19 +11,34 @@ import { Micro } from 'effect'; import { exitIsFail, exitIsSuccess } from 'effect/Micro'; import { authorizeµ } from './authorize.request.js'; -import { createClientStore, createError } from './client.store.utils.js'; +import { createClientStore, createLogoutError, createTokenError } from './client.store.utils.js'; import { createValuesµ, handleTokenResponseµ, validateValuesµ } from './exchange.utils.js'; -import { GenericError } from './error.types.js'; import { oidcApi } from './oidc.api.js'; import { wellknownApi, wellknownSelector } from './wellknown.api.js'; import type { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-middleware'; -import type { GetAuthorizationUrlOptions } from '@forgerock/sdk-types'; +import type { GenericError, GetAuthorizationUrlOptions } from '@forgerock/sdk-types'; import type { OauthTokens, OidcConfig } from './config.types.js'; -import type { AuthorizeErrorResponse } from './authorize.request.types.js'; -import type { TokenExchangeErrorResponse } from './exchange.types.js'; - +import type { + AuthorizeErrorResponse, + AuthorizeSuccessResponse, +} from './authorize.request.types.js'; +import type { TokenExchangeErrorResponse, TokenExchangeResponse } from './exchange.types.js'; + +/** + * @function oidc + * @description Factory function to create an OIDC client with methods for authorization, token exchange, + * user info retrieval, and logout. It initializes the client with the provided configuration, + * request middleware, logger, and storage options. + * @param parameter - configuration object containing the OIDC client configuration, request middleware, logger, + * @param {OidcConfig} parameter.config - OIDC configuration including server details, client ID, redirect URI, + * storage options, scope, and response type. + * @param {RequestMiddleware} parameter.requestMiddleware - optional array of request middleware functions to process requests. + * @param {{ level: LogLevel, custom: CustomLogger }} parameter.logger - optional logger configuration with log level and custom logger. + * @param {Partial} parameter.storage - optional storage configuration for persisting OIDC tokens. + * @returns {ReturnType} - Returns an object with methods for authorization, token exchange, user info retrieval, and logout. + */ export async function oidc({ config, requestMiddleware, @@ -72,7 +87,16 @@ export async function oidc({ } return { + /** + * An object containing methods for the creation, and background use, of the authorization URL + */ authorize: { + /** + * @function url + * @description Creates an authorization URL with the provided options or defaults from the configuration. + * @param {GetAuthorizationUrlOptions} options - Optional parameters to customize the authorization URL. + * @returns {Promise} - Returns a promise that resolves to the authorization URL or an error. + */ url: async (options?: GetAuthorizationUrlOptions): Promise => { const optionsWithDefaults = { clientId: config.clientId, @@ -98,7 +122,14 @@ export async function oidc({ return createAuthorizeUrl(wellknown.authorization_endpoint, optionsWithDefaults); }, - background: async (options?: GetAuthorizationUrlOptions) => { + /** + * @function background - Initiates the authorization process in the background, returning an authorization URL or an error. + * @param {GetAuthorizationUrlOptions} options - Optional parameters to customize the authorization URL. + * @returns {Promise} - Returns a promise that resolves to the authorization URL or an error response. + */ + background: async ( + options?: GetAuthorizationUrlOptions, + ): Promise => { const state = store.getState(); const wellknown = wellknownSelector(wellknownUrl, state); @@ -131,8 +162,24 @@ export async function oidc({ } }, }, + /** + * An object containing methods for token exchange + */ token: { - exchange: async (code: string, state: string, options?: Partial) => { + /** + * @function exchange + * @description Exchanges an authorization code for tokens using the token endpoint from the wellknown configuration + * and stores them in the configured storage. + * @param {string} code - The authorization code received from the authorization server. + * @param {string} state - The state parameter from the authorization URL creation. + * @param {Partial} options - Optional storage configuration for persisting tokens. + * @returns {Promise} + */ + exchange: async ( + code: string, + state: string, + options?: Partial, + ): Promise => { const storeState = store.getState(); const wellknown = wellknownSelector(wellknownUrl, storeState); @@ -140,7 +187,7 @@ export async function oidc({ const err = { error: 'Wellknown missing token endpoint', type: 'wellknown_error', - } as AuthorizeErrorResponse; + } as const; log.error(err.error); @@ -152,8 +199,8 @@ export async function oidc({ ).pipe( Micro.flatMap((options) => validateValuesµ(options)), Micro.flatMap((requestOptions) => - Micro.promise( - async () => await store.dispatch(oidcApi.endpoints.exchange.initiate(requestOptions)), + Micro.promise(() => + store.dispatch(oidcApi.endpoints.exchange.initiate(requestOptions)), ), ), Micro.flatMap(({ data, error }) => handleTokenResponseµ(data, error)), @@ -174,7 +221,7 @@ export async function oidc({ if (exitIsSuccess(result)) { return result.value; - } else if (exitIsFail(result) && 'error' in result.cause) { + } else if (exitIsFail(result)) { return result.cause.error; } else { return { @@ -185,8 +232,16 @@ export async function oidc({ } }, }, + /** + * An object containing methods for user info retrieval and logout + */ user: { - info: async () => { + /** + * @function info - Retrieves user information using the userinfo endpoint from the wellknown configuration. + * It requires an access token stored in the configured storage. + * @returns {Promise} - Returns a promise that resolves to user information or an error response. + */ + info: async (): Promise => { const state = store.getState(); const wellknown = wellknownSelector(wellknownUrl, state); @@ -207,21 +262,70 @@ export async function oidc({ const err = { error: 'No access token found', type: 'auth_error', - } as AuthorizeErrorResponse; + } as const; log.error(err.error); return err; } - return await store.dispatch( - oidcApi.endpoints.userInfo.initiate({ - accessToken: tokens.accessToken, - endpoint: wellknown.userinfo_endpoint, + const info = Micro.promise(() => + store.dispatch( + oidcApi.endpoints.userInfo.initiate({ + accessToken: tokens.accessToken, + endpoint: wellknown.userinfo_endpoint, + }), + ), + ).pipe( + Micro.flatMap(({ data, error }) => { + if (error) { + let message = 'An error occurred while fetching user info'; + let status: number | string = 'unknown'; + if ('message' in error && error.message) { + message = error.message; + } + if ('status' in error) { + status = error.status; + } + return Micro.fail({ + error: 'User Info retrieval failure', + message, + type: 'auth_error', + status, + } as const); + } + return Micro.succeed(data); }), ); + + const result = await Micro.runPromiseExit(info); + + if (exitIsSuccess(result)) { + return result.value; + } else if (exitIsFail(result)) { + return result.cause.error; + } else { + return { + error: 'User Info retrieval failure', + message: result.cause.message, + type: 'auth_error', + } as const; + } }, - logout: async () => { + /** + * @function logout + * @description Logs out the user by revoking tokens and clearing the storage. + * It uses the end session endpoint from the wellknown configuration. + * @returns {Promise} - Returns a promise that resolves to the logout response or an error. + */ + logout: async (): Promise< + | GenericError + | { + sessionResponse: GenericError | null; + revokeResponse: GenericError | null; + deleteResponse: void; + } + > => { const state = store.getState(); const wellknown = wellknownSelector(wellknownUrl, state); @@ -229,7 +333,7 @@ export async function oidc({ const err = { error: 'Wellknown missing end session endpoint', type: 'wellknown_error', - } as AuthorizeErrorResponse; + } as const; log.error(err.error); @@ -239,65 +343,46 @@ export async function oidc({ const tokens = await storageClient.get(); if (!tokens) { - return createError('no_tokens', log); + return createTokenError('no_tokens', log); } if (!('accessToken' in tokens)) { - return createError('no_access_token', log); + return createTokenError('no_access_token', log); } if (!('idToken' in tokens)) { - return createError('no_id_token', log); + return createTokenError('no_id_token', log); } const logout = Micro.zip( - Micro.tryPromise({ - try: () => - store.dispatch( - oidcApi.endpoints.endSession.initiate({ - idToken: tokens.idToken, - endpoint: - wellknown.ping_end_idp_session_endpoint || wellknown.end_session_endpoint, - }), - ), - catch: () => { - const err = { - error: 'Logout request failed', - message: 'network_error', - } as GenericError; - - log.error(err); - - return err; - }, - }), - Micro.tryPromise({ - try: () => - store.dispatch( - oidcApi.endpoints.revoke.initiate({ - accessToken: tokens.accessToken, - clientId: config.clientId, - endpoint: wellknown.revocation_endpoint, - }), - ), - catch: () => { - const err = { - error: 'Revoke request failed', - message: 'network_error', - } as GenericError; - - log.error(err); - - return err; - }, - }), + // End session with the ID token + Micro.promise(() => + store.dispatch( + oidcApi.endpoints.endSession.initiate({ + idToken: tokens.idToken, + endpoint: wellknown.ping_end_idp_session_endpoint || wellknown.end_session_endpoint, + }), + ), + ).pipe(Micro.map(({ data, error }) => createLogoutError(data, error))), + + // Revoke the access token + Micro.promise(() => + store.dispatch( + oidcApi.endpoints.revoke.initiate({ + accessToken: tokens.accessToken, + clientId: config.clientId, + endpoint: wellknown.revocation_endpoint, + }), + ), + ).pipe(Micro.map(({ data, error }) => createLogoutError(data, error))), ).pipe( + // Delete local token and return combined results Micro.flatMap(([sessionResponse, revokeResponse]) => - Micro.gen(function* () { - const deleteResponse = yield* Micro.promise(storageClient.remove); + Micro.promise(async () => { + const deleteResponse = await storageClient.remove(); return { - sessionResponse: sessionResponse, - revokeResponse: revokeResponse, + sessionResponse, + revokeResponse, deleteResponse, }; }), @@ -307,16 +392,15 @@ export async function oidc({ const result = await Micro.runPromiseExit(logout); if (exitIsSuccess(result)) { - await storageClient.remove(); return result.value; } else if (exitIsFail(result)) { return result.cause.error; } else { return { - error: 'Logout failure', + error: 'Logout_Failure', message: result.cause.message, type: 'auth_error', - } as GenericError; + } as const; } }, }, diff --git a/packages/oidc-client/src/lib/client.store.utils.ts b/packages/oidc-client/src/lib/client.store.utils.ts index 332276e7d..dd7c9d734 100644 --- a/packages/oidc-client/src/lib/client.store.utils.ts +++ b/packages/oidc-client/src/lib/client.store.utils.ts @@ -7,11 +7,21 @@ import type { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-middleware'; import type { logger as loggerFn } from '@forgerock/sdk-logger'; -import { configureStore } from '@reduxjs/toolkit'; +import { configureStore, SerializedError } from '@reduxjs/toolkit'; import { oidcApi } from './oidc.api.js'; import { wellknownApi } from './wellknown.api.js'; -import { GenericError } from './error.types.js'; +import type { GenericError } from '@forgerock/sdk-types'; +import { FetchBaseQueryError } from '@reduxjs/toolkit/query'; + +/** + * @function createClientStore + * @description Creates a Redux store configured with OIDC and well-known APIs. + * @param param - Configuration options for the client store. + * @param {RequestMiddleware} param.requestMiddleware - An array of request middleware functions to be applied to the store. + * @param {ReturnType} param.logger - An optional logger function for logging messages. + * @returns { ReturnType } - Returns a configured Redux store with OIDC and well-known APIs. + */ export function createClientStore({ requestMiddleware, logger, @@ -42,7 +52,37 @@ export function createClientStore({ }); } -export function createError( +/** + * @function createLogoutError + * @description Creates a logout error object based on the provided data and error. + * @param {object | null | undefined} data - The data returned from the logout API call. + * @param {FetchBaseQueryError | SerializedError} error - An optional error object that may contain details about the error that occurred during the logout process. + * @returns {null | GenericError} - Returns a `GenericError` object if an error occurred, or `null` if no error is present. + */ +export function createLogoutError( + data: object | null | undefined, + error?: FetchBaseQueryError | SerializedError, +): null | GenericError { + if (error) { + let message = 'An error occurred while ending the session'; + let status: number | string = 'unknown'; + if ('message' in error && error.message) { + message = error.message; + } + if ('status' in error) { + status = error.status; + } + return { + error: 'End Session failure', + message, + type: 'auth_error', + status, + } as const; + } + return null; +} + +export function createTokenError( type: 'no_tokens' | 'no_access_token' | 'no_id_token', log: ReturnType, ) { @@ -50,31 +90,31 @@ export function createError( if (type === 'no_tokens') { error = { - error: 'No tokens found in storage', + error: 'Token_Error', message: 'Required for ending session and revoking access token', type: 'state_error', } as const; } else if (type === 'no_access_token') { error = { - error: 'No access token found', + error: 'Token_Error', message: 'No access token found in storage; required for revoking access token', type: 'state_error', } as const; } else if (type === 'no_id_token') { error = { - error: 'No ID token found', + error: 'Token_Error', message: 'No ID token found in storage; required for ending session', type: 'state_error', } as const; } else { error = { - error: 'Unknown error type', + error: 'Token_Error', message: 'An unknown error occurred while creating the error object', type: 'unknown_error', } as const; } - log.error(error.error); + log.error(error.message || 'Error occurred related to tokens'); return error; } diff --git a/packages/oidc-client/src/lib/exchange.utils.ts b/packages/oidc-client/src/lib/exchange.utils.ts index 06825de9b..79de316b8 100644 --- a/packages/oidc-client/src/lib/exchange.utils.ts +++ b/packages/oidc-client/src/lib/exchange.utils.ts @@ -2,12 +2,15 @@ import { SerializedError } from '@reduxjs/toolkit'; import { FetchBaseQueryError } from '@reduxjs/toolkit/query'; import { Micro } from 'effect'; +import { getStoredAuthUrlValues } from '@forgerock/sdk-oidc'; + +import type { GetAuthorizationUrlOptions } from '@forgerock/sdk-oidc'; +import type { GenericError, WellKnownResponse } from '@forgerock/sdk-types'; +import type { StorageConfig } from '@forgerock/storage'; + import type { TokenExchangeResponse, TokenRequestOptions } from './exchange.types.js'; import type { TokenExchangeErrorResponse } from './exchange.types.js'; -import { GenericError, StorageConfig } from '@forgerock/storage'; -import { GetAuthorizationUrlOptions, getStoredAuthUrlValues } from '@forgerock/sdk-oidc'; -import { OidcConfig } from './config.types.js'; -import { WellKnownResponse } from '@forgerock/sdk-types'; +import type { OidcConfig } from './config.types.js'; export function createValuesµ( code: string, @@ -30,7 +33,7 @@ export function createValuesµ( export function handleTokenResponseµ( data: TokenExchangeResponse | undefined, error?: FetchBaseQueryError | SerializedError, -) { +): Micro.Micro { if (error) { let message; if ('status' in error) { diff --git a/packages/oidc-client/src/lib/oidc.api.ts b/packages/oidc-client/src/lib/oidc.api.ts index d6932d58e..9350c62e7 100644 --- a/packages/oidc-client/src/lib/oidc.api.ts +++ b/packages/oidc-client/src/lib/oidc.api.ts @@ -1,6 +1,7 @@ import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query'; import { OidcConfig } from './config.types.js'; import { TokenExchangeResponse } from './exchange.types.js'; +import { transformError } from './oidc.api.utils.js'; export const oidcApi = createApi({ reducerPath: 'oidc', @@ -21,11 +22,18 @@ export const oidcApi = createApi({ }, }; }, - transformResponse: (res) => { - if (res && typeof res === 'object') { - return null; // Successful logout, no content expected + transformErrorResponse: (error) => { + let message = 'An error occurred while trying to end the session'; + + if (error.status === 400) { + message = 'Bad request to end session endpoint'; + } else if (error.status === 401) { + message = 'Unauthorized request to end session endpoint'; + } else if (error.status === 403) { + message = 'Forbidden request to end session endpoint'; } - throw new Error('Invalid response from end session endpoint'); + + return transformError('End Session Error', message, error.status); }, }), exchange: builder.mutation< @@ -60,17 +68,21 @@ export const oidcApi = createApi({ body, }; }, - transformResponse: (res) => { - if (!res || typeof res !== 'object') { - throw new Error('Invalid response from token exchange'); - } - if ('access_token' in res) { - return res as TokenExchangeResponse; + transformErrorResponse: (error) => { + let message = 'An error occurred while exchanging the authorization code'; + + if (error.status === 400) { + message = 'Bad request to token exchange endpoint'; + } else if (error.status === 401) { + message = 'Unauthorized request to token exchange endpoint'; + } else if (error.status === 403) { + message = 'Forbidden request to token exchange endpoint'; } - throw new Error('Token exchange response does not contain access_token'); + + return transformError('Token Exchange Error', message, error.status); }, }), - revoke: builder.mutation({ + revoke: builder.mutation({ query: ({ accessToken, clientId, endpoint }) => { const body = new URLSearchParams({ ...(clientId ? { client_id: clientId } : {}), @@ -86,11 +98,18 @@ export const oidcApi = createApi({ body, }; }, - transformResponse: (res) => { - if (res && typeof res === 'object') { - return null; // Successful revoke, no content expected + transformErrorResponse: (error) => { + let message = 'An error occurred while revoking the token'; + + if (error.status === 400) { + message = 'Bad request to revoke endpoint'; + } else if (error.status === 401) { + message = 'Unauthorized request to revoke endpoint'; + } else if (error.status === 403) { + message = 'Forbidden request to revoke endpoint'; } - throw new Error('Invalid response from token revoke endpoint'); + + return transformError('Token Revoke Error', message, error.status); }, }), userInfo: builder.mutation({ @@ -105,11 +124,18 @@ export const oidcApi = createApi({ }, }; }, - transformResponse: (res) => { - if (!res || typeof res !== 'object') { - throw new Error('Invalid response from userinfo endpoint'); + transformErrorResponse: (error) => { + let message = 'An error occurred while fetching user info'; + + if (error.status === 400) { + message = 'Bad request to user info endpoint'; + } else if (error.status === 401) { + message = 'Unauthorized request to user info endpoint'; + } else if (error.status === 403) { + message = 'Forbidden request to user info endpoint'; } - return res as TokenExchangeResponse; + + return transformError('User Info Error', message, error.status); }, }), }), diff --git a/packages/oidc-client/src/lib/oidc.api.utils.ts b/packages/oidc-client/src/lib/oidc.api.utils.ts new file mode 100644 index 000000000..5f8283f40 --- /dev/null +++ b/packages/oidc-client/src/lib/oidc.api.utils.ts @@ -0,0 +1,20 @@ +/* + * 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 { GenericError } from '@forgerock/sdk-types'; + +export function transformError( + error: string, + message: string, + status: number | string, +): GenericError { + return { + error, + message, + status, + type: 'network_error', + }; +} diff --git a/packages/sdk-effects/storage/src/lib/storage.effects.ts b/packages/sdk-effects/storage/src/lib/storage.effects.ts index 4f55235f6..0799fd097 100644 --- a/packages/sdk-effects/storage/src/lib/storage.effects.ts +++ b/packages/sdk-effects/storage/src/lib/storage.effects.ts @@ -4,7 +4,7 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ -import { CustomStorageObject } from '@forgerock/sdk-types'; +import { CustomStorageObject, GenericError } from '@forgerock/sdk-types'; export interface StorageClient { get: () => Promise; @@ -31,18 +31,6 @@ export interface CustomStorageConfig { custom: CustomStorageObject; } -export interface GenericError { - code?: string | number; - message: string; - type: - | 'argument_error' - | 'davinci_error' - | 'internal_error' - | 'network_error' - | 'state_error' - | 'unknown_error'; -} - export function createStorage(config: StorageConfig) { const { type: storeType, prefix = 'pic', name } = config; @@ -63,9 +51,9 @@ export function createStorage(config: StorageConfig) { return parsed as Value; } catch { return { - code: 'Parse_Error', - message: 'Eror parsing value from provided storage', - type: 'unknown_error', + error: 'Parse_Error', + message: 'Error parsing value from provided storage', + type: 'parse_error', }; } } @@ -79,9 +67,9 @@ export function createStorage(config: StorageConfig) { return parsed as Value; } catch { return { - code: 'Parse_Error', - message: 'Eror parsing value from session storage', - type: 'unknown_error', + error: 'Parse_Error', + message: 'Error parsing value from session storage', + type: 'parse_error', }; } } @@ -95,9 +83,9 @@ export function createStorage(config: StorageConfig) { return parsed as Value; } catch { return { - code: 'Parse_Error', - message: 'Eror parsing value from local storage', - type: 'unknown_error', + error: 'Parse_Error', + message: 'Error parsing value from local storage', + type: 'parse_error', }; } }, @@ -110,7 +98,7 @@ export function createStorage(config: StorageConfig) { } catch { return { code: 'Storing_Error', - message: 'Eror storing value in custom storage', + message: 'Error storing value in custom storage', type: 'unknown_error', }; } @@ -122,7 +110,7 @@ export function createStorage(config: StorageConfig) { } catch { return { code: 'Storing_Error', - message: 'Eror storing value in session storage', + message: 'Error storing value in session storage', type: 'unknown_error', }; } @@ -133,7 +121,7 @@ export function createStorage(config: StorageConfig) { } catch { return { code: 'Storing_Error', - message: 'Eror storing value in local storage', + message: 'Error storing value in local storage', type: 'unknown_error', }; } diff --git a/packages/sdk-types/src/index.ts b/packages/sdk-types/src/index.ts index a4335cea6..7402cbf6e 100644 --- a/packages/sdk-types/src/index.ts +++ b/packages/sdk-types/src/index.ts @@ -6,6 +6,7 @@ */ export * from './lib/am-callback.types.js'; +export * from './lib/error.types.js'; export * from './lib/legacy-config.types.js'; export * from './lib/legacy-mware.types.js'; export * from './lib/branded.types.js'; diff --git a/packages/oidc-client/src/lib/error.types.ts b/packages/sdk-types/src/lib/error.types.ts similarity index 86% rename from packages/oidc-client/src/lib/error.types.ts rename to packages/sdk-types/src/lib/error.types.ts index 14b415fe1..6a73c8c5d 100644 --- a/packages/oidc-client/src/lib/error.types.ts +++ b/packages/sdk-types/src/lib/error.types.ts @@ -8,11 +8,14 @@ export interface GenericError { code?: string | number; error: string; message?: string; + status?: number | string; type: | 'argument_error' | 'auth_error' + | 'davinci_error' | 'internal_error' | 'network_error' + | 'parse_error' | 'state_error' | 'unknown_error' | 'wellknown_error';