From 316458c4db98be36bbcb316211f0bcfa459bad74 Mon Sep 17 00:00:00 2001 From: ryanbas21 Date: Mon, 30 Jun 2025 14:41:57 -0600 Subject: [PATCH 1/4] feat: implement-authorize-oidc-client introduce the oidc-client with the authorize api. authoirize handles the calling of authorize routes based on the well-known config. when a p1 env is detected, it will use a post request otherwise, a background request is done via a hidden iframe --- .../oidc-client/src/lib/authorize.slice.ts | 23 +++++++ .../src/lib/client.store.utils.test.ts | 67 +++++++++++++++++++ packages/oidc-client/src/lib/store.ts | 36 ++++++++++ packages/oidc-client/tsconfig.json | 12 ++++ pnpm-lock.yaml | 54 +++++++++++---- 5 files changed, 178 insertions(+), 14 deletions(-) create mode 100644 packages/oidc-client/src/lib/authorize.slice.ts create mode 100644 packages/oidc-client/src/lib/client.store.utils.test.ts create mode 100644 packages/oidc-client/src/lib/store.ts diff --git a/packages/oidc-client/src/lib/authorize.slice.ts b/packages/oidc-client/src/lib/authorize.slice.ts new file mode 100644 index 0000000000..72838b0a09 --- /dev/null +++ b/packages/oidc-client/src/lib/authorize.slice.ts @@ -0,0 +1,23 @@ +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query'; + +const authorizeSlice = createApi({ + reducerPath: 'authorizeSlice', + baseQuery: fetchBaseQuery({ + credentials: 'include', + prepareHeaders: (headers) => { + headers.set('Content-Type', 'application/json'); + headers.set('Accept', 'application/json'); + headers.set('x-requested-with', 'ping-sdk'); + headers.set('x-requested-platform', 'javascript'); + + return headers; + }, + }), + endpoints: (builder) => ({ + handleAuthorize: builder.query({ + query: (authorizeUrl) => authorizeUrl, + }), + }), +}); + +export { authorizeSlice }; diff --git a/packages/oidc-client/src/lib/client.store.utils.test.ts b/packages/oidc-client/src/lib/client.store.utils.test.ts new file mode 100644 index 0000000000..1775c5f29b --- /dev/null +++ b/packages/oidc-client/src/lib/client.store.utils.test.ts @@ -0,0 +1,67 @@ +import { recreateAuthorizeUrl, handleAuthorize } from './client.store.utils.js'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; + +// Mock createAuthorizeUrl from sdk to avoid actual URL generation logic or network calls +vi.mock('@forgerock/sdk-oidc', () => { + return { + createAuthorizeUrl: vi + .fn() + .mockResolvedValue( + 'https://example.com/authorize?client_id=client_id&redirect_uri=https%3A%2F%2Fexample.com%2Fredirect&scope=openid&state=state&response_type=code', + ), + }; +}); + +// Stub the global fetch used inside handleAuthorize so it returns predictable JSON +beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + json: async () => ({ + authorizeResponse: { + code: 'code', + state: 'state', + }, + }), + }), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('client-store utilities', () => { + it('should handle authorize', async () => { + const result = await handleAuthorize('https://example.com/authorize'); + expect(result).toEqual({ + code: 'code', + state: 'state', + }); + }); + + it('should recreate authorize url', async () => { + const result = await recreateAuthorizeUrl( + { + error: 'error', + error_description: 'error_description', + state: 'state', + }, + 'https://example.com/authorize', + { + clientId: 'client_id', + redirectUri: 'https://example.com/redirect', + scope: 'openid', + state: 'state', + responseType: 'code', + }, + ); + expect(result).toEqual({ + error: 'error', + error_description: 'error_description', + state: 'state', + redirectUrl: + 'https://example.com/authorize?client_id=client_id&redirect_uri=https%3A%2F%2Fexample.com%2Fredirect&scope=openid&state=state&response_type=code', + }); + }); +}); diff --git a/packages/oidc-client/src/lib/store.ts b/packages/oidc-client/src/lib/store.ts new file mode 100644 index 0000000000..4ece16015b --- /dev/null +++ b/packages/oidc-client/src/lib/store.ts @@ -0,0 +1,36 @@ +import type { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-middleware'; +import type { logger as loggerFn } from '@forgerock/sdk-logger'; + +import { configureStore } from '@reduxjs/toolkit'; +import { fetchWellKnownConfig } from './wellknown.api.js'; +import { authorizeSlice } from './authorize.slice.js'; + +export function createOidcStore({ + requestMiddleware, + logger, +}: { + requestMiddleware?: RequestMiddleware[]; + logger?: ReturnType; +}) { + return configureStore({ + reducer: { + [fetchWellKnownConfig.reducerPath]: fetchWellKnownConfig.reducer, + [authorizeSlice.reducerPath]: authorizeSlice.reducer, + }, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ + thunk: { + extraArgument: { + /** + * This becomes the `api.extra` argument, and will be passed into the + * customer query wrapper for `baseQuery` + */ + requestMiddleware, + logger, + }, + }, + }) + .concat(fetchWellKnownConfig.middleware) + .concat(authorizeSlice.middleware), + }); +} diff --git a/packages/oidc-client/tsconfig.json b/packages/oidc-client/tsconfig.json index 6d83720aaa..b7a543d3be 100644 --- a/packages/oidc-client/tsconfig.json +++ b/packages/oidc-client/tsconfig.json @@ -18,6 +18,18 @@ { "path": "../sdk-effects/iframe-manager" }, + { + "path": "../sdk-effects/sdk-request-middleware" + }, + { + "path": "../sdk-effects/oidc" + }, + { + "path": "../sdk-effects/logger" + }, + { + "path": "../sdk-effects/iframe-manager" + }, { "path": "./tsconfig.lib.json" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22ec94592f..f0ec57c88f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -220,7 +220,7 @@ importers: version: 6.2.6(@types/node@22.14.1)(jiti@2.4.2)(terser@5.42.0)(yaml@2.8.0) vitest: specifier: 3.0.5 - version: 3.0.5(@types/node@22.14.1)(@vitest/ui@3.0.4(vitest@3.0.5))(jiti@2.4.2)(jsdom@26.1.0)(msw@2.8.5(@types/node@22.14.1)(typescript@5.8.3))(terser@5.42.0)(yaml@2.8.0) + version: 3.0.5(@types/node@22.14.1)(@vitest/ui@3.0.4)(jiti@2.4.2)(jsdom@26.1.0)(msw@2.8.5(@types/node@22.14.1)(typescript@5.8.3))(terser@5.42.0)(yaml@2.8.0) vitest-canvas-mock: specifier: ^0.3.3 version: 0.3.3(vitest@3.0.5) @@ -284,10 +284,13 @@ importers: devDependencies: '@effect/vitest': specifier: ^0.19.0 - version: 0.19.10(effect@3.16.0)(vitest@3.0.5(@types/node@22.14.1)(@vitest/ui@3.0.4(vitest@3.0.5))(jiti@2.4.2)(jsdom@26.1.0)(msw@2.8.5(@types/node@22.14.1)(typescript@5.8.3))(terser@5.42.0)(yaml@2.8.0)) + version: 0.19.10(effect@3.16.0)(vitest@3.0.5(@types/node@22.14.1)(@vitest/ui@3.0.4)(jiti@2.4.2)(jsdom@26.1.0)(msw@2.8.5(@types/node@22.14.1)(typescript@5.8.3))(terser@5.42.0)(yaml@2.8.0)) e2e/oidc-app: dependencies: + '@forgerock/javascript-sdk': + specifier: ^4.8.2 + version: 4.8.2 '@forgerock/javascript-sdk': specifier: ^4.8.2 version: 4.8.2 @@ -334,7 +337,7 @@ importers: devDependencies: vitest: specifier: ^3.0.4 - version: 3.0.5(@types/node@22.14.1)(@vitest/ui@3.0.4(vitest@3.0.5))(jiti@2.4.2)(jsdom@26.1.0)(msw@2.8.5(@types/node@22.14.1)(typescript@5.8.3))(terser@5.42.0)(yaml@2.8.0) + version: 3.0.5(@types/node@22.14.1)(@vitest/ui@3.0.4)(jiti@2.4.2)(jsdom@26.1.0)(msw@2.8.5(@types/node@22.14.1)(typescript@5.8.3))(terser@5.42.0)(yaml@2.8.0) packages/device-client: dependencies: @@ -351,6 +354,18 @@ importers: packages/oidc-client: dependencies: + '@forgerock/iframe-manager': + specifier: workspace:* + version: link:../sdk-effects/iframe-manager + '@forgerock/sdk-logger': + specifier: workspace:* + version: link:../sdk-effects/logger + '@forgerock/sdk-oidc': + specifier: workspace:* + version: link:../sdk-effects/oidc + '@forgerock/sdk-request-middleware': + specifier: workspace:* + version: link:../sdk-effects/sdk-request-middleware '@forgerock/iframe-manager': specifier: workspace:* version: link:../sdk-effects/iframe-manager @@ -366,12 +381,12 @@ importers: '@forgerock/sdk-types': specifier: workspace:* version: link:../sdk-types + '@forgerock/storage': + specifier: workspace:* + version: link:../sdk-effects/storage '@reduxjs/toolkit': specifier: 'catalog:' version: 2.8.2 - effect: - specifier: ^3.12.7 - version: 3.16.0 packages/protect: dependencies: @@ -1515,6 +1530,9 @@ packages: '@forgerock/javascript-sdk@4.8.2': resolution: {integrity: sha512-vk30flcVa0ypa92YOqEPiIZlXxTF+QVrd/ADtn0G5fIAGNUE2LMKpiGVZGAooqdaT9DpAymvaXrWCV9JLDDlMA==} + '@forgerock/javascript-sdk@4.8.2': + resolution: {integrity: sha512-vk30flcVa0ypa92YOqEPiIZlXxTF+QVrd/ADtn0G5fIAGNUE2LMKpiGVZGAooqdaT9DpAymvaXrWCV9JLDDlMA==} + '@gerrit0/mini-shiki@1.27.2': resolution: {integrity: sha512-GeWyHz8ao2gBiUW4OJnQDxXQnFgZQwwQk05t/CVVgNBN7/rK8XZ7xY6YhLVv9tH3VppWWmr9DCl3MwemB/i+Og==} @@ -8484,15 +8502,15 @@ snapshots: dependencies: effect: 3.16.0 - '@effect/vitest@0.19.10(effect@3.16.0)(vitest@3.0.5(@types/node@22.14.1)(@vitest/ui@3.0.4(vitest@3.0.5))(jiti@2.4.2)(jsdom@26.1.0)(msw@2.8.5(@types/node@22.14.1)(typescript@5.8.3))(terser@5.42.0)(yaml@2.8.0))': + '@effect/vitest@0.19.10(effect@3.16.0)(vitest@3.0.5(@types/node@22.14.1)(@vitest/ui@3.0.4)(jiti@2.4.2)(jsdom@26.1.0)(msw@2.8.5(@types/node@22.14.1)(typescript@5.8.3))(terser@5.42.0)(yaml@2.8.0))': dependencies: effect: 3.16.0 - vitest: 3.0.5(@types/node@22.14.1)(@vitest/ui@3.0.4(vitest@3.0.5))(jiti@2.4.2)(jsdom@26.1.0)(msw@2.8.5(@types/node@22.14.1)(typescript@5.8.3))(terser@5.42.0)(yaml@2.8.0) + vitest: 3.0.5(@types/node@22.14.1)(@vitest/ui@3.0.4)(jiti@2.4.2)(jsdom@26.1.0)(msw@2.8.5(@types/node@22.14.1)(typescript@5.8.3))(terser@5.42.0)(yaml@2.8.0) '@effect/vitest@0.6.12(effect@3.16.0)(vitest@3.0.5)': dependencies: effect: 3.16.0 - vitest: 3.0.5(@types/node@22.14.1)(@vitest/ui@3.0.4(vitest@3.0.5))(jiti@2.4.2)(jsdom@26.1.0)(msw@2.8.5(@types/node@22.14.1)(typescript@5.8.3))(terser@5.42.0)(yaml@2.8.0) + vitest: 3.0.5(@types/node@22.14.1)(@vitest/ui@3.0.4)(jiti@2.4.2)(jsdom@26.1.0)(msw@2.8.5(@types/node@22.14.1)(typescript@5.8.3))(terser@5.42.0)(yaml@2.8.0) '@emnapi/core@1.4.3': dependencies: @@ -8646,6 +8664,14 @@ snapshots: - react - react-redux + '@forgerock/javascript-sdk@4.8.2': + dependencies: + '@reduxjs/toolkit': 2.8.2 + immer: 10.1.1 + transitivePeerDependencies: + - react + - react-redux + '@gerrit0/mini-shiki@1.27.2': dependencies: '@shikijs/engine-oniguruma': 1.29.2 @@ -9198,7 +9224,7 @@ snapshots: semver: 7.7.2 tsconfig-paths: 4.2.0 vite: 6.2.6(@types/node@22.14.1)(jiti@2.4.2)(terser@5.42.0)(yaml@2.8.0) - vitest: 3.0.5(@types/node@22.14.1)(@vitest/ui@3.0.4(vitest@3.0.5))(jiti@2.4.2)(jsdom@26.1.0)(msw@2.8.5(@types/node@22.14.1)(typescript@5.8.3))(terser@5.42.0)(yaml@2.8.0) + vitest: 3.0.5(@types/node@22.14.1)(@vitest/ui@3.0.4)(jiti@2.4.2)(jsdom@26.1.0)(msw@2.8.5(@types/node@22.14.1)(typescript@5.8.3))(terser@5.42.0)(yaml@2.8.0) transitivePeerDependencies: - '@babel/traverse' - '@swc-node/register' @@ -10058,7 +10084,7 @@ snapshots: std-env: 3.9.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.0.5(@types/node@22.14.1)(@vitest/ui@3.0.4(vitest@3.0.5))(jiti@2.4.2)(jsdom@26.1.0)(msw@2.8.5(@types/node@22.14.1)(typescript@5.8.3))(terser@5.42.0)(yaml@2.8.0) + vitest: 3.0.5(@types/node@22.14.1)(@vitest/ui@3.0.4)(jiti@2.4.2)(jsdom@26.1.0)(msw@2.8.5(@types/node@22.14.1)(typescript@5.8.3))(terser@5.42.0)(yaml@2.8.0) transitivePeerDependencies: - supports-color @@ -10114,7 +10140,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.0.5(@types/node@22.14.1)(@vitest/ui@3.0.4(vitest@3.0.5))(jiti@2.4.2)(jsdom@26.1.0)(msw@2.8.5(@types/node@22.14.1)(typescript@5.8.3))(terser@5.42.0)(yaml@2.8.0) + vitest: 3.0.5(@types/node@22.14.1)(@vitest/ui@3.0.4)(jiti@2.4.2)(jsdom@26.1.0)(msw@2.8.5(@types/node@22.14.1)(typescript@5.8.3))(terser@5.42.0)(yaml@2.8.0) '@vitest/utils@3.0.4': dependencies: @@ -14974,9 +15000,9 @@ snapshots: vitest-canvas-mock@0.3.3(vitest@3.0.5): dependencies: jest-canvas-mock: 2.5.2 - vitest: 3.0.5(@types/node@22.14.1)(@vitest/ui@3.0.4(vitest@3.0.5))(jiti@2.4.2)(jsdom@26.1.0)(msw@2.8.5(@types/node@22.14.1)(typescript@5.8.3))(terser@5.42.0)(yaml@2.8.0) + vitest: 3.0.5(@types/node@22.14.1)(@vitest/ui@3.0.4)(jiti@2.4.2)(jsdom@26.1.0)(msw@2.8.5(@types/node@22.14.1)(typescript@5.8.3))(terser@5.42.0)(yaml@2.8.0) - vitest@3.0.5(@types/node@22.14.1)(@vitest/ui@3.0.4(vitest@3.0.5))(jiti@2.4.2)(jsdom@26.1.0)(msw@2.8.5(@types/node@22.14.1)(typescript@5.8.3))(terser@5.42.0)(yaml@2.8.0): + vitest@3.0.5(@types/node@22.14.1)(@vitest/ui@3.0.4)(jiti@2.4.2)(jsdom@26.1.0)(msw@2.8.5(@types/node@22.14.1)(typescript@5.8.3))(terser@5.42.0)(yaml@2.8.0): dependencies: '@vitest/expect': 3.0.5 '@vitest/mocker': 3.0.5(msw@2.8.5(@types/node@22.14.1)(typescript@5.8.3))(vite@6.2.6(@types/node@22.14.1)(jiti@2.4.2)(terser@5.42.0)(yaml@2.8.0)) From 41a0e289c8c237ded9f503e313b4129187a3dfb1 Mon Sep 17 00:00:00 2001 From: Justin Lowery Date: Wed, 16 Jul 2025 01:40:29 -0500 Subject: [PATCH 2/4] feat(oidc-client): improve Effect usage --- .../oidc-client/src/lib/authorize.request.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/packages/oidc-client/src/lib/authorize.request.ts b/packages/oidc-client/src/lib/authorize.request.ts index eea5d11f8e..134c8899b5 100644 --- a/packages/oidc-client/src/lib/authorize.request.ts +++ b/packages/oidc-client/src/lib/authorize.request.ts @@ -1,9 +1,12 @@ +<<<<<<< HEAD /* * 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. */ +======= +>>>>>>> f35d74ad (feat(oidc-client): improve Effect usage) import { CustomLogger } from '@forgerock/sdk-logger'; import { GetAuthorizationUrlOptions } from '@forgerock/sdk-oidc'; import { Micro } from 'effect'; @@ -19,7 +22,11 @@ import { import type { WellKnownResponse } from '@forgerock/sdk-types'; import type { OidcConfig } from './config.types.js'; +<<<<<<< HEAD import { AuthorizeErrorResponse, AuthorizeSuccessResponse } from './authorize.request.types.js'; +======= +import { AuthorizeSuccessResponse } from './authorize.request.types.js'; +>>>>>>> f35d74ad (feat(oidc-client): improve Effect usage) export async function authorizeµ( wellknown: WellKnownResponse, @@ -27,10 +34,27 @@ export async function authorizeµ( log: CustomLogger, options?: GetAuthorizationUrlOptions, ) { +<<<<<<< HEAD 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))), +======= + const buildAuthorizeRequestµ = buildAuthorizeOptionsµ(wellknown, config, options).pipe( + Micro.flatMap(([url, config, options]) => createAuthorizeUrlµ(url, config, options)), + (effect) => { + return Micro.matchEffect(effect, { + onSuccess: (url) => { + log.debug('Created authorization URL', { url }); + return effect; + }, + onFailure: (error) => { + log.error('Error creating authorization URL', { error }); + return effect; + }, + }); + }, +>>>>>>> f35d74ad (feat(oidc-client): improve Effect usage) Micro.flatMap(([url, config, options]) => { if (options.responseMode === 'pi.flow') { /** @@ -40,12 +64,24 @@ export async function authorizeµ( */ return authorizeFetchµ(url).pipe( Micro.flatMap((response) => { +<<<<<<< HEAD if ('authorizeResponse' in response) { log.debug('Received authorize response', response.authorizeResponse); return Micro.succeed(response.authorizeResponse); } log.error('Error in authorize response', response); return Micro.fail(createAuthorizeErrorµ(response, wellknown, config, options)); +======= + return Micro.gen(function* () { + if ('authorizeResponse' in response) { + log.debug('Received authorize response', response.authorizeResponse); + return yield* Micro.succeed(response.authorizeResponse as AuthorizeSuccessResponse); + } + log.error('Error in authorize response', response); + const errorResponse = response as { error: string; error_description: string }; + return yield* createAuthorizeErrorµ(errorResponse, wellknown, config, options); + }); +>>>>>>> f35d74ad (feat(oidc-client): improve Effect usage) }), ); } else { @@ -55,6 +91,7 @@ export async function authorizeµ( */ return authorizeIframeµ(url, config).pipe( Micro.flatMap((response) => { +<<<<<<< HEAD if ('code' in response && 'state' in response) { log.debug('Received authorization code', response); return Micro.succeed(response as unknown as AuthorizeSuccessResponse); @@ -62,9 +99,25 @@ export async function authorizeµ( log.error('Error in authorize response', response); const errorResponse = response as unknown as AuthorizeErrorResponse; return Micro.fail(createAuthorizeErrorµ(errorResponse, wellknown, config, options)); +======= + return Micro.gen(function* () { + if ('code' in response && 'state' in response) { + log.debug('Received authorization code', response); + return yield* Micro.succeed(response as unknown as AuthorizeSuccessResponse); + } + log.error('Error in authorize response', response); + const errorResponse = response as { error: string; error_description: string }; + return yield* createAuthorizeErrorµ(errorResponse, wellknown, config, options); + }); +>>>>>>> f35d74ad (feat(oidc-client): improve Effect usage) }), ); } }), ); +<<<<<<< HEAD +======= + + return Micro.runPromiseExit(buildAuthorizeRequestµ); +>>>>>>> f35d74ad (feat(oidc-client): improve Effect usage) } From 3129ce2fd3b5bb5c04d511c9d6b29627da002f92 Mon Sep 17 00:00:00 2001 From: Justin Lowery Date: Thu, 17 Jul 2025 17:39:32 -0500 Subject: [PATCH 3/4] feat(oidc-client): implement token exchange --- e2e/oidc-app/src/main.ts | 12 ++- packages/oidc-client/package.json | 1 + .../src/lib/authorize.request.types.ts | 4 +- .../src/lib/authorize.request.utils.ts | 5 +- packages/oidc-client/src/lib/client.store.ts | 94 ++++++++++++++++--- .../oidc-client/src/lib/client.store.types.ts | 6 ++ .../oidc-client/src/lib/client.store.utils.ts | 6 +- packages/oidc-client/src/lib/error.types.ts | 3 +- packages/oidc-client/src/lib/oidc.api.ts | 43 +++++++++ packages/oidc-client/src/lib/token.types.ts | 17 ++++ packages/oidc-client/tsconfig.json | 3 + packages/oidc-client/tsconfig.lib.json | 3 + 12 files changed, 177 insertions(+), 20 deletions(-) create mode 100644 packages/oidc-client/src/lib/client.store.types.ts create mode 100644 packages/oidc-client/src/lib/oidc.api.ts create mode 100644 packages/oidc-client/src/lib/token.types.ts diff --git a/e2e/oidc-app/src/main.ts b/e2e/oidc-app/src/main.ts index 92c9748d09..22940b7f95 100644 --- a/e2e/oidc-app/src/main.ts +++ b/e2e/oidc-app/src/main.ts @@ -16,7 +16,7 @@ async function app() { // create object from URL query parameters const urlParams = new URLSearchParams(window.location.search); const code = urlParams.get('code'); - // const state = urlParams.get('state'); + const state = urlParams.get('state'); // get error and error_description if they exist const error = urlParams.get('error'); // const errorDescription = urlParams.get('error_description'); @@ -26,11 +26,19 @@ async function app() { if ('error' in response) { console.error('Authorization Error:', response); - // window.location.assign(response.redirectUrl); + window.location.assign(response.redirectUrl); return; } else if ('code' in response) { console.log('Authorization Code:', response.code); } + } else if (code && state) { + const response = await oidcClient.token.exchange(code, state); + + if ('error' in response) { + console.error('Token Exchange Error:', response); + } else { + console.log('Token Exchange Response:', response); + } } } diff --git a/packages/oidc-client/package.json b/packages/oidc-client/package.json index e3a18ba5da..a87760bc26 100644 --- a/packages/oidc-client/package.json +++ b/packages/oidc-client/package.json @@ -31,6 +31,7 @@ "@forgerock/sdk-oidc": "workspace:*", "@forgerock/sdk-request-middleware": "workspace:*", "@forgerock/sdk-types": "workspace:*", + "@forgerock/storage": "workspace:*", "@reduxjs/toolkit": "catalog:", "effect": "^3.12.7" }, diff --git a/packages/oidc-client/src/lib/authorize.request.types.ts b/packages/oidc-client/src/lib/authorize.request.types.ts index 1618b27e90..0ecd3d5c98 100644 --- a/packages/oidc-client/src/lib/authorize.request.types.ts +++ b/packages/oidc-client/src/lib/authorize.request.types.ts @@ -13,6 +13,6 @@ export interface AuthorizeSuccessResponse { export interface AuthorizeErrorResponse { error: string; error_description: string; - redirectUrl: string; // URL to redirect the user to for re-authorization - type: 'auth_error'; + redirectUrl?: string; // URL to redirect the user to for re-authorization + type: 'auth_error' | 'wellknown_error' | 'network_error' | 'unknown_error'; } diff --git a/packages/oidc-client/src/lib/authorize.request.utils.ts b/packages/oidc-client/src/lib/authorize.request.utils.ts index 7c260c2e3c..5be338617d 100644 --- a/packages/oidc-client/src/lib/authorize.request.utils.ts +++ b/packages/oidc-client/src/lib/authorize.request.utils.ts @@ -105,11 +105,10 @@ export function createAuthorizeErrorµ( try: async () => { const url = await createAuthorizeUrl(wellknown.authorization_endpoint, { ...options, - prompt: 'none', }); return { - error: 'AuthorizationUrlError', - error_description: `Error creating authorization URL for ${url}`, + error: res.error, + error_description: res.error_description, type: 'auth_error', redirectUrl: url, } as AuthorizeErrorResponse; diff --git a/packages/oidc-client/src/lib/client.store.ts b/packages/oidc-client/src/lib/client.store.ts index c066f9a3d8..30ceb828d0 100644 --- a/packages/oidc-client/src/lib/client.store.ts +++ b/packages/oidc-client/src/lib/client.store.ts @@ -5,19 +5,24 @@ * of the MIT license. See the LICENSE file for details. */ import { CustomLogger, logger as loggerFn, LogLevel } from '@forgerock/sdk-logger'; -import { createAuthorizeUrl } from '@forgerock/sdk-oidc'; +import { createAuthorizeUrl, getStoredAuthUrlValues } from '@forgerock/sdk-oidc'; +import { createStorage } from '@forgerock/storage'; +import { Micro } from 'effect'; import { exitIsSuccess } from 'effect/Micro'; import { authorizeµ } from './authorize.request.js'; import { createClientStore } from './client.store.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 { OidcConfig } from './config.types.js'; -import { Micro } from 'effect'; +import { AuthorizeErrorResponse } from './authorize.request.types.js'; +import { TokenExchangeOptions } from './client.store.types.js'; +import { TokenRequestOptions } from './token.types.js'; export async function oidc({ config, @@ -36,13 +41,13 @@ export async function oidc({ if (!config?.serverConfig?.wellknown) { return { - message: 'Requires a wellknown url initializing this factory.', + error: 'Requires a wellknown url initializing this factory.', type: 'argument_error', }; } if (!config?.clientId) { return { - message: 'Requires a clientId.', + error: 'Requires a clientId.', type: 'argument_error', }; } @@ -54,7 +59,7 @@ export async function oidc({ if (error || !data) { return { - message: `Error fetching wellknown config`, + error: `Error fetching wellknown config`, type: 'network_error', }; } @@ -75,11 +80,11 @@ export async function oidc({ if (!wellknown?.authorization_endpoint) { const err = { - message: 'Authorization endpoint not found in wellknown configuration', + error: 'Authorization endpoint not found in wellknown configuration', type: 'wellknown_error', } as const; - log.error(err.message); + log.error(err.error); return err; } @@ -92,11 +97,12 @@ export async function oidc({ if (!wellknown?.authorization_endpoint) { const err = { - message: 'Authorization endpoint not found in wellknown configuration', + error: 'Wellknown missing authorization endpoint', + error_description: 'Authorization endpoint not found in wellknown configuration', type: 'wellknown_error', - } as const; + } as AuthorizeErrorResponse; - log.error(err.message); + log.error(err.error); return err; } @@ -108,9 +114,75 @@ export async function oidc({ if (exitIsSuccess(result)) { return result.value; } else { - return result.cause; + return { + error: 'Authorization failure', + error_description: result.cause.message, + type: 'auth_error', + } as AuthorizeErrorResponse; } }, }, + token: { + exchange: async (code: string, state: string, options?: TokenExchangeOptions) => { + const storeState = store.getState(); + const wellknown = wellknownSelector(wellknownUrl, storeState); + + if (!wellknown?.token_endpoint) { + const err = { + error: 'Wellknown missing token endpoint', + type: 'wellknown_error', + } as AuthorizeErrorResponse; + + log.error(err.error); + + return err; + } + + // TODO: Validate state + const values = getStoredAuthUrlValues(config.clientId, options?.prefix); + + if (values.state !== state) { + const err = { + error: 'State mismatch', + type: 'auth_error', + } as GenericError; + + log.error(err.error); + + return err; + } + + const requestOptions: TokenRequestOptions = { + code, + config, + endpoint: wellknown.token_endpoint, + }; + if (values.verifier) { + requestOptions.verifier = values.verifier; + } + + const { data, error } = await store.dispatch( + oidcApi.endpoints.exchange.initiate(requestOptions), + ); + + if (error || !data) { + const err = { + error: 'Error exchanging token', + type: 'network_error', + } as GenericError; + + log.error(err.error); + + return err; + } + + // TODO: handle response and errors; if success, store tokens and return them + createStorage({ storeType: 'localStorage' }, 'oidcTokens', options?.customStorage).set( + data, + ); + + return data; + }, + }, }; } diff --git a/packages/oidc-client/src/lib/client.store.types.ts b/packages/oidc-client/src/lib/client.store.types.ts new file mode 100644 index 0000000000..bdc303d5b7 --- /dev/null +++ b/packages/oidc-client/src/lib/client.store.types.ts @@ -0,0 +1,6 @@ +import { CustomStorageObject } from '@forgerock/sdk-types'; + +export interface TokenExchangeOptions { + prefix?: string; + customStorage: CustomStorageObject; +} diff --git a/packages/oidc-client/src/lib/client.store.utils.ts b/packages/oidc-client/src/lib/client.store.utils.ts index 8667426cd4..7f0faa7fe6 100644 --- a/packages/oidc-client/src/lib/client.store.utils.ts +++ b/packages/oidc-client/src/lib/client.store.utils.ts @@ -8,6 +8,7 @@ import type { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-midd import type { logger as loggerFn } from '@forgerock/sdk-logger'; import { configureStore } from '@reduxjs/toolkit'; +import { oidcApi } from './oidc.api.js'; import { wellknownApi } from './wellknown.api.js'; export function createClientStore({ @@ -19,6 +20,7 @@ export function createClientStore({ }) { return configureStore({ reducer: { + [oidcApi.reducerPath]: oidcApi.reducer, [wellknownApi.reducerPath]: wellknownApi.reducer, }, middleware: (getDefaultMiddleware) => @@ -33,7 +35,9 @@ export function createClientStore({ logger, }, }, - }).concat(wellknownApi.middleware), + }) + .concat(wellknownApi.middleware) + .concat(oidcApi.middleware), }); } diff --git a/packages/oidc-client/src/lib/error.types.ts b/packages/oidc-client/src/lib/error.types.ts index a800c58a56..14b415fe14 100644 --- a/packages/oidc-client/src/lib/error.types.ts +++ b/packages/oidc-client/src/lib/error.types.ts @@ -6,7 +6,8 @@ */ export interface GenericError { code?: string | number; - message: string; + error: string; + message?: string; type: | 'argument_error' | 'auth_error' diff --git a/packages/oidc-client/src/lib/oidc.api.ts b/packages/oidc-client/src/lib/oidc.api.ts new file mode 100644 index 0000000000..502384eea2 --- /dev/null +++ b/packages/oidc-client/src/lib/oidc.api.ts @@ -0,0 +1,43 @@ +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query'; +import { OidcConfig } from './config.types.js'; +import { TokenExchangeResponse } from './token.types.js'; + +export const oidcApi = createApi({ + reducerPath: 'oidc', + baseQuery: fetchBaseQuery(), + endpoints: (builder) => ({ + exchange: builder.mutation< + TokenExchangeResponse, + { + code: string; + config: OidcConfig; + endpoint: string; + verifier?: string; + } + >({ + query: ({ code, config, endpoint, verifier }) => { + const { clientId, redirectUri } = config; + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code, + client_id: clientId, + redirect_uri: redirectUri, + }); + + if (verifier) { + body.append('code_verifier', verifier); + } + + return { + url: endpoint, + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body, + }; + }, + }), + }), +}); diff --git a/packages/oidc-client/src/lib/token.types.ts b/packages/oidc-client/src/lib/token.types.ts new file mode 100644 index 0000000000..786c91d39e --- /dev/null +++ b/packages/oidc-client/src/lib/token.types.ts @@ -0,0 +1,17 @@ +import { OidcConfig } from './config.types.js'; + +export interface TokenExchangeResponse { + token: string; + idToken?: string; + refreshToken?: string; + expiresIn?: number; + scope?: string; + tokenType?: string; +} + +export interface TokenRequestOptions { + code: string; + config: OidcConfig; + endpoint: string; + verifier?: string; +} diff --git a/packages/oidc-client/tsconfig.json b/packages/oidc-client/tsconfig.json index b7a543d3be..f9c5bdebb9 100644 --- a/packages/oidc-client/tsconfig.json +++ b/packages/oidc-client/tsconfig.json @@ -3,6 +3,9 @@ "files": [], "include": [], "references": [ + { + "path": "../sdk-effects/storage" + }, { "path": "../sdk-types" }, diff --git a/packages/oidc-client/tsconfig.lib.json b/packages/oidc-client/tsconfig.lib.json index c2112b390b..6a283a7df7 100644 --- a/packages/oidc-client/tsconfig.lib.json +++ b/packages/oidc-client/tsconfig.lib.json @@ -16,6 +16,9 @@ }, "include": ["src/**/*.ts"], "references": [ + { + "path": "../sdk-effects/storage/tsconfig.lib.json" + }, { "path": "../sdk-types/tsconfig.lib.json" }, From beb349a9a13e7bb8fbad35bf9bda9e340545cffa Mon Sep 17 00:00:00 2001 From: Justin Lowery Date: Mon, 21 Jul 2025 18:49:08 -0500 Subject: [PATCH 4/4] feat(oidc-client): use Effect within token exchange --- .changeset/calm-waves-change.md | 9 ++ e2e/oidc-app/src/main.ts | 53 ++++++++--- .../davinci-client/src/lib/client.store.ts | 8 +- packages/oidc-client/README.md | 2 +- .../oidc-client/src/lib/authorize.request.ts | 78 ++++------------ .../src/lib/authorize.request.types.ts | 2 +- .../src/lib/authorize.request.utils.ts | 36 ++++---- packages/oidc-client/src/lib/client.store.ts | 90 +++++++++---------- .../oidc-client/src/lib/client.store.types.ts | 6 -- .../src/lib/client.store.utils.test.ts | 67 -------------- .../oidc-client/src/lib/exchange.types.ts | 23 +++++ .../oidc-client/src/lib/exchange.utils.ts | 89 ++++++++++++++++++ packages/oidc-client/src/lib/oidc.api.ts | 11 ++- packages/oidc-client/src/lib/store.ts | 6 +- packages/oidc-client/src/lib/token.types.ts | 17 ---- .../src/lib/iframe-manager.effects.ts | 2 +- .../storage/src/lib/storage.effects.test.ts | 26 +++--- .../storage/src/lib/storage.effects.ts | 50 +++++++---- pnpm-lock.yaml | 29 +----- 19 files changed, 314 insertions(+), 290 deletions(-) create mode 100644 .changeset/calm-waves-change.md delete mode 100644 packages/oidc-client/src/lib/client.store.types.ts delete mode 100644 packages/oidc-client/src/lib/client.store.utils.test.ts create mode 100644 packages/oidc-client/src/lib/exchange.types.ts create mode 100644 packages/oidc-client/src/lib/exchange.utils.ts delete mode 100644 packages/oidc-client/src/lib/token.types.ts diff --git a/.changeset/calm-waves-change.md b/.changeset/calm-waves-change.md new file mode 100644 index 0000000000..9f7566fe82 --- /dev/null +++ b/.changeset/calm-waves-change.md @@ -0,0 +1,9 @@ +--- +'@forgerock/iframe-manager': minor +'@forgerock/storage': minor +'@forgerock/sdk-oidc': minor +'@forgerock/davinci-client': minor +'@forgerock/oidc-client': minor +--- + +Implemented token exchange within OIDC Client diff --git a/e2e/oidc-app/src/main.ts b/e2e/oidc-app/src/main.ts index 22940b7f95..0a1612c864 100644 --- a/e2e/oidc-app/src/main.ts +++ b/e2e/oidc-app/src/main.ts @@ -1,17 +1,30 @@ import { oidc } from '@forgerock/oidc-client'; -async function app() { - const oidcClient = await oidc({ - config: { - clientId: 'WebOAuthClient', - redirectUri: 'http://localhost:8443/', - scope: 'openid', - serverConfig: { - wellknown: - 'https://openam-sdks.forgeblocks.com/am/oauth2/alpha/.well-known/openid-configuration', - }, +// const pingAmConfig = { +// config: { +// clientId: 'WebOAuthClient', +// redirectUri: 'http://localhost:8443/', +// scope: 'openid', +// serverConfig: { +// wellknown: +// 'https://openam-sdks.forgeblocks.com/am/oauth2/alpha/.well-known/openid-configuration', +// }, +// }, +// }; +const pingOneConfig = { + config: { + clientId: '654b14e2-7cc5-4977-8104-c4113e43c537', + redirectUri: 'http://localhost:8443/', + scope: 'openid', + serverConfig: { + wellknown: + 'https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/.well-known/openid-configuration', }, - }); + }, +}; + +async function app() { + const oidcClient = await oidc(pingOneConfig); // create object from URL query parameters const urlParams = new URLSearchParams(window.location.search); @@ -21,16 +34,32 @@ async function app() { const error = urlParams.get('error'); // const errorDescription = urlParams.get('error_description'); + // Handle background authorization flow if (!code && !error) { const response = await oidcClient.authorize.background(); if ('error' in response) { console.error('Authorization Error:', response); - window.location.assign(response.redirectUrl); + + if (response.redirectUrl) { + window.location.assign(response.redirectUrl); + } else { + console.log('Authorization failed with no ability to redirect:', response); + } return; + + // Handle success response from background authorization } else if ('code' in response) { console.log('Authorization Code:', response.code); + const tokenResponse = await oidcClient.token.exchange(response.code, response.state); + if ('error' in response) { + console.error('Token Exchange Error:', tokenResponse); + } else { + console.log('Token Exchange Response:', tokenResponse); + } } + + // Handle the user redirecting after authentication } else if (code && state) { const response = await oidcClient.token.exchange(code, state); diff --git a/packages/davinci-client/src/lib/client.store.ts b/packages/davinci-client/src/lib/client.store.ts index 6277229ae6..1fae891068 100644 --- a/packages/davinci-client/src/lib/client.store.ts +++ b/packages/davinci-client/src/lib/client.store.ts @@ -66,10 +66,10 @@ export async function davinci({ }) { const log = loggerFn({ level: logger?.level || 'error', custom: logger?.custom }); const store = createClientStore({ requestMiddleware, logger: log }); - const serverInfo = createStorage( - { storeType: 'localStorage' }, - 'serverInfo', - ); + const serverInfo = createStorage({ + type: 'localStorage', + name: 'serverInfo', + }); if (!config.serverConfig.wellknown) { const error = new Error( '`wellknown` property is a required as part of the `config.serverConfig`', diff --git a/packages/oidc-client/README.md b/packages/oidc-client/README.md index 23dccf0ba7..63ae29fb7e 100644 --- a/packages/oidc-client/README.md +++ b/packages/oidc-client/README.md @@ -4,7 +4,7 @@ A generic OpenID Connect (OIDC) client library for JavaScript and TypeScript, de ```js // Initialize OIDC Client -const oidcClient = oidc({ +const oidcClient1 = oidc({ /* config */ }); diff --git a/packages/oidc-client/src/lib/authorize.request.ts b/packages/oidc-client/src/lib/authorize.request.ts index 134c8899b5..ba2e1e8137 100644 --- a/packages/oidc-client/src/lib/authorize.request.ts +++ b/packages/oidc-client/src/lib/authorize.request.ts @@ -1,12 +1,9 @@ -<<<<<<< HEAD /* * 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. */ -======= ->>>>>>> f35d74ad (feat(oidc-client): improve Effect usage) import { CustomLogger } from '@forgerock/sdk-logger'; import { GetAuthorizationUrlOptions } from '@forgerock/sdk-oidc'; import { Micro } from 'effect'; @@ -22,11 +19,7 @@ import { import type { WellKnownResponse } from '@forgerock/sdk-types'; import type { OidcConfig } from './config.types.js'; -<<<<<<< HEAD import { AuthorizeErrorResponse, AuthorizeSuccessResponse } from './authorize.request.types.js'; -======= -import { AuthorizeSuccessResponse } from './authorize.request.types.js'; ->>>>>>> f35d74ad (feat(oidc-client): improve Effect usage) export async function authorizeµ( wellknown: WellKnownResponse, @@ -34,27 +27,10 @@ export async function authorizeµ( log: CustomLogger, options?: GetAuthorizationUrlOptions, ) { -<<<<<<< HEAD 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))), -======= - const buildAuthorizeRequestµ = buildAuthorizeOptionsµ(wellknown, config, options).pipe( - Micro.flatMap(([url, config, options]) => createAuthorizeUrlµ(url, config, options)), - (effect) => { - return Micro.matchEffect(effect, { - onSuccess: (url) => { - log.debug('Created authorization URL', { url }); - return effect; - }, - onFailure: (error) => { - log.error('Error creating authorization URL', { error }); - return effect; - }, - }); - }, ->>>>>>> f35d74ad (feat(oidc-client): improve Effect usage) Micro.flatMap(([url, config, options]) => { if (options.responseMode === 'pi.flow') { /** @@ -63,26 +39,19 @@ export async function authorizeµ( * set iframe's to DENY. */ return authorizeFetchµ(url).pipe( - Micro.flatMap((response) => { -<<<<<<< HEAD - if ('authorizeResponse' in response) { - log.debug('Received authorize response', response.authorizeResponse); - return Micro.succeed(response.authorizeResponse); - } - log.error('Error in authorize response', response); - return Micro.fail(createAuthorizeErrorµ(response, wellknown, config, options)); -======= - return Micro.gen(function* () { + Micro.flatMap( + (response): Micro.Micro => { if ('authorizeResponse' in response) { log.debug('Received authorize response', response.authorizeResponse); - return yield* Micro.succeed(response.authorizeResponse as AuthorizeSuccessResponse); + return Micro.succeed(response.authorizeResponse); } log.error('Error in authorize response', response); - const errorResponse = response as { error: string; error_description: string }; - return yield* createAuthorizeErrorµ(errorResponse, wellknown, config, options); - }); ->>>>>>> f35d74ad (feat(oidc-client): improve Effect usage) - }), + // For redirection, we need to remore `pi.flow` from the options + const redirectOptions = options; + delete redirectOptions.responseMode; + return createAuthorizeErrorµ(response, wellknown, config, options); + }, + ), ); } else { /** @@ -90,34 +59,19 @@ export async function authorizeµ( * redirect based server supporting iframes. An example would be PingAM. */ return authorizeIframeµ(url, config).pipe( - Micro.flatMap((response) => { -<<<<<<< HEAD - 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 Micro.fail(createAuthorizeErrorµ(errorResponse, wellknown, config, options)); -======= - return Micro.gen(function* () { + Micro.flatMap( + (response): Micro.Micro => { if ('code' in response && 'state' in response) { log.debug('Received authorization code', response); - return yield* Micro.succeed(response as unknown as AuthorizeSuccessResponse); + return Micro.succeed(response as unknown as AuthorizeSuccessResponse); } log.error('Error in authorize response', response); - const errorResponse = response as { error: string; error_description: string }; - return yield* createAuthorizeErrorµ(errorResponse, wellknown, config, options); - }); ->>>>>>> f35d74ad (feat(oidc-client): improve Effect usage) - }), + const errorResponse = response as unknown as AuthorizeErrorResponse; + return createAuthorizeErrorµ(errorResponse, wellknown, config, options); + }, + ), ); } }), ); -<<<<<<< HEAD -======= - - return Micro.runPromiseExit(buildAuthorizeRequestµ); ->>>>>>> f35d74ad (feat(oidc-client): improve Effect usage) } diff --git a/packages/oidc-client/src/lib/authorize.request.types.ts b/packages/oidc-client/src/lib/authorize.request.types.ts index 0ecd3d5c98..9df7efe629 100644 --- a/packages/oidc-client/src/lib/authorize.request.types.ts +++ b/packages/oidc-client/src/lib/authorize.request.types.ts @@ -14,5 +14,5 @@ export interface AuthorizeErrorResponse { error: string; error_description: string; redirectUrl?: string; // URL to redirect the user to for re-authorization - type: 'auth_error' | 'wellknown_error' | 'network_error' | 'unknown_error'; + type: 'auth_error' | 'argument_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 5be338617d..34f8046021 100644 --- a/packages/oidc-client/src/lib/authorize.request.utils.ts +++ b/packages/oidc-client/src/lib/authorize.request.utils.ts @@ -45,8 +45,8 @@ export function authorizeFetchµ(url: string) { export function authorizeIframeµ(url: string, config: OidcConfig) { return Micro.tryPromise({ - try: () => - iFrameManager().getParamsByRedirect({ + try: () => { + const params = iFrameManager().getParamsByRedirect({ url, /*** * https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2 @@ -55,15 +55,17 @@ export function authorizeIframeµ(url: string, config: OidcConfig) { successParams: ['code', 'state'], errorParams: ['error', 'error_description'], timeout: config.serverConfig.timeout || 3000, - }), + }); + return params; + }, catch: (err) => { - let message = 'Error fetching authorization URL'; + let message = 'Error calling authorization URL'; if (err instanceof Error) { message = err.message; } return { - error: 'Authorization Notwork Failure', + error: 'Authorization Network Failure', error_description: message, type: 'auth_error', } as AuthorizeErrorResponse; @@ -102,17 +104,10 @@ export function createAuthorizeErrorµ( options: GetAuthorizationUrlOptions, ) { return Micro.tryPromise({ - try: async () => { - const url = await createAuthorizeUrl(wellknown.authorization_endpoint, { + try: () => + createAuthorizeUrl(wellknown.authorization_endpoint, { ...options, - }); - return { - error: res.error, - error_description: res.error_description, - type: 'auth_error', - redirectUrl: url, - } as AuthorizeErrorResponse; - }, + }), catch: (error) => { let message = 'Error creating authorization URL'; if (error instanceof Error) { @@ -124,7 +119,16 @@ export function createAuthorizeErrorµ( type: 'auth_error', } as AuthorizeErrorResponse; }, - }); + }).pipe( + Micro.flatMap((url) => { + return Micro.fail({ + error: res.error, + error_description: res.error_description, + type: 'auth_error', + redirectUrl: url, + } as AuthorizeErrorResponse); + }), + ); } export function createAuthorizeUrlµ( diff --git a/packages/oidc-client/src/lib/client.store.ts b/packages/oidc-client/src/lib/client.store.ts index 30ceb828d0..072148c1c4 100644 --- a/packages/oidc-client/src/lib/client.store.ts +++ b/packages/oidc-client/src/lib/client.store.ts @@ -5,13 +5,14 @@ * of the MIT license. See the LICENSE file for details. */ import { CustomLogger, logger as loggerFn, LogLevel } from '@forgerock/sdk-logger'; -import { createAuthorizeUrl, getStoredAuthUrlValues } from '@forgerock/sdk-oidc'; -import { createStorage } from '@forgerock/storage'; +import { createAuthorizeUrl } from '@forgerock/sdk-oidc'; +import { createStorage, StorageConfig } from '@forgerock/storage'; import { Micro } from 'effect'; -import { exitIsSuccess } from 'effect/Micro'; +import { exitIsFail, exitIsSuccess } from 'effect/Micro'; import { authorizeµ } from './authorize.request.js'; import { createClientStore } 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'; @@ -20,14 +21,14 @@ import type { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-midd import type { GetAuthorizationUrlOptions } from '@forgerock/sdk-types'; import type { OidcConfig } from './config.types.js'; -import { AuthorizeErrorResponse } from './authorize.request.types.js'; -import { TokenExchangeOptions } from './client.store.types.js'; -import { TokenRequestOptions } from './token.types.js'; +import type { AuthorizeErrorResponse } from './authorize.request.types.js'; +import type { TokenExchangeErrorResponse } from './exchange.types.js'; export async function oidc({ config, requestMiddleware, logger, + storage, }: { config: OidcConfig; requestMiddleware?: RequestMiddleware[]; @@ -35,8 +36,14 @@ export async function oidc({ level: LogLevel; custom?: CustomLogger; }; + storage?: Partial; }) { const log = loggerFn({ level: logger?.level || 'error', custom: logger?.custom }); + const storageClient = createStorage({ + type: 'localStorage', + name: 'oidcTokens', + ...storage, + } as StorageConfig); const store = createClientStore({ requestMiddleware, logger: log }); if (!config?.serverConfig?.wellknown) { @@ -113,6 +120,8 @@ export async function oidc({ if (exitIsSuccess(result)) { return result.value; + } else if (exitIsFail(result)) { + return result.cause.error; } else { return { error: 'Authorization failure', @@ -123,7 +132,7 @@ export async function oidc({ }, }, token: { - exchange: async (code: string, state: string, options?: TokenExchangeOptions) => { + exchange: async (code: string, state: string, options?: Partial) => { const storeState = store.getState(); const wellknown = wellknownSelector(wellknownUrl, storeState); @@ -138,50 +147,37 @@ export async function oidc({ return err; } - // TODO: Validate state - const values = getStoredAuthUrlValues(config.clientId, options?.prefix); - - if (values.state !== state) { - const err = { - error: 'State mismatch', - type: 'auth_error', - } as GenericError; - - log.error(err.error); - - return err; - } - - const requestOptions: TokenRequestOptions = { - code, - config, - endpoint: wellknown.token_endpoint, - }; - if (values.verifier) { - requestOptions.verifier = values.verifier; - } - - const { data, error } = await store.dispatch( - oidcApi.endpoints.exchange.initiate(requestOptions), + const buildTokenExchangeµ = Micro.sync(() => + createValuesµ(code, config, state, wellknown, options), + ).pipe( + Micro.flatMap((options) => validateValuesµ(options)), + Micro.flatMap((requestOptions) => + Micro.promise( + async () => await store.dispatch(oidcApi.endpoints.exchange.initiate(requestOptions)), + ), + ), + Micro.flatMap(({ data, error }) => handleTokenResponseµ(data, error)), + Micro.flatMap((data) => + Micro.promise(async () => { + await storageClient.set(data); + return data; + }), + ), ); - if (error || !data) { - const err = { - error: 'Error exchanging token', - type: 'network_error', - } as GenericError; - - log.error(err.error); + const result = await Micro.runPromiseExit(buildTokenExchangeµ); - return err; + if (exitIsSuccess(result)) { + return result.value; + } else if (exitIsFail(result) && 'error' in result.cause) { + return result.cause.error; + } else { + return { + error: 'Token Exchange failure', + message: result.cause.message, + type: 'exchange_error', + } as TokenExchangeErrorResponse; } - - // TODO: handle response and errors; if success, store tokens and return them - createStorage({ storeType: 'localStorage' }, 'oidcTokens', options?.customStorage).set( - data, - ); - - return data; }, }, }; diff --git a/packages/oidc-client/src/lib/client.store.types.ts b/packages/oidc-client/src/lib/client.store.types.ts deleted file mode 100644 index bdc303d5b7..0000000000 --- a/packages/oidc-client/src/lib/client.store.types.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { CustomStorageObject } from '@forgerock/sdk-types'; - -export interface TokenExchangeOptions { - prefix?: string; - customStorage: CustomStorageObject; -} diff --git a/packages/oidc-client/src/lib/client.store.utils.test.ts b/packages/oidc-client/src/lib/client.store.utils.test.ts deleted file mode 100644 index 1775c5f29b..0000000000 --- a/packages/oidc-client/src/lib/client.store.utils.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { recreateAuthorizeUrl, handleAuthorize } from './client.store.utils.js'; -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; - -// Mock createAuthorizeUrl from sdk to avoid actual URL generation logic or network calls -vi.mock('@forgerock/sdk-oidc', () => { - return { - createAuthorizeUrl: vi - .fn() - .mockResolvedValue( - 'https://example.com/authorize?client_id=client_id&redirect_uri=https%3A%2F%2Fexample.com%2Fredirect&scope=openid&state=state&response_type=code', - ), - }; -}); - -// Stub the global fetch used inside handleAuthorize so it returns predictable JSON -beforeEach(() => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - json: async () => ({ - authorizeResponse: { - code: 'code', - state: 'state', - }, - }), - }), - ); -}); - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -describe('client-store utilities', () => { - it('should handle authorize', async () => { - const result = await handleAuthorize('https://example.com/authorize'); - expect(result).toEqual({ - code: 'code', - state: 'state', - }); - }); - - it('should recreate authorize url', async () => { - const result = await recreateAuthorizeUrl( - { - error: 'error', - error_description: 'error_description', - state: 'state', - }, - 'https://example.com/authorize', - { - clientId: 'client_id', - redirectUri: 'https://example.com/redirect', - scope: 'openid', - state: 'state', - responseType: 'code', - }, - ); - expect(result).toEqual({ - error: 'error', - error_description: 'error_description', - state: 'state', - redirectUrl: - 'https://example.com/authorize?client_id=client_id&redirect_uri=https%3A%2F%2Fexample.com%2Fredirect&scope=openid&state=state&response_type=code', - }); - }); -}); diff --git a/packages/oidc-client/src/lib/exchange.types.ts b/packages/oidc-client/src/lib/exchange.types.ts new file mode 100644 index 0000000000..312eb74d0e --- /dev/null +++ b/packages/oidc-client/src/lib/exchange.types.ts @@ -0,0 +1,23 @@ +import { OidcConfig } from './config.types.js'; + +export interface TokenExchangeResponse { + access_token: string; + id_token?: string; + refresh_token?: string; + expires_in?: number; + scope?: string; + token_type?: string; +} + +export interface TokenExchangeErrorResponse { + error: string; + message: string; + type: 'exchange_error' | 'network_error' | 'unknown_error'; +} + +export interface TokenRequestOptions { + code: string; + config: OidcConfig; + endpoint: string; + verifier?: string; +} diff --git a/packages/oidc-client/src/lib/exchange.utils.ts b/packages/oidc-client/src/lib/exchange.utils.ts new file mode 100644 index 0000000000..06825de9b0 --- /dev/null +++ b/packages/oidc-client/src/lib/exchange.utils.ts @@ -0,0 +1,89 @@ +import { SerializedError } from '@reduxjs/toolkit'; +import { FetchBaseQueryError } from '@reduxjs/toolkit/query'; +import { Micro } from 'effect'; + +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'; + +export function createValuesµ( + code: string, + config: OidcConfig, + state: string, + wellknown: WellKnownResponse, + options?: Partial, +) { + const storedValues = getStoredAuthUrlValues(config.clientId, options?.prefix); + + return { + code, + config, + state, + storedValues, + wellknown, + }; +} + +export function handleTokenResponseµ( + data: TokenExchangeResponse | undefined, + error?: FetchBaseQueryError | SerializedError, +) { + if (error) { + let message; + if ('status' in error) { + message = 'error' in error ? error.error : JSON.stringify(error.data); + } else if ('message' in error) { + message = error.message; + } + + return Micro.fail({ + error: 'Token Exchange failure', + message: message || 'Unknown error during token exchange', + type: 'exchange_error', + } as TokenExchangeErrorResponse); + } + + if (!data) { + return Micro.fail({ + error: 'Token Exchange failure', + message: 'No data returned from token exchange', + type: 'exchange_error', + } as TokenExchangeErrorResponse); + } + + return Micro.succeed(data); +} + +export function validateValuesµ({ + code, + config, + state, + storedValues, + wellknown, +}: { + code: string; + config: OidcConfig; + state: string; + storedValues: GetAuthorizationUrlOptions; + wellknown: { token_endpoint: string }; +}) { + if (!storedValues || storedValues.state !== state) { + const err = { + error: 'State mismatch', + 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; + + return Micro.fail(err as GenericError); + } + return Micro.succeed({ + code, + config, + endpoint: wellknown.token_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 502384eea2..20dab8e40f 100644 --- a/packages/oidc-client/src/lib/oidc.api.ts +++ b/packages/oidc-client/src/lib/oidc.api.ts @@ -1,6 +1,6 @@ import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query'; import { OidcConfig } from './config.types.js'; -import { TokenExchangeResponse } from './token.types.js'; +import { TokenExchangeResponse } from './exchange.types.js'; export const oidcApi = createApi({ reducerPath: 'oidc', @@ -38,6 +38,15 @@ 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; + } + throw new Error('Token exchange response does not contain access_token'); + }, }), }), }); diff --git a/packages/oidc-client/src/lib/store.ts b/packages/oidc-client/src/lib/store.ts index 4ece16015b..20f6f11392 100644 --- a/packages/oidc-client/src/lib/store.ts +++ b/packages/oidc-client/src/lib/store.ts @@ -2,7 +2,7 @@ import type { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-midd import type { logger as loggerFn } from '@forgerock/sdk-logger'; import { configureStore } from '@reduxjs/toolkit'; -import { fetchWellKnownConfig } from './wellknown.api.js'; +import { wellknownApi } from './wellknown.api.js'; import { authorizeSlice } from './authorize.slice.js'; export function createOidcStore({ @@ -14,7 +14,7 @@ export function createOidcStore({ }) { return configureStore({ reducer: { - [fetchWellKnownConfig.reducerPath]: fetchWellKnownConfig.reducer, + [wellknownApi.reducerPath]: wellknownApi.reducer, [authorizeSlice.reducerPath]: authorizeSlice.reducer, }, middleware: (getDefaultMiddleware) => @@ -30,7 +30,7 @@ export function createOidcStore({ }, }, }) - .concat(fetchWellKnownConfig.middleware) + .concat(wellknownApi.middleware) .concat(authorizeSlice.middleware), }); } diff --git a/packages/oidc-client/src/lib/token.types.ts b/packages/oidc-client/src/lib/token.types.ts deleted file mode 100644 index 786c91d39e..0000000000 --- a/packages/oidc-client/src/lib/token.types.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { OidcConfig } from './config.types.js'; - -export interface TokenExchangeResponse { - token: string; - idToken?: string; - refreshToken?: string; - expiresIn?: number; - scope?: string; - tokenType?: string; -} - -export interface TokenRequestOptions { - code: string; - config: OidcConfig; - endpoint: string; - verifier?: string; -} diff --git a/packages/sdk-effects/iframe-manager/src/lib/iframe-manager.effects.ts b/packages/sdk-effects/iframe-manager/src/lib/iframe-manager.effects.ts index 057d98eaf4..2fee278e3a 100644 --- a/packages/sdk-effects/iframe-manager/src/lib/iframe-manager.effects.ts +++ b/packages/sdk-effects/iframe-manager/src/lib/iframe-manager.effects.ts @@ -116,7 +116,7 @@ export function iFrameManager() { // 1. Check for Error Parameters if (hasErrorParams(searchParams, errorParams)) { cleanup(); - reject(parsedParams); // Reject with all parsed params for context + resolve(parsedParams); // Reject with all parsed params for context return; } diff --git a/packages/sdk-effects/storage/src/lib/storage.effects.test.ts b/packages/sdk-effects/storage/src/lib/storage.effects.test.ts index ea732d4623..b910495abf 100644 --- a/packages/sdk-effects/storage/src/lib/storage.effects.test.ts +++ b/packages/sdk-effects/storage/src/lib/storage.effects.test.ts @@ -85,7 +85,8 @@ const mockCustomStore: CustomStorageObject = { describe('storage Effect', () => { const storageName = 'MyStorage'; const baseConfig: Omit = { - storeType: 'localStorage', + type: 'localStorage', + name: storageName, prefix: 'testPrefix', }; const expectedKey = `${baseConfig.prefix}-${storageName}`; @@ -104,10 +105,11 @@ describe('storage Effect', () => { describe('with localStorage', () => { const config: StorageConfig = { ...baseConfig, - storeType: 'localStorage', + name: storageName, + type: 'localStorage', }; - const storageInstance = createStorage(config, storageName); + const storageInstance = createStorage(config); it('should call localStorage.getItem with the correct key and return value', async () => { localStorageMock.setItem(expectedKey, JSON.stringify(testValue)); @@ -169,12 +171,13 @@ describe('storage Effect', () => { }); describe('with sessionStorage', () => { + const storageName = 'MyStorage'; const config: StorageConfig = { ...baseConfig, - storeType: 'sessionStorage', + name: storageName, + type: 'sessionStorage', }; - const storageName = 'MyStorage'; - const storageInstance = createStorage(config, storageName); + const storageInstance = createStorage(config); it('should call sessionStorage.getItem with the correct key and return value', async () => { sessionStorageMock.setItem(expectedKey, JSON.stringify(testValue)); @@ -235,12 +238,13 @@ describe('storage Effect', () => { }); describe('with custom TokenStoreObject', () => { + const myStorage = 'MyStorage'; const config: StorageConfig = { ...baseConfig, - storeType: 'localStorage', + type: 'custom', + custom: mockCustomStore, }; - const myStorage = 'MyStorage'; - const storageInstance = createStorage(config, myStorage, mockCustomStore); + const storageInstance = createStorage(config); it('should call customStore.get with the correct key and return its value', async () => { (mockCustomStore.get as Mock).mockResolvedValueOnce(JSON.stringify(testValue)); @@ -298,9 +302,9 @@ describe('storage Effect', () => { }); it('should return a function that returns the storage interface', () => { - const config: StorageConfig = { ...baseConfig, storeType: 'localStorage' }; const myStorage = 'MyStorage'; - const storageInterface = createStorage(config, myStorage); + const config: StorageConfig = { ...baseConfig, type: 'localStorage' }; + const storageInterface = createStorage(config); expect(storageInterface).toHaveProperty('get'); expect(storageInterface).toHaveProperty('set'); expect(storageInterface).toHaveProperty('remove'); diff --git a/packages/sdk-effects/storage/src/lib/storage.effects.ts b/packages/sdk-effects/storage/src/lib/storage.effects.ts index 9b19992779..4f55235f6e 100644 --- a/packages/sdk-effects/storage/src/lib/storage.effects.ts +++ b/packages/sdk-effects/storage/src/lib/storage.effects.ts @@ -6,9 +6,29 @@ */ import { CustomStorageObject } from '@forgerock/sdk-types'; -export interface StorageConfig { - storeType: CustomStorageObject | 'localStorage' | 'sessionStorage'; +export interface StorageClient { + get: () => Promise; + set: (value: Value) => Promise; + remove: () => Promise; +} + +export type StorageConfig = BrowserStorageConfig | CustomStorageConfig; + +export interface BrowserStorageConfig { + type: 'localStorage' | 'sessionStorage'; prefix?: string; + name: string; +} + +export interface CustomStorageConfig { + type: 'custom'; + prefix?: string; + name: string; + custom: CustomStorageObject; } export interface GenericError { @@ -23,18 +43,18 @@ export interface GenericError { | 'unknown_error'; } -export function createStorage( - config: StorageConfig, - storageName: string, - customStore?: CustomStorageObject, -) { - const { storeType, prefix = 'pic' } = config; +export function createStorage(config: StorageConfig) { + const { type: storeType, prefix = 'pic', name } = config; + + if (storeType === 'custom' && !('custom' in config)) { + throw new Error('Custom storage configuration must include a custom storage object'); + } - const key = `${prefix}-${storageName}`; + const key = `${prefix}-${name}`; return { get: async function storageGet(): Promise { - if (customStore) { - const value = await customStore.get(key); + if ('custom' in config) { + const value = await config.custom.get(key); if (value === null) { return value; } @@ -83,9 +103,9 @@ export function createStorage( }, set: async function storageSet(value: Value) { const valueToStore = JSON.stringify(value); - if (customStore) { + if ('custom' in config) { try { - await customStore.set(key, valueToStore); + await config.custom.set(key, valueToStore); return Promise.resolve(); } catch { return { @@ -119,8 +139,8 @@ export function createStorage( } }, remove: async function storageSet() { - if (customStore) { - return await customStore.remove(key); + if ('custom' in config) { + return await config.custom.remove(key); } if (storeType === 'sessionStorage') { return await sessionStorage.removeItem(key); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0ec57c88f..b9931b9611 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -288,9 +288,6 @@ importers: e2e/oidc-app: dependencies: - '@forgerock/javascript-sdk': - specifier: ^4.8.2 - version: 4.8.2 '@forgerock/javascript-sdk': specifier: ^4.8.2 version: 4.8.2 @@ -354,18 +351,6 @@ importers: packages/oidc-client: dependencies: - '@forgerock/iframe-manager': - specifier: workspace:* - version: link:../sdk-effects/iframe-manager - '@forgerock/sdk-logger': - specifier: workspace:* - version: link:../sdk-effects/logger - '@forgerock/sdk-oidc': - specifier: workspace:* - version: link:../sdk-effects/oidc - '@forgerock/sdk-request-middleware': - specifier: workspace:* - version: link:../sdk-effects/sdk-request-middleware '@forgerock/iframe-manager': specifier: workspace:* version: link:../sdk-effects/iframe-manager @@ -387,6 +372,9 @@ importers: '@reduxjs/toolkit': specifier: 'catalog:' version: 2.8.2 + effect: + specifier: ^3.12.7 + version: 3.16.0 packages/protect: dependencies: @@ -1530,9 +1518,6 @@ packages: '@forgerock/javascript-sdk@4.8.2': resolution: {integrity: sha512-vk30flcVa0ypa92YOqEPiIZlXxTF+QVrd/ADtn0G5fIAGNUE2LMKpiGVZGAooqdaT9DpAymvaXrWCV9JLDDlMA==} - '@forgerock/javascript-sdk@4.8.2': - resolution: {integrity: sha512-vk30flcVa0ypa92YOqEPiIZlXxTF+QVrd/ADtn0G5fIAGNUE2LMKpiGVZGAooqdaT9DpAymvaXrWCV9JLDDlMA==} - '@gerrit0/mini-shiki@1.27.2': resolution: {integrity: sha512-GeWyHz8ao2gBiUW4OJnQDxXQnFgZQwwQk05t/CVVgNBN7/rK8XZ7xY6YhLVv9tH3VppWWmr9DCl3MwemB/i+Og==} @@ -8664,14 +8649,6 @@ snapshots: - react - react-redux - '@forgerock/javascript-sdk@4.8.2': - dependencies: - '@reduxjs/toolkit': 2.8.2 - immer: 10.1.1 - transitivePeerDependencies: - - react - - react-redux - '@gerrit0/mini-shiki@1.27.2': dependencies: '@shikijs/engine-oniguruma': 1.29.2