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", }; }