Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ jobs:
cd ../reactjs-todo-davinci && npm run e2e -- --shard=${{ matrix.shardIndex }}/4
cd ../angular-todo && npm run e2e -- --shard=${{ matrix.shardIndex }}/4
cd ../angular-todo-davinci && npm run e2e -- --shard=${{ matrix.shardIndex }}/4
cd ../central-login-oidc-client && npm run e2e -- --shard=${{ matrix.shardIndex }}/4
env:
REST_OAUTH_SECRET: ${{ secrets.REST_OAUTH_SECRET }}

Expand All @@ -59,3 +60,5 @@ jobs:
./javascript/reactjs-todo-davinci/playwright-report
./javascript/angular-todo-davinci/test-results
./javascript/angular-todo-davinci/playwright-report
./javascript/central-login-oidc-client/test-results
./javascript/central-login-oidc-client/playwright-report
5 changes: 5 additions & 0 deletions javascript/central-login-oidc-client/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
VITE_PORT=8443
VITE_SCOPE=$SCOPE # For PingOne servers, the `revoke` scope must be set as well.
VITE_TIMEOUT=$TIMEOUT
VITE_WEB_OAUTH_CLIENT=$WEB_OAUTH_CLIENT
VITE_WELLKNOWN_URL=$WELLKNOWN_URL
24 changes: 24 additions & 0 deletions javascript/central-login-oidc-client/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

node_modules
dist
dist-ssr
*.local

# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
27 changes: 27 additions & 0 deletions javascript/central-login-oidc-client/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# OIDC Login

The SDK provides an OIDC client for using the Authorization Code Flow
(with PKCE) with a centralized application using OIDC Login.

For a non-authenticated user, use the `authorize.url()` method to get an authorization URL. This can be used to redirect the user to the login application that uses either PingAM or PingOne. After successful authentication, the user is redirected back to the sample application. Then use the `token.exchange()` method to request OAuth/OIDC tokens.

### Instructions

To configure your server, following the steps in the [OIDC Login](https://docs.pingidentity.com/sdks/latest/oidc/configure-the-sdks.html) documentation.

Then, create your configuration `.env` file based on the included `.env.example`.

- Set the `VITE_SCOPE` property to a "space separated" string, containing the scopes your app is requesting. For PingOne servers the `revoke` scope is required. eg: `openid profile revoke`

- Set the `VITE_TIMEOUT` property to the amount of miliseconds. Any value between 3000 to 5000 is good, this impacts the redirect time to login. Change that according to your needs.

- Set the `VITE_WEB_OAUTH_CLIENT` to the OAuth2.0 client ID that has been set up on your server.

- Set the `VITE_WELLKNOWN_URL` to the wellknown URL for your OAuth client


Then, from the `/javascript` folder run the sample app as follows:
```
npm install
npm run start:central-login-oidc-client
```
87 changes: 87 additions & 0 deletions javascript/central-login-oidc-client/e2e/login.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* 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 { test, expect } from '@playwright/test';
import { asyncEvents } from './utils/async-events';
import {
pingAmUsername,
pingAmPassword,
pingOneUsername,
pingOnePassword,
} from './utils/demo-users';

test.describe('Login Tests', () => {
test('PingAM redirect login with valid credentials', async ({ page }) => {
const { navigate, clickButton } = asyncEvents(page);
await navigate('http://localhost:8444/');
expect(page.url()).toBe('http://localhost:8444/');

await clickButton('Login', '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:8444/**');
expect(page.url()).toContain('code');
expect(page.url()).toContain('state');
await expect(page.locator('#User pre')).toContainText('Sdk User');
await expect(page.locator('#User pre')).toContainText('[email protected]');
});

test('PingOne redirect login with valid credentials', async ({ page }) => {
const { navigate, clickButton } = asyncEvents(page);
await navigate('http://localhost:8443/');
expect(page.url()).toBe('http://localhost:8443/');

await clickButton('Login', '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/**');
expect(page.url()).toContain('code');
expect(page.url()).toContain('state');
await expect(page.locator('#User pre')).toContainText('demouser');
await expect(page.locator('#User pre')).toContainText('[email protected]');
});

test('login with valid token skips redirect', async ({ page }) => {
const { navigate, clickButton } = asyncEvents(page);
await navigate('http://localhost:8443/');
expect(page.url()).toBe('http://localhost:8443/');

await clickButton('Login', '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/**');
expect(page.url()).toContain('code');
expect(page.url()).toContain('state');

// Refresh the page and clear tokens
await navigate('http://localhost:8443/');
await page.evaluate(() => window.localStorage.clear());
await page.on('request', (request) => {
expect(request.url()).not.toContain('https://apps.pingone.ca/');
});

await page.getByRole('button', { name: 'Login' }).click();
await expect(page.locator('#User pre')).toContainText('demouser');
await expect(page.locator('#User pre')).toContainText('[email protected]');
});

test('login with invalid state fails with error', async ({ page }) => {
const { navigate } = asyncEvents(page);
await navigate('http://localhost:8443/?code=12345&state=abcxyz');
expect(page.url()).toBe('http://localhost:8443/?code=12345&state=abcxyz');

await expect(page.locator('#Error span')).toContainText('State mismatch');
});
});
110 changes: 110 additions & 0 deletions javascript/central-login-oidc-client/e2e/logout.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/*
* 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 { test, expect } from '@playwright/test';
import { asyncEvents } from './utils/async-events';
import {
pingAmUsername,
pingAmPassword,
pingOneUsername,
pingOnePassword,
} from './utils/demo-users';

test.describe('Logout tests', () => {
test('PingOne login then logout', async ({ page }) => {
const { navigate, clickButton } = asyncEvents(page);
await navigate('http://localhost:8443/');
expect(page.url()).toBe('http://localhost:8443/');

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', '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/**');
expect(page.url()).toContain('code');
expect(page.url()).toContain('state');
await expect(page.getByRole('button', { name: 'Login' })).toBeHidden();

await page.getByRole('button', { name: 'Sign Out' }).click();
await expect(page.getByRole('button', { name: 'Login' })).toBeVisible();

expect(endSessionStatus).toBeTruthy();
expect(revokeStatus).toBeTruthy();
});

test('PingAM login then logout', async ({ page }) => {
const { navigate, clickButton } = asyncEvents(page);
await navigate('http://localhost:8444/');
expect(page.url()).toBe('http://localhost:8444/');

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', '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:8444/**');
expect(page.url()).toContain('code');
expect(page.url()).toContain('state');
await expect(page.getByRole('button', { name: 'Login' })).toBeHidden();

await page.getByRole('button', { name: 'Sign Out' }).click();
await expect(page.getByRole('button', { name: 'Login' })).toBeVisible();

expect(endSessionStatus).toBeTruthy();
expect(revokeStatus).toBeTruthy();
});

test('logout without tokens should error', async ({ page }) => {
const { navigate, clickButton } = asyncEvents(page);
await navigate('http://localhost:8443/');
expect(page.url()).toBe('http://localhost:8443/');

await clickButton('Login', '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/**');
expect(page.url()).toContain('code');
expect(page.url()).toContain('state');
await expect(page.getByRole('button', { name: 'Login' })).toBeHidden();

await page.evaluate(() => window.localStorage.clear());

await page.getByRole('button', { name: 'Sign Out' }).click();
await expect(page.locator('#Error span')).toContainText('Token_Error');
});
});
79 changes: 79 additions & 0 deletions javascript/central-login-oidc-client/e2e/utils/async-events.ts
Original file line number Diff line number Diff line change
@@ -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: [email protected]' : 'Email: [email protected]';
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);
}
13 changes: 13 additions & 0 deletions javascript/central-login-oidc-client/e2e/utils/demo-users.ts
Original file line number Diff line number Diff line change
@@ -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 = 'yvk4uwq2edr@gxb7UWD';
Loading