diff --git a/.changeset/thin-nights-send.md b/.changeset/thin-nights-send.md new file mode 100644 index 000000000..62da6a336 --- /dev/null +++ b/.changeset/thin-nights-send.md @@ -0,0 +1,5 @@ +--- +'@forgerock/oidc-client': minor +--- + +Implement token `get` method for local tokens and autorenew diff --git a/e2e/oidc-app/index.html b/e2e/oidc-app/index.html index e235e069a..adf60736b 100644 --- a/e2e/oidc-app/index.html +++ b/e2e/oidc-app/index.html @@ -11,7 +11,8 @@ @@ -21,6 +22,7 @@

Welcome

+ diff --git a/e2e/oidc-app/src/main.ts b/e2e/oidc-app/src/main.ts index 9a43cae2a..7ddd57030 100644 --- a/e2e/oidc-app/src/main.ts +++ b/e2e/oidc-app/src/main.ts @@ -55,6 +55,7 @@ async function app() { console.log('Token Exchange Response:', tokenResponse); document.getElementById('logout')!.style.display = 'block'; document.getElementById('userinfo')!.style.display = 'block'; + document.getElementById('tokens')!.style.display = 'block'; document.getElementById('login')!.style.display = 'none'; } } @@ -79,10 +80,21 @@ async function app() { console.log('Logout successful'); document.getElementById('logout')!.style.display = 'none'; document.getElementById('userinfo')!.style.display = 'none'; + document.getElementById('tokens')!.style.display = 'none'; document.getElementById('login')!.style.display = 'block'; } }); + document.getElementById('tokens')?.addEventListener('click', async () => { + const tokens = await oidcClient.token.get({ backgroundRenew: true }); + + if ('error' in tokens) { + console.error('Token Retrieval Error:', tokens); + } else { + console.log('Tokens:', tokens); + } + }); + if (code && state) { const response = await oidcClient.token.exchange(code, state); diff --git a/packages/davinci-client/src/lib/davinci.api.ts b/packages/davinci-client/src/lib/davinci.api.ts index 27272f1c9..8f47cc2f8 100644 --- a/packages/davinci-client/src/lib/davinci.api.ts +++ b/packages/davinci-client/src/lib/davinci.api.ts @@ -21,12 +21,13 @@ import { * Import internal modules */ import { initQuery } from '@forgerock/sdk-request-middleware'; -import type { logger as loggerFn } from '@forgerock/sdk-logger'; import { createAuthorizeUrl } from '@forgerock/sdk-oidc'; -import type { RequestMiddleware } from '@forgerock/sdk-request-middleware'; import { handleResponse, transformActionRequest, transformSubmitRequest } from './davinci.utils.js'; +import type { logger as loggerFn } from '@forgerock/sdk-logger'; +import type { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-middleware'; + /** * Import the DaVinci types */ @@ -39,7 +40,6 @@ import type { } from './davinci.types.js'; import type { ContinueNode } from './node.types.js'; import type { StartNode } from '../types.js'; -import { ActionTypes } from '@forgerock/sdk-request-middleware'; type BaseQueryResponse = Promise< QueryReturnValue diff --git a/packages/oidc-client/src/lib/authorize.request.ts b/packages/oidc-client/src/lib/authorize.request.ts index ea3222e47..cb143a245 100644 --- a/packages/oidc-client/src/lib/authorize.request.ts +++ b/packages/oidc-client/src/lib/authorize.request.ts @@ -30,7 +30,7 @@ import { AuthorizeErrorResponse, AuthorizeSuccessResponse } from './authorize.re * @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µ( +export function authorizeµ( wellknown: WellKnownResponse, config: OidcConfig, log: CustomLogger, @@ -60,7 +60,7 @@ export async function authorizeµ( return Micro.succeed(response); } log.error('Error in authorize response', response); - // For redirection, we need to remore `pi.flow` from the options + // For redirection, we need to remove `pi.flow` from the options const redirectOptions = options; delete redirectOptions.responseMode; return createAuthorizeErrorµ(response, wellknown, config, options); diff --git a/packages/oidc-client/src/lib/authorize.request.types.ts b/packages/oidc-client/src/lib/authorize.request.types.ts index 9df7efe62..bc9daf9f8 100644 --- a/packages/oidc-client/src/lib/authorize.request.types.ts +++ b/packages/oidc-client/src/lib/authorize.request.types.ts @@ -7,7 +7,6 @@ export interface AuthorizeSuccessResponse { code: string; state: string; - redirectUrl?: string; // Optional, used when the response is from a P1 server } export interface AuthorizeErrorResponse { diff --git a/packages/oidc-client/src/lib/client.store.ts b/packages/oidc-client/src/lib/client.store.ts index 177bc08de..4afb2d425 100644 --- a/packages/oidc-client/src/lib/client.store.ts +++ b/packages/oidc-client/src/lib/client.store.ts @@ -11,32 +11,34 @@ import { Micro } from 'effect'; import { exitIsFail, exitIsSuccess } from 'effect/Micro'; import { authorizeµ } from './authorize.request.js'; +import { buildTokenExchangeµ } from './exchange.request.js'; import { createClientStore, createLogoutError, createTokenError } from './client.store.utils.js'; -import { createValuesµ, handleTokenResponseµ, validateValuesµ } from './exchange.utils.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 { GenericError, GetAuthorizationUrlOptions } from '@forgerock/sdk-types'; +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 { TokenExchangeErrorResponse, TokenExchangeResponse } from './exchange.types.js'; +import { isExpiryWithinThreshold } from './token.utils.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, + * @param param - configuration object containing the OIDC client configuration, request middleware, logger, + * @param {OidcConfig} param.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. + * @param {RequestMiddleware} param.requestMiddleware - optional array of request middleware functions to process requests. + * @param {{ level: LogLevel, custom: CustomLogger }} param.logger - optional logger configuration with log level and custom logger. + * @param {Partial} param.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({ @@ -54,6 +56,7 @@ export async function oidc({ storage?: Partial; }) { 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', @@ -92,7 +95,7 @@ export async function oidc({ */ authorize: { /** - * @function url + * @method 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. @@ -110,18 +113,15 @@ export async function oidc({ const wellknown = wellknownSelector(wellknownUrl, state); if (!wellknown?.authorization_endpoint) { - const err = { + return { error: 'Authorization endpoint not found in wellknown configuration', type: 'wellknown_error', - } as const; - - log.error(err.error); - - return err; + }; } return createAuthorizeUrl(wellknown.authorization_endpoint, optionsWithDefaults); }, + /** * @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. @@ -134,15 +134,11 @@ export async function oidc({ const wellknown = wellknownSelector(wellknownUrl, state); if (!wellknown?.authorization_endpoint) { - const err = { + return { error: 'Wellknown missing authorization endpoint', error_description: 'Authorization endpoint not found in wellknown configuration', type: 'wellknown_error', - } as AuthorizeErrorResponse; - - log.error(err.error); - - return err; + }; } const result = await Micro.runPromiseExit( @@ -158,7 +154,7 @@ export async function oidc({ error: 'Authorization failure', error_description: result.cause.message, type: 'auth_error', - } as AuthorizeErrorResponse; + }; } }, }, @@ -167,57 +163,44 @@ export async function oidc({ */ token: { /** - * @function exchange - * @description Exchanges an authorization code for tokens using the token endpoint from the wellknown configuration - * and stores them in the configured storage. + * @method 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} + * @returns {Promise} */ exchange: async ( code: string, state: string, options?: Partial, - ): Promise => { + ): Promise => { const storeState = store.getState(); const wellknown = wellknownSelector(wellknownUrl, storeState); if (!wellknown?.token_endpoint) { - const err = { + return { error: 'Wellknown missing token endpoint', type: 'wellknown_error', - } as const; - - log.error(err.error); - - return err; + }; } - const buildTokenExchangeµ = Micro.sync(() => - createValuesµ(code, config, state, wellknown, options), - ).pipe( - Micro.flatMap((options) => validateValuesµ(options)), - Micro.flatMap((requestOptions) => - Micro.promise(() => - store.dispatch(oidcApi.endpoints.exchange.initiate(requestOptions)), - ), - ), - Micro.flatMap(({ data, error }) => handleTokenResponseµ(data, error)), - Micro.flatMap((data) => - Micro.promise(async () => { - await storageClient.set({ - accessToken: data.access_token, - idToken: data.id_token, - refreshToken: data.refresh_token, - expiresAt: data.expires_in, - }); - return data; - }), - ), + const getTokensµ = buildTokenExchangeµ({ + code, + config, + state, + log, + endpoint: wellknown.token_endpoint, + store, + options, + }).pipe( + Micro.tap(async (tokens) => { + await storageClient.set(tokens); + }), ); - const result = await Micro.runPromiseExit(buildTokenExchangeµ); + const result = await Micro.runPromiseExit(getTokensµ); if (exitIsSuccess(result)) { return result.value; @@ -228,17 +211,105 @@ export async function oidc({ error: 'Token Exchange failure', message: result.cause.message, type: 'exchange_error', - } as TokenExchangeErrorResponse; + }; + } + }, + + /** + * @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} + */ + get: async ( + options?: GetTokensOptions, + ): Promise< + OauthTokens | TokenExchangeErrorResponse | AuthorizeErrorResponse | GenericError + > => { + const { backgroundRenew, authorizeOptions, storageOptions } = options || {}; + const state = store.getState(); + const wellknown = wellknownSelector(wellknownUrl, state); + + if (!wellknown?.authorization_endpoint) { + return { + error: 'Wellknown missing authorization endpoint', + type: 'wellknown_error', + }; + } + + const tokens = await storageClient.get(); + + // If there's an error, return early as there is an unknown issue from getting tokens + if (tokens && 'error' in tokens) { + return { + error: 'Error occurred while retrieving tokens', + message: 'Please log the user out completely and try again', + type: 'state_error', + }; + } + + // If we have tokens, and they are NOT expired, return them + if (tokens && !isExpiryWithinThreshold(oauthThreshold, tokens.expiryTimestamp)) { + return tokens; + } + + // If backgroundRenew is false, return token, regardless of expiration, or the "no tokens found" error + if (!backgroundRenew) { + return ( + tokens || { + error: 'No tokens found', + type: 'state_error', + } + ); + } + + // If we're here, backgroundRenew is true and we have no OR expired tokens, so renewal is needed + const attemptAuthorizeGetTokensµ = authorizeµ( + wellknown, + config, + log, + authorizeOptions, + ).pipe( + Micro.flatMap((response): Micro.Micro => { + return buildTokenExchangeµ({ + code: response.code, + config, + log, + state: response.state, + endpoint: wellknown.token_endpoint, + store, + options: storageOptions, + }); + }), + Micro.tap(async (tokens) => { + await storageClient.set(tokens); + }), + ); + + const result = await Micro.runPromiseExit(attemptAuthorizeGetTokensµ); + + if (exitIsSuccess(result)) { + return result.value; + } else if (exitIsFail(result)) { + return result.cause.error; + } else { + return { + error: 'Background token renewal failed', + error_description: result.cause.message, + type: 'auth_error', + }; } }, }, + /** * An object containing methods for user info retrieval and logout */ user: { /** - * @function info - Retrieves user information using the userinfo endpoint from the wellknown configuration. - * It requires an access token stored in the configured storage. + * @method info + * @description 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 => { @@ -246,27 +317,19 @@ export async function oidc({ const wellknown = wellknownSelector(wellknownUrl, state); if (!wellknown?.userinfo_endpoint) { - const err = { + return { error: 'Wellknown missing userinfo endpoint', type: 'wellknown_error', - } as AuthorizeErrorResponse; - - log.error(err.error); - - return err; + }; } const tokens = await storageClient.get(); if (!tokens || !('accessToken' in tokens)) { - const err = { + return { error: 'No access token found', type: 'auth_error', - } as const; - - log.error(err.error); - - return err; + }; } const info = Micro.promise(() => @@ -309,49 +372,39 @@ export async function oidc({ error: 'User Info retrieval failure', message: result.cause.message, type: 'auth_error', - } as const; + }; } }, + /** - * @function logout + * @method 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. + * @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; - } - > => { + logout: async (): Promise => { const state = store.getState(); const wellknown = wellknownSelector(wellknownUrl, state); if (!wellknown?.end_session_endpoint) { - const err = { + return { error: 'Wellknown missing end session endpoint', type: 'wellknown_error', - } as const; - - log.error(err.error); - - return err; + }; } const tokens = await storageClient.get(); if (!tokens) { - return createTokenError('no_tokens', log); + return createTokenError('no_tokens'); } if (!('accessToken' in tokens)) { - return createTokenError('no_access_token', log); + return createTokenError('no_access_token'); } if (!('idToken' in tokens)) { - return createTokenError('no_id_token', log); + return createTokenError('no_id_token'); } const logout = Micro.zip( @@ -400,7 +453,7 @@ export async function oidc({ error: 'Logout_Failure', message: result.cause.message, type: 'auth_error', - } 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 dd7c9d734..8800fedb3 100644 --- a/packages/oidc-client/src/lib/client.store.utils.ts +++ b/packages/oidc-client/src/lib/client.store.utils.ts @@ -82,10 +82,7 @@ export function createLogoutError( return null; } -export function createTokenError( - type: 'no_tokens' | 'no_access_token' | 'no_id_token', - log: ReturnType, -) { +export function createTokenError(type: 'no_tokens' | 'no_access_token' | 'no_id_token') { let error: GenericError; if (type === 'no_tokens') { @@ -114,8 +111,6 @@ export function createTokenError( } as const; } - log.error(error.message || 'Error occurred related to tokens'); - return error; } diff --git a/packages/oidc-client/src/lib/client.types.ts b/packages/oidc-client/src/lib/client.types.ts new file mode 100644 index 000000000..bb86a8024 --- /dev/null +++ b/packages/oidc-client/src/lib/client.types.ts @@ -0,0 +1,17 @@ +import type { GenericError, GetAuthorizationUrlOptions } from '@forgerock/sdk-types'; +import type { StorageConfig } from '@forgerock/storage'; + +export interface GetTokensOptions { + backgroundRenew?: boolean; + authorizeOptions?: GetAuthorizationUrlOptions; + storageOptions?: Partial; +} + +export type LogoutResult = Promise< + | GenericError + | { + sessionResponse: GenericError | null; + revokeResponse: GenericError | null; + deleteResponse: void; + } +>; diff --git a/packages/oidc-client/src/lib/config.types.ts b/packages/oidc-client/src/lib/config.types.ts index faf3bb501..28fa3e5f2 100644 --- a/packages/oidc-client/src/lib/config.types.ts +++ b/packages/oidc-client/src/lib/config.types.ts @@ -7,6 +7,7 @@ import type { AsyncLegacyConfigOptions, WellKnownResponse } from '@forgerock/sdk-types'; export interface OidcConfig extends AsyncLegacyConfigOptions { + // Redundant properties are redeclared to define as required clientId: string; redirectUri: string; scope: string; @@ -26,4 +27,5 @@ export interface OauthTokens { idToken: string; refreshToken?: string; expiresAt?: number; + expiryTimestamp?: number; } diff --git a/packages/oidc-client/src/lib/exchange.request.ts b/packages/oidc-client/src/lib/exchange.request.ts new file mode 100644 index 000000000..57c157b30 --- /dev/null +++ b/packages/oidc-client/src/lib/exchange.request.ts @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ +import { Micro } from 'effect'; + +import { logger } from '@forgerock/sdk-logger'; + +import { createValuesµ, handleTokenResponseµ, validateValuesµ } from './exchange.utils.js'; +import { oidcApi } from './oidc.api.js'; +import { createClientStore } from './client.store.utils.js'; + +import type { OauthTokens, OidcConfig } from './config.types.js'; +import type { StorageConfig } from 'node_modules/@forgerock/storage/src/lib/storage.effects.js'; +import { TokenExchangeErrorResponse } from './exchange.types.js'; + +interface BuildTokenExchangeµParams { + code: string; + config: OidcConfig; + endpoint: string; + log: ReturnType; + state: string; + store: ReturnType; + options?: Partial; +} + +export function buildTokenExchangeµ({ + code, + config, + endpoint, + log, + state, + store, + options, +}: BuildTokenExchangeµParams): Micro.Micro { + return createValuesµ(code, config, state, endpoint, options).pipe( + Micro.flatMap((options) => validateValuesµ(options)), + Micro.tap((options) => log.debug('Token exchange values created', options)), + Micro.tapError((options) => + Micro.sync(() => log.error('Error creating token exchange values', options)), + ), + Micro.flatMap((requestOptions) => + Micro.promise(() => store.dispatch(oidcApi.endpoints.exchange.initiate(requestOptions))), + ), + Micro.flatMap(({ data, error }) => handleTokenResponseµ(data, error)), + Micro.tap((data) => log.debug('Token exchange response handled', data)), + Micro.tapError((error) => + Micro.sync(() => log.error('Error handling token exchange response', error)), + ), + Micro.map((data) => { + const tokens = { + accessToken: data.access_token, + idToken: data.id_token, + ...(data.refresh_token && { refreshToken: data.refresh_token }), + ...(data.expires_in && { expiresAt: data.expires_in }), + ...(data.expires_in && { expiryTimestamp: Date.now() + data.expires_in * 1000 }), + }; + + return tokens; + }), + ); +} diff --git a/packages/oidc-client/src/lib/exchange.types.ts b/packages/oidc-client/src/lib/exchange.types.ts index 089b2ae31..4169b5de2 100644 --- a/packages/oidc-client/src/lib/exchange.types.ts +++ b/packages/oidc-client/src/lib/exchange.types.ts @@ -1,3 +1,9 @@ +/* + * 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 { OidcConfig } from './config.types.js'; export interface TokenExchangeResponse { @@ -12,7 +18,7 @@ export interface TokenExchangeResponse { export interface TokenExchangeErrorResponse { error: string; message: string; - type: 'exchange_error' | 'network_error' | 'unknown_error'; + type: 'exchange_error' | 'network_error' | 'state_error' | 'unknown_error'; } export interface TokenRequestOptions { diff --git a/packages/oidc-client/src/lib/exchange.utils.ts b/packages/oidc-client/src/lib/exchange.utils.ts index 79de316b8..695467833 100644 --- a/packages/oidc-client/src/lib/exchange.utils.ts +++ b/packages/oidc-client/src/lib/exchange.utils.ts @@ -1,3 +1,9 @@ +/* + * 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 { SerializedError } from '@reduxjs/toolkit'; import { FetchBaseQueryError } from '@reduxjs/toolkit/query'; import { Micro } from 'effect'; @@ -5,7 +11,6 @@ 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'; @@ -16,18 +21,20 @@ export function createValuesµ( code: string, config: OidcConfig, state: string, - wellknown: WellKnownResponse, + endpoint: string, options?: Partial, ) { - const storedValues = getStoredAuthUrlValues(config.clientId, options?.prefix); + return Micro.sync(() => { + const storedValues = getStoredAuthUrlValues(config.clientId, options?.prefix); - return { - code, - config, - state, - storedValues, - wellknown, - }; + return { + code, + config, + state, + storedValues, + endpoint, + }; + }); } export function handleTokenResponseµ( @@ -65,13 +72,13 @@ export function validateValuesµ({ config, state, storedValues, - wellknown, + endpoint, }: { code: string; config: OidcConfig; state: string; storedValues: GetAuthorizationUrlOptions; - wellknown: { token_endpoint: string }; + endpoint: string; }) { if (!storedValues || storedValues.state !== state) { const err = { @@ -79,14 +86,14 @@ export function validateValuesµ({ message: 'The provided state does not match the stored state. This is likely due to passing in used, returned, authorize parameters.', type: 'state_error', - } as GenericError; + } as const; - return Micro.fail(err as GenericError); + return Micro.fail(err); } return Micro.succeed({ code, config, - endpoint: wellknown.token_endpoint, + endpoint, ...(storedValues.verifier && { verifier: storedValues.verifier }), // Optional PKCE } as TokenRequestOptions); } diff --git a/packages/oidc-client/src/lib/oidc.api.ts b/packages/oidc-client/src/lib/oidc.api.ts index 9350c62e7..bc25f6d9d 100644 --- a/packages/oidc-client/src/lib/oidc.api.ts +++ b/packages/oidc-client/src/lib/oidc.api.ts @@ -1,19 +1,33 @@ -import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query'; +import { createApi, FetchArgs, fetchBaseQuery } from '@reduxjs/toolkit/query'; import { OidcConfig } from './config.types.js'; -import { TokenExchangeResponse } from './exchange.types.js'; import { transformError } from './oidc.api.utils.js'; +import type { logger as loggerFn } from '@forgerock/sdk-logger'; +import { + initQuery, + type ActionTypes, + type RequestMiddleware, +} from '@forgerock/sdk-request-middleware'; + +import type { TokenExchangeResponse } from './exchange.types.js'; + +interface Extras { + requestMiddleware: RequestMiddleware[]; + logger: ReturnType; +} + export const oidcApi = createApi({ reducerPath: 'oidc', baseQuery: fetchBaseQuery(), endpoints: (builder) => ({ endSession: builder.mutation({ - query: ({ idToken, endpoint }) => { - // append the id_token_hint to the end session endpoint + queryFn: async ({ idToken, endpoint }, api, _, baseQuery) => { + const { requestMiddleware, logger } = api.extra as Extras; + const url = new URL(endpoint); url.searchParams.append('id_token_hint', idToken); - return { + const request: FetchArgs = { url: url.toString(), method: 'GET', credentials: 'include', @@ -21,19 +35,33 @@ export const oidcApi = createApi({ Accept: 'application/json', }, }; - }, - 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'; + + logger.debug('OIDC endSession API request', request); + + const response = await initQuery(request, 'endSession') + .applyMiddleware(requestMiddleware) + .applyQuery(async (req: FetchArgs) => await baseQuery(req)); + + if (response.error) { + let message = 'An error occurred while trying to end the session'; + + if (response.error.status === 400) { + message = 'Bad request to end session endpoint'; + } else if (response.error.status === 401) { + message = 'Unauthorized request to end session endpoint'; + } else if (response.error.status === 403) { + message = 'Forbidden request to end session endpoint'; + } + + logger.error('End Session API error', message); + + response.error.data = transformError('End Session Error', message, response.error.status); + return response; } - return transformError('End Session Error', message, error.status); + logger.debug('OIDC endSession API response', response); + + return response as { data: null }; }, }), exchange: builder.mutation< @@ -45,7 +73,9 @@ export const oidcApi = createApi({ verifier?: string; } >({ - query: ({ code, config, endpoint, verifier }) => { + queryFn: async ({ code, config, endpoint, verifier }, api, _, baseQuery) => { + const { requestMiddleware, logger } = api.extra as Extras; + const { clientId, redirectUri } = config; const body = new URLSearchParams({ grant_type: 'authorization_code', @@ -58,7 +88,7 @@ export const oidcApi = createApi({ body.append('code_verifier', verifier); } - return { + const request = { url: endpoint, method: 'POST', headers: { @@ -67,28 +97,49 @@ export const oidcApi = createApi({ }, body, }; - }, - 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'; + + logger.debug('OIDC tokenExchange API request', request); + + const response = await initQuery(request, 'tokenExchange') + .applyMiddleware(requestMiddleware) + .applyQuery(async (req: FetchArgs) => await baseQuery(req)); + + if (response.error) { + let message = 'An error occurred while exchanging the authorization code'; + + if (response.error.status === 400) { + message = 'Bad request to token exchange endpoint'; + } else if (response.error.status === 401) { + message = 'Unauthorized request to token exchange endpoint'; + } else if (response.error.status === 403) { + message = 'Forbidden request to token exchange endpoint'; + } + + logger.error('Token Exchange API error', message); + + response.error.data = transformError( + 'Token Exchange Error', + message, + response.error.status, + ); + + return response; } - return transformError('Token Exchange Error', message, error.status); + logger.debug('OIDC tokenExchange API response', response); + + return response as { data: TokenExchangeResponse }; }, }), revoke: builder.mutation({ - query: ({ accessToken, clientId, endpoint }) => { + queryFn: async ({ accessToken, clientId, endpoint }, api, _, baseQuery) => { + const { requestMiddleware, logger } = api.extra as Extras; + const body = new URLSearchParams({ ...(clientId ? { client_id: clientId } : {}), token: accessToken, }); - return { + const request: FetchArgs = { url: endpoint, method: 'POST', credentials: 'include', @@ -97,24 +148,44 @@ export const oidcApi = createApi({ }, body, }; - }, - 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'; + + logger.debug('OIDC revoke API request', request); + + const response = await initQuery(request, 'revoke') + .applyMiddleware(requestMiddleware) + .applyQuery(async (req: FetchArgs) => await baseQuery(req)); + + if (response.error) { + let message = 'An error occurred while revoking the token'; + + if (response.error.status === 400) { + message = 'Bad request to revoke endpoint'; + } else if (response.error.status === 401) { + message = 'Unauthorized request to revoke endpoint'; + } else if (response.error.status === 403) { + message = 'Forbidden request to revoke endpoint'; + } + + logger.error('Token Revoke API error', message); + + response.error.data = transformError( + 'Token Revoke Error', + message, + response.error.status, + ); + return response; } - return transformError('Token Revoke Error', message, error.status); + logger.debug('OIDC revoke API response', response); + + return response as { data: object }; }, }), userInfo: builder.mutation({ - query: ({ accessToken, endpoint }) => { - return { + queryFn: async ({ accessToken, endpoint }, api, _, baseQuery) => { + const { requestMiddleware, logger } = api.extra as Extras; + + const request: FetchArgs = { url: endpoint, method: 'GET', credentials: 'include', @@ -123,19 +194,33 @@ export const oidcApi = createApi({ Authorization: `Bearer ${accessToken}`, }, }; - }, - transformErrorResponse: (error) => { + + logger.debug('OIDC userInfo API request', request); + + const response = await initQuery(request, 'userInfo') + .applyMiddleware(requestMiddleware) + .applyQuery(async (req: FetchArgs) => await baseQuery(req)); + 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'; + if (response.error) { + if (response.error.status === 400) { + message = 'Bad request to user info endpoint'; + } else if (response.error.status === 401) { + message = 'Unauthorized request to user info endpoint'; + } else if (response.error.status === 403) { + message = 'Forbidden request to user info endpoint'; + } + + logger.error('User Info API error', message); + + response.error.data = transformError('User Info Error', message, response.error.status); + return response; } - return transformError('User Info Error', message, error.status); + logger.debug('OIDC userInfo API response', response); + + return response as { data: TokenExchangeResponse }; }, }), }), diff --git a/packages/oidc-client/src/lib/token.utils.ts b/packages/oidc-client/src/lib/token.utils.ts new file mode 100644 index 000000000..81ed54992 --- /dev/null +++ b/packages/oidc-client/src/lib/token.utils.ts @@ -0,0 +1,15 @@ +/* + * 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 function isExpiryWithinThreshold(oauthThreshold?: number, tokenExpiry?: number): boolean { + if (oauthThreshold && tokenExpiry) { + const expiryTimeMinusThreshold = tokenExpiry - oauthThreshold; + const result = expiryTimeMinusThreshold < Date.now(); + + return result; + } + return false; +} diff --git a/packages/oidc-client/src/types.ts b/packages/oidc-client/src/types.ts new file mode 100644 index 000000000..8bbefe628 --- /dev/null +++ b/packages/oidc-client/src/types.ts @@ -0,0 +1,9 @@ +/* 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 * from './lib/client.types.js'; +export * from './lib/config.types.js'; +export * from './lib/authorize.request.types.js'; +export * from './lib/exchange.types.js'; 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 749b02bd4..6a880c147 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 @@ -6,6 +6,7 @@ */ export const actionTypes = { + // DaVinci start: 'DAVINCI_START', next: 'DAVINCI_NEXT', flow: 'DAVINCI_FLOW', @@ -13,6 +14,13 @@ export const actionTypes = { error: 'DAVINCI_ERROR', failure: 'DAVINCI_FAILURE', resume: 'DAVINCI_RESUME', + + // OIDC + authorize: 'AUTHORIZE', + tokenExchange: 'TOKEN_EXCHANGE', + revoke: 'REVOKE', + userInfo: 'USER_INFO', + endSession: 'END_SESSION', } as const; export type ActionTypes = (typeof actionTypes)[keyof typeof actionTypes]; diff --git a/packages/sdk-types/src/lib/error.types.ts b/packages/sdk-types/src/lib/error.types.ts index 6a73c8c5d..0d64896a9 100644 --- a/packages/sdk-types/src/lib/error.types.ts +++ b/packages/sdk-types/src/lib/error.types.ts @@ -13,6 +13,7 @@ export interface GenericError { | 'argument_error' | 'auth_error' | 'davinci_error' + | 'exchange_error' | 'internal_error' | 'network_error' | 'parse_error'