diff --git a/CHANGELOG.md b/CHANGELOG.md index fd1ddd7e0d..0bd24256b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/browser/src/ClientAuthentication.spec.ts b/packages/browser/src/ClientAuthentication.spec.ts index f2c4990b99..5755ee6f9f 100644 --- a/packages/browser/src/ClientAuthentication.spec.ts +++ b/packages/browser/src/ClientAuthentication.spec.ts @@ -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, + }), + ); + }); + }); }); diff --git a/packages/browser/src/Session.spec.ts b/packages/browser/src/Session.spec.ts index c9846243dc..49b6fc8cde 100644 --- a/packages/browser/src/Session.spec.ts +++ b/packages/browser/src/Session.spec.ts @@ -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( @@ -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", }); @@ -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(); + + 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", () => { diff --git a/packages/browser/src/Session.ts b/packages/browser/src/Session.ts index 425df6ac82..313cd0e48e 100644 --- a/packages/browser/src/Session.ts +++ b/packages/browser/src/Session.ts @@ -85,6 +85,18 @@ export async function silentlyAuthenticate( ): Promise { 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 diff --git a/packages/browser/src/sessionInfo/SessionInfoManager.spec.ts b/packages/browser/src/sessionInfo/SessionInfoManager.spec.ts index 38fd5d7fe9..95aa1e4f53 100644 --- a/packages/browser/src/sessionInfo/SessionInfoManager.spec.ts +++ b/packages/browser/src/sessionInfo/SessionInfoManager.spec.ts @@ -130,6 +130,7 @@ describe("SessionInfoManager", () => { refreshToken: "some refresh token", redirectUrl: "https://some.redirect/url", tokenType: "DPoP", + clientExpiresAt: undefined, }); }); @@ -158,6 +159,7 @@ describe("SessionInfoManager", () => { refreshToken: undefined, redirectUrl: undefined, tokenType: "DPoP", + clientExpiresAt: undefined, }); }); @@ -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"; diff --git a/packages/browser/src/sessionInfo/SessionInfoManager.ts b/packages/browser/src/sessionInfo/SessionInfoManager.ts index d33ef5f194..cb914cb906 100644 --- a/packages/browser/src/sessionInfo/SessionInfoManager.ts +++ b/packages/browser/src/sessionInfo/SessionInfoManager.ts @@ -71,6 +71,7 @@ export class SessionInfoManager refreshToken, issuer, tokenType, + expiresAt, ] = await Promise.all([ this.storageUtility.getForUser(sessionId, "isLoggedIn", { secure: true, @@ -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)) { @@ -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, }; } diff --git a/packages/core/src/sessionInfo/ISessionInfo.ts b/packages/core/src/sessionInfo/ISessionInfo.ts index 9444c9e382..91f50e20a5 100644 --- a/packages/core/src/sessionInfo/ISessionInfo.ts +++ b/packages/core/src/sessionInfo/ISessionInfo.ts @@ -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(