Skip to content

Commit 091227c

Browse files
timgentclaude
andcommitted
Fix silent auth redirect with expired client credentials
When client credentials expire, the silent authentication flow now correctly detects the expiration and gracefully falls back to a logged-out state instead of redirecting to the OAuth provider and showing an error page. Adds a clientExpiresAt field to ISessionInternalInfo, reads it from storage in SessionInfoManager, and updates the CHANGELOG. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent a90e0df commit 091227c

7 files changed

Lines changed: 232 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ const session = await getSessionFromStorage(sessionId, {
3333

3434
The following changes have been implemented but not released yet:
3535

36+
### Bugfix
37+
38+
#### browser
39+
40+
- 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.
41+
3642
## [3.1.1](https://github.com/inrupt/solid-client-authn-js/releases/tag/v3.1.1) - 2025-10-29
3743

3844
### Bugfix

packages/browser/src/ClientAuthentication.spec.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,4 +601,37 @@ describe("ClientAuthentication", () => {
601601
);
602602
});
603603
});
604+
605+
describe("validateCurrentSession", () => {
606+
it("returns clientExpiresAt when expiresAt is in storage", async () => {
607+
const sessionId = "mySession";
608+
const expiresAt = Math.floor(Date.now() / 1000) + 10000;
609+
const mockedStorage = new StorageUtility(
610+
mockStorage({
611+
[`${USER_SESSION_PREFIX}:${sessionId}`]: {
612+
isLoggedIn: "true",
613+
webId: "https://my.pod/profile#me",
614+
},
615+
}),
616+
mockStorage({
617+
[`${USER_SESSION_PREFIX}:${sessionId}`]: {
618+
clientId: "https://some.app/registration",
619+
clientSecret: "some-secret",
620+
issuer: "https://some.issuer",
621+
expiresAt: String(expiresAt),
622+
},
623+
}),
624+
);
625+
const clientAuthn = getClientAuthentication({
626+
sessionInfoManager: mockSessionInfoManager(mockedStorage),
627+
});
628+
629+
const result = await clientAuthn.validateCurrentSession(sessionId);
630+
expect(result).toStrictEqual(
631+
expect.objectContaining({
632+
clientExpiresAt: expiresAt,
633+
}),
634+
);
635+
});
636+
});
604637
});

packages/browser/src/Session.spec.ts

Lines changed: 107 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -458,9 +458,12 @@ describe("Session", () => {
458458
.mockReturnValueOnce(
459459
incomingRedirectPromise,
460460
) as typeof clientAuthentication.handleIncomingRedirect;
461-
const validateCurrentSessionPromise = Promise.resolve(
462-
"https://some.issuer/",
463-
);
461+
const validateCurrentSessionPromise = Promise.resolve({
462+
issuer: "https://some.issuer/",
463+
clientAppId: "some client ID",
464+
redirectUrl: "https://some.redirect/url",
465+
tokenType: "DPoP",
466+
});
464467
clientAuthentication.validateCurrentSession = jest
465468
.fn()
466469
.mockReturnValue(
@@ -536,6 +539,7 @@ describe("Session", () => {
536539
issuer: "https://some.issuer",
537540
clientAppId: "some client ID",
538541
clientAppSecret: "some client secret",
542+
clientExpiresAt: Math.floor(Date.now() / 1000) + 10000,
539543
redirectUrl: "https://some.redirect/url",
540544
tokenType: "DPoP",
541545
});
@@ -761,6 +765,106 @@ describe("Session", () => {
761765
// The local storage should have been cleared by the auth error
762766
expect(window.localStorage.getItem(KEY_CURRENT_SESSION)).toBeNull();
763767
});
768+
769+
it("does not attempt silent authentication if the stored client has expired", async () => {
770+
const sessionId = "mySession";
771+
mockLocalStorage({
772+
[KEY_CURRENT_SESSION]: sessionId,
773+
});
774+
mockLocation("https://mock.current/location");
775+
const mockedStorage = new StorageUtility(
776+
mockStorage({
777+
[`${USER_SESSION_PREFIX}:${sessionId}`]: {
778+
isLoggedIn: "true",
779+
},
780+
}),
781+
mockStorage({}),
782+
);
783+
const clientAuthentication = mockClientAuthentication({
784+
sessionInfoManager: mockSessionInfoManager(mockedStorage),
785+
});
786+
787+
// Mock validateCurrentSession to return session info with an expired client
788+
const validateCurrentSessionPromise = Promise.resolve({
789+
issuer: "https://some.issuer",
790+
clientAppId: "some client ID",
791+
clientAppSecret: "some client secret",
792+
clientExpiresAt: Math.floor(Date.now() / 1000) - 1000,
793+
redirectUrl: "https://some.redirect/url",
794+
tokenType: "DPoP",
795+
});
796+
clientAuthentication.validateCurrentSession = jest
797+
.fn()
798+
.mockReturnValue(
799+
validateCurrentSessionPromise,
800+
) as typeof clientAuthentication.validateCurrentSession;
801+
802+
const incomingRedirectPromise = Promise.resolve();
803+
clientAuthentication.handleIncomingRedirect = jest
804+
.fn()
805+
.mockReturnValueOnce(
806+
incomingRedirectPromise,
807+
) as typeof clientAuthentication.handleIncomingRedirect;
808+
clientAuthentication.login = jest.fn<typeof clientAuthentication.login>();
809+
810+
const mySession = new Session({ clientAuthentication });
811+
const result = await mySession.handleIncomingRedirect({
812+
url: "https://some.redirect/url",
813+
restorePreviousSession: true,
814+
});
815+
816+
await incomingRedirectPromise;
817+
await validateCurrentSessionPromise;
818+
819+
// Silent auth should NOT have been attempted
820+
expect(clientAuthentication.login).not.toHaveBeenCalled();
821+
// The stored session should be cleared to prevent retry loops
822+
expect(window.localStorage.getItem(KEY_CURRENT_SESSION)).toBeNull();
823+
// The function should resolve (not hang)
824+
expect(result).toBeUndefined();
825+
});
826+
827+
it("clears stored session when client has expired during silent auth attempt", async () => {
828+
const sessionId = "mySession";
829+
mockLocalStorage({
830+
[KEY_CURRENT_SESSION]: sessionId,
831+
});
832+
mockLocation("https://mock.current/location");
833+
const mockedStorage = new StorageUtility(
834+
mockStorage({
835+
[`${USER_SESSION_PREFIX}:${sessionId}`]: {
836+
isLoggedIn: "true",
837+
},
838+
}),
839+
mockStorage({}),
840+
);
841+
const clientAuthentication = mockClientAuthentication({
842+
sessionInfoManager: mockSessionInfoManager(mockedStorage),
843+
});
844+
845+
clientAuthentication.validateCurrentSession = (
846+
jest.fn() as any
847+
).mockResolvedValue({
848+
issuer: "https://some.issuer",
849+
clientAppId: "some client ID",
850+
clientAppSecret: "some client secret",
851+
clientExpiresAt: Math.floor(Date.now() / 1000) - 1000,
852+
redirectUrl: "https://some.redirect/url",
853+
});
854+
855+
clientAuthentication.handleIncomingRedirect = (
856+
jest.fn() as any
857+
).mockResolvedValue(undefined);
858+
859+
const mySession = new Session({ clientAuthentication });
860+
await mySession.handleIncomingRedirect({
861+
url: "https://some.redirect/url",
862+
restorePreviousSession: true,
863+
});
864+
865+
// The stored session ID should have been cleared
866+
expect(window.localStorage.getItem(KEY_CURRENT_SESSION)).toBeNull();
867+
});
764868
});
765869

766870
describe("events.on", () => {

packages/browser/src/Session.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,18 @@ export async function silentlyAuthenticate(
8585
): Promise<boolean> {
8686
const storedSessionInfo = await clientAuthn.validateCurrentSession(sessionId);
8787
if (storedSessionInfo !== null) {
88+
// Check if the client registration has expired before attempting silent auth.
89+
// Expiration only applies to confidential clients (those with a secret).
90+
// clientExpiresAt === 0 means the registration never expires.
91+
// clientExpiresAt === undefined with a secret means legacy data — treat as expired.
92+
if (storedSessionInfo.clientAppSecret !== undefined) {
93+
const expiresAt = storedSessionInfo.clientExpiresAt ?? -1;
94+
if (expiresAt !== 0 && Math.floor(Date.now() / 1000) > expiresAt) {
95+
window.localStorage.removeItem(KEY_CURRENT_SESSION);
96+
return false;
97+
}
98+
}
99+
88100
// It can be really useful to save the user's current browser location,
89101
// so that we can restore it after completing the silent authentication
90102
// on incoming redirect. This way, the user is eventually redirected back

packages/browser/src/sessionInfo/SessionInfoManager.spec.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ describe("SessionInfoManager", () => {
130130
refreshToken: "some refresh token",
131131
redirectUrl: "https://some.redirect/url",
132132
tokenType: "DPoP",
133+
clientExpiresAt: undefined,
133134
});
134135
});
135136

@@ -158,6 +159,7 @@ describe("SessionInfoManager", () => {
158159
refreshToken: undefined,
159160
redirectUrl: undefined,
160161
tokenType: "DPoP",
162+
clientExpiresAt: undefined,
161163
});
162164
});
163165

@@ -219,6 +221,65 @@ describe("SessionInfoManager", () => {
219221
);
220222
});
221223

224+
it("returns clientExpiresAt when expiresAt is in storage", async () => {
225+
const sessionId = "commanderCool";
226+
const expiresAt = 1700000000;
227+
228+
const storageMock = new StorageUtility(
229+
mockStorage({
230+
[`solidClientAuthenticationUser:${sessionId}`]: {
231+
isLoggedIn: "true",
232+
},
233+
}),
234+
mockStorage({
235+
[`solidClientAuthenticationUser:${sessionId}`]: {
236+
clientId: "https://some.app/registration",
237+
clientSecret: "some client secret",
238+
issuer: "https://some.issuer",
239+
expiresAt: String(expiresAt),
240+
},
241+
}),
242+
);
243+
244+
const sessionManager = getSessionInfoManager({
245+
storageUtility: storageMock,
246+
});
247+
const session = await sessionManager.get(sessionId);
248+
expect(session).toStrictEqual(
249+
expect.objectContaining({
250+
clientExpiresAt: expiresAt,
251+
}),
252+
);
253+
});
254+
255+
it("returns undefined clientExpiresAt when expiresAt is not in storage", async () => {
256+
const sessionId = "commanderCool";
257+
258+
const storageMock = new StorageUtility(
259+
mockStorage({
260+
[`solidClientAuthenticationUser:${sessionId}`]: {
261+
isLoggedIn: "true",
262+
},
263+
}),
264+
mockStorage({
265+
[`solidClientAuthenticationUser:${sessionId}`]: {
266+
clientId: "https://some.app/registration",
267+
issuer: "https://some.issuer",
268+
},
269+
}),
270+
);
271+
272+
const sessionManager = getSessionInfoManager({
273+
storageUtility: storageMock,
274+
});
275+
const session = await sessionManager.get(sessionId);
276+
expect(session).toStrictEqual(
277+
expect.objectContaining({
278+
clientExpiresAt: undefined,
279+
}),
280+
);
281+
});
282+
222283
it("throws if the stored token type isn't supported", async () => {
223284
const sessionId = "commanderCool";
224285

packages/browser/src/sessionInfo/SessionInfoManager.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export class SessionInfoManager
7171
refreshToken,
7272
issuer,
7373
tokenType,
74+
expiresAt,
7475
] = await Promise.all([
7576
this.storageUtility.getForUser(sessionId, "isLoggedIn", {
7677
secure: true,
@@ -96,6 +97,9 @@ export class SessionInfoManager
9697
this.storageUtility.getForUser(sessionId, "tokenType", {
9798
secure: false,
9899
}),
100+
this.storageUtility.getForUser(sessionId, "expiresAt", {
101+
secure: false,
102+
}),
99103
]);
100104

101105
if (typeof redirectUrl === "string" && !isValidRedirectUrl(redirectUrl)) {
@@ -133,6 +137,8 @@ export class SessionInfoManager
133137
clientAppSecret: clientSecret,
134138
// Default the token type to DPoP if unspecified.
135139
tokenType: tokenType ?? "DPoP",
140+
clientExpiresAt:
141+
expiresAt !== undefined ? Number.parseInt(expiresAt, 10) : undefined,
136142
};
137143
}
138144

packages/core/src/sessionInfo/ISessionInfo.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,13 @@ export interface ISessionInternalInfo {
102102
* @since 2.4.0
103103
*/
104104
publicKey?: string;
105+
106+
/**
107+
* The expiration timestamp (in seconds since epoch) of the dynamically
108+
* registered client. 0 means the client never expires. Only applicable
109+
* to confidential clients (those with a clientAppSecret).
110+
*/
111+
clientExpiresAt?: number;
105112
}
106113

107114
export function isSupportedTokenType(

0 commit comments

Comments
 (0)