From 5fe1f95667761a6a35b69e0b278e086e7cbc7e98 Mon Sep 17 00:00:00 2001 From: AJ Ancheta <7781450+ancheetah@users.noreply.github.com> Date: Mon, 11 Aug 2025 09:18:32 -0400 Subject: [PATCH] test(oidc-client): add oidc client tests --- .changeset/moody-chefs-hammer.md | 5 + e2e/oidc-app/index.html | 28 -- e2e/oidc-app/package.json | 3 +- e2e/oidc-app/src/assets/.gitkeep | 0 e2e/oidc-app/src/index.html | 19 ++ e2e/oidc-app/src/index.ts | 10 + e2e/oidc-app/src/main.ts | 109 ------- e2e/oidc-app/src/ping-am/index.html | 27 ++ e2e/oidc-app/src/ping-am/main.ts | 26 ++ e2e/oidc-app/src/ping-one/index.html | 27 ++ e2e/oidc-app/src/ping-one/main.ts | 26 ++ e2e/oidc-app/src/styles.css | 100 +++++++ e2e/oidc-app/src/utils/oidc-app.ts | 146 +++++++++ e2e/oidc-app/tsconfig.app.json | 3 + e2e/oidc-app/tsconfig.json | 3 + e2e/oidc-app/vite.config.ts | 25 +- e2e/oidc-suites/eslint.config.mjs | 14 +- e2e/oidc-suites/playwright.config.ts | 65 +---- e2e/oidc-suites/src/example.spec.ts | 8 - e2e/oidc-suites/src/login.spec.ts | 185 ++++++++++++ e2e/oidc-suites/src/logout.spec.ts | 90 ++++++ e2e/oidc-suites/src/token.spec.ts | 157 ++++++++++ e2e/oidc-suites/src/user.spec.ts | 58 ++++ e2e/oidc-suites/src/utils/async-events.ts | 79 +++++ e2e/oidc-suites/src/utils/demo-users.ts | 13 + e2e/protect-app/src/protect-native.ts | 31 +- packages/device-client/package.json | 2 +- packages/oidc-client/README.md | 8 +- packages/oidc-client/package.json | 25 +- .../oidc-client/src/lib/authorize.request.ts | 9 +- .../src/lib/authorize.request.utils.test.ts | 69 +++++ .../src/lib/authorize.request.utils.ts | 4 +- packages/oidc-client/src/lib/client.store.ts | 40 +-- packages/oidc-client/src/lib/config.types.ts | 8 +- .../src/lib/exchange.utils.test.ts | 149 ++++++++++ .../oidc-client/src/lib/exchange.utils.ts | 3 +- .../src/lib/logout.request.test.ts | 276 ++++++++++++++++++ .../oidc-client/src/lib/logout.request.ts | 61 ++++ packages/oidc-client/src/lib/store.ts | 36 --- packages/oidc-client/tsconfig.lib.json | 11 +- packages/sdk-effects/oidc/src/index.ts | 1 - .../oidc/src/lib/authorize.effects.ts | 2 +- .../oidc/src/lib/authorize.types.ts | 36 --- .../oidc/src/lib/state-pkce.effects.ts | 2 +- packages/sdk-types/src/lib/authorize.types.ts | 10 +- pnpm-lock.yaml | 17 +- pnpm-workspace.yaml | 1 + 47 files changed, 1671 insertions(+), 356 deletions(-) create mode 100644 .changeset/moody-chefs-hammer.md delete mode 100644 e2e/oidc-app/index.html delete mode 100644 e2e/oidc-app/src/assets/.gitkeep create mode 100644 e2e/oidc-app/src/index.html create mode 100644 e2e/oidc-app/src/index.ts delete mode 100644 e2e/oidc-app/src/main.ts create mode 100644 e2e/oidc-app/src/ping-am/index.html create mode 100644 e2e/oidc-app/src/ping-am/main.ts create mode 100644 e2e/oidc-app/src/ping-one/index.html create mode 100644 e2e/oidc-app/src/ping-one/main.ts create mode 100644 e2e/oidc-app/src/utils/oidc-app.ts delete mode 100644 e2e/oidc-suites/src/example.spec.ts create mode 100644 e2e/oidc-suites/src/login.spec.ts create mode 100644 e2e/oidc-suites/src/logout.spec.ts create mode 100644 e2e/oidc-suites/src/token.spec.ts create mode 100644 e2e/oidc-suites/src/user.spec.ts create mode 100644 e2e/oidc-suites/src/utils/async-events.ts create mode 100644 e2e/oidc-suites/src/utils/demo-users.ts create mode 100644 packages/oidc-client/src/lib/authorize.request.utils.test.ts create mode 100644 packages/oidc-client/src/lib/exchange.utils.test.ts create mode 100644 packages/oidc-client/src/lib/logout.request.test.ts create mode 100644 packages/oidc-client/src/lib/logout.request.ts delete mode 100644 packages/oidc-client/src/lib/store.ts delete mode 100644 packages/sdk-effects/oidc/src/lib/authorize.types.ts diff --git a/.changeset/moody-chefs-hammer.md b/.changeset/moody-chefs-hammer.md new file mode 100644 index 000000000..aca4a37e7 --- /dev/null +++ b/.changeset/moody-chefs-hammer.md @@ -0,0 +1,5 @@ +--- +'@forgerock/oidc-client': minor +--- + +Added tests for oidc client diff --git a/e2e/oidc-app/index.html b/e2e/oidc-app/index.html deleted file mode 100644 index adf60736b..000000000 --- a/e2e/oidc-app/index.html +++ /dev/null @@ -1,28 +0,0 @@ - - - - - OidcApp - - - - - - - - - -

Welcome

- - - - - - - diff --git a/e2e/oidc-app/package.json b/e2e/oidc-app/package.json index df5cfe5d6..d9b8e2964 100644 --- a/e2e/oidc-app/package.json +++ b/e2e/oidc-app/package.json @@ -10,7 +10,8 @@ }, "dependencies": { "@forgerock/javascript-sdk": "^4.8.2", - "@forgerock/oidc-client": "workspace:*" + "@forgerock/oidc-client": "workspace:*", + "@forgerock/sdk-types": "workspace:*" }, "nx": { "tags": ["scope:app"] diff --git a/e2e/oidc-app/src/assets/.gitkeep b/e2e/oidc-app/src/assets/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/e2e/oidc-app/src/index.html b/e2e/oidc-app/src/index.html new file mode 100644 index 000000000..862f53f9d --- /dev/null +++ b/e2e/oidc-app/src/index.html @@ -0,0 +1,19 @@ + + + + + + + OIDC Client E2E Test Index | Ping Identity JavaScript SDK + + +
+

OIDC Client E2E Test Index | Ping Identity JavaScript SDK

+ +
+ + + diff --git a/e2e/oidc-app/src/index.ts b/e2e/oidc-app/src/index.ts new file mode 100644 index 000000000..0f3a63512 --- /dev/null +++ b/e2e/oidc-app/src/index.ts @@ -0,0 +1,10 @@ +/* + * + * Copyright © 2025 Ping Identity Corporation. All right reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + * + */ + +import './styles.css'; diff --git a/e2e/oidc-app/src/main.ts b/e2e/oidc-app/src/main.ts deleted file mode 100644 index 7ddd57030..000000000 --- a/e2e/oidc-app/src/main.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { oidc } from '@forgerock/oidc-client'; - -// 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 revoke', - 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); - const code = urlParams.get('code'); - const state = urlParams.get('state'); - - document.getElementById('login')?.addEventListener('click', async () => { - const response = await oidcClient.authorize.background(); - - if ('error' in response) { - console.error('Authorization Error:', response); - - 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); - document.getElementById('logout')!.style.display = 'block'; - document.getElementById('userinfo')!.style.display = 'block'; - document.getElementById('tokens')!.style.display = 'block'; - document.getElementById('login')!.style.display = 'none'; - } - } - }); - - document.getElementById('userinfo')?.addEventListener('click', async () => { - const userInfo = await oidcClient.user.info(); - - if ('error' in userInfo) { - console.error('User Info Error:', userInfo); - } else { - console.log('User Info:', userInfo); - } - }); - - document.getElementById('logout')?.addEventListener('click', async () => { - const response = await oidcClient.user.logout(); - - if (response && 'error' in response) { - console.error('Logout Error:', response); - } else { - 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); - - if ('error' in response) { - console.error('Token Exchange Error:', response); - } else { - console.log('Token Exchange Response:', response); - } - } -} - -app(); diff --git a/e2e/oidc-app/src/ping-am/index.html b/e2e/oidc-app/src/ping-am/index.html new file mode 100644 index 000000000..9ae63641e --- /dev/null +++ b/e2e/oidc-app/src/ping-am/index.html @@ -0,0 +1,27 @@ + + + + E2E Test | Ping Identity JavaScript SDK + + + + +
+ Home +

OIDC App | PingAM Login

+ + + + + + + Start Over +
+ + + diff --git a/e2e/oidc-app/src/ping-am/main.ts b/e2e/oidc-app/src/ping-am/main.ts new file mode 100644 index 000000000..ed4dcf7f9 --- /dev/null +++ b/e2e/oidc-app/src/ping-am/main.ts @@ -0,0 +1,26 @@ +/* + * + * Copyright © 2025 Ping Identity Corporation. All right reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + * + */ +import { oidcApp } from '../utils/oidc-app.js'; + +const urlParams = new URLSearchParams(window.location.search); +const clientId = urlParams.get('clientid'); +const wellknown = urlParams.get('wellknown'); + +const config = { + clientId: clientId || 'WebOAuthClient', + redirectUri: 'http://localhost:8443/ping-am/', + scope: 'openid profile email', + serverConfig: { + wellknown: + wellknown || + 'https://openam-sdks.forgeblocks.com/am/oauth2/alpha/.well-known/openid-configuration', + }, +}; + +oidcApp({ config, urlParams }); diff --git a/e2e/oidc-app/src/ping-one/index.html b/e2e/oidc-app/src/ping-one/index.html new file mode 100644 index 000000000..bdcc56f70 --- /dev/null +++ b/e2e/oidc-app/src/ping-one/index.html @@ -0,0 +1,27 @@ + + + + E2E Test | Ping Identity JavaScript SDK + + + + +
+ Home +

OIDC App | P1 Login

+ + + + + + + Start Over +
+ + + diff --git a/e2e/oidc-app/src/ping-one/main.ts b/e2e/oidc-app/src/ping-one/main.ts new file mode 100644 index 000000000..d0c5de556 --- /dev/null +++ b/e2e/oidc-app/src/ping-one/main.ts @@ -0,0 +1,26 @@ +/* + * + * Copyright © 2025 Ping Identity Corporation. All right reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + * + */ +import { oidcApp } from '../utils/oidc-app.js'; + +const urlParams = new URLSearchParams(window.location.search); +const clientId = urlParams.get('clientid'); +const wellknown = urlParams.get('wellknown'); + +const config = { + clientId: clientId || '654b14e2-7cc5-4977-8104-c4113e43c537', + redirectUri: 'http://localhost:8443/ping-one/', + scope: 'openid revoke profile email', + serverConfig: { + wellknown: + wellknown || + 'https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/.well-known/openid-configuration', + }, +}; + +oidcApp({ config, urlParams }); diff --git a/e2e/oidc-app/src/styles.css b/e2e/oidc-app/src/styles.css index 90d4ee007..4ebc0a8e5 100644 --- a/e2e/oidc-app/src/styles.css +++ b/e2e/oidc-app/src/styles.css @@ -1 +1,101 @@ /* You can add global styles to this file, and also import other style files */ +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +#nav > a { + display: block; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +#app { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.vanilla:hover { + filter: drop-shadow(0 0 2em #3178c6aa); +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/e2e/oidc-app/src/utils/oidc-app.ts b/e2e/oidc-app/src/utils/oidc-app.ts new file mode 100644 index 000000000..15c795371 --- /dev/null +++ b/e2e/oidc-app/src/utils/oidc-app.ts @@ -0,0 +1,146 @@ +/* + * + * Copyright © 2025 Ping Identity Corporation. All right reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + * + */ +import { oidc } from '@forgerock/oidc-client'; +import type { + AuthorizeErrorResponse, + OauthTokens, + TokenExchangeErrorResponse, +} from '@forgerock/oidc-client/types'; +import { GenericError, GetAuthorizationUrlOptions } from '@forgerock/sdk-types'; + +let tokenIndex = 0; + +function displayError(error) { + const errorEl = document.createElement('div'); + errorEl.innerHTML = `

Error: ${JSON.stringify(error, null, 2)}

`; + document.body.appendChild(errorEl); +} + +function displayTokenResponse( + response: OauthTokens | TokenExchangeErrorResponse | GenericError | AuthorizeErrorResponse, +) { + const appEl = document.getElementById('app'); + if ('error' in response) { + console.error('Token Error:', response); + displayError(response); + } else { + console.log('Token Response:', response); + document.getElementById('logout').style.display = 'block'; + document.getElementById('userinfo').style.display = 'block'; + document.getElementById('login-background').style.display = 'none'; + document.getElementById('login-redirect').style.display = 'none'; + + const tokenInfoEl = document.createElement('div'); + tokenInfoEl.innerHTML = `

Access Token: ${response.accessToken}

`; + appEl.appendChild(tokenInfoEl); + tokenIndex++; + } +} + +export async function oidcApp({ config, urlParams }) { + const code = urlParams.get('code'); + const state = urlParams.get('state'); + const piflow = urlParams.get('piflow'); + + const oidcClient = await oidc({ config }); + if ('error' in oidcClient) { + displayError(oidcClient); + } + + document.getElementById('login-background').addEventListener('click', async () => { + const authorizeOptions: GetAuthorizationUrlOptions = + piflow === 'true' + ? { + clientId: config.clientId, + redirectUri: config.redirectUri, + scope: config.scope, + responseType: config.responseType ?? 'code', + responseMode: 'pi.flow', + } + : undefined; + const response = await oidcClient.authorize.background(authorizeOptions); + + if ('error' in response) { + console.error('Authorization Error:', response); + displayError(response); + + 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); + displayTokenResponse(tokenResponse); + } + }); + + document.getElementById('login-redirect').addEventListener('click', async () => { + const authorizeUrl = await oidcClient.authorize.url(); + if (typeof authorizeUrl !== 'string' && 'error' in authorizeUrl) { + console.error('Authorization URL Error:', authorizeUrl); + displayError(authorizeUrl); + return; + } else { + console.log('Authorization URL:', authorizeUrl); + window.location.assign(authorizeUrl); + } + }); + + document.getElementById('get-tokens').addEventListener('click', async () => { + const response = await oidcClient.token.get(); + displayTokenResponse(response); + }); + + document.getElementById('renew-tokens').addEventListener('click', async () => { + const response = await oidcClient.token.get({ backgroundRenew: true }); + displayTokenResponse(response); + }); + + document.getElementById('userinfo').addEventListener('click', async () => { + const userInfo = await oidcClient.user.info(); + + if ('error' in userInfo) { + console.error('User Info Error:', userInfo); + displayError(userInfo); + } else { + console.log('User Info:', userInfo); + + const appEl = document.getElementById('app'); + const userInfoEl = document.createElement('div'); + userInfoEl.innerHTML = `

User Info: ${JSON.stringify(userInfo, null, 2)}

`; + appEl.appendChild(userInfoEl); + } + }); + + document.getElementById('logout').addEventListener('click', async () => { + const response = await oidcClient.user.logout(); + + if (response && 'error' in response) { + console.error('Logout Error:', response); + displayError(response); + } else { + console.log('Logout successful'); + document.getElementById('logout').style.display = 'none'; + document.getElementById('userinfo').style.display = 'none'; + document.getElementById('login-background').style.display = 'block'; + document.getElementById('login-redirect').style.display = 'block'; + window.location.assign(window.location.origin + window.location.pathname); + } + }); + + if (code && state) { + const response = await oidcClient.token.exchange(code, state); + displayTokenResponse(response); + } +} diff --git a/e2e/oidc-app/tsconfig.app.json b/e2e/oidc-app/tsconfig.app.json index d15af865e..634d8de01 100644 --- a/e2e/oidc-app/tsconfig.app.json +++ b/e2e/oidc-app/tsconfig.app.json @@ -19,6 +19,9 @@ ], "include": ["src/**/*.ts"], "references": [ + { + "path": "../../packages/sdk-types/tsconfig.lib.json" + }, { "path": "../../packages/oidc-client/tsconfig.lib.json" } diff --git a/e2e/oidc-app/tsconfig.json b/e2e/oidc-app/tsconfig.json index 5469f156e..d56132594 100644 --- a/e2e/oidc-app/tsconfig.json +++ b/e2e/oidc-app/tsconfig.json @@ -3,6 +3,9 @@ "files": [], "include": [], "references": [ + { + "path": "../../packages/sdk-types" + }, { "path": "../../packages/oidc-client" }, diff --git a/e2e/oidc-app/vite.config.ts b/e2e/oidc-app/vite.config.ts index 8ca71c299..d2a956b1a 100644 --- a/e2e/oidc-app/vite.config.ts +++ b/e2e/oidc-app/vite.config.ts @@ -1,9 +1,14 @@ /// import { defineConfig } from 'vite'; +import { dirname, resolve } from 'path'; +import { fileURLToPath } from 'url'; +const __dirname = dirname(fileURLToPath(import.meta.url)); +const pages = ['ping-am', 'ping-one']; export default defineConfig(() => ({ - root: __dirname, + root: __dirname + '/src', cacheDir: '../../node_modules/.vite/e2e/oidc-app', + publicDir: __dirname + '/public', server: { port: 8443, host: 'localhost', @@ -18,11 +23,23 @@ export default defineConfig(() => ({ // plugins: [ nxViteTsPaths() ], // }, build: { - outDir: './dist', + outDir: __dirname + '/dist', emptyOutDir: true, reportCompressedSize: true, - commonjsOptions: { - transformMixedEsModules: true, + rollupOptions: { + input: { + main: resolve(__dirname + '/src', 'index.html'), + ...pages.reduce( + (acc, page) => { + acc[page as keyof typeof pages] = resolve(__dirname + '/src', `${page}/index.html`); + return acc; + }, + {} as Record, + ), + }, + output: { + entryFileNames: '[name]/main.js', + }, }, }, })); diff --git a/e2e/oidc-suites/eslint.config.mjs b/e2e/oidc-suites/eslint.config.mjs index b2e9fac09..98b9e5ae5 100644 --- a/e2e/oidc-suites/eslint.config.mjs +++ b/e2e/oidc-suites/eslint.config.mjs @@ -1,9 +1,19 @@ -import playwright from 'eslint-plugin-playwright'; import baseConfig from '../../eslint.config.mjs'; export default [ - playwright.configs['flat/recommended'], ...baseConfig, + { + ignores: [ + '.playwright/', + 'node_modules', + '*.md', + 'LICENSE', + '.babelrc', + '.env*', + '.bin', + 'dist', + ], + }, { files: ['**/*.ts', '**/*.js'], // Override or add rules here diff --git a/e2e/oidc-suites/playwright.config.ts b/e2e/oidc-suites/playwright.config.ts index 5065f9a9f..0a722e28d 100644 --- a/e2e/oidc-suites/playwright.config.ts +++ b/e2e/oidc-suites/playwright.config.ts @@ -1,68 +1,31 @@ -import { defineConfig, devices } from '@playwright/test'; -import { nxE2EPreset } from '@nx/playwright/preset'; +import { defineConfig } from '@playwright/test'; import { workspaceRoot } from '@nx/devkit'; // For CI, you may want to set BASE_URL to the deployed application. const baseURL = process.env['BASE_URL'] || 'http://localhost:8443'; -/** - * Read environment variables from file. - * https://github.com/motdotla/dotenv - */ -// require('dotenv').config(); - /** * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ - ...nxE2EPreset(__filename, { testDir: './src' }), - /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + outputDir: './.playwright', + testDir: './src', + reporter: process.env.CI ? 'github' : 'list', + timeout: 30000, use: { baseURL, - /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: 'on-first-retry', + headless: true, + ignoreHTTPSErrors: true, + geolocation: { latitude: 24.9884, longitude: -87.3459 }, + bypassCSP: true, + trace: process.env.CI ? 'on-first-retry' : 'retain-on-failure', }, /* Run your local dev server before starting the tests */ webServer: { - command: 'pnpm exec nx run oidc-app:serve', - url: 'http://localhost:8443', - reuseExistingServer: true, + command: 'pnpm nx serve @forgerock/oidc-app', + port: 8443, + ignoreHTTPSErrors: true, + reuseExistingServer: !process.env.CI, cwd: workspaceRoot, }, - projects: [ - { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, - }, - - { - name: 'firefox', - use: { ...devices['Desktop Firefox'] }, - }, - - { - name: 'webkit', - use: { ...devices['Desktop Safari'] }, - }, - - // Uncomment for mobile browsers support - /* { - name: 'Mobile Chrome', - use: { ...devices['Pixel 5'] }, - }, - { - name: 'Mobile Safari', - use: { ...devices['iPhone 12'] }, - }, */ - - // Uncomment for branded browsers - /* { - name: 'Microsoft Edge', - use: { ...devices['Desktop Edge'], channel: 'msedge' }, - }, - { - name: 'Google Chrome', - use: { ...devices['Desktop Chrome'], channel: 'chrome' }, - } */ - ], }); diff --git a/e2e/oidc-suites/src/example.spec.ts b/e2e/oidc-suites/src/example.spec.ts deleted file mode 100644 index fa8f1f335..000000000 --- a/e2e/oidc-suites/src/example.spec.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { test, expect } from '@playwright/test'; - -test('has title', async ({ page }) => { - await page.goto('/'); - - // Expect h1 to contain a substring. - expect(await page.locator('h1').innerText()).toContain('Welcome'); -}); diff --git a/e2e/oidc-suites/src/login.spec.ts b/e2e/oidc-suites/src/login.spec.ts new file mode 100644 index 000000000..d09756177 --- /dev/null +++ b/e2e/oidc-suites/src/login.spec.ts @@ -0,0 +1,185 @@ +/* + * + * Copyright © 2025 Ping Identity Corporation. All right reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + * + */ +import { test, expect } from '@playwright/test'; +import { + pingAmUsername, + pingAmPassword, + pingOneUsername, + pingOnePassword, +} from './utils/demo-users.js'; +import { asyncEvents } from './utils/async-events.js'; + +test.describe('PingAM login and get token tests', () => { + test('background login with valid credentials', async ({ page }) => { + const { navigate, clickButton } = asyncEvents(page); + await navigate('/ping-am/'); + expect(page.url()).toBe('http://localhost:8443/ping-am/'); + + await clickButton('Login (Background)', 'https://openam-sdks.forgeblocks.com/'); + + await page.getByLabel('User Name').fill(pingAmUsername); + await page.getByRole('textbox', { name: 'Password' }).fill(pingAmPassword); + await page.getByRole('button', { name: 'Next' }).click(); + + expect(page.url()).toContain('code'); + expect(page.url()).toContain('state'); + await expect(page.locator('#accessToken-0')).not.toBeEmpty(); + await expect(page.locator('#accessToken-0')).not.toHaveText('undefined'); + }); + + test('redirect login with valid credentials', async ({ page }) => { + const { navigate, clickButton } = asyncEvents(page); + await navigate('/ping-am/'); + expect(page.url()).toBe('http://localhost:8443/ping-am/'); + + await clickButton('Login (Redirect)', 'https://openam-sdks.forgeblocks.com/'); + + await page.getByLabel('User Name').fill(pingAmUsername); + await page.getByRole('textbox', { name: 'Password' }).fill(pingAmPassword); + await page.getByRole('button', { name: 'Next' }).click(); + + expect(page.url()).toContain('code'); + expect(page.url()).toContain('state'); + await expect(page.locator('#accessToken-0')).not.toBeEmpty(); + await expect(page.locator('#accessToken-0')).not.toHaveText('undefined'); + }); + + test('background login with invalid client id fails', async ({ page }) => { + const { navigate } = asyncEvents(page); + await navigate('/ping-am/?clientid=bad-id'); + expect(page.url()).toBe('http://localhost:8443/ping-am/?clientid=bad-id'); + + await page.getByRole('button', { name: 'Login (Background)' }).click(); + + await expect(page.locator('.error')).toContainText(`"error": "Authorization Network Failure"`); + await expect(page.locator('.error')).toContainText('Error calling authorization URL'); + await expect(page.locator('.error')).toContainText(`"type": "auth_error"`); + }); + + test('redirect login with invalid client id fails', async ({ page }) => { + const { navigate, clickButton } = asyncEvents(page); + await navigate('/ping-am/?clientid=bad-id'); + expect(page.url()).toBe('http://localhost:8443/ping-am/?clientid=bad-id'); + + await clickButton('Login (Redirect)', 'https://openam-sdks.forgeblocks.com/'); + + await expect(page.getByText('invalid_client')).toBeDefined(); + }); +}); + +test.describe('PingOne login and get token tests', () => { + test('background login with valid credentials', async ({ page }) => { + const { navigate, clickButton } = asyncEvents(page); + await navigate('/ping-one/'); + expect(page.url()).toBe('http://localhost:8443/ping-one/'); + + await clickButton('Login (Background)', 'https://apps.pingone.ca/'); + + await page.getByLabel('Username').fill(pingOneUsername); + await page.getByRole('textbox', { name: 'Password' }).fill(pingOnePassword); + await page.getByRole('button', { name: 'Sign On' }).click(); + + await page.waitForURL('http://localhost:8443/ping-one/**'); + expect(page.url()).toContain('code'); + expect(page.url()).toContain('state'); + await expect(page.locator('#accessToken-0')).not.toBeEmpty(); + await expect(page.locator('#accessToken-0')).not.toHaveText('undefined'); + }); + + test('redirect login with valid credentials', async ({ page }) => { + const { navigate, clickButton } = asyncEvents(page); + await navigate('/ping-one/'); + expect(page.url()).toBe('http://localhost:8443/ping-one/'); + + await clickButton('Login (Redirect)', 'https://apps.pingone.ca/'); + + await page.getByLabel('Username').fill(pingOneUsername); + await page.getByRole('textbox', { name: 'Password' }).fill(pingOnePassword); + await page.getByRole('button', { name: 'Sign On' }).click(); + + await page.waitForURL('http://localhost:8443/ping-one/**'); + expect(page.url()).toContain('code'); + expect(page.url()).toContain('state'); + await expect(page.locator('#accessToken-0')).not.toBeEmpty(); + await expect(page.locator('#accessToken-0')).not.toHaveText('undefined'); + }); + + test('login with invalid client id fails', async ({ page }) => { + const { navigate } = asyncEvents(page); + await navigate('/ping-one/?clientid=bad-id'); + expect(page.url()).toBe('http://localhost:8443/ping-one/?clientid=bad-id'); + + await page.getByRole('button', { name: 'Login (Background)' }).click(); + + await expect(page.locator('.error')).toContainText(`"error": "Authorization Network Failure"`); + await expect(page.locator('.error')).toContainText('Failed to fetch'); + await expect(page.locator('.error')).toContainText(`"type": "auth_error"`); + }); + + test('redirect login with invalid client id fails', async ({ page }) => { + const { navigate, clickButton } = asyncEvents(page); + await navigate('/ping-one/?clientid=bad-id'); + expect(page.url()).toBe('http://localhost:8443/ping-one/?clientid=bad-id'); + + await clickButton('Login (Redirect)', 'https://apps.pingone.ca/'); + + await expect(page.getByText('Error')).toBeDefined(); + await expect( + page.getByText('The request could not be completed. The requested resource was not found.'), + ).toBeDefined(); + }); + + test('login with pi.flow response mode', async ({ page }) => { + const { navigate, clickButton } = asyncEvents(page); + await navigate('/ping-one/?piflow=true'); + expect(page.url()).toBe('http://localhost:8443/ping-one/?piflow=true'); + + await page.on('request', (request) => { + const method = request.method(); + const requestUrl = request.url(); + + if (method === 'POST' && requestUrl.includes('/as/authorize')) { + expect(requestUrl).toContain('response_mode=pi.flow'); + } + }); + + await clickButton('Login (Background)', 'https://apps.pingone.ca/'); + + await page.getByLabel('Username').fill(pingOneUsername); + await page.getByRole('textbox', { name: 'Password' }).fill(pingOnePassword); + await page.getByRole('button', { name: 'Sign On' }).click(); + + await page.waitForURL('http://localhost:8443/ping-one/**'); + expect(page.url()).toContain('code'); + expect(page.url()).toContain('state'); + await expect(page.locator('#accessToken-0')).not.toBeEmpty(); + await expect(page.locator('#accessToken-0')).not.toHaveText('undefined'); + }); +}); + +test('login with invalid state fails with error', async ({ page }) => { + const { navigate } = asyncEvents(page); + await navigate('/ping-am/?code=12345&state=abcxyz'); + expect(page.url()).toBe('http://localhost:8443/ping-am/?code=12345&state=abcxyz'); + + await expect(page.locator('.error')).toContainText(`"error": "State mismatch"`); + await expect(page.locator('.error')).toContainText(`"type": "state_error"`); + await expect(page.locator('.error')).toContainText( + 'The provided state does not match the stored state. This is likely due to passing in used, returned, authorize parameters.', + ); +}); + +test('oidc client fails to initialize with bad wellknown', async ({ page }) => { + const { navigate } = asyncEvents(page); + await navigate('/ping-am/?wellknown=bad-wellknown'); + expect(page.url()).toBe('http://localhost:8443/ping-am/?wellknown=bad-wellknown'); + + await expect(page.locator('.error')).toContainText(`"error": "Error fetching wellknown config"`); + await expect(page.locator('.error')).toContainText(`"type": "network_error"`); +}); diff --git a/e2e/oidc-suites/src/logout.spec.ts b/e2e/oidc-suites/src/logout.spec.ts new file mode 100644 index 000000000..24aebc3c1 --- /dev/null +++ b/e2e/oidc-suites/src/logout.spec.ts @@ -0,0 +1,90 @@ +/* + * + * Copyright © 2025 Ping Identity Corporation. All right reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + * + */ +import { test, expect } from '@playwright/test'; +import { + pingAmUsername, + pingAmPassword, + pingOneUsername, + pingOnePassword, +} from './utils/demo-users.js'; +import { asyncEvents } from './utils/async-events.js'; + +test.describe('Logout tests', () => { + test('PingAM login then logout', async ({ page }) => { + const { navigate, clickButton } = asyncEvents(page); + await navigate('/ping-am/'); + expect(page.url()).toBe('http://localhost:8443/ping-am/'); + + let endSessionStatus, revokeStatus; + page.on('response', (response) => { + const responseUrl = response.url(); + const status = response.ok(); + + if (responseUrl.includes('/endSession?id_token_hint')) { + endSessionStatus = status; + } + if (responseUrl.includes('/revoke')) { + revokeStatus = status; + } + }); + + await clickButton('Login (Background)', 'https://openam-sdks.forgeblocks.com/'); + + await page.getByLabel('User Name').fill(pingAmUsername); + await page.getByRole('textbox', { name: 'Password' }).fill(pingAmPassword); + await page.getByRole('button', { name: 'Next' }).click(); + + await page.waitForURL('http://localhost:8443/ping-am/**'); + expect(page.url()).toContain('code'); + expect(page.url()).toContain('state'); + await expect(page.getByRole('button', { name: 'Login (Background)' })).toBeHidden(); + + await page.getByRole('button', { name: 'Logout' }).click(); + await expect(page.getByRole('button', { name: 'Login (Background)' })).toBeVisible(); + + expect(endSessionStatus).toBeTruthy(); + expect(revokeStatus).toBeTruthy(); + }); + + test('PingOne login then logout', async ({ page }) => { + const { navigate, clickButton } = asyncEvents(page); + await navigate('/ping-one/'); + expect(page.url()).toBe('http://localhost:8443/ping-one/'); + + let endSessionStatus, revokeStatus; + page.on('response', (response) => { + const responseUrl = response.url(); + const status = response.ok(); + + if (responseUrl.includes('/as/idpSignoff?id_token_hint')) { + endSessionStatus = status; + } + if (responseUrl.includes('/revoke')) { + revokeStatus = status; + } + }); + + await clickButton('Login (Background)', 'https://apps.pingone.ca/'); + + await page.getByLabel('Username').fill(pingOneUsername); + await page.getByRole('textbox', { name: 'Password' }).fill(pingOnePassword); + await page.getByRole('button', { name: 'Sign On' }).click(); + + await page.waitForURL('http://localhost:8443/ping-one/**'); + expect(page.url()).toContain('code'); + expect(page.url()).toContain('state'); + await expect(page.getByRole('button', { name: 'Login (Background)' })).toBeHidden(); + + await page.getByRole('button', { name: 'Logout' }).click(); + await expect(page.getByRole('button', { name: 'Login (Background)' })).toBeVisible(); + + expect(endSessionStatus).toBeTruthy(); + expect(revokeStatus).toBeTruthy(); + }); +}); diff --git a/e2e/oidc-suites/src/token.spec.ts b/e2e/oidc-suites/src/token.spec.ts new file mode 100644 index 000000000..bfd5d7f5d --- /dev/null +++ b/e2e/oidc-suites/src/token.spec.ts @@ -0,0 +1,157 @@ +/* + * + * Copyright © 2025 Ping Identity Corporation. All right reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + * + */ +import { test, expect } from '@playwright/test'; +import { + pingAmUsername, + pingAmPassword, + pingOneUsername, + pingOnePassword, +} from './utils/demo-users.js'; +import { asyncEvents } from './utils/async-events.js'; + +test('get tokens without logging in should error', async ({ page }) => { + const { navigate } = asyncEvents(page); + await navigate('/ping-am/'); + expect(page.url()).toBe('http://localhost:8443/ping-am/'); + + await page.getByRole('button', { name: 'Get Tokens' }).click(); + + await expect(page.locator('.error')).toContainText(`"error": "No tokens found"`); + await expect(page.locator('.error')).toContainText(`"type": "state_error"`); +}); + +test.describe('PingAM tokens', () => { + test('login and get tokens', async ({ page }) => { + const { navigate, clickButton } = asyncEvents(page); + await navigate('/ping-am/'); + expect(page.url()).toBe('http://localhost:8443/ping-am/'); + + await clickButton('Login (Background)', 'https://openam-sdks.forgeblocks.com/'); + + await page.getByLabel('User Name').fill(pingAmUsername); + await page.getByRole('textbox', { name: 'Password' }).fill(pingAmPassword); + await page.getByRole('button', { name: 'Next' }).click(); + + await page.waitForURL('http://localhost:8443/ping-am/**', { waitUntil: 'networkidle' }); + expect(page.url()).toContain('code'); + expect(page.url()).toContain('state'); + + await page.getByRole('button', { name: 'Get Tokens' }).click(); + await expect(page.locator('#accessToken-1')).not.toBeEmpty(); + await expect(page.locator('#accessToken-1')).not.toHaveText('undefined'); + + const accessToken0 = await page.locator('#accessToken-0').textContent(); + const accessToken1 = await page.locator('#accessToken-1').textContent(); + await expect(accessToken0).toBe(accessToken1); + }); + + test('login and renew tokens', async ({ page }) => { + const { navigate, clickButton } = asyncEvents(page); + await navigate('/ping-am/'); + expect(page.url()).toBe('http://localhost:8443/ping-am/'); + + await clickButton('Login (Background)', 'https://openam-sdks.forgeblocks.com/'); + + await page.getByLabel('User Name').fill(pingAmUsername); + await page.getByRole('textbox', { name: 'Password' }).fill(pingAmPassword); + await page.getByRole('button', { name: 'Next' }).click(); + + await page.waitForURL('http://localhost:8443/ping-am/**', { waitUntil: 'networkidle' }); + expect(page.url()).toContain('code'); + expect(page.url()).toContain('state'); + + await page.evaluate(() => window.localStorage.clear()); + await page.getByRole('button', { name: 'Renew Tokens' }).click(); + + await expect(page.locator('#accessToken-1')).not.toBeEmpty(); + await expect(page.locator('#accessToken-1')).not.toHaveText('undefined'); + + const accessToken0 = await page.locator('#accessToken-0').textContent(); + const accessToken1 = await page.locator('#accessToken-1').textContent(); + await expect(accessToken0).not.toBe(accessToken1); + }); + + test('renew tokens without logging in should error', async ({ page }) => { + const { navigate } = asyncEvents(page); + await navigate('/ping-am/'); + expect(page.url()).toBe('http://localhost:8443/ping-am/'); + + await page.getByRole('button', { name: 'Renew Tokens' }).click(); + + await expect(page.locator('.error')).toContainText(`"error": "interaction_required"`); + await expect(page.locator('.error')).toContainText(`"type": "auth_error"`); + await expect(page.locator('.error')).toContainText( + 'The request requires some interaction that is not allowed.', + ); + }); +}); + +test.describe('PingOne tokens', () => { + test('login and get tokens', async ({ page }) => { + const { navigate, clickButton } = asyncEvents(page); + await navigate('/ping-one/'); + expect(page.url()).toBe('http://localhost:8443/ping-one/'); + + await clickButton('Login (Background)', 'https://apps.pingone.ca/'); + + await page.getByLabel('Username').fill(pingOneUsername); + await page.getByRole('textbox', { name: 'Password' }).fill(pingOnePassword); + await page.getByRole('button', { name: 'Sign On' }).click(); + + await page.waitForURL('http://localhost:8443/ping-one/**', { waitUntil: 'networkidle' }); + expect(page.url()).toContain('code'); + expect(page.url()).toContain('state'); + + await page.getByRole('button', { name: 'Get Tokens' }).click(); + await expect(page.locator('#accessToken-1')).not.toBeEmpty(); + await expect(page.locator('#accessToken-1')).not.toHaveText('undefined'); + + const accessToken0 = await page.locator('#accessToken-0').textContent(); + const accessToken1 = await page.locator('#accessToken-1').textContent(); + await expect(accessToken0).toBe(accessToken1); + }); + + test('login and renew tokens', async ({ page }) => { + const { navigate, clickButton } = asyncEvents(page); + await navigate('/ping-one/'); + expect(page.url()).toBe('http://localhost:8443/ping-one/'); + + await clickButton('Login (Background)', 'https://apps.pingone.ca/'); + + await page.getByLabel('Username').fill(pingOneUsername); + await page.getByRole('textbox', { name: 'Password' }).fill(pingOnePassword); + await page.getByRole('button', { name: 'Sign On' }).click(); + + await page.waitForURL('http://localhost:8443/ping-one/**', { waitUntil: 'networkidle' }); + expect(page.url()).toContain('code'); + expect(page.url()).toContain('state'); + + await page.evaluate(() => window.localStorage.clear()); + await page.getByRole('button', { name: 'Renew Tokens' }).click(); + + await expect(page.locator('#accessToken-1')).not.toBeEmpty(); + await expect(page.locator('#accessToken-1')).not.toHaveText('undefined'); + + const accessToken0 = await page.locator('#accessToken-0').textContent(); + const accessToken1 = await page.locator('#accessToken-1').textContent(); + await expect(accessToken0).not.toBe(accessToken1); + }); + + test('renew tokens without logging in should error', async ({ page }) => { + const { navigate } = asyncEvents(page); + await navigate('/ping-one/'); + expect(page.url()).toBe('http://localhost:8443/ping-one/'); + + await page.getByRole('button', { name: 'Renew Tokens' }).click(); + + await expect(page.locator('.error')).toContainText(`"error": "LOGIN_REQUIRED"`); + await expect(page.locator('.error')).toContainText(`"type": "auth_error"`); + await expect(page.locator('.error')).toContainText('User authentication is required'); + }); +}); diff --git a/e2e/oidc-suites/src/user.spec.ts b/e2e/oidc-suites/src/user.spec.ts new file mode 100644 index 000000000..f35de3df8 --- /dev/null +++ b/e2e/oidc-suites/src/user.spec.ts @@ -0,0 +1,58 @@ +/* + * + * Copyright © 2025 Ping Identity Corporation. All right reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + * + */ +import { test, expect } from '@playwright/test'; +import { + pingAmUsername, + pingAmPassword, + pingOneUsername, + pingOnePassword, +} from './utils/demo-users.js'; +import { asyncEvents } from './utils/async-events.js'; + +test.describe('User tests', () => { + test('get user info from PingAM', async ({ page }) => { + const { navigate, clickButton } = asyncEvents(page); + await navigate('/ping-am/'); + expect(page.url()).toBe('http://localhost:8443/ping-am/'); + + await clickButton('Login (Background)', 'https://openam-sdks.forgeblocks.com/'); + + await page.getByLabel('User Name').fill(pingAmUsername); + await page.getByRole('textbox', { name: 'Password' }).fill(pingAmPassword); + await page.getByRole('button', { name: 'Next' }).click(); + + await page.waitForURL('http://localhost:8443/ping-am/**'); + expect(page.url()).toContain('code'); + expect(page.url()).toContain('state'); + + await page.getByRole('button', { name: 'User Info' }).click(); + await expect(page.locator('#userInfo')).not.toBeEmpty(); + await expect(page.getByText('sdkuser')).toBeVisible(); + }); + + test('get user info from PingOne', async ({ page }) => { + const { navigate, clickButton } = asyncEvents(page); + await navigate('/ping-one/'); + expect(page.url()).toBe('http://localhost:8443/ping-one/'); + + await clickButton('Login (Background)', 'https://apps.pingone.ca/'); + + await page.getByLabel('Username').fill(pingOneUsername); + await page.getByRole('textbox', { name: 'Password' }).fill(pingOnePassword); + await page.getByRole('button', { name: 'Sign On' }).click(); + + await page.waitForURL('http://localhost:8443/ping-one/**'); + expect(page.url()).toContain('code'); + expect(page.url()).toContain('state'); + + await page.getByRole('button', { name: 'User Info' }).click(); + await expect(page.locator('#userInfo')).not.toBeEmpty(); + await expect(page.getByText('demouser')).toBeVisible(); + }); +}); diff --git a/e2e/oidc-suites/src/utils/async-events.ts b/e2e/oidc-suites/src/utils/async-events.ts new file mode 100644 index 000000000..b0874e914 --- /dev/null +++ b/e2e/oidc-suites/src/utils/async-events.ts @@ -0,0 +1,79 @@ +/* + * 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 asyncEvents(page) { + return { + async clickButton(text, endpoint) { + if (!endpoint) + throw new Error('Must provide endpoint argument, type string, e.g. "/authenticate"'); + await Promise.all([ + page.waitForResponse((response) => response.url().includes(endpoint)), + page.getByRole('button', { name: text }).click(), + ]); + }, + async clickLink(text, endpoint) { + if (!endpoint) + throw new Error('Must provide endpoint argument, type string, e.g. "/authenticate"'); + await Promise.all([ + page.waitForResponse((response) => response.url().includes(endpoint)), + page.getByRole('link', { name: text }).click(), + ]); + }, + async getTokens(origin, clientId) { + const webStorage = await page.context().storageState(); + + const originStorage = webStorage.origins.find((item) => item.origin === origin); + // Storage may not have any items + if (!originStorage) { + return null; + } + const clientIdStorage = originStorage?.localStorage.find((item) => item.name === clientId); + + if (clientIdStorage && typeof clientIdStorage.value !== 'string' && !clientIdStorage.value) { + return null; + } + try { + return JSON.parse(clientIdStorage.value); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (e) { + return null; + } + }, + async navigate(route) { + await page.goto(route, { waitUntil: 'networkidle' }); + }, + async pressEnter(endpoint) { + if (!endpoint) + throw new Error('Must provide endpoint argument, type string, e.g. "/authenticate"'); + await Promise.all([ + page.waitForResponse((response) => response.url().includes(endpoint)), + page.keyboard.press('Enter'), + ]); + }, + async pressSpacebar(endpoint) { + if (!endpoint) + throw new Error('Must provide endpoint argument, type string, e.g. "/authenticate"'); + await Promise.all([ + page.waitForResponse((response) => response.url().includes(endpoint)), + page.keyboard.press(' '), + ]); + }, + }; +} + +export async function verifyUserInfo(page, expect, type) { + const emailString = type === 'register' ? 'Email: test@auto.com' : 'Email: demo@user.com'; + const nameString = 'Full name: Demo User'; + + const name = page.getByText(nameString); + const email = page.getByText(emailString); + + // Just wait for one of them to be visible + await name.waitFor(); + + expect(await name.textContent()).toBe(nameString); + expect(await email.textContent()).toBe(emailString); +} diff --git a/e2e/oidc-suites/src/utils/demo-users.ts b/e2e/oidc-suites/src/utils/demo-users.ts new file mode 100644 index 000000000..351e055b0 --- /dev/null +++ b/e2e/oidc-suites/src/utils/demo-users.ts @@ -0,0 +1,13 @@ +/* + * + * Copyright © 2025 Ping Identity Corporation. All right reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + * + */ + +export const pingAmUsername = 'sdkuser'; +export const pingAmPassword = 'password'; +export const pingOneUsername = 'demouser'; +export const pingOnePassword = 'U.QPDWEN47ZMyJhCDmhGLK*nr'; diff --git a/e2e/protect-app/src/protect-native.ts b/e2e/protect-app/src/protect-native.ts index fee6d69c7..94a4cb8af 100644 --- a/e2e/protect-app/src/protect-native.ts +++ b/e2e/protect-app/src/protect-native.ts @@ -26,19 +26,6 @@ import { const protectAPI = protect({ envId: '02fb4743-189a-4bc7-9d6c-a919edfe6447' }); const FATAL = 'Fatal'; -await Config.setAsync({ - clientId: 'WebOAuthClient', - redirectUri: `${window.location.origin}/callback.html`, - scope: 'openid profile email', - serverConfig: { - wellknown: - 'https://openam-sdks.forgeblocks.com/am/oauth2/alpha/.well-known/openid-configuration', - timeout: 3000, - }, - realmPath: 'alpha', - tree: 'TEST_Protect', -}); - // Check URL for query parameters const url = new URL(document.location.href); const params = url.searchParams; @@ -208,8 +195,24 @@ const handleFatalError = (err) => { }; // Begin the login flow -await nextStep(); +const startLoginFlow = async () => { + await Config.setAsync({ + clientId: 'WebOAuthClient', + redirectUri: `${window.location.origin}/callback.html`, + scope: 'openid profile email', + serverConfig: { + wellknown: + 'https://openam-sdks.forgeblocks.com/am/oauth2/alpha/.well-known/openid-configuration', + timeout: 3000, + }, + realmPath: 'alpha', + tree: 'TEST_Protect', + }); + await nextStep(); +}; document.getElementById('Error')?.addEventListener('click', nextStep); document.getElementById('start-over')?.addEventListener('click', nextStep); document.getElementById('Fatal')?.addEventListener('click', nextStep); + +await startLoginFlow(); diff --git a/packages/device-client/package.json b/packages/device-client/package.json index 47571cfb1..bec0adc91 100644 --- a/packages/device-client/package.json +++ b/packages/device-client/package.json @@ -30,7 +30,7 @@ "@reduxjs/toolkit": "catalog:" }, "devDependencies": { - "msw": "^2.5.1" + "msw": "catalog:" }, "nx": { "tags": ["scope:package"] diff --git a/packages/oidc-client/README.md b/packages/oidc-client/README.md index 63ae29fb7..d612f98a6 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 oidcClient1 = oidc({ +const oidcClient = oidc({ /* config */ }); @@ -13,12 +13,10 @@ const authResponse = oidcClient.authorize.background(); // Returns code and stat const authUrl = oidcClient.authorize.url(); // Returns Auth URL or error // Tokens API -const newTokens = oidcClient.tokens.exchange({ +const newTokens = oidcClient.token.exchange({ /* code, state */ }); // Returns new tokens or error -const existingTokens = oidcClient.tokens.get(); // Returns existing tokens or error -const revokeResponse = oidcClient.tokens.revoke(); // Returns null or error -const endSessionResponse = oidcClient.tokens.endSession(); // Returns null or error +const existingTokens = oidcClient.token.get(); // Returns existing tokens or error // User API const user = oidcClient.user.info(); // Returns user object or error diff --git a/packages/oidc-client/package.json b/packages/oidc-client/package.json index a87760bc2..623d225ef 100644 --- a/packages/oidc-client/package.json +++ b/packages/oidc-client/package.json @@ -14,11 +14,13 @@ "import": "./dist/src/index.js", "default": "./dist/src/index.js" }, - "./package.json": "./package.json" + "./package.json": "./package.json", + "./types": "./dist/src/types.d.ts" }, "main": "./dist/src/index.js", "module": "./dist/src/index.js", "types": "./dist/src/index.d.ts", + "files": ["dist"], "scripts": { "build": "pnpm nx nxBuild", "lint": "pnpm nx nxLint", @@ -33,9 +35,26 @@ "@forgerock/sdk-types": "workspace:*", "@forgerock/storage": "workspace:*", "@reduxjs/toolkit": "catalog:", - "effect": "^3.12.7" + "effect": "catalog:effect" + }, + "devDependencies": { + "@effect/vitest": "catalog:effect", + "msw": "catalog:" }, "nx": { - "tags": ["scope:package"] + "tags": ["scope:package"], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "packages/oidc-client/dist", + "main": "packages/oidc-client/src/index.ts", + "tsConfig": "packages/oidc-client/tsconfig.lib.json", + "generatePackageJson": false, + "assets": [] + } + } + } } } diff --git a/packages/oidc-client/src/lib/authorize.request.ts b/packages/oidc-client/src/lib/authorize.request.ts index cb143a245..4429692f5 100644 --- a/packages/oidc-client/src/lib/authorize.request.ts +++ b/packages/oidc-client/src/lib/authorize.request.ts @@ -5,7 +5,6 @@ * of the MIT license. See the LICENSE file for details. */ import { CustomLogger } from '@forgerock/sdk-logger'; -import { GetAuthorizationUrlOptions } from '@forgerock/sdk-oidc'; import { Micro } from 'effect'; import { @@ -16,10 +15,12 @@ import { createAuthorizeErrorµ, } from './authorize.request.utils.js'; -import type { WellKnownResponse } from '@forgerock/sdk-types'; - +import type { GetAuthorizationUrlOptions, WellKnownResponse } from '@forgerock/sdk-types'; import type { OidcConfig } from './config.types.js'; -import { AuthorizeErrorResponse, AuthorizeSuccessResponse } from './authorize.request.types.js'; +import type { + AuthorizeErrorResponse, + AuthorizeSuccessResponse, +} from './authorize.request.types.js'; /** * @function authorizeµ diff --git a/packages/oidc-client/src/lib/authorize.request.utils.test.ts b/packages/oidc-client/src/lib/authorize.request.utils.test.ts new file mode 100644 index 000000000..b51faf442 --- /dev/null +++ b/packages/oidc-client/src/lib/authorize.request.utils.test.ts @@ -0,0 +1,69 @@ +/* + * 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 { it, expect } from '@effect/vitest'; +import { Micro } from 'effect'; +import { buildAuthorizeOptionsµ } from './authorize.request.utils.js'; +import { OidcConfig } from './config.types.js'; +import { WellKnownResponse } from '@forgerock/sdk-types'; + +const clientId = '123456789'; +const redirectUri = 'https://example.com/callback.html'; +const scope = 'openid profile'; +const responseType = 'code'; +const config: OidcConfig = { + clientId, + redirectUri, + scope, + serverConfig: { + wellknown: 'https://example.com/wellknown', + }, + responseType, +}; +const wellknown: WellKnownResponse = { + issuer: 'https://example.com/issuer', + authorization_endpoint: 'https://example.com/authorize', + token_endpoint: 'https://example.com/token', + userinfo_endpoint: 'https://example.com/userinfo', + end_session_endpoint: 'https://example.com/endSession', + introspection_endpoint: 'https://example.com/introspect', + revocation_endpoint: 'https://example.com/revoke', +}; + +it.effect('buildAuthorizeOptionsµ succeeds with BuildAuthorizationData', () => + Micro.gen(function* () { + const result = yield* buildAuthorizeOptionsµ(wellknown, config); + + expect(result).toStrictEqual([ + wellknown.authorization_endpoint, + config, + { + clientId, + redirectUri, + scope, + responseType, + }, + ]); + }), +); + +it.effect('buildAuthorizeOptionsµ with pi.flow succeeds with BuildAuthorizationData', () => + Micro.gen(function* () { + const result = yield* buildAuthorizeOptionsµ(wellknown, config, { responseMode: 'pi.flow' }); + + expect(result).toStrictEqual([ + wellknown.authorization_endpoint, + config, + { + clientId, + redirectUri, + scope, + responseType, + responseMode: 'pi.flow', + }, + ]); + }), +); diff --git a/packages/oidc-client/src/lib/authorize.request.utils.ts b/packages/oidc-client/src/lib/authorize.request.utils.ts index 5e6eadd33..53887b592 100644 --- a/packages/oidc-client/src/lib/authorize.request.utils.ts +++ b/packages/oidc-client/src/lib/authorize.request.utils.ts @@ -4,12 +4,12 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ -import { createAuthorizeUrl, GetAuthorizationUrlOptions } from '@forgerock/sdk-oidc'; +import { createAuthorizeUrl } from '@forgerock/sdk-oidc'; import { Micro } from 'effect'; import { iFrameManager, ResolvedParams } from '@forgerock/iframe-manager'; -import type { WellKnownResponse } from '@forgerock/sdk-types'; +import type { WellKnownResponse, GetAuthorizationUrlOptions } from '@forgerock/sdk-types'; import type { AuthorizeErrorResponse, diff --git a/packages/oidc-client/src/lib/client.store.ts b/packages/oidc-client/src/lib/client.store.ts index 4afb2d425..b3fa599e2 100644 --- a/packages/oidc-client/src/lib/client.store.ts +++ b/packages/oidc-client/src/lib/client.store.ts @@ -12,7 +12,7 @@ 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 { createClientStore, createTokenError } from './client.store.utils.js'; import { oidcApi } from './oidc.api.js'; import { wellknownApi, wellknownSelector } from './wellknown.api.js'; @@ -27,6 +27,7 @@ import type { } from './authorize.request.types.js'; import type { TokenExchangeErrorResponse, TokenExchangeResponse } from './exchange.types.js'; import { isExpiryWithinThreshold } from './token.utils.js'; +import { logoutµ } from './logout.request.js'; /** * @function oidc @@ -407,43 +408,10 @@ export async function oidc({ return createTokenError('no_id_token'); } - const logout = Micro.zip( - // End session with the ID token - Micro.promise(() => - store.dispatch( - oidcApi.endpoints.endSession.initiate({ - idToken: tokens.idToken, - endpoint: wellknown.ping_end_idp_session_endpoint || wellknown.end_session_endpoint, - }), - ), - ).pipe(Micro.map(({ data, error }) => createLogoutError(data, error))), - - // Revoke the access token - Micro.promise(() => - store.dispatch( - oidcApi.endpoints.revoke.initiate({ - accessToken: tokens.accessToken, - clientId: config.clientId, - endpoint: wellknown.revocation_endpoint, - }), - ), - ).pipe(Micro.map(({ data, error }) => createLogoutError(data, error))), - ).pipe( - // Delete local token and return combined results - Micro.flatMap(([sessionResponse, revokeResponse]) => - Micro.promise(async () => { - const deleteResponse = await storageClient.remove(); - return { - sessionResponse, - revokeResponse, - deleteResponse, - }; - }), - ), + const result = await Micro.runPromiseExit( + logoutµ({ tokens, config, wellknown, store, storageClient }), ); - const result = await Micro.runPromiseExit(logout); - if (exitIsSuccess(result)) { return result.value; } else if (exitIsFail(result)) { diff --git a/packages/oidc-client/src/lib/config.types.ts b/packages/oidc-client/src/lib/config.types.ts index 28fa3e5f2..47a186fb2 100644 --- a/packages/oidc-client/src/lib/config.types.ts +++ b/packages/oidc-client/src/lib/config.types.ts @@ -4,7 +4,11 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ -import type { AsyncLegacyConfigOptions, WellKnownResponse } from '@forgerock/sdk-types'; +import type { + AsyncLegacyConfigOptions, + WellKnownResponse, + ResponseType, +} from '@forgerock/sdk-types'; export interface OidcConfig extends AsyncLegacyConfigOptions { // Redundant properties are redeclared to define as required @@ -15,7 +19,7 @@ export interface OidcConfig extends AsyncLegacyConfigOptions { wellknown: string; timeout?: number; }; - responseType?: 'code' | 'token'; + responseType?: ResponseType; } export interface InternalDaVinciConfig extends OidcConfig { diff --git a/packages/oidc-client/src/lib/exchange.utils.test.ts b/packages/oidc-client/src/lib/exchange.utils.test.ts new file mode 100644 index 000000000..4f092bfda --- /dev/null +++ b/packages/oidc-client/src/lib/exchange.utils.test.ts @@ -0,0 +1,149 @@ +/* + * 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 { it, expect } from '@effect/vitest'; +import { Micro } from 'effect'; +import { handleTokenResponseµ, validateValuesµ } from './exchange.utils.js'; +import { OidcConfig } from './config.types.js'; +import { GetAuthorizationUrlOptions } from '@forgerock/sdk-types'; + +const clientId = '123456789'; +const redirectUri = 'https://example.com/callback.html'; +const scope = 'openid profile'; +const state = 'xyz789'; +const code = 'abc123'; +const responseType = 'code'; +const tokenEndpoint = 'https://example.com/token'; +const config: OidcConfig = { + clientId, + redirectUri, + scope, + serverConfig: { + wellknown: 'https://example.com/wellknown', + }, + responseType, +}; +const storedValues: GetAuthorizationUrlOptions = { + state, + responseType, + clientId, + scope, + redirectUri, +}; + +it.effect('validateValuesµ succeeds with TokenRequestOptions', () => + Micro.gen(function* () { + const result = yield* validateValuesµ({ + code, + state, + storedValues, + config, + endpoint: tokenEndpoint, + }); + + expect(result).toStrictEqual({ + code, + config, + endpoint: tokenEndpoint, + }); + }), +); + +it.effect('validateValuesµ with verifier succeeds with TokenRequestOptions', () => + Micro.gen(function* () { + const verifier = 'verifier123'; + const result = yield* validateValuesµ({ + code, + state, + storedValues: { + ...storedValues, + verifier, + }, + config, + endpoint: tokenEndpoint, + }); + + expect(result).toStrictEqual({ + code, + config, + endpoint: tokenEndpoint, + verifier, + }); + }), +); + +it.effect('validateValuesµ fails with state mismatch', () => + Micro.gen(function* () { + const result = yield* Micro.exit( + validateValuesµ({ + code, + state: 'abcState', + storedValues: { + ...storedValues, + state: 'xyzState', + }, + config, + endpoint: tokenEndpoint, + }), + ); + + expect(result).toStrictEqual( + Micro.fail({ + 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', + }), + ); + }), +); + +it.effect('handleTokenResponseµ with data succeeds', () => { + const data = { + access_token: '12345', + id_token: '67890', + }; + + return Micro.gen(function* () { + const result = yield* handleTokenResponseµ(data); + + expect(result).toStrictEqual(data); + }); +}); + +it.effect('handleTokenResponseµ with no data fails', () => { + return Micro.gen(function* () { + const result = yield* Micro.exit(handleTokenResponseµ(undefined)); + + expect(result).toStrictEqual( + Micro.fail({ + error: 'Token Exchange failure', + message: 'No data returned from token exchange', + type: 'exchange_error', + }), + ); + }); +}); + +it.effect('handleTokenResponseµ with error fails', () => { + const errMessage = 'Fetch error message'; + return Micro.gen(function* () { + const result = yield* Micro.exit( + handleTokenResponseµ(undefined, { + status: 'FETCH_ERROR', + error: errMessage, + }), + ); + + expect(result).toStrictEqual( + Micro.fail({ + error: 'Token Exchange failure', + message: errMessage, + type: 'exchange_error', + }), + ); + }); +}); diff --git a/packages/oidc-client/src/lib/exchange.utils.ts b/packages/oidc-client/src/lib/exchange.utils.ts index 695467833..418ea5321 100644 --- a/packages/oidc-client/src/lib/exchange.utils.ts +++ b/packages/oidc-client/src/lib/exchange.utils.ts @@ -9,8 +9,7 @@ import { FetchBaseQueryError } from '@reduxjs/toolkit/query'; import { Micro } from 'effect'; import { getStoredAuthUrlValues } from '@forgerock/sdk-oidc'; - -import type { GetAuthorizationUrlOptions } from '@forgerock/sdk-oidc'; +import type { GetAuthorizationUrlOptions } from '@forgerock/sdk-types'; import type { StorageConfig } from '@forgerock/storage'; import type { TokenExchangeResponse, TokenRequestOptions } from './exchange.types.js'; diff --git a/packages/oidc-client/src/lib/logout.request.test.ts b/packages/oidc-client/src/lib/logout.request.test.ts new file mode 100644 index 000000000..2d0843593 --- /dev/null +++ b/packages/oidc-client/src/lib/logout.request.test.ts @@ -0,0 +1,276 @@ +/* + * 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 { it, expect, describe } from '@effect/vitest'; +import { Micro } from 'effect'; +import { setupServer } from 'msw/node'; +import { logoutµ } from './logout.request.js'; +import { OauthTokens, OidcConfig } from './config.types.js'; +import { createStorage } from '@forgerock/storage'; +import { createClientStore } from './client.store.utils.js'; +import { logger as loggerFn } from '@forgerock/sdk-logger'; + +import { http, HttpResponse } from 'msw'; + +const server = setupServer( + // Ping AM End Session + http.get('*/am/oauth2/:realm/connect/endSession', async ({ params }) => { + if (params['realm'] === 'fake-realm') { + return HttpResponse.json({ error: 'bad realm' }, { status: 400 }); + } + return new HttpResponse(null, { status: 204 }); + }), + + // Ping AM Revoke + http.post('*/am/oauth2/:realm/token/revoke', async ({ params }) => { + if (params['realm'] === 'fake-realm') { + return HttpResponse.json({ error: 'bad realm' }, { status: 400 }); + } + return new HttpResponse(null, { status: 200 }); + }), + + // P1 End Session + http.get('*/as/idpSignoff', async () => new HttpResponse(null, { status: 204 })), + http.get('*/as/badIdpSignoff', async () => + HttpResponse.json({ error: 'bad request' }, { status: 400 }), + ), + + // P1 Revoke + http.post('*/as/revoke', async () => new HttpResponse(null, { status: 204 })), + http.post('*/as/badRevoke', async () => + HttpResponse.json({ error: 'bad request' }, { status: 400 }), + ), +); + +// Establish API mocking before all tests. +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); + +// Reset any request handlers that we may add during the tests, +// so they don't affect other tests. +afterEach(() => server.resetHandlers()); + +// Clean up after the tests are finished. +afterAll(() => server.close()); + +const config: OidcConfig = { + clientId: '123456789', + redirectUri: 'https://example.com/callback.html', + scope: 'openid profile', + serverConfig: { + wellknown: 'https://example.com/wellknown', + }, + responseType: 'code', +}; + +const customStorage: Record = {}; +const storageClient = createStorage({ + type: 'custom', + name: 'oidcTokens', + custom: { + get: async (key: string) => customStorage[key], + set: async (key: string, valueToSet: string) => { + customStorage[key] = valueToSet; + }, + remove: async (key: string) => { + delete customStorage[key]; + }, + }, +}); + +const logger = loggerFn({ level: 'error' }); +const store = createClientStore({ logger }); + +const tokens = { + accessToken: '1234567890', + idToken: '0987654321', +}; + +const partialWellknown = { + issuer: 'https://example.com/issuer', + authorization_endpoint: 'https://example.com/authorize', + token_endpoint: 'https://example.com/token', + userinfo_endpoint: 'https://example.com/userinfo', + introspection_endpoint: 'https://example.com/introspect', +}; + +describe('Ping AM', () => { + it.effect('logoutµ succeeds with valid wellknown endpoints', () => + Micro.gen(function* () { + const end_session_endpoint = 'https://example.com/am/oauth2/alpha/connect/endSession'; + const revocation_endpoint = 'https://example.com/am/oauth2/alpha/token/revoke'; + + const result = yield* logoutµ({ + tokens, + config, + wellknown: { + ...partialWellknown, + end_session_endpoint, + revocation_endpoint, + }, + store, + storageClient, + }); + + expect(result).toStrictEqual({ + sessionResponse: null, + revokeResponse: null, + deleteResponse: undefined, + }); + }), + ); + + it.effect('logoutµ fails on bad endSession', () => + Micro.gen(function* () { + const end_session_endpoint = 'https://example.com/am/oauth2/fake-realm/connect/endSession'; + const revocation_endpoint = 'https://example.com/am/oauth2/alpha/token/revoke'; + + const result = yield* logoutµ({ + tokens, + config, + wellknown: { + ...partialWellknown, + end_session_endpoint, + revocation_endpoint, + }, + store, + storageClient, + }); + + expect(result).toStrictEqual({ + sessionResponse: { + error: 'End Session failure', + message: 'An error occurred while ending the session', + type: 'auth_error', + status: 400, + }, + revokeResponse: null, + deleteResponse: undefined, + }); + }), + ); + + it.effect('logoutµ fails on bad revoke', () => + Micro.gen(function* () { + const end_session_endpoint = 'https://example.com/am/oauth2/alpha/connect/endSession'; + const revocation_endpoint = 'https://example.com/am/oauth2/fake-realm/token/revoke'; + + const result = yield* logoutµ({ + tokens, + config, + wellknown: { + ...partialWellknown, + end_session_endpoint, + revocation_endpoint, + }, + store, + storageClient, + }); + + expect(result).toStrictEqual({ + sessionResponse: null, + revokeResponse: { + error: 'End Session failure', + message: 'An error occurred while ending the session', + type: 'auth_error', + status: 400, + }, + deleteResponse: undefined, + }); + }), + ); +}); + +describe('PingOne', () => { + const fakeEndSessionEndpoint = 'https://example.com/endSession'; + + it.effect('logoutµ succeeds with valid wellknown endpoints', () => + Micro.gen(function* () { + const ping_end_idp_session_endpoint = 'https://example.com/as/idpSignoff'; + const revocation_endpoint = 'https://example.com/as/revoke'; + + const result = yield* logoutµ({ + tokens, + config, + wellknown: { + ...partialWellknown, + ping_end_idp_session_endpoint, + end_session_endpoint: fakeEndSessionEndpoint, + revocation_endpoint, + }, + store, + storageClient, + }); + + expect(result).toStrictEqual({ + sessionResponse: null, + revokeResponse: null, + deleteResponse: undefined, + }); + }), + ); + + it.effect('logoutµ fails on bad endSession', () => + Micro.gen(function* () { + const ping_end_idp_session_endpoint = 'https://example.com/as/badIdpSignoff'; + const revocation_endpoint = 'https://example.com/as/revoke'; + + const result = yield* logoutµ({ + tokens, + config, + wellknown: { + ...partialWellknown, + ping_end_idp_session_endpoint, + end_session_endpoint: fakeEndSessionEndpoint, + revocation_endpoint, + }, + store, + storageClient, + }); + + expect(result).toStrictEqual({ + sessionResponse: { + error: 'End Session failure', + message: 'An error occurred while ending the session', + type: 'auth_error', + status: 400, + }, + revokeResponse: null, + deleteResponse: undefined, + }); + }), + ); + + it.effect('logoutµ fails on bad revoke', () => + Micro.gen(function* () { + const ping_end_idp_session_endpoint = 'https://example.com/as/idpSignoff'; + const revocation_endpoint = 'https://example.com/as/badRevoke'; + + const result = yield* logoutµ({ + tokens, + config, + wellknown: { + ...partialWellknown, + ping_end_idp_session_endpoint, + end_session_endpoint: fakeEndSessionEndpoint, + revocation_endpoint, + }, + store, + storageClient, + }); + + expect(result).toStrictEqual({ + sessionResponse: null, + revokeResponse: { + error: 'End Session failure', + message: 'An error occurred while ending the session', + type: 'auth_error', + status: 400, + }, + deleteResponse: undefined, + }); + }), + ); +}); diff --git a/packages/oidc-client/src/lib/logout.request.ts b/packages/oidc-client/src/lib/logout.request.ts new file mode 100644 index 000000000..1948f282f --- /dev/null +++ b/packages/oidc-client/src/lib/logout.request.ts @@ -0,0 +1,61 @@ +/* + * 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 { oidcApi } from './oidc.api.js'; +import { createClientStore, createLogoutError } from './client.store.utils.js'; +import { OauthTokens, OidcConfig } from './config.types.js'; +import { WellKnownResponse } from '@forgerock/sdk-types'; +import { createStorage } from '@forgerock/storage'; + +export function logoutµ({ + tokens, + config, + wellknown, + store, + storageClient, +}: { + tokens: OauthTokens; + config: OidcConfig; + wellknown: WellKnownResponse; + store: ReturnType; + storageClient: ReturnType>; +}) { + return Micro.zip( + // End session with the ID token + Micro.promise(() => + store.dispatch( + oidcApi.endpoints.endSession.initiate({ + idToken: tokens.idToken, + endpoint: wellknown.ping_end_idp_session_endpoint || wellknown.end_session_endpoint, + }), + ), + ).pipe(Micro.map(({ data, error }) => createLogoutError(data, error))), + + // Revoke the access token + Micro.promise(() => + store.dispatch( + oidcApi.endpoints.revoke.initiate({ + accessToken: tokens.accessToken, + clientId: config.clientId, + endpoint: wellknown.revocation_endpoint, + }), + ), + ).pipe(Micro.map(({ data, error }) => createLogoutError(data, error))), + ).pipe( + // Delete local token and return combined results + Micro.flatMap(([sessionResponse, revokeResponse]) => + Micro.promise(async () => { + const deleteResponse = await storageClient.remove(); + return { + sessionResponse, + revokeResponse, + deleteResponse, + }; + }), + ), + ); +} diff --git a/packages/oidc-client/src/lib/store.ts b/packages/oidc-client/src/lib/store.ts deleted file mode 100644 index 20f6f1139..000000000 --- a/packages/oidc-client/src/lib/store.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-middleware'; -import type { logger as loggerFn } from '@forgerock/sdk-logger'; - -import { configureStore } from '@reduxjs/toolkit'; -import { wellknownApi } from './wellknown.api.js'; -import { authorizeSlice } from './authorize.slice.js'; - -export function createOidcStore({ - requestMiddleware, - logger, -}: { - requestMiddleware?: RequestMiddleware[]; - logger?: ReturnType; -}) { - return configureStore({ - reducer: { - [wellknownApi.reducerPath]: wellknownApi.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(wellknownApi.middleware) - .concat(authorizeSlice.middleware), - }); -} diff --git a/packages/oidc-client/tsconfig.lib.json b/packages/oidc-client/tsconfig.lib.json index 6a283a7df..d4b07d31d 100644 --- a/packages/oidc-client/tsconfig.lib.json +++ b/packages/oidc-client/tsconfig.lib.json @@ -2,17 +2,20 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "baseUrl": ".", - "outDir": "dist", - "tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo", + "outDir": "./dist", + "tsBuildInfoFile": "./dist/tsconfig.lib.tsbuildinfo", "emitDeclarationOnly": false, "module": "nodenext", "moduleResolution": "nodenext", "forceConsistentCasingInFileNames": true, "strict": true, - "importHelpers": true, "noImplicitOverride": true, "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + "declaration": true, + "declarationMap": true, + "skipLibCheck": true, + "sourceMap": true }, "include": ["src/**/*.ts"], "references": [ diff --git a/packages/sdk-effects/oidc/src/index.ts b/packages/sdk-effects/oidc/src/index.ts index eef2d0cee..6529c8230 100644 --- a/packages/sdk-effects/oidc/src/index.ts +++ b/packages/sdk-effects/oidc/src/index.ts @@ -1,3 +1,2 @@ export * from './lib//authorize.effects.js'; -export * from './lib//authorize.types.js'; export * from './lib/state-pkce.effects.js'; diff --git a/packages/sdk-effects/oidc/src/lib/authorize.effects.ts b/packages/sdk-effects/oidc/src/lib/authorize.effects.ts index 7019dffae..f003665f8 100644 --- a/packages/sdk-effects/oidc/src/lib/authorize.effects.ts +++ b/packages/sdk-effects/oidc/src/lib/authorize.effects.ts @@ -12,7 +12,7 @@ import { createChallenge } from '@forgerock/sdk-utilities'; import { generateAndStoreAuthUrlValues } from './state-pkce.effects.js'; -import type { GetAuthorizationUrlOptions } from './authorize.types.js'; +import type { GetAuthorizationUrlOptions } from '@forgerock/sdk-types'; /** * @function createAuthorizeUrl - Create authorization URL for initial call to DaVinci diff --git a/packages/sdk-effects/oidc/src/lib/authorize.types.ts b/packages/sdk-effects/oidc/src/lib/authorize.types.ts deleted file mode 100644 index e2f18c7a9..000000000 --- a/packages/sdk-effects/oidc/src/lib/authorize.types.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. - * - * This software may be modified and distributed under the terms - * of the MIT license. See the LICENSE file for details. - */ - -import type { LegacyConfigOptions } from '@forgerock/sdk-types'; - -/** - * Define the options for the authorization URL - * @param clientId The client ID of the application - * @param redirectUri The redirect URI of the application - * @param responseType The response type of the authorization request - * @param scope The scope of the authorization request - */ -export type ResponseType = 'code' | 'token'; -export interface GetAuthorizationUrlOptions extends LegacyConfigOptions { - successParams?: string[]; - errorParams?: string[]; - - /** - * These three properties clientid, scope and redirectUri are required - * when using this type, which are not required when defining Config. - */ - clientId: string; - login?: 'redirect' | 'embedded'; - scope: string; - redirectUri: string; - responseType: ResponseType; - responseMode?: 'fragment' | 'form_post' | 'pi.flow' | 'query'; - state?: string; - verifier?: string; - query?: Record; - prompt?: 'none' | 'login' | 'consent'; -} diff --git a/packages/sdk-effects/oidc/src/lib/state-pkce.effects.ts b/packages/sdk-effects/oidc/src/lib/state-pkce.effects.ts index f17ca625c..d29657c4f 100644 --- a/packages/sdk-effects/oidc/src/lib/state-pkce.effects.ts +++ b/packages/sdk-effects/oidc/src/lib/state-pkce.effects.ts @@ -7,7 +7,7 @@ import { createVerifier, createState } from '@forgerock/sdk-utilities'; -import type { GetAuthorizationUrlOptions } from './authorize.types.js'; +import type { GetAuthorizationUrlOptions } from '@forgerock/sdk-types'; function getStorageKey(clientId: string, prefix?: string) { return `${prefix || 'FR-SDK'}-authflow-${clientId}`; diff --git a/packages/sdk-types/src/lib/authorize.types.ts b/packages/sdk-types/src/lib/authorize.types.ts index b4d3f22c9..523958c36 100644 --- a/packages/sdk-types/src/lib/authorize.types.ts +++ b/packages/sdk-types/src/lib/authorize.types.ts @@ -6,6 +6,8 @@ */ import type { LegacyConfigOptions } from './legacy-config.types.js'; +export type ResponseType = 'code' | 'token'; + /** * Define the options for the authorization URL * @param clientId The client ID of the application @@ -13,22 +15,24 @@ import type { LegacyConfigOptions } from './legacy-config.types.js'; * @param responseType The response type of the authorization request * @param scope The scope of the authorization request */ -export type ResponseType = 'code' | 'token'; export interface GetAuthorizationUrlOptions extends LegacyConfigOptions { /** - * These three properties clientid, scope and redirectUri are required + * These four properties clientid, scope, responseType and redirectUri are required * when using this type, which are not required when defining Config. */ clientId: string; - login?: 'redirect' | 'embedded'; scope: string; redirectUri: string; responseType: ResponseType; + responseMode?: 'fragment' | 'form_post' | 'pi.flow' | 'query'; + login?: 'redirect' | 'embedded'; state?: string; verifier?: string; query?: Record; prompt?: 'none' | 'login' | 'consent'; + successParams?: string[]; + errorParams?: string[]; } /** * Generate and store PKCE values for later use diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb14971ae..89be5a62d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,9 @@ catalogs: immer: specifier: ^10.1.1 version: 10.1.1 + msw: + specifier: ^2.5.1 + version: 2.10.4 effect: '@effect/cli': specifier: ^0.67.1 @@ -314,6 +317,9 @@ importers: '@forgerock/oidc-client': specifier: workspace:* version: link:../../packages/oidc-client + '@forgerock/sdk-types': + specifier: workspace:* + version: link:../../packages/sdk-types e2e/oidc-suites: {} @@ -366,7 +372,7 @@ importers: version: 2.8.2 devDependencies: msw: - specifier: ^2.5.1 + specifier: 'catalog:' version: 2.10.4(@types/node@22.14.1)(typescript@5.8.3) packages/oidc-client: @@ -393,8 +399,15 @@ importers: specifier: 'catalog:' version: 2.8.2 effect: - specifier: ^3.12.7 + specifier: catalog:effect version: 3.17.7 + devDependencies: + '@effect/vitest': + specifier: catalog:effect + version: 0.23.13(effect@3.17.7)(vitest@3.2.4(@types/node@22.14.1)(@vitest/ui@3.0.4)(jiti@2.4.2)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.14.1)(typescript@5.8.3))(terser@5.43.1)(tsx@4.17.0)(yaml@2.8.1)) + msw: + specifier: 'catalog:' + version: 2.10.4(@types/node@22.14.1)(typescript@5.8.3) packages/protect: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3676e5920..ee27f4731 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,6 +10,7 @@ packages: catalog: '@reduxjs/toolkit': ^2.8.2 immer: ^10.1.1 + msw: ^2.5.1 catalogs: effect: