From a158ff4b08c48865ada56d3856d5e24c195c5999 Mon Sep 17 00:00:00 2001 From: Zwifi Date: Fri, 3 May 2024 12:04:18 +0200 Subject: [PATCH] Allow to disable session background refresh (#3473) * Allow to disable session background refresh In a server-side scenario, one may not want sessions to proactively refresh to keep their access token active. Instead, the session is logged in using the refresh token when loaded from storage, but it will only be active during the lifetime of its Access Token. When building a Session, a new option "keepAlive" is supported. It defaults to 'true' for backwards compatibility. If set to 'false', the refresh token of the session will not be used to proactively refresh the access token. `keepAlive` is persisted in storage with the session state so that it is used after the authorization request during the token request in the authorization code flow. --------- Co-authored-by: Pete Edwards Co-authored-by: Jarlath Holleran --- CHANGELOG.md | 10 + e2e/node/server/e2e-app-test.spec.ts | 175 ++++++++++-------- e2e/node/server/express.ts | 9 +- package.json | 2 +- .../browser/src/ClientAuthentication.spec.ts | 1 + packages/browser/src/ClientAuthentication.ts | 6 +- packages/browser/src/Session.ts | 2 +- packages/core/src/Session.ts | 24 +++ packages/core/src/SessionEventListener.ts | 2 +- .../src/authenticatedFetch/fetchFactory.ts | 2 +- packages/core/src/index.ts | 2 + packages/core/src/login/ILoginOptions.ts | 2 + .../login/oidc/IIncomingRedirectHandler.ts | 7 +- packages/core/src/login/oidc/IOidcOptions.ts | 5 + .../AuthorizationCodeWithPkceOidcHandler.ts | 16 +- packages/core/src/sessionInfo/ISessionInfo.ts | 5 + .../core/src/storage/StorageUtility.spec.ts | 1 + packages/core/src/storage/StorageUtility.ts | 7 +- .../multiSession/src/serverSideApp.mjs | 3 +- .../node/src/ClientAuthentication.spec.ts | 40 +++- packages/node/src/ClientAuthentication.ts | 10 +- packages/node/src/Session.ts | 15 +- .../oidc/AggregateIncomingRedirectHandler.ts | 3 +- .../src/login/oidc/OidcLoginHandler.spec.ts | 29 +++ .../node/src/login/oidc/OidcLoginHandler.ts | 1 + .../AuthCodeRedirectHandler.ts | 2 +- .../oidcHandlers/RefreshTokenOidcHandler.ts | 4 +- packages/node/src/multiSession.ts | 1 + .../src/sessionInfo/SessionInfoManager.ts | 19 +- 29 files changed, 299 insertions(+), 106 deletions(-) create mode 100644 packages/core/src/Session.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 328cb58bd5..a371ec7f48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html The following changes have been implemented but not released yet: +### New Feature + +#### node + +- It is now possible to prevent a `Session` self-refreshing in NodeJS. To do so, a new + parameter is added to the constructor: `Session({ keepAlive: false })`. This prevents + the `Session` setting a callback to refresh the Access Token before it expires, which + could cause a memory leak in the case of a server-side application with many users. + It also avoids unnecessary requests being sent to the OpenID Provider. + ## [2.1.0](https://github.com/inrupt/solid-client-authn-js/releases/tag/v2.1.0) - 2024-03-13 ### New Feature diff --git a/e2e/node/server/e2e-app-test.spec.ts b/e2e/node/server/e2e-app-test.spec.ts index 17c59df18f..0cdf14f455 100644 --- a/e2e/node/server/e2e-app-test.spec.ts +++ b/e2e/node/server/e2e-app-test.spec.ts @@ -56,20 +56,88 @@ if (process.env.CI === "true") { const ENV = getNodeTestingEnvironment(); const BROWSER_ENV = getBrowserTestingEnvironment(); -describe("Testing against express app", () => { +async function performTest(seedInfo: ISeedPodResponse) { + const browser = await firefox.launch(); + const page = await browser.newPage(); + const url = new URL( + `http://localhost:${CONSTANTS.CLIENT_AUTHN_TEST_PORT}/login`, + ); + url.searchParams.append("oidcIssuer", ENV.idp); + url.searchParams.append("clientId", seedInfo.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:${CONSTANTS.CLIENT_AUTHN_TEST_PORT}/`, + ); + + // Fetching a protected resource once logged in + const resourceUrl = new URL( + `http://localhost:${CONSTANTS.CLIENT_AUTHN_TEST_PORT}/fetch`, + ); + resourceUrl.searchParams.append("resource", seedInfo.clientResourceUrl); + await page.goto(resourceUrl.toString()); + await expect(page.content()).resolves.toBe( + `${seedInfo.clientResourceContent}`, + ); + + // Performing idp logout and being redirected to the postLogoutUrl after doing so + await page.goto( + `http://localhost:${CONSTANTS.CLIENT_AUTHN_TEST_PORT}/idplogout`, + ); + await page.waitForURL( + `http://localhost:${CONSTANTS.CLIENT_AUTHN_TEST_PORT}/postLogoutUrl`, + ); + await expect(page.content()).resolves.toBe( + `successfully at post logout`, + ); + + // Should not be able to retrieve the protected resource after logout + await page.goto(resourceUrl.toString()); + await expect(page.content()).resolves.toMatch("Unauthorized"); + + // 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(); +} + +describe("Testing against express app with default session", () => { 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((res) => { - app = createApp(res); + app = createApp(res, { keepAlive: true }); }); }, 30_000); @@ -81,76 +149,29 @@ describe("Testing against express app", () => { }, 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:${CONSTANTS.CLIENT_AUTHN_TEST_PORT}/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:${CONSTANTS.CLIENT_AUTHN_TEST_PORT}/`, - ); - - // Fetching a protected resource once logged in - const resourceUrl = new URL( - `http://localhost:${CONSTANTS.CLIENT_AUTHN_TEST_PORT}/fetch`, - ); - resourceUrl.searchParams.append("resource", clientResourceUrl); - await page.goto(resourceUrl.toString()); - await expect(page.content()).resolves.toBe( - `${clientResourceContent}`, - ); - - // Performing idp logout and being redirected to the postLogoutUrl after doing so - await page.goto( - `http://localhost:${CONSTANTS.CLIENT_AUTHN_TEST_PORT}/idplogout`, - ); - await page.waitForURL( - `http://localhost:${CONSTANTS.CLIENT_AUTHN_TEST_PORT}/postLogoutUrl`, - ); - await expect(page.content()).resolves.toBe( - `successfully at post logout`, - ); - - // Should not be able to retrieve the protected resource after logout - await page.goto(resourceUrl.toString()); - await expect(page.content()).resolves.toMatch("Unauthorized"); - - // 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 performTest(seedInfo); + }, 120_000); +}); + +describe("Testing against express app with session keep alive off", () => { + let app: Server; + let seedInfo: ISeedPodResponse; + + beforeEach(async () => { + seedInfo = await seedPod(ENV); + await new Promise((res) => { + app = createApp(res, { keepAlive: false }); + }); + }, 30_000); + + afterEach(async () => { + await tearDownPod(seedInfo); + await new Promise((res) => { + app.close(() => res()); }); + }, 30_000); - await browser.close(); + it("Should be able to properly login and out with idp logout", async () => { + await performTest(seedInfo); }, 120_000); }); diff --git a/e2e/node/server/express.ts b/e2e/node/server/express.ts index 7053039aa6..3dde982517 100644 --- a/e2e/node/server/express.ts +++ b/e2e/node/server/express.ts @@ -22,6 +22,8 @@ import log from "loglevel"; import express from "express"; // Here we want to test how the local code behaves, not the already published one. // eslint-disable-next-line import/no-relative-packages +import type { ISessionOptions } from "../../../packages/node/src/index"; +// eslint-disable-next-line import/no-relative-packages import { Session } from "../../../packages/node/src/index"; // Extensions are required for JSON-LD imports. // eslint-disable-next-line import/extensions @@ -29,7 +31,10 @@ import CONSTANTS from "../../../playwright.client-authn.constants.json"; log.setLevel("TRACE"); -export function createApp(onStart: () => void) { +export function createApp( + onStart: (value: PromiseLike | void) => void, + sessionOptions: Partial = {}, +) { const app = express(); // Initialised when the server comes up and is running... @@ -111,7 +116,7 @@ export function createApp(onStart: () => void) { }); return app.listen(CONSTANTS.CLIENT_AUTHN_TEST_PORT, async () => { - session = new Session(); + session = new Session(sessionOptions); onStart(); }); diff --git a/package.json b/package.json index 108097f12b..2533e0c6ee 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "test:unit:node": "jest --coverage --verbose --selectProjects node", "test:unit:browser": "jest --coverage --verbose --selectProjects browser", "test:unit:core": "jest --coverage --verbose --selectProjects core", - "test:unit:oidc-node": "jest --coverage --verbose --selectProjects oidc-node", + "test:unit:oidc-browser": "jest --coverage --verbose --selectProjects oidc-browser", "test:e2e:node:all": "jest --selectProjects e2e-node-script e2e-node-server --collectCoverage false", "test:e2e:node": "jest --selectProjects e2e-node-script --collectCoverage false", "test:e2e:node:script": "jest --selectProjects e2e-node-script --collectCoverage false", diff --git a/packages/browser/src/ClientAuthentication.spec.ts b/packages/browser/src/ClientAuthentication.spec.ts index 492d1a1363..5c22f00eaa 100644 --- a/packages/browser/src/ClientAuthentication.spec.ts +++ b/packages/browser/src/ClientAuthentication.spec.ts @@ -424,6 +424,7 @@ describe("ClientAuthentication", () => { expect(defaultMocks.redirectHandler.handle).toHaveBeenCalledWith( url, mockEmitter, + undefined, ); // Calling the redirect handler should have updated the fetch. diff --git a/packages/browser/src/ClientAuthentication.ts b/packages/browser/src/ClientAuthentication.ts index acaf5537eb..1f33e3748a 100644 --- a/packages/browser/src/ClientAuthentication.ts +++ b/packages/browser/src/ClientAuthentication.ts @@ -101,7 +101,11 @@ export default class ClientAuthentication extends ClientAuthenticationBase { eventEmitter: EventEmitter, ): Promise => { try { - const redirectInfo = await this.redirectHandler.handle(url, eventEmitter); + const redirectInfo = await this.redirectHandler.handle( + url, + eventEmitter, + undefined, + ); // The `FallbackRedirectHandler` directly returns the global `fetch` for // his value, so we should ensure it's bound to `window` rather than to // ClientAuthentication, to avoid the following error: diff --git a/packages/browser/src/Session.ts b/packages/browser/src/Session.ts index 867786f597..1f0901cdcc 100644 --- a/packages/browser/src/Session.ts +++ b/packages/browser/src/Session.ts @@ -115,7 +115,7 @@ function isLoggedIn( } /** - * A {@link Session} object represents a user's session on an application. The session holds state, as it stores information enabling acces to private resources after login for instance. + * A {@link Session} object represents a user's session on an application. The session holds state, as it stores information enabling access to private resources after login for instance. */ export class Session implements IHasSessionEventListener { /** diff --git a/packages/core/src/Session.ts b/packages/core/src/Session.ts new file mode 100644 index 0000000000..ddbbfb72d7 --- /dev/null +++ b/packages/core/src/Session.ts @@ -0,0 +1,24 @@ +// +// 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. +// + +export type SessionConfig = { + keepAlive: boolean; +}; diff --git a/packages/core/src/SessionEventListener.ts b/packages/core/src/SessionEventListener.ts index c3b6126373..264dbb2279 100644 --- a/packages/core/src/SessionEventListener.ts +++ b/packages/core/src/SessionEventListener.ts @@ -54,7 +54,7 @@ type NEW_REFRESH_TOKEN_ARGS = { }; type FALLBACK_ARGS = { eventName: Parameters["on"]>[0]; - // Prevents from using a SessionEventEmitter as an aritrary EventEmitter. + // Prevents from using a SessionEventEmitter as an arbitrary EventEmitter. listener: never; }; diff --git a/packages/core/src/authenticatedFetch/fetchFactory.ts b/packages/core/src/authenticatedFetch/fetchFactory.ts index c0af9a82ea..6a8b77c7d4 100644 --- a/packages/core/src/authenticatedFetch/fetchFactory.ts +++ b/packages/core/src/authenticatedFetch/fetchFactory.ts @@ -191,7 +191,7 @@ export async function buildAuthenticatedFetch( if (refreshToken !== undefined) { currentRefreshOptions.refreshToken = refreshToken; } - // Each time the access token is refreshed, we must plan fo the next + // Each time the access token is refreshed, we must plan for the next // refresh iteration. clearTimeout(latestTimeout); latestTimeout = setTimeout( diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 174678c9c6..1270df5eba 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -131,6 +131,8 @@ export { TokenEndpointResponse, } from "./login/oidc/refresh/ITokenRefresher"; +export type { SessionConfig } from "./Session"; + // Mocks. /** * @deprecated diff --git a/packages/core/src/login/ILoginOptions.ts b/packages/core/src/login/ILoginOptions.ts index e4b4dee639..6395d019de 100644 --- a/packages/core/src/login/ILoginOptions.ts +++ b/packages/core/src/login/ILoginOptions.ts @@ -53,4 +53,6 @@ export default interface ILoginOptions extends ILoginInputOptions { * Event emitter enabling calling user-specified callbacks. */ eventEmitter?: EventEmitter; + + keepAlive?: boolean; } diff --git a/packages/core/src/login/oidc/IIncomingRedirectHandler.ts b/packages/core/src/login/oidc/IIncomingRedirectHandler.ts index 731158083e..267b092014 100644 --- a/packages/core/src/login/oidc/IIncomingRedirectHandler.ts +++ b/packages/core/src/login/oidc/IIncomingRedirectHandler.ts @@ -29,11 +29,16 @@ import type { EventEmitter } from "events"; import type IHandleable from "../../util/handlerPattern/IHandleable"; import type { ISessionInfo } from "../../sessionInfo/ISessionInfo"; import type { IRpLogoutOptions } from "../../logout/ILogoutHandler"; +import type { SessionConfig } from "../../Session"; export type IncomingRedirectResult = ISessionInfo & { fetch: typeof fetch } & { getLogoutUrl?: (options: IRpLogoutOptions) => string; }; -export type IncomingRedirectInput = [string, EventEmitter | undefined]; +export type IncomingRedirectInput = [ + string, + EventEmitter | undefined, + SessionConfig | undefined, +]; /** * @hidden diff --git a/packages/core/src/login/oidc/IOidcOptions.ts b/packages/core/src/login/oidc/IOidcOptions.ts index 4728039311..0146a72f45 100644 --- a/packages/core/src/login/oidc/IOidcOptions.ts +++ b/packages/core/src/login/oidc/IOidcOptions.ts @@ -65,6 +65,11 @@ export interface IOidcOptions { redirectUrl?: string; handleRedirect?: (url: string) => unknown; eventEmitter?: EventEmitter; + /** + * Should the resulting session be refreshed in the background? This is persisted prior to redirection. + * Defaults to true. + */ + keepAlive?: boolean; } export default IOidcOptions; diff --git a/packages/core/src/login/oidc/oidcHandlers/AuthorizationCodeWithPkceOidcHandler.ts b/packages/core/src/login/oidc/oidcHandlers/AuthorizationCodeWithPkceOidcHandler.ts index 759f52a184..ce72dba0d5 100644 --- a/packages/core/src/login/oidc/oidcHandlers/AuthorizationCodeWithPkceOidcHandler.ts +++ b/packages/core/src/login/oidc/oidcHandlers/AuthorizationCodeWithPkceOidcHandler.ts @@ -27,6 +27,16 @@ import type { IRedirector } from "../IRedirector"; * @packageDocumentation */ +function booleanWithFallback( + value: boolean | undefined, + fallback: boolean, +): boolean { + if (typeof value === "boolean") { + return Boolean(value); + } + return Boolean(fallback); +} + /** * @hidden * Authorization code flow spec: https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth @@ -94,7 +104,11 @@ export default abstract class AuthorizationCodeWithPkceOidcHandlerBase { issuer: oidcLoginOptions.issuer.toString(), // The redirect URL is read after redirect, so it must be stored now. redirectUrl: oidcLoginOptions.redirectUrl, - dpop: oidcLoginOptions.dpop ? "true" : "false", + dpop: Boolean(oidcLoginOptions.dpop).toString(), + keepAlive: booleanWithFallback( + oidcLoginOptions.keepAlive, + true, + ).toString(), }), ]); diff --git a/packages/core/src/sessionInfo/ISessionInfo.ts b/packages/core/src/sessionInfo/ISessionInfo.ts index e20f851e1a..c41dc2b98f 100644 --- a/packages/core/src/sessionInfo/ISessionInfo.ts +++ b/packages/core/src/sessionInfo/ISessionInfo.ts @@ -85,6 +85,11 @@ export interface ISessionInternalInfo { * The token type used by the session */ tokenType?: "DPoP" | "Bearer"; + + /** + * Whether the session is refreshed in the background or not. + */ + keepAlive?: boolean; } export function isSupportedTokenType( diff --git a/packages/core/src/storage/StorageUtility.spec.ts b/packages/core/src/storage/StorageUtility.spec.ts index 1d640337e3..8361f764f1 100644 --- a/packages/core/src/storage/StorageUtility.spec.ts +++ b/packages/core/src/storage/StorageUtility.spec.ts @@ -436,6 +436,7 @@ describe("loadOidcContextFromStorage", () => { codeVerifier: "some code verifier", redirectUrl: "https://my.app/redirect", dpop: true, + keepAlive: true, }); }); diff --git a/packages/core/src/storage/StorageUtility.ts b/packages/core/src/storage/StorageUtility.ts index ddd2e489d5..f4c0f4fa62 100644 --- a/packages/core/src/storage/StorageUtility.ts +++ b/packages/core/src/storage/StorageUtility.ts @@ -39,6 +39,7 @@ export type OidcContext = { codeVerifier?: string; redirectUrl?: string; dpop: boolean; + keepAlive: boolean; }; export async function getSessionIdFromOauthState( @@ -62,7 +63,7 @@ export async function loadOidcContextFromStorage( configFetcher: IIssuerConfigFetcher, ): Promise { try { - const [issuerIri, codeVerifier, storedRedirectIri, dpop] = + const [issuerIri, codeVerifier, storedRedirectIri, dpop, keepAlive] = await Promise.all([ storageUtility.getForUser(sessionId, "issuer", { errorIfNull: true, @@ -70,10 +71,10 @@ export async function loadOidcContextFromStorage( storageUtility.getForUser(sessionId, "codeVerifier"), storageUtility.getForUser(sessionId, "redirectUrl"), storageUtility.getForUser(sessionId, "dpop", { errorIfNull: true }), + storageUtility.getForUser(sessionId, "keepAlive"), ]); // Clear the code verifier, which is one-time use. await storageUtility.deleteForUser(sessionId, "codeVerifier"); - // Unlike openid-client, this looks up the configuration from storage const issuerConfig = await configFetcher.fetchConfig(issuerIri as string); return { @@ -81,6 +82,8 @@ export async function loadOidcContextFromStorage( redirectUrl: storedRedirectIri, issuerConfig, dpop: dpop === "true", + // Default keepAlive to true if not found in storage. + keepAlive: typeof keepAlive === "string" ? keepAlive === "true" : true, }; } catch (e) { throw new Error( diff --git a/packages/node/examples/multiSession/src/serverSideApp.mjs b/packages/node/examples/multiSession/src/serverSideApp.mjs index 598ea0fad2..b1b047eb6f 100644 --- a/packages/node/examples/multiSession/src/serverSideApp.mjs +++ b/packages/node/examples/multiSession/src/serverSideApp.mjs @@ -69,7 +69,7 @@ app.get("/", async (req, res, next) => { }); app.get("/login", async (req, res, next) => { - const session = new Session(); + const session = new Session({ keepAlive: false }); req.session.sessionId = session.info.sessionId; await session.login({ redirectUrl: REDIRECT_URL, @@ -93,6 +93,7 @@ app.get("/redirect", async (req, res) => { } else { await session.handleIncomingRedirect(getRequestFullUrl(req)); if (session.info.isLoggedIn) { + session.events.on("sessionExtended", () => { console.log("Extended session.")}) res.send( `

Logged in as [${session.info.webId}] after redirect

`, ); diff --git a/packages/node/src/ClientAuthentication.spec.ts b/packages/node/src/ClientAuthentication.spec.ts index 1ecb9d65f0..a2826a2a10 100644 --- a/packages/node/src/ClientAuthentication.spec.ts +++ b/packages/node/src/ClientAuthentication.spec.ts @@ -63,7 +63,7 @@ describe("ClientAuthentication", () => { describe("login", () => { const mockEmitter = new EventEmitter(); - it("calls login, and defaults to a DPoP token", async () => { + it("calls login, and defaults to a DPoP token and keep session alive on", async () => { const clientAuthn = getClientAuthentication(); await clientAuthn.login( "mySession", @@ -86,6 +86,7 @@ describe("ClientAuthentication", () => { tokenType: "DPoP", eventEmitter: mockEmitter, refreshToken: undefined, + keepAlive: true, }); }); @@ -220,6 +221,34 @@ describe("ClientAuthentication", () => { handleRedirect: undefined, tokenType: "Bearer", eventEmitter: mockEmitter, + keepAlive: true, + }); + }); + + it("turn off keeping the session alive", async () => { + const clientAuthn = getClientAuthentication(); + await clientAuthn.login( + "mySession", + { + clientId: "coolApp", + redirectUrl: "https://coolapp.com/redirect", + oidcIssuer: "https://idp.com", + }, + mockEmitter, + { keepAlive: false }, + ); + expect(defaultMocks.loginHandler.handle).toHaveBeenCalledWith({ + sessionId: "mySession", + clientId: "coolApp", + redirectUrl: "https://coolapp.com/redirect", + oidcIssuer: "https://idp.com", + clientName: "coolApp", + clientSecret: undefined, + handleRedirect: undefined, + tokenType: "DPoP", + eventEmitter: mockEmitter, + refreshToken: undefined, + keepAlive: false, }); }); }); @@ -262,6 +291,7 @@ describe("ClientAuthentication", () => { sessionId: "mySession", webId: "https://pod.com/profile/card#me", issuer: "https://some.idp", + keepAlive: "true", }; const clientAuthn = getClientAuthentication({ sessionInfoManager: mockSessionInfoManager( @@ -272,7 +302,11 @@ describe("ClientAuthentication", () => { }); const session = await clientAuthn.getSessionInfo("mySession"); // isLoggedIn is stored as a string under the hood, but deserialized as a boolean - expect(session).toEqual({ ...sessionInfo, isLoggedIn: true }); + expect(session).toEqual({ + ...sessionInfo, + isLoggedIn: true, + keepAlive: true, + }); }); }); @@ -341,6 +375,7 @@ describe("ClientAuthentication", () => { expect(defaultMocks.redirectHandler.handle).toHaveBeenCalledWith( url, session.events, + { keepAlive: true }, ); // Calling the redirect handler should have updated the fetch. @@ -363,6 +398,7 @@ describe("ClientAuthentication", () => { expect(defaultMocks.redirectHandler.handle).toHaveBeenCalledWith( url, session.events, + { keepAlive: true }, ); // Calling the redirect handler should have updated the fetch. diff --git a/packages/node/src/ClientAuthentication.ts b/packages/node/src/ClientAuthentication.ts index 7d9572844e..ec623349af 100644 --- a/packages/node/src/ClientAuthentication.ts +++ b/packages/node/src/ClientAuthentication.ts @@ -31,6 +31,7 @@ import { import type { ILoginInputOptions, ISessionInfo, + SessionConfig, } from "@inrupt/solid-client-authn-core"; import type { EventEmitter } from "events"; @@ -44,6 +45,7 @@ export default class ClientAuthentication extends ClientAuthenticationBase { sessionId: string, options: ILoginInputOptions, eventEmitter: EventEmitter, + config: SessionConfig = { keepAlive: true }, ): Promise => { // Keep track of the session ID await this.sessionInfoManager.register(sessionId); @@ -67,6 +69,7 @@ export default class ClientAuthentication extends ClientAuthenticationBase { // Defaults to DPoP tokenType: options.tokenType ?? "DPoP", eventEmitter, + keepAlive: config.keepAlive, }); if (loginReturn !== undefined) { @@ -94,8 +97,13 @@ export default class ClientAuthentication extends ClientAuthenticationBase { handleIncomingRedirect = async ( url: string, eventEmitter: EventEmitter, + config: SessionConfig = { keepAlive: true }, ): Promise => { - const redirectInfo = await this.redirectHandler.handle(url, eventEmitter); + const redirectInfo = await this.redirectHandler.handle( + url, + eventEmitter, + config, + ); this.fetch = redirectInfo.fetch; this.boundLogout = redirectInfo.getLogoutUrl; diff --git a/packages/node/src/Session.ts b/packages/node/src/Session.ts index 8f274e3f5a..408d3470b0 100644 --- a/packages/node/src/Session.ts +++ b/packages/node/src/Session.ts @@ -29,6 +29,7 @@ import type { ISessionEventListener, IHasSessionEventListener, ILogoutOptions, + SessionConfig, } from "@inrupt/solid-client-authn-core"; import { InMemoryStorage, EVENTS } from "@inrupt/solid-client-authn-core"; import { v4 } from "uuid"; @@ -70,6 +71,10 @@ export interface ISessionOptions { * An instance of the library core. Typically obtained using `getClientAuthenticationWithDependencies`. */ clientAuthentication: ClientAuthentication; + /** + * A boolean flag indicating whether a session should be constantly kept alive in the background. + */ + keepAlive: boolean; } /** @@ -78,7 +83,7 @@ export interface ISessionOptions { export const defaultStorage = new InMemoryStorage(); /** - * A {@link Session} object represents a user's session on an application. The session holds state, as it stores information enabling acces to private resources after login for instance. + * A {@link Session} object represents a user's session on an application. The session holds state, as it stores information enabling access to private resources after login for instance. */ export class Session implements IHasSessionEventListener { /** @@ -99,6 +104,8 @@ export class Session implements IHasSessionEventListener { private lastTimeoutHandle = 0; + private config: SessionConfig; + /** * Session object constructor. Typically called as follows: * @@ -152,6 +159,10 @@ export class Session implements IHasSessionEventListener { isLoggedIn: false, }; } + this.config = { + // Default to true for backwards compatibility. + keepAlive: sessionOptions.keepAlive ?? true, + }; // Keeps track of the latest timeout handle in order to clean up on logout // and not leave open timeouts. this.events.on(EVENTS.TIMEOUT_SET, (timeoutHandle: number) => { @@ -177,6 +188,7 @@ export class Session implements IHasSessionEventListener { ...options, }, this.events, + this.config, ); if (loginInfo !== undefined) { this.info.isLoggedIn = loginInfo.isLoggedIn; @@ -279,6 +291,7 @@ export class Session implements IHasSessionEventListener { sessionInfo = await this.clientAuthentication.handleIncomingRedirect( url, this.events, + this.config, ); if (sessionInfo) { diff --git a/packages/node/src/login/oidc/AggregateIncomingRedirectHandler.ts b/packages/node/src/login/oidc/AggregateIncomingRedirectHandler.ts index 8b4599b26f..71c4b0e807 100644 --- a/packages/node/src/login/oidc/AggregateIncomingRedirectHandler.ts +++ b/packages/node/src/login/oidc/AggregateIncomingRedirectHandler.ts @@ -30,6 +30,7 @@ import type { IIncomingRedirectHandler, ISessionInfo, + SessionConfig, } from "@inrupt/solid-client-authn-core"; import { AggregateHandler } from "@inrupt/solid-client-authn-core"; import type { EventEmitter } from "events"; @@ -39,7 +40,7 @@ import type { EventEmitter } from "events"; */ export default class AggregateIncomingRedirectHandler extends AggregateHandler< - [string, EventEmitter], + [string, EventEmitter, SessionConfig], ISessionInfo & { fetch: typeof fetch } > implements IIncomingRedirectHandler diff --git a/packages/node/src/login/oidc/OidcLoginHandler.spec.ts b/packages/node/src/login/oidc/OidcLoginHandler.spec.ts index 587872049b..74bc245d97 100644 --- a/packages/node/src/login/oidc/OidcLoginHandler.spec.ts +++ b/packages/node/src/login/oidc/OidcLoginHandler.spec.ts @@ -348,5 +348,34 @@ describe("OidcLoginHandler", () => { }), ); }); + + it("passes keep alive session through to OIDC Handler", async () => { + const { oidcHandler } = defaultMocks; + const mockedStorage = mockStorageUtility({}); + await mockedStorage.setForUser("mySession", { + refreshToken: "some token", + }); + const clientRegistrar = mockDefaultClientRegistrar(); + clientRegistrar.getClient = (jest.fn() as any).mockResolvedValueOnce( + mockDefaultClient(), + ); + const handler = getInitialisedHandler({ + oidcHandler, + clientRegistrar, + storageUtility: mockedStorage, + }); + await handler.handle({ + sessionId: "mySession", + oidcIssuer: "https://arbitrary.url", + redirectUrl: "https://app.com/redirect", + tokenType: "DPoP", + keepAlive: false, + }); + expect(oidcHandler.handle).toHaveBeenCalledWith( + expect.objectContaining({ + keepAlive: false, + }), + ); + }); }); }); diff --git a/packages/node/src/login/oidc/OidcLoginHandler.ts b/packages/node/src/login/oidc/OidcLoginHandler.ts index bd7dff73f7..2ebeed3538 100644 --- a/packages/node/src/login/oidc/OidcLoginHandler.ts +++ b/packages/node/src/login/oidc/OidcLoginHandler.ts @@ -123,6 +123,7 @@ export default class OidcLoginHandler implements ILoginHandler { )), handleRedirect: options.handleRedirect, eventEmitter: options.eventEmitter, + keepAlive: options.keepAlive, }; // Call proper OIDC Handler return this.oidcHandler.handle(oidcOptions); diff --git a/packages/node/src/login/oidc/incomingRedirectHandler/AuthCodeRedirectHandler.ts b/packages/node/src/login/oidc/incomingRedirectHandler/AuthCodeRedirectHandler.ts index 99f1eb6bf8..4183b2c4f2 100644 --- a/packages/node/src/login/oidc/incomingRedirectHandler/AuthCodeRedirectHandler.ts +++ b/packages/node/src/login/oidc/incomingRedirectHandler/AuthCodeRedirectHandler.ts @@ -169,7 +169,7 @@ export class AuthCodeRedirectHandler implements IIncomingRedirectHandler { } const authFetch = await buildAuthenticatedFetch(tokenSet.access_token, { dpopKey, - refreshOptions, + refreshOptions: oidcContext.keepAlive ? refreshOptions : undefined, eventEmitter, expiresIn: tokenSet.expires_in, }); diff --git a/packages/node/src/login/oidc/oidcHandlers/RefreshTokenOidcHandler.ts b/packages/node/src/login/oidc/oidcHandlers/RefreshTokenOidcHandler.ts index abea5cb1b0..734ed96086 100644 --- a/packages/node/src/login/oidc/oidcHandlers/RefreshTokenOidcHandler.ts +++ b/packages/node/src/login/oidc/oidcHandlers/RefreshTokenOidcHandler.ts @@ -71,6 +71,7 @@ function validateOptions( async function refreshAccess( refreshOptions: RefreshOptions, dpop: boolean, + keepAlive: boolean, refreshBindingKey?: KeyPair, eventEmitter?: EventEmitter, ): Promise { @@ -93,7 +94,7 @@ async function refreshAccess( }; const authFetch = await buildAuthenticatedFetch(tokens.accessToken, { dpopKey, - refreshOptions: rotatedRefreshOptions, + refreshOptions: keepAlive ? rotatedRefreshOptions : undefined, eventEmitter, }); return Object.assign(tokens, { @@ -179,6 +180,7 @@ export default class RefreshTokenOidcHandler implements IOidcHandler { const accessInfo = await refreshAccess( refreshOptions, oidcLoginOptions.dpop, + oidcLoginOptions.keepAlive ?? true, keyPair, ); diff --git a/packages/node/src/multiSession.ts b/packages/node/src/multiSession.ts index 5df3de1b7e..9235f203d7 100644 --- a/packages/node/src/multiSession.ts +++ b/packages/node/src/multiSession.ts @@ -62,6 +62,7 @@ export async function getSessionFromStorage( const session = new Session({ sessionInfo, clientAuthentication: clientAuth, + keepAlive: sessionInfo.keepAlive, }); if (onNewRefreshToken !== undefined) { session.events.on(EVENTS.NEW_REFRESH_TOKEN, onNewRefreshToken); diff --git a/packages/node/src/sessionInfo/SessionInfoManager.ts b/packages/node/src/sessionInfo/SessionInfoManager.ts index 5177ea28c9..a9dae3cff7 100644 --- a/packages/node/src/sessionInfo/SessionInfoManager.ts +++ b/packages/node/src/sessionInfo/SessionInfoManager.ts @@ -47,16 +47,14 @@ export class SessionInfoManager async get( sessionId: string, ): Promise<(ISessionInfo & ISessionInternalInfo) | undefined> { - const webId = await this.storageUtility.getForUser(sessionId, "webId"); - const isLoggedIn = await this.storageUtility.getForUser( - sessionId, - "isLoggedIn", - ); - const refreshToken = await this.storageUtility.getForUser( - sessionId, - "refreshToken", - ); - const issuer = await this.storageUtility.getForUser(sessionId, "issuer"); + const [webId, isLoggedIn, refreshToken, issuer, keepAlive] = + await Promise.all([ + this.storageUtility.getForUser(sessionId, "webId"), + this.storageUtility.getForUser(sessionId, "isLoggedIn"), + this.storageUtility.getForUser(sessionId, "refreshToken"), + this.storageUtility.getForUser(sessionId, "issuer"), + this.storageUtility.getForUser(sessionId, "keepAlive"), + ]); if (issuer !== undefined) { return { @@ -65,6 +63,7 @@ export class SessionInfoManager isLoggedIn: isLoggedIn === "true", refreshToken, issuer, + keepAlive: keepAlive === "true", }; }