Skip to content
Open
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
33 changes: 33 additions & 0 deletions packages/browser/src/ClientAuthentication.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -601,4 +601,37 @@ 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,
}),
);
});
});
});
110 changes: 107 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 @@ describe("Session", () => {
.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 @@ describe("Session", () => {
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,106 @@ describe("Session", () => {
// 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");
const mockedStorage = new StorageUtility(
mockStorage({
[`${USER_SESSION_PREFIX}:${sessionId}`]: {
isLoggedIn: "true",
},
}),
mockStorage({}),
);
const clientAuthentication = mockClientAuthentication({
sessionInfoManager: mockSessionInfoManager(mockedStorage),
});

// Mock validateCurrentSession to return session info with an expired client
const validateCurrentSessionPromise = Promise.resolve({
issuer: "https://some.issuer",
clientAppId: "some client ID",
clientAppSecret: "some client secret",
clientExpiresAt: Math.floor(Date.now() / 1000) - 1000,
redirectUrl: "https://some.redirect/url",
tokenType: "DPoP",
});
clientAuthentication.validateCurrentSession = jest
.fn()
.mockReturnValue(
validateCurrentSessionPromise,
) as typeof clientAuthentication.validateCurrentSession;

const incomingRedirectPromise = Promise.resolve();
clientAuthentication.handleIncomingRedirect = jest
.fn()
.mockReturnValueOnce(
incomingRedirectPromise,
) as typeof clientAuthentication.handleIncomingRedirect;
clientAuthentication.login = jest.fn<typeof clientAuthentication.login>();

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

await incomingRedirectPromise;
await validateCurrentSessionPromise;

// Silent auth should NOT have been attempted
expect(clientAuthentication.login).not.toHaveBeenCalled();
// The stored session should be cleared to prevent retry loops
expect(window.localStorage.getItem(KEY_CURRENT_SESSION)).toBeNull();
// The function should resolve (not hang)
expect(result).toBeUndefined();
});

it("clears stored session when client has expired during silent auth attempt", async () => {
const sessionId = "mySession";
mockLocalStorage({
[KEY_CURRENT_SESSION]: sessionId,
});
mockLocation("https://mock.current/location");
const mockedStorage = new StorageUtility(
mockStorage({
[`${USER_SESSION_PREFIX}:${sessionId}`]: {
isLoggedIn: "true",
},
}),
mockStorage({}),
);
const clientAuthentication = mockClientAuthentication({
sessionInfoManager: mockSessionInfoManager(mockedStorage),
});

clientAuthentication.validateCurrentSession = (
jest.fn() as any
).mockResolvedValue({
issuer: "https://some.issuer",
clientAppId: "some client ID",
clientAppSecret: "some client secret",
clientExpiresAt: Math.floor(Date.now() / 1000) - 1000,
redirectUrl: "https://some.redirect/url",
});

clientAuthentication.handleIncomingRedirect = (
jest.fn() as any
).mockResolvedValue(undefined);

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

// The stored session ID should have been cleared
expect(window.localStorage.getItem(KEY_CURRENT_SESSION)).toBeNull();
});
});

describe("events.on", () => {
Expand Down
12 changes: 12 additions & 0 deletions packages/browser/src/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,18 @@ export async function silentlyAuthenticate(
): Promise<boolean> {
const storedSessionInfo = await clientAuthn.validateCurrentSession(sessionId);
if (storedSessionInfo !== null) {
// Check if the client registration has expired before attempting silent auth.
// Expiration only applies to confidential clients (those with a secret).
// clientExpiresAt === 0 means the registration never expires.
// clientExpiresAt === undefined with a secret means legacy data — treat as expired.
if (storedSessionInfo.clientAppSecret !== undefined) {
const expiresAt = storedSessionInfo.clientExpiresAt ?? -1;
if (expiresAt !== 0 && Math.floor(Date.now() / 1000) > expiresAt) {
window.localStorage.removeItem(KEY_CURRENT_SESSION);
return false;
}
}

// It can be really useful to save the user's current browser location,
// so that we can restore it after completing the silent authentication
// on incoming redirect. This way, the user is eventually redirected back
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