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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ const session = await getSessionFromStorage(sessionId, {

The following changes have been implemented but not released yet:

### Bugfix

#### browser

- Fixed an issue where `handleIncomingRedirect({ restorePreviousSession: true })` would redirect to the OAuth provider with expired client credentials, causing users to be stuck on an error page. The library now validates client expiration before attempting silent authentication and gracefully falls back to a logged-out state when the client has expired.

## [3.1.1](https://github.com/inrupt/solid-client-authn-js/releases/tag/v3.1.1) - 2025-10-29

### Bugfix
Expand Down
126 changes: 126 additions & 0 deletions packages/browser/src/ClientAuthentication.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -601,4 +601,130 @@ describe("ClientAuthentication", () => {
);
});
});

describe("validateCurrentSession", () => {
it("returns clientExpiresAt when expiresAt is in storage", async () => {
const sessionId = "mySession";
const expiresAt = Math.floor(Date.now() / 1000) + 10000;
const mockedStorage = new StorageUtility(
mockStorage({
[`${USER_SESSION_PREFIX}:${sessionId}`]: {
isLoggedIn: "true",
webId: "https://my.pod/profile#me",
},
}),
mockStorage({
[`${USER_SESSION_PREFIX}:${sessionId}`]: {
clientId: "https://some.app/registration",
clientSecret: "some-secret",
issuer: "https://some.issuer",
expiresAt: String(expiresAt),
},
}),
);
const clientAuthn = getClientAuthentication({
sessionInfoManager: mockSessionInfoManager(mockedStorage),
});

const result = await clientAuthn.validateCurrentSession(sessionId);
expect(result).toStrictEqual(
expect.objectContaining({
clientExpiresAt: expiresAt,
}),
);
});

it("returns null when client has expired", async () => {
const sessionId = "mySession";
const expiredAt = Math.floor(Date.now() / 1000) - 1000;
const mockedStorage = new StorageUtility(
mockStorage({
[`${USER_SESSION_PREFIX}:${sessionId}`]: {
isLoggedIn: "true",
webId: "https://my.pod/profile#me",
},
}),
mockStorage({
[`${USER_SESSION_PREFIX}:${sessionId}`]: {
clientId: "https://some.app/registration",
clientSecret: "some-secret",
issuer: "https://some.issuer",
expiresAt: String(expiredAt),
},
}),
);
const clientAuthn = getClientAuthentication({
sessionInfoManager: mockSessionInfoManager(mockedStorage),
});

await expect(
clientAuthn.validateCurrentSession(sessionId),
).resolves.toBeNull();
});

it("returns session when client has not expired", async () => {
const sessionId = "mySession";
const futureExpiry = Math.floor(Date.now() / 1000) + 10000;
const mockedStorage = new StorageUtility(
mockStorage({
[`${USER_SESSION_PREFIX}:${sessionId}`]: {
isLoggedIn: "true",
webId: "https://my.pod/profile#me",
},
}),
mockStorage({
[`${USER_SESSION_PREFIX}:${sessionId}`]: {
clientId: "https://some.app/registration",
clientSecret: "some-secret",
issuer: "https://some.issuer",
expiresAt: String(futureExpiry),
},
}),
);
const clientAuthn = getClientAuthentication({
sessionInfoManager: mockSessionInfoManager(mockedStorage),
});

const result = await clientAuthn.validateCurrentSession(sessionId);
expect(result).not.toBeNull();
expect(result).toStrictEqual(
expect.objectContaining({
clientAppId: "https://some.app/registration",
issuer: "https://some.issuer",
}),
);
});

it("returns session when clientExpiresAt is 0 (never expires per OIDC DCR spec)", async () => {
const sessionId = "mySession";
const mockedStorage = new StorageUtility(
mockStorage({
[`${USER_SESSION_PREFIX}:${sessionId}`]: {
isLoggedIn: "true",
webId: "https://my.pod/profile#me",
},
}),
mockStorage({
[`${USER_SESSION_PREFIX}:${sessionId}`]: {
clientId: "https://some.app/registration",
clientSecret: "some-secret",
issuer: "https://some.issuer",
expiresAt: "0",
},
}),
);
const clientAuthn = getClientAuthentication({
sessionInfoManager: mockSessionInfoManager(mockedStorage),
});

const result = await clientAuthn.validateCurrentSession(sessionId);
expect(result).not.toBeNull();
expect(result).toStrictEqual(
expect.objectContaining({
clientAppId: "https://some.app/registration",
clientExpiresAt: 0,
}),
);
});
});
});
19 changes: 17 additions & 2 deletions packages/browser/src/ClientAuthentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,20 @@ import {
import { normalizeCallbackUrl } from "@inrupt/oidc-client-ext";
import type { EventEmitter } from "events";

/**
* Checks if a client's registration has expired.
*/
function isClientExpired(sessionInfo: { clientExpiresAt?: number }): boolean {
// clientExpiresAt === 0 means the client never expires (per OIDC DCR spec)
if (
sessionInfo.clientExpiresAt === undefined ||
sessionInfo.clientExpiresAt === 0
) {
return false;
}
return sessionInfo.clientExpiresAt < Math.floor(Date.now() / 1000);
}

/**
* @hidden
*/
Expand Down Expand Up @@ -77,7 +91,7 @@ export default class ClientAuthentication extends ClientAuthenticationBase {
};

// Collects session information from storage, and returns them. Returns null
// if the expected information cannot be found.
// if the expected information cannot be found or if the client has expired.
// Note that the ID token is not stored, which means the session information
// cannot be validated at this point.
validateCurrentSession = async (
Expand All @@ -87,7 +101,8 @@ export default class ClientAuthentication extends ClientAuthenticationBase {
if (
sessionInfo === undefined ||
sessionInfo.clientAppId === undefined ||
sessionInfo.issuer === undefined
sessionInfo.issuer === undefined ||
isClientExpired(sessionInfo)
) {
return null;
}
Expand Down
58 changes: 55 additions & 3 deletions packages/browser/src/Session.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,9 +458,12 @@
.mockReturnValueOnce(
incomingRedirectPromise,
) as typeof clientAuthentication.handleIncomingRedirect;
const validateCurrentSessionPromise = Promise.resolve(
"https://some.issuer/",
);
const validateCurrentSessionPromise = Promise.resolve({
issuer: "https://some.issuer/",
clientAppId: "some client ID",
redirectUrl: "https://some.redirect/url",
tokenType: "DPoP",
});
clientAuthentication.validateCurrentSession = jest
.fn()
.mockReturnValue(
Expand Down Expand Up @@ -536,6 +539,7 @@
issuer: "https://some.issuer",
clientAppId: "some client ID",
clientAppSecret: "some client secret",
clientExpiresAt: Math.floor(Date.now() / 1000) + 10000,
redirectUrl: "https://some.redirect/url",
tokenType: "DPoP",
});
Expand Down Expand Up @@ -761,6 +765,54 @@
// The local storage should have been cleared by the auth error
expect(window.localStorage.getItem(KEY_CURRENT_SESSION)).toBeNull();
});

it("does not attempt silent authentication if the stored client has expired", async () => {
const sessionId = "mySession";
mockLocalStorage({
[KEY_CURRENT_SESSION]: sessionId,
});
mockLocation("https://mock.current/location");

// Set up storage with an expired client (expiresAt in the past)
const expiredAt = Math.floor(Date.now() / 1000) - 1000;
const mockedStorage = new StorageUtility(
mockStorage({
[`${USER_SESSION_PREFIX}:${sessionId}`]: {
isLoggedIn: "true",
webId: "https://my.pod/profile#me",
},
}),
mockStorage({
[`${USER_SESSION_PREFIX}:${sessionId}`]: {
clientId: "https://some.app/registration",
clientSecret: "some-secret",
issuer: "https://some.issuer",
expiresAt: String(expiredAt),
},
}),
);
const clientAuthentication = mockClientAuthentication({
sessionInfoManager: mockSessionInfoManager(mockedStorage),
});

// Mock handleIncomingRedirect to return undefined (no OAuth params in URL)
clientAuthentication.handleIncomingRedirect = jest
.fn<typeof clientAuthentication.handleIncomingRedirect>()
.mockResolvedValue(undefined);
clientAuthentication.login = jest.fn<typeof clientAuthentication.login>();

const mySession = new Session({ clientAuthentication });
const result = await mySession.handleIncomingRedirect({
url: "https://some.redirect/url",
restorePreviousSession: true,
});

// Silent auth should NOT have been attempted because client is expired
expect(clientAuthentication.login).not.toHaveBeenCalled();
// The function should resolve (not hang)
expect(result).toBeUndefined();
});

Check failure on line 815 in packages/browser/src/Session.spec.ts

View workflow job for this annotation

GitHub Actions / lint / lint

Delete `⏎`
});

describe("events.on", () => {
Expand Down
61 changes: 61 additions & 0 deletions packages/browser/src/sessionInfo/SessionInfoManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ describe("SessionInfoManager", () => {
refreshToken: "some refresh token",
redirectUrl: "https://some.redirect/url",
tokenType: "DPoP",
clientExpiresAt: undefined,
});
});

Expand Down Expand Up @@ -158,6 +159,7 @@ describe("SessionInfoManager", () => {
refreshToken: undefined,
redirectUrl: undefined,
tokenType: "DPoP",
clientExpiresAt: undefined,
});
});

Expand Down Expand Up @@ -219,6 +221,65 @@ describe("SessionInfoManager", () => {
);
});

it("returns clientExpiresAt when expiresAt is in storage", async () => {
const sessionId = "commanderCool";
const expiresAt = 1700000000;

const storageMock = new StorageUtility(
mockStorage({
[`solidClientAuthenticationUser:${sessionId}`]: {
isLoggedIn: "true",
},
}),
mockStorage({
[`solidClientAuthenticationUser:${sessionId}`]: {
clientId: "https://some.app/registration",
clientSecret: "some client secret",
issuer: "https://some.issuer",
expiresAt: String(expiresAt),
},
}),
);

const sessionManager = getSessionInfoManager({
storageUtility: storageMock,
});
const session = await sessionManager.get(sessionId);
expect(session).toStrictEqual(
expect.objectContaining({
clientExpiresAt: expiresAt,
}),
);
});

it("returns undefined clientExpiresAt when expiresAt is not in storage", async () => {
const sessionId = "commanderCool";

const storageMock = new StorageUtility(
mockStorage({
[`solidClientAuthenticationUser:${sessionId}`]: {
isLoggedIn: "true",
},
}),
mockStorage({
[`solidClientAuthenticationUser:${sessionId}`]: {
clientId: "https://some.app/registration",
issuer: "https://some.issuer",
},
}),
);

const sessionManager = getSessionInfoManager({
storageUtility: storageMock,
});
const session = await sessionManager.get(sessionId);
expect(session).toStrictEqual(
expect.objectContaining({
clientExpiresAt: undefined,
}),
);
});

it("throws if the stored token type isn't supported", async () => {
const sessionId = "commanderCool";

Expand Down
6 changes: 6 additions & 0 deletions packages/browser/src/sessionInfo/SessionInfoManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export class SessionInfoManager
refreshToken,
issuer,
tokenType,
expiresAt,
] = await Promise.all([
this.storageUtility.getForUser(sessionId, "isLoggedIn", {
secure: true,
Expand All @@ -96,6 +97,9 @@ export class SessionInfoManager
this.storageUtility.getForUser(sessionId, "tokenType", {
secure: false,
}),
this.storageUtility.getForUser(sessionId, "expiresAt", {
secure: false,
}),
]);

if (typeof redirectUrl === "string" && !isValidRedirectUrl(redirectUrl)) {
Expand Down Expand Up @@ -133,6 +137,8 @@ export class SessionInfoManager
clientAppSecret: clientSecret,
// Default the token type to DPoP if unspecified.
tokenType: tokenType ?? "DPoP",
clientExpiresAt:
expiresAt !== undefined ? Number.parseInt(expiresAt, 10) : undefined,
};
}

Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/sessionInfo/ISessionInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,13 @@ export interface ISessionInternalInfo {
* @since 2.4.0
*/
publicKey?: string;

/**
* The expiration timestamp (in seconds since epoch) of the dynamically
* registered client. 0 means the client never expires. Only applicable
* to confidential clients (those with a clientAppSecret).
*/
clientExpiresAt?: number;
}

export function isSupportedTokenType(
Expand Down
Loading