From 4d0ee71ad7570d63a2d7dba965e1469ffb4cff08 Mon Sep 17 00:00:00 2001 From: Justin Lowery Date: Tue, 26 Aug 2025 18:45:41 -0500 Subject: [PATCH] refactor(oidc-client): move authorize requests into rtk query --- .changeset/spotty-tires-admire.md | 5 + e2e/oidc-app/package.json | 2 +- e2e/oidc-app/src/utils/oidc-app.ts | 7 +- e2e/oidc-suites/src/login.spec.ts | 59 ++---- .../oidc-client/src/lib/authorize.request.ts | 198 +++++++++++++----- .../src/lib/authorize.request.types.ts | 42 +++- .../src/lib/authorize.request.utils.ts | 138 ++---------- packages/oidc-client/src/lib/client.store.ts | 26 +-- packages/oidc-client/src/lib/client.types.ts | 2 +- .../src/lib/logout.request.test.ts | 12 +- .../oidc-client/src/lib/logout.request.ts | 3 +- packages/oidc-client/src/lib/oidc.api.ts | 157 +++++++++++++- packages/oidc-client/src/types.ts | 2 + 13 files changed, 410 insertions(+), 243 deletions(-) create mode 100644 .changeset/spotty-tires-admire.md diff --git a/.changeset/spotty-tires-admire.md b/.changeset/spotty-tires-admire.md new file mode 100644 index 000000000..69dd85f1a --- /dev/null +++ b/.changeset/spotty-tires-admire.md @@ -0,0 +1,5 @@ +--- +'@forgerock/oidc-client': minor +--- + +Migrate /authorize to RTK Query and improve result types diff --git a/e2e/oidc-app/package.json b/e2e/oidc-app/package.json index d9b8e2964..14eae1170 100644 --- a/e2e/oidc-app/package.json +++ b/e2e/oidc-app/package.json @@ -14,6 +14,6 @@ "@forgerock/sdk-types": "workspace:*" }, "nx": { - "tags": ["scope:app"] + "tags": ["scope:e2e"] } } diff --git a/e2e/oidc-app/src/utils/oidc-app.ts b/e2e/oidc-app/src/utils/oidc-app.ts index 15c795371..a03e00729 100644 --- a/e2e/oidc-app/src/utils/oidc-app.ts +++ b/e2e/oidc-app/src/utils/oidc-app.ts @@ -8,11 +8,12 @@ */ import { oidc } from '@forgerock/oidc-client'; import type { - AuthorizeErrorResponse, + AuthorizationError, + GenericError, + GetAuthorizationUrlOptions, OauthTokens, TokenExchangeErrorResponse, } from '@forgerock/oidc-client/types'; -import { GenericError, GetAuthorizationUrlOptions } from '@forgerock/sdk-types'; let tokenIndex = 0; @@ -23,7 +24,7 @@ function displayError(error) { } function displayTokenResponse( - response: OauthTokens | TokenExchangeErrorResponse | GenericError | AuthorizeErrorResponse, + response: OauthTokens | TokenExchangeErrorResponse | GenericError | AuthorizationError, ) { const appEl = document.getElementById('app'); if ('error' in response) { diff --git a/e2e/oidc-suites/src/login.spec.ts b/e2e/oidc-suites/src/login.spec.ts index 25c84c404..26c212e1e 100644 --- a/e2e/oidc-suites/src/login.spec.ts +++ b/e2e/oidc-suites/src/login.spec.ts @@ -21,7 +21,7 @@ test.describe('PingAM login and get token tests', () => { await navigate('/ping-am/'); expect(page.url()).toBe('http://localhost:8443/ping-am/'); - await clickButton('Login (Background)', 'https://openam-sdks.forgeblocks.com/'); + await clickButton('Login (Background)', '/authorize'); await page.getByLabel('User Name').fill(pingAmUsername); await page.getByRole('textbox', { name: 'Password' }).fill(pingAmPassword); @@ -38,7 +38,7 @@ test.describe('PingAM login and get token tests', () => { await navigate('/ping-am/'); expect(page.url()).toBe('http://localhost:8443/ping-am/'); - await clickButton('Login (Redirect)', 'https://openam-sdks.forgeblocks.com/'); + await clickButton('Login (Redirect)', '/authorize'); await page.getByLabel('User Name').fill(pingAmUsername); await page.getByRole('textbox', { name: 'Password' }).fill(pingAmPassword); @@ -57,19 +57,11 @@ test.describe('PingAM login and get token tests', () => { await page.getByRole('button', { name: 'Login (Background)' }).click(); - await expect(page.locator('.error')).toContainText(`"error": "Authorization Network Failure"`); - await expect(page.locator('.error')).toContainText('Error calling authorization URL'); - await expect(page.locator('.error')).toContainText(`"type": "auth_error"`); - }); - - test('redirect login with invalid client id fails', async ({ page }) => { - const { navigate, clickButton } = asyncEvents(page); - await navigate('/ping-am/?clientid=bad-id'); - expect(page.url()).toBe('http://localhost:8443/ping-am/?clientid=bad-id'); - - await clickButton('Login (Redirect)', 'https://openam-sdks.forgeblocks.com/'); - - await expect(page.getByText('invalid_client')).toBeVisible(); + await expect(page.locator('.error')).toContainText(`CONFIGURATION_ERROR`); + await expect(page.locator('.error')).toContainText( + 'Configuration error. Please check your OAuth configuration, like clientId or allowed redirect URLs.', + ); + await expect(page.locator('.error')).toContainText(`"type": "network_error"`); }); }); @@ -79,7 +71,7 @@ test.describe('PingOne login and get token tests', () => { await navigate('/ping-one/'); expect(page.url()).toBe('http://localhost:8443/ping-one/'); - await clickButton('Login (Background)', 'https://apps.pingone.ca/'); + await clickButton('Login (Background)', '/authorize'); await page.getByLabel('Username').fill(pingOneUsername); await page.getByRole('textbox', { name: 'Password' }).fill(pingOnePassword); @@ -97,7 +89,7 @@ test.describe('PingOne login and get token tests', () => { await navigate('/ping-one/'); expect(page.url()).toBe('http://localhost:8443/ping-one/'); - await clickButton('Login (Redirect)', 'https://apps.pingone.ca/'); + await clickButton('Login (Redirect)', '/authorize'); await page.getByLabel('Username').fill(pingOneUsername); await page.getByRole('textbox', { name: 'Password' }).fill(pingOnePassword); @@ -117,24 +109,11 @@ test.describe('PingOne login and get token tests', () => { await page.getByRole('button', { name: 'Login (Background)' }).click(); - await expect(page.locator('.error')).toContainText(`"error": "Authorization Network Failure"`); - await expect(page.locator('.error')).toContainText('Failed to fetch'); - await expect(page.locator('.error')).toContainText(`"type": "auth_error"`); - }); - - test('redirect login with invalid client id fails', async ({ page }) => { - const { navigate, clickButton } = asyncEvents(page); - await navigate('/ping-one/?clientid=bad-id'); - expect(page.url()).toBe('http://localhost:8443/ping-one/?clientid=bad-id'); - - await clickButton('Login (Redirect)', 'https://apps.pingone.ca/'); - - await expect(page.getByText('Error')).toBeVisible(); - await expect( - page - .getByText('The request could not be completed. The requested resource was not found.') - .first(), - ).toBeVisible(); + await expect(page.locator('.error')).toContainText(`CONFIGURATION_ERROR`); + await expect(page.locator('.error')).toContainText( + 'Configuration error. Please check your OAuth configuration, like clientId or allowed redirect URLs.', + ); + await expect(page.locator('.error')).toContainText(`"type": "network_error"`); }); test('login with pi.flow response mode', async ({ page }) => { @@ -151,7 +130,7 @@ test.describe('PingOne login and get token tests', () => { } }); - await clickButton('Login (Background)', 'https://apps.pingone.ca/'); + await clickButton('Login (Background)', '/authorize'); await page.getByLabel('Username').fill(pingOneUsername); await page.getByRole('textbox', { name: 'Password' }).fill(pingOnePassword); @@ -182,6 +161,10 @@ test('oidc client fails to initialize with bad wellknown', async ({ page }) => { await navigate('/ping-am/?wellknown=bad-wellknown'); expect(page.url()).toBe('http://localhost:8443/ping-am/?wellknown=bad-wellknown'); - await expect(page.locator('.error')).toContainText(`"error": "Error fetching wellknown config"`); - await expect(page.locator('.error')).toContainText(`"type": "network_error"`); + await page.getByRole('button', { name: 'Login (Background)' }).click(); + + await expect(page.locator('.error')).toContainText( + 'Authorization endpoint not found in wellknown configuration', + ); + await expect(page.locator('.error')).toContainText('wellknown_error'); }); diff --git a/packages/oidc-client/src/lib/authorize.request.ts b/packages/oidc-client/src/lib/authorize.request.ts index 4429692f5..1b2010622 100644 --- a/packages/oidc-client/src/lib/authorize.request.ts +++ b/packages/oidc-client/src/lib/authorize.request.ts @@ -8,19 +8,17 @@ import { CustomLogger } from '@forgerock/sdk-logger'; import { Micro } from 'effect'; import { - authorizeFetchµ, createAuthorizeUrlµ, - authorizeIframeµ, buildAuthorizeOptionsµ, createAuthorizeErrorµ, } from './authorize.request.utils.js'; import type { GetAuthorizationUrlOptions, WellKnownResponse } from '@forgerock/sdk-types'; + +import type { AuthorizationError, AuthorizationSuccess } from './authorize.request.types.js'; +import type { createClientStore } from './client.store.utils.js'; import type { OidcConfig } from './config.types.js'; -import type { - AuthorizeErrorResponse, - AuthorizeSuccessResponse, -} from './authorize.request.types.js'; +import { oidcApi } from './oidc.api.js'; /** * @function authorizeµ @@ -29,67 +27,153 @@ import type { * @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. + * @returns {Micro.Micro} - A micro effect that resolves to the authorization response. */ export function authorizeµ( wellknown: WellKnownResponse, config: OidcConfig, log: CustomLogger, + store: ReturnType, options?: GetAuthorizationUrlOptions, ) { return buildAuthorizeOptionsµ(wellknown, config, options).pipe( Micro.flatMap(([url, config, options]) => createAuthorizeUrlµ(url, config, options)), Micro.tap((url) => log.debug('Authorize URL created', url)), Micro.tapError((url) => Micro.sync(() => log.error('Error creating authorize URL', url))), - Micro.flatMap(([url, config, options]) => { - if (options.responseMode === 'pi.flow') { - /** - * 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 ('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 remove `pi.flow` from the options - const redirectOptions = options; - delete redirectOptions.responseMode; - return createAuthorizeErrorµ(response, wellknown, config, options); - }, - ), - ); - } else { - /** - * 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( - (response): Micro.Micro => { - if ('code' in response && 'state' in response) { - log.debug('Received authorization code', response); - return Micro.succeed(response as unknown as AuthorizeSuccessResponse); - } - log.error('Error in authorize response', response); - const errorResponse = response as unknown as AuthorizeErrorResponse; - return createAuthorizeErrorµ(errorResponse, wellknown, config, options); - }, - ), - ); - } - }), + Micro.flatMap( + ([url, options]): Micro.Micro => { + if (options.responseMode === 'pi.flow') { + /** + * 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 Micro.promise(() => + store.dispatch(oidcApi.endpoints.authorizeFetch.initiate({ url })), + ).pipe( + Micro.flatMap( + ({ error, data }): Micro.Micro => { + if (error) { + // Check for serialized error + if (!('status' in error)) { + // This is a network or fetch error, so return it as-is + return Micro.fail({ + error: error.code || 'Unknown_Error', + error_description: + error.message || 'An unknown error occurred during authorization', + type: 'unknown_error', + }); + } + + // If there is no data, this is an unknown error + if (!('data' in error)) { + return Micro.fail({ + error: 'Unknown_Error', + error_description: 'An unknown error occurred during authorization', + type: 'unknown_error', + }); + } + + const errorDetails = error.data as AuthorizationError; + + // If the error is a configuration issue, return it as-is + if ('statusText' in error && error.statusText === 'CONFIGURATION_ERROR') { + return Micro.fail(errorDetails); + } + + // If the error is not a configuration issue, we build a new Authorize URL + // For redirection, we need to remove `pi.flow` from the options + const redirectOptions = options; + delete redirectOptions.responseMode; + + // Create an error with a new Authorize URL + return createAuthorizeErrorµ(errorDetails, wellknown, options); + } + + log.debug('Received success response', data); + + if (data.authorizeResponse) { + // Authorization was successful + return Micro.succeed(data.authorizeResponse); + } else { + // This should never be reached, but just in case + return Micro.fail({ + error: 'Unknown_Error', + error_description: 'Response schema was not recognized', + type: 'unknown_error', + }); + } + }, + ), + ); + } else { + /** + * 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 Micro.promise(() => + store.dispatch(oidcApi.endpoints.authorizeIframe.initiate({ url })), + ).pipe( + Micro.flatMap( + ({ error, data }): Micro.Micro => { + if (error) { + // Check for serialized error + if (!('status' in error)) { + // This is a network or fetch error, so return it as-is + return Micro.fail({ + error: error.code || 'Unknown_Error', + error_description: + error.message || 'An unknown error occurred during authorization', + type: 'unknown_error', + }); + } + + // If there is no data, this is an unknown error + if (!('data' in error)) { + return Micro.fail({ + error: 'Unknown_Error', + error_description: 'An unknown error occurred during authorization', + type: 'unknown_error', + }); + } + + const errorDetails = error.data as AuthorizationError; + + // If the error is a configuration issue, return it as-is + if ('statusText' in error && error.statusText === 'CONFIGURATION_ERROR') { + return Micro.fail(errorDetails); + } + + // This is an expected error, so combine error with a new Authorize URL + return createAuthorizeErrorµ(errorDetails, wellknown, options); + } + + log.debug('Received success response', data); + + if (data) { + // Authorization was successful + return Micro.succeed(data); + } else { + // This should never be reached, but just in case + return Micro.fail({ + error: 'Unknown_Error', + error_description: 'Redirect parameters was not recognized', + type: 'unknown_error', + }); + } + }, + ), + ); + } + }, + ), ); } diff --git a/packages/oidc-client/src/lib/authorize.request.types.ts b/packages/oidc-client/src/lib/authorize.request.types.ts index bc9daf9f8..1fcde3618 100644 --- a/packages/oidc-client/src/lib/authorize.request.types.ts +++ b/packages/oidc-client/src/lib/authorize.request.types.ts @@ -4,14 +4,52 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ +export interface AuthorizeErrorResponse { + id?: string; + code?: string; + message?: string; + details?: [ + { + code: string; + message: string; + }, + ]; +} + export interface AuthorizeSuccessResponse { + _links?: { + [key: string]: { + href: string; + }; + }; + _embedded?: { + [key: string]: unknown; + }; + id?: string; + environment?: { + id: string; + }; + session?: { + id: string; + }; + resumeUrl?: string; + status?: string; + createdAt?: string; + expiresAt?: string; + authorizeResponse?: { + code: string; + state: string; + }; +} + +export interface AuthorizationSuccess { code: string; state: string; } -export interface AuthorizeErrorResponse { +export interface AuthorizationError { error: string; error_description: string; redirectUrl?: string; // URL to redirect the user to for re-authorization - type: 'auth_error' | 'argument_error' | 'wellknown_error'; + type: 'auth_error' | 'argument_error' | 'network_error' | 'unknown_error' | 'wellknown_error'; } diff --git a/packages/oidc-client/src/lib/authorize.request.utils.ts b/packages/oidc-client/src/lib/authorize.request.utils.ts index 53887b592..0972b13c5 100644 --- a/packages/oidc-client/src/lib/authorize.request.utils.ts +++ b/packages/oidc-client/src/lib/authorize.request.utils.ts @@ -7,118 +7,11 @@ import { createAuthorizeUrl } from '@forgerock/sdk-oidc'; import { Micro } from 'effect'; -import { iFrameManager, ResolvedParams } from '@forgerock/iframe-manager'; - import type { WellKnownResponse, GetAuthorizationUrlOptions } from '@forgerock/sdk-types'; -import type { - AuthorizeErrorResponse, - AuthorizeSuccessResponse, -} from './authorize.request.types.js'; +import type { AuthorizationError, AuthorizationSuccess } from './authorize.request.types.js'; import type { OidcConfig } from './config.types.js'; -/** - * @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', - }); - const resJson = (await response.json()) as - | { authorizeResponse: AuthorizeSuccessResponse } - | 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'; - if (err instanceof Error) { - message = err.message; - } - - return { - error: 'Authorization Network Failure', - error_description: message, - type: 'auth_error', - } as AuthorizeErrorResponse; - }, - }); -} - -/** - * @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({ - url, - /*** - * https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2 - * The client MUST ignore unrecognized response parameters. - */ - successParams: ['code', 'state'], - errorParams: ['error', 'error_description'], - timeout: config.serverConfig.timeout || 3000, - }); - return params; - }, - catch: (err) => { - let message = 'Error calling authorization URL'; - if (err instanceof Error) { - message = err.message; - } - - return { - error: 'Authorization Network Failure', - error_description: message, - type: 'auth_error', - } as AuthorizeErrorResponse; - }, - }); -} - type BuildAuthorizationData = [string, OidcConfig, GetAuthorizationUrlOptions]; export type OptionalAuthorizeOptions = Partial; @@ -134,7 +27,7 @@ export function buildAuthorizeOptionsµ( wellknown: WellKnownResponse, config: OidcConfig, options?: OptionalAuthorizeOptions, -): Micro.Micro { +): Micro.Micro { const isPiFlow = wellknown.response_modes_supported?.includes('pi.flow'); return Micro.sync( (): BuildAuthorizationData => [ @@ -164,9 +57,8 @@ export function buildAuthorizeOptionsµ( export function createAuthorizeErrorµ( res: { error: string; error_description: string }, wellknown: WellKnownResponse, - config: OidcConfig, options: GetAuthorizationUrlOptions, -): Micro.Micro { +): Micro.Micro { return Micro.tryPromise({ try: () => createAuthorizeUrl(wellknown.authorization_endpoint, { @@ -181,7 +73,7 @@ export function createAuthorizeErrorµ( error: 'AuthorizationUrlError', error_description: message, type: 'auth_error', - } as AuthorizeErrorResponse; + } as const; }, }).pipe( Micro.flatMap((url) => { @@ -190,7 +82,7 @@ export function createAuthorizeErrorµ( error_description: res.error_description, type: 'auth_error', redirectUrl: url, - } as AuthorizeErrorResponse); + } as const); }), ); } @@ -201,20 +93,19 @@ export function createAuthorizeErrorµ( * @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> } + * @returns { Micro.Micro<[string, OidcConfig, GetAuthorizationUrlOptions], AuthorizationError, never> } */ export function createAuthorizeUrlµ( path: string, config: OidcConfig, options: GetAuthorizationUrlOptions, -): Micro.Micro<[string, OidcConfig, GetAuthorizationUrlOptions], AuthorizeErrorResponse, never> { +): Micro.Micro<[string, GetAuthorizationUrlOptions], AuthorizationError, never> { return Micro.tryPromise({ try: async () => [ await createAuthorizeUrl(path, { ...options, prompt: 'none', }), - config, options, ], catch: (error) => { @@ -226,7 +117,20 @@ export function createAuthorizeUrlµ( error: 'AuthorizationUrlError', error_description: message, type: 'auth_error', - } as AuthorizeErrorResponse; + } as const; }, }); } + +export function handleResponseµ( + response: AuthorizationSuccess | AuthorizationError, + wellknown: WellKnownResponse, + config: OidcConfig, + options: GetAuthorizationUrlOptions, +): Micro.Micro { + if ('code' in response) { + return Micro.sync(() => response); + } else { + return createAuthorizeErrorµ(response, wellknown, options); + } +} diff --git a/packages/oidc-client/src/lib/client.store.ts b/packages/oidc-client/src/lib/client.store.ts index b3fa599e2..d7b50f209 100644 --- a/packages/oidc-client/src/lib/client.store.ts +++ b/packages/oidc-client/src/lib/client.store.ts @@ -21,10 +21,7 @@ import type { GenericError, GetAuthorizationUrlOptions } from '@forgerock/sdk-ty import type { GetTokensOptions, LogoutResult } from './client.types.js'; import type { OauthTokens, OidcConfig } from './config.types.js'; -import type { - AuthorizeErrorResponse, - AuthorizeSuccessResponse, -} from './authorize.request.types.js'; +import type { AuthorizationError, AuthorizationSuccess } from './authorize.request.types.js'; import type { TokenExchangeErrorResponse, TokenExchangeResponse } from './exchange.types.js'; import { isExpiryWithinThreshold } from './token.utils.js'; import { logoutµ } from './logout.request.js'; @@ -59,8 +56,9 @@ export async function oidc({ const log = loggerFn({ level: logger?.level || 'error', custom: logger?.custom }); const oauthThreshold = config.oauthThreshold || 30 * 1000; // Default to 30 seconds const storageClient = createStorage({ - type: 'localStorage', - name: 'oidcTokens', + type: storage?.type || 'localStorage', + name: storage?.name || config.clientId, + prefix: storage?.prefix || 'pic', ...storage, } as StorageConfig); const store = createClientStore({ requestMiddleware, logger: log }); @@ -84,10 +82,7 @@ export async function oidc({ ); if (error || !data) { - return { - error: `Error fetching wellknown config`, - type: 'network_error', - }; + log.error(`Error fetching wellknown config. Please check the URL: ${wellknownUrl}`); } return { @@ -130,7 +125,7 @@ export async function oidc({ */ background: async ( options?: GetAuthorizationUrlOptions, - ): Promise => { + ): Promise => { const state = store.getState(); const wellknown = wellknownSelector(wellknownUrl, state); @@ -143,7 +138,7 @@ export async function oidc({ } const result = await Micro.runPromiseExit( - await authorizeµ(wellknown, config, log, options), + await authorizeµ(wellknown, config, log, store, options), ); if (exitIsSuccess(result)) { @@ -220,13 +215,11 @@ export async function oidc({ * @method get * @description Retrieves the current OAuth tokens from storage, or auto-renew if backgroundRenew is true. * @param {GetTokensOptions} param - An object containing options for the token retrieval. - * @returns {Promise} + * @returns {Promise} */ get: async ( options?: GetTokensOptions, - ): Promise< - OauthTokens | TokenExchangeErrorResponse | AuthorizeErrorResponse | GenericError - > => { + ): Promise => { const { backgroundRenew, authorizeOptions, storageOptions } = options || {}; const state = store.getState(); const wellknown = wellknownSelector(wellknownUrl, state); @@ -269,6 +262,7 @@ export async function oidc({ wellknown, config, log, + store, authorizeOptions, ).pipe( Micro.flatMap((response): Micro.Micro => { diff --git a/packages/oidc-client/src/lib/client.types.ts b/packages/oidc-client/src/lib/client.types.ts index bb86a8024..a7c3f2a31 100644 --- a/packages/oidc-client/src/lib/client.types.ts +++ b/packages/oidc-client/src/lib/client.types.ts @@ -12,6 +12,6 @@ export type LogoutResult = Promise< | { sessionResponse: GenericError | null; revokeResponse: GenericError | null; - deleteResponse: void; + deleteResponse: GenericError | null; } >; diff --git a/packages/oidc-client/src/lib/logout.request.test.ts b/packages/oidc-client/src/lib/logout.request.test.ts index 2d0843593..5f8388e3c 100644 --- a/packages/oidc-client/src/lib/logout.request.test.ts +++ b/packages/oidc-client/src/lib/logout.request.test.ts @@ -117,7 +117,7 @@ describe('Ping AM', () => { expect(result).toStrictEqual({ sessionResponse: null, revokeResponse: null, - deleteResponse: undefined, + deleteResponse: null, }); }), ); @@ -147,7 +147,7 @@ describe('Ping AM', () => { status: 400, }, revokeResponse: null, - deleteResponse: undefined, + deleteResponse: null, }); }), ); @@ -177,7 +177,7 @@ describe('Ping AM', () => { type: 'auth_error', status: 400, }, - deleteResponse: undefined, + deleteResponse: null, }); }), ); @@ -207,7 +207,7 @@ describe('PingOne', () => { expect(result).toStrictEqual({ sessionResponse: null, revokeResponse: null, - deleteResponse: undefined, + deleteResponse: null, }); }), ); @@ -238,7 +238,7 @@ describe('PingOne', () => { status: 400, }, revokeResponse: null, - deleteResponse: undefined, + deleteResponse: null, }); }), ); @@ -269,7 +269,7 @@ describe('PingOne', () => { type: 'auth_error', status: 400, }, - deleteResponse: undefined, + deleteResponse: null, }); }), ); diff --git a/packages/oidc-client/src/lib/logout.request.ts b/packages/oidc-client/src/lib/logout.request.ts index 1948f282f..7c80ad8e3 100644 --- a/packages/oidc-client/src/lib/logout.request.ts +++ b/packages/oidc-client/src/lib/logout.request.ts @@ -49,7 +49,8 @@ export function logoutµ({ // Delete local token and return combined results Micro.flatMap(([sessionResponse, revokeResponse]) => Micro.promise(async () => { - const deleteResponse = await storageClient.remove(); + const deleteRes = await storageClient.remove(); + const deleteResponse = typeof deleteRes === 'undefined' ? null : deleteRes; return { sessionResponse, revokeResponse, diff --git a/packages/oidc-client/src/lib/oidc.api.ts b/packages/oidc-client/src/lib/oidc.api.ts index bc25f6d9d..7da87da26 100644 --- a/packages/oidc-client/src/lib/oidc.api.ts +++ b/packages/oidc-client/src/lib/oidc.api.ts @@ -1,4 +1,4 @@ -import { createApi, FetchArgs, fetchBaseQuery } from '@reduxjs/toolkit/query'; +import { createApi, FetchArgs, fetchBaseQuery, FetchBaseQueryError } from '@reduxjs/toolkit/query'; import { OidcConfig } from './config.types.js'; import { transformError } from './oidc.api.utils.js'; @@ -10,6 +10,8 @@ import { } from '@forgerock/sdk-request-middleware'; import type { TokenExchangeResponse } from './exchange.types.js'; +import { AuthorizationSuccess, AuthorizeSuccessResponse } from './authorize.request.types.js'; +import { iFrameManager } from '@forgerock/iframe-manager'; interface Extras { requestMiddleware: RequestMiddleware[]; @@ -20,6 +22,159 @@ export const oidcApi = createApi({ reducerPath: 'oidc', baseQuery: fetchBaseQuery(), endpoints: (builder) => ({ + authorizeFetch: builder.mutation({ + queryFn: async ({ url }, api, _, baseQuery) => { + const { requestMiddleware, logger } = api.extra as Extras; + + const request: FetchArgs = { + url, + method: 'POST', + credentials: 'include', + headers: { + Accept: 'application/json', + }, + }; + + logger.debug('OIDC authorize API request', request); + + const response = await initQuery(request, 'authorize') + .applyMiddleware(requestMiddleware) + .applyQuery(async (req: FetchArgs) => await baseQuery(req)); + + if (response.error) { + const responseError = response.error; + + // If the details field is present in data, use it to create a more specific error response + if ( + responseError.data && + typeof responseError.data === 'object' && + 'details' in responseError.data && + Array.isArray(responseError.data.details) + ) { + logger.debug('Error in authorize response', responseError); + + const details = responseError.data.details[0] as { + code: string; + message: string; + }; + + response.error = { + status: responseError.status, + statusText: 'AUTHORIZE_ERROR', + data: { + error: details.code, + error_description: details.message, + type: 'auth_error', + }, + } as FetchBaseQueryError; + return response; + } + + logger.error('Error in OAuth configuration', responseError); + + // Since this is likely a configuration issue, avoid providing a redirect URL + response.error = { + status: responseError.status, + statusText: 'CONFIGURATION_ERROR', + data: { + error: 'CONFIGURATION_ERROR', + error_description: + 'Configuration error. Please check your OAuth configuration, like clientId or allowed redirect URLs.', + type: 'network_error', + }, + } as FetchBaseQueryError; + return response; + } + + logger.debug('OIDC Authorize fetch API response', response); + + return response as { data: AuthorizeSuccessResponse }; + }, + }), + authorizeIframe: builder.mutation({ + queryFn: async ({ url }, api) => { + const { requestMiddleware, logger } = api.extra as Extras; + + const request: FetchArgs = { + url, + method: 'POST', + credentials: 'include', + headers: { + Accept: 'application/json', + }, + }; + + logger.debug('OIDC authorize API request', request); + + const response = await initQuery(request, 'authorize') + .applyMiddleware(requestMiddleware) + .applyQuery(async (req: FetchArgs) => { + try { + const res = await iFrameManager().getParamsByRedirect({ + url: req.url, + /*** + * https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2 + * The client MUST ignore unrecognized response parameters. + */ + successParams: ['code', 'state'], + errorParams: ['error', 'error_description'], + timeout: req.timeout || 3000, + }); + return { data: res }; + } catch (error) { + return { + error: { + status: 400, + message: 'Unknown error occurred calling authorize endpoint', + data: error, + }, + }; + } + }); + + if ('error' in response) { + logger.error('Received authorization code', response); + return { + error: { + status: 400, + statusText: 'CONFIGURATION_ERROR', + data: { + error: 'CONFIGURATION_ERROR', + error_description: + 'Configuration error. Please check your OAuth configuration, like clientId or allowed redirect URLs.', + type: 'network_error', + }, + }, + }; + } + + const data = response.data as { + code?: string; + state?: string; + error?: string; + error_description?: string; + }; + + // TODO: Consider refactoring iframe manager to reject when an error occurs + if ('error' in data) { + logger.debug('Error in authorize response', response); + return { + error: { + status: 400, + statusText: 'AUTHORIZE_ERROR', + data: { + error: data.error || 'Unknown_Error', + error_description: + data.error_description || 'An unknown error occurred during authorization', + type: 'auth_error', + }, + }, + }; + } + + return { data: response.data } as { data: AuthorizationSuccess }; + }, + }), endSession: builder.mutation({ queryFn: async ({ idToken, endpoint }, api, _, baseQuery) => { const { requestMiddleware, logger } = api.extra as Extras; diff --git a/packages/oidc-client/src/types.ts b/packages/oidc-client/src/types.ts index 8bbefe628..f409a8a47 100644 --- a/packages/oidc-client/src/types.ts +++ b/packages/oidc-client/src/types.ts @@ -3,6 +3,8 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ +export { GenericError, GetAuthorizationUrlOptions } from '@forgerock/sdk-types'; + export * from './lib/client.types.js'; export * from './lib/config.types.js'; export * from './lib/authorize.request.types.js';