Skip to content

Commit

Permalink
feat: implement RP initiated logut for node (#3044)
Browse files Browse the repository at this point in the history
* feat: implement RP initiated logut for node

* chore: remove commented out code

* chore: supply E2E_TEST_USER and E2E_TEST_PASSWORD in node env

* chore: build before linting

* chore: install playwright for node tests

* chore: clean up e2e session

* chore: launch e2e browser headless

* chore: test calling idp logout twice and without login

* chore: add express-based test

* chore: rename e2e app tests

* chore: remove buildRpInitiatedLogout function

* chore: don't return function returning void

* fix: use relative import for node e2e tests

* chore: make firefox tests headless

* chore: add unit tests for RP logout

* chore: extend RP logout unit tests

* chore: move authn-node playwrwight tests to a separate file

* chore: fix CI workflow

* chore: fix logout test
  • Loading branch information
jeswr authored Jul 14, 2023
1 parent c313c34 commit ea80194
Show file tree
Hide file tree
Showing 30 changed files with 1,226 additions and 302 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ concurrency:
jobs:
lint:
uses: inrupt/typescript-sdk-tools/.github/workflows/[email protected]
with:
build: true

test:
runs-on: ubuntu-latest
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/e2e-node.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ jobs:
cache: npm
- run: npm ci
if: github.actor != 'dependabot[bot]'
- name: Install e2e tests dependencies
run: npx playwright install --with-deps
if: github.actor != 'dependabot[bot]'
- # Dependabot cannot access secrets, so it doesn't have a token to authenticate to ESS.
# We want jobs in this workflow to be gating PRs, so the whole matrix must
# run even for dependabot so that the matrixed jobs are skipped, instead
Expand All @@ -46,3 +49,5 @@ jobs:
E2E_TEST_OWNER_CLIENT_ID: ${{ secrets.E2E_TEST_OWNER_CLIENT_ID }}
E2E_TEST_OWNER_CLIENT_SECRET: ${{ secrets.E2E_TEST_OWNER_CLIENT_SECRET }}
E2E_TEST_ENVIRONMENT: ${{ matrix.environment-name }}
E2E_TEST_USER: ${{ secrets.E2E_TEST_USER }}
E2E_TEST_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }}
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ The following have been deprecated, and will be removed in future major releases

The following changes have been implemented but not released yet:

### New Feature

- Support for [RP-Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html) in Node and Browser
libraries.

## [1.16.0](https://github.com/inrupt/solid-client-authn-js/releases/tag/v1.16.0) - 2023-05-09

### New Feature
Expand Down
155 changes: 99 additions & 56 deletions e2e/browser/test/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ const createClientIdDoc = async (
"@context": ["https://www.w3.org/ns/solid/oidc-context.jsonld"],
client_name: clientInfo.clientName,
client_id: clientId,
redirect_uris: [clientInfo.redirectUrl],
redirect_uris: [clientInfo.redirectUrl, "http://localhost:3001/redirect"],
// Note: No refresh token will be issued by default. If the tests last too long, this
// should be updated so that it has the offline_access scope and supports the
// refresh_token grant type.
Expand Down Expand Up @@ -241,6 +241,91 @@ const createClientResource = async (
const clientApplicationUrl =
process.env.E2E_DEMO_CLIENT_APP_URL ?? "http://localhost:3001/";

export interface ISeedPodResponse {
clientId: string;
clientResourceContent: string;
clientResourceUrl: string;
session: Session;
}

export async function seedPod(
setupEnvironment: TestingEnvironmentNode
): Promise<ISeedPodResponse> {
if (
setupEnvironment.clientCredentials.owner.type !== "ESS Client Credentials"
) {
throw new Error("Unsupported client credentials");
}

const credentials = {
oidcIssuer: setupEnvironment.idp,
clientId: setupEnvironment.clientCredentials.owner.id,
clientSecret: setupEnvironment.clientCredentials.owner.secret,
};

// Make the Client ID document publicly available.
const session = new Session();
session.events.on("sessionExpired", async () => {
await session.login(credentials);
});

try {
await session.login(credentials);
} catch (err) {
throw new Error(`Failed to login: ${(err as Error).message}`);
}

if (typeof session.info.webId !== "string") {
throw new Error("The provided session isn't logged in.");
}
const parentContainers = await getPodUrlAll(session.info.webId);
if (parentContainers.length === 0) {
throw new Error(`Couldn't find storage for ${session.info.webId}`);
}
const [podRoot] = parentContainers;

const clientId = await createClientIdDoc(
{
clientName: "Browser test app",
redirectUrl: new URL(`http://localhost:${PLAYWRIGHT_PORT}`).href,
},
podRoot,
session
);
const clientResourceContent =
"Access to this file is restricted to a specific client.";
const clientResourceUrl = await createClientResource(
podRoot,
clientResourceContent,
clientId,
session
);

return {
clientId,
clientResourceContent,
clientResourceUrl,
session,
};
}

export async function tearDownPod({
clientId,
session,
clientResourceUrl,
}: ISeedPodResponse) {
// Teardown
await deleteFile(clientId, {
fetch: session.fetch,
});

await deleteFile(clientResourceUrl, {
fetch: session.fetch,
});

await session.logout();
}

// Extend basic test by providing a "defaultItem" option and a "todoPage" fixture.
export const test = base.extend<Fixtures>({
app: async ({ page }, use) => {
Expand Down Expand Up @@ -281,6 +366,12 @@ export const test = base.extend<Fixtures>({
const session = new Session();

try {
if (
setupEnvironment.clientCredentials.owner.type !==
"ESS Client Credentials"
) {
throw new Error("Unsupported client credentials");
}
await session.login({
oidcIssuer: setupEnvironment.idp,
clientId: setupEnvironment.clientCredentials.owner.id,
Expand Down Expand Up @@ -386,65 +477,17 @@ export const test = base.extend<Fixtures>({
},

clientAccessControl: async ({ setupEnvironment }, use) => {
// Make the Client ID document publicly available.
const session = new Session();
session.events.on("sessionExpired", async () => {
await session.login({
oidcIssuer: setupEnvironment.idp,
clientId: setupEnvironment.clientCredentials.owner.id,
clientSecret: setupEnvironment.clientCredentials.owner.secret,
});
});

try {
await session.login({
oidcIssuer: setupEnvironment.idp,
clientId: setupEnvironment.clientCredentials.owner.id,
clientSecret: setupEnvironment.clientCredentials.owner.secret,
});
} catch (err) {
throw new Error(`Failed to login: ${(err as Error).message}`);
}

if (typeof session.info.webId !== "string") {
throw new Error("The provided session isn't logged in.");
}
const parentContainers = await getPodUrlAll(session.info.webId);
if (parentContainers.length === 0) {
throw new Error(`Couldn't find storage for ${session.info.webId}`);
}
const [podRoot] = parentContainers;

const clientId = await createClientIdDoc(
{
clientName: "Browser test app",
redirectUrl: new URL(`http://localhost:${PLAYWRIGHT_PORT}`).href,
},
podRoot,
session
);
const clientResourceContent =
"Access to this file is restricted to a specific client.";
const clientResourceUrl = await createClientResource(
podRoot,
clientResourceContent,
clientId,
session
);

const { session, clientId, clientResourceUrl, clientResourceContent } =
await seedPod(setupEnvironment);
// The code before the call to use is the setup, and after is the teardown.
// This is the value the Fixture will be using.
await use({ clientId, clientResourceUrl, clientResourceContent });

// Teardown
await deleteFile(clientId, {
fetch: session.fetch,
});

await deleteFile(clientResourceUrl, {
fetch: session.fetch,
await tearDownPod({
session,
clientId,
clientResourceUrl,
clientResourceContent,
});

await session.logout();
},
});
145 changes: 145 additions & 0 deletions e2e/node/e2e-app-test.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
//
// Copyright Inrupt Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to use,
// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
// Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
//
import { CognitoPage, OpenIdPage } from "@inrupt/internal-playwright-helpers";
import {
getBrowserTestingEnvironment,
getNodeTestingEnvironment,
} from "@inrupt/internal-test-env";
import {
afterEach,
beforeEach,
describe,
expect,
it,
jest,
} from "@jest/globals";
import { firefox } from "@playwright/test";
import { custom } from "openid-client";
import type { Server } from "http";
import {
type ISeedPodResponse,
seedPod,
tearDownPod,
} from "../browser/test/fixtures";
import { createApp } from "./express";

custom.setHttpOptionsDefaults({
timeout: 15000,
});

if (process.env.CI === "true") {
// Tests running in the CI runners tend to be more flaky.
jest.retryTimes(3, { logErrorsBeforeRetry: true });
}

const ENV = getNodeTestingEnvironment();
const BROWSER_ENV = getBrowserTestingEnvironment();

describe("Testing against express app", () => {
let app: Server;
let seedInfo: ISeedPodResponse;
let clientId: string;
let clientResourceUrl: string;
let clientResourceContent: string;

beforeEach(async () => {
seedInfo = await seedPod(ENV);
clientId = seedInfo.clientId;
clientResourceUrl = seedInfo.clientResourceUrl;
clientResourceContent = seedInfo.clientResourceContent;
await new Promise<void>((res) => {
app = createApp(res);
});
}, 30_000);

afterEach(async () => {
await tearDownPod(seedInfo);
await new Promise<void>((res) => {
app.close(() => res());
});
}, 30_000);

it("Should be able to properly login and out with idp logout", async () => {
const browser = await firefox.launch();
const page = await browser.newPage();
const url = new URL("http://localhost:3001/login");
url.searchParams.append("oidcIssuer", ENV.idp);
url.searchParams.append("clientId", clientId);

await page.goto(url.toString());

// Wait for navigation outside the localhost session
await page.waitForURL(/^https/);
const cognitoPageUrl = page.url();

const cognitoPage = new CognitoPage(page);
await cognitoPage.login(
BROWSER_ENV.clientCredentials.owner.login,
BROWSER_ENV.clientCredentials.owner.password
);
const openidPage = new OpenIdPage(page);
try {
await openidPage.allow();
} catch (e) {
// Ignore allow error for now
}

await page.waitForURL("http://localhost:3001/");

// Fetching a protected resource once logged in
const resourceUrl = new URL("http://localhost:3001/fetch");
resourceUrl.searchParams.append("resource", clientResourceUrl);
await page.goto(resourceUrl.toString());
await expect(page.content()).resolves.toBe(
`<html><head></head><body>${clientResourceContent}</body></html>`
);

// Performing idp logout and being redirected to the postLogoutUrl after doing so
await page.goto("http://localhost:3001/idplogout");
await page.waitForURL("http://localhost:3001/postLogoutUrl");
await expect(page.content()).resolves.toBe(
`<html><head></head><body>successfully at post logout</body></html>`
);

// Should not be able to retrieve the protected resource after logout
await page.goto(resourceUrl.toString());
await expect(page.content()).resolves.toBe(
`<html><head></head><body>{"description":"HTTP 401 Unauthorized"}</body></html>`
);

// Testing what happens if we try to log back in again after logging out
await page.goto(url.toString());

// It should go back to the cognito page when we try to log back in
// rather than skipping straight to the consent page
await page.waitForURL((navigationUrl) => {
const u1 = new URL(navigationUrl);
u1.searchParams.delete("state");

const u2 = new URL(cognitoPageUrl);
u2.searchParams.delete("state");

return u1.toString() === u2.toString();
});

await browser.close();
}, 120_000);
});
Loading

0 comments on commit ea80194

Please sign in to comment.