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: