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