Skip to content

Commit 0930fdd

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 3e606a1 commit 0930fdd

7 files changed

Lines changed: 278 additions & 5 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: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,4 +601,130 @@ 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+
637+
it("returns null when client has expired", async () => {
638+
const sessionId = "mySession";
639+
const expiredAt = Math.floor(Date.now() / 1000) - 1000;
640+
const mockedStorage = new StorageUtility(
641+
mockStorage({
642+
[`${USER_SESSION_PREFIX}:${sessionId}`]: {
643+
isLoggedIn: "true",
644+
webId: "https://my.pod/profile#me",
645+
},
646+
}),
647+
mockStorage({
648+
[`${USER_SESSION_PREFIX}:${sessionId}`]: {
649+
clientId: "https://some.app/registration",
650+
clientSecret: "some-secret",
651+
issuer: "https://some.issuer",
652+
expiresAt: String(expiredAt),
653+
},
654+
}),
655+
);
656+
const clientAuthn = getClientAuthentication({
657+
sessionInfoManager: mockSessionInfoManager(mockedStorage),
658+
});
659+
660+
await expect(
661+
clientAuthn.validateCurrentSession(sessionId),
662+
).resolves.toBeNull();
663+
});
664+
665+
it("returns session when client has not expired", async () => {
666+
const sessionId = "mySession";
667+
const futureExpiry = Math.floor(Date.now() / 1000) + 10000;
668+
const mockedStorage = new StorageUtility(
669+
mockStorage({
670+
[`${USER_SESSION_PREFIX}:${sessionId}`]: {
671+
isLoggedIn: "true",
672+
webId: "https://my.pod/profile#me",
673+
},
674+
}),
675+
mockStorage({
676+
[`${USER_SESSION_PREFIX}:${sessionId}`]: {
677+
clientId: "https://some.app/registration",
678+
clientSecret: "some-secret",
679+
issuer: "https://some.issuer",
680+
expiresAt: String(futureExpiry),
681+
},
682+
}),
683+
);
684+
const clientAuthn = getClientAuthentication({
685+
sessionInfoManager: mockSessionInfoManager(mockedStorage),
686+
});
687+
688+
const result = await clientAuthn.validateCurrentSession(sessionId);
689+
expect(result).not.toBeNull();
690+
expect(result).toStrictEqual(
691+
expect.objectContaining({
692+
clientAppId: "https://some.app/registration",
693+
issuer: "https://some.issuer",
694+
}),
695+
);
696+
});
697+
698+
it("returns session when clientExpiresAt is 0 (never expires per OIDC DCR spec)", async () => {
699+
const sessionId = "mySession";
700+
const mockedStorage = new StorageUtility(
701+
mockStorage({
702+
[`${USER_SESSION_PREFIX}:${sessionId}`]: {
703+
isLoggedIn: "true",
704+
webId: "https://my.pod/profile#me",
705+
},
706+
}),
707+
mockStorage({
708+
[`${USER_SESSION_PREFIX}:${sessionId}`]: {
709+
clientId: "https://some.app/registration",
710+
clientSecret: "some-secret",
711+
issuer: "https://some.issuer",
712+
expiresAt: "0",
713+
},
714+
}),
715+
);
716+
const clientAuthn = getClientAuthentication({
717+
sessionInfoManager: mockSessionInfoManager(mockedStorage),
718+
});
719+
720+
const result = await clientAuthn.validateCurrentSession(sessionId);
721+
expect(result).not.toBeNull();
722+
expect(result).toStrictEqual(
723+
expect.objectContaining({
724+
clientAppId: "https://some.app/registration",
725+
clientExpiresAt: 0,
726+
}),
727+
);
728+
});
729+
});
604730
});

packages/browser/src/ClientAuthentication.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,20 @@ import {
3737
import { normalizeCallbackUrl } from "@inrupt/oidc-client-ext";
3838
import type { EventEmitter } from "events";
3939

40+
/**
41+
* Checks if a client's registration has expired.
42+
*/
43+
function isClientExpired(sessionInfo: { clientExpiresAt?: number }): boolean {
44+
// clientExpiresAt === 0 means the client never expires (per OIDC DCR spec)
45+
if (
46+
sessionInfo.clientExpiresAt === undefined ||
47+
sessionInfo.clientExpiresAt === 0
48+
) {
49+
return false;
50+
}
51+
return sessionInfo.clientExpiresAt < Math.floor(Date.now() / 1000);
52+
}
53+
4054
/**
4155
* @hidden
4256
*/
@@ -77,7 +91,7 @@ export default class ClientAuthentication extends ClientAuthenticationBase {
7791
};
7892

7993
// Collects session information from storage, and returns them. Returns null
80-
// if the expected information cannot be found.
94+
// if the expected information cannot be found or if the client has expired.
8195
// Note that the ID token is not stored, which means the session information
8296
// cannot be validated at this point.
8397
validateCurrentSession = async (
@@ -87,7 +101,8 @@ export default class ClientAuthentication extends ClientAuthenticationBase {
87101
if (
88102
sessionInfo === undefined ||
89103
sessionInfo.clientAppId === undefined ||
90-
sessionInfo.issuer === undefined
104+
sessionInfo.issuer === undefined ||
105+
isClientExpired(sessionInfo)
91106
) {
92107
return null;
93108
}

packages/browser/src/Session.spec.ts

Lines changed: 55 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,54 @@ 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+
776+
// Set up storage with an expired client (expiresAt in the past)
777+
const expiredAt = Math.floor(Date.now() / 1000) - 1000;
778+
const mockedStorage = new StorageUtility(
779+
mockStorage({
780+
[`${USER_SESSION_PREFIX}:${sessionId}`]: {
781+
isLoggedIn: "true",
782+
webId: "https://my.pod/profile#me",
783+
},
784+
}),
785+
mockStorage({
786+
[`${USER_SESSION_PREFIX}:${sessionId}`]: {
787+
clientId: "https://some.app/registration",
788+
clientSecret: "some-secret",
789+
issuer: "https://some.issuer",
790+
expiresAt: String(expiredAt),
791+
},
792+
}),
793+
);
794+
const clientAuthentication = mockClientAuthentication({
795+
sessionInfoManager: mockSessionInfoManager(mockedStorage),
796+
});
797+
798+
// Mock handleIncomingRedirect to return undefined (no OAuth params in URL)
799+
clientAuthentication.handleIncomingRedirect = jest
800+
.fn<typeof clientAuthentication.handleIncomingRedirect>()
801+
.mockResolvedValue(undefined);
802+
clientAuthentication.login = jest.fn<typeof clientAuthentication.login>();
803+
804+
const mySession = new Session({ clientAuthentication });
805+
const result = await mySession.handleIncomingRedirect({
806+
url: "https://some.redirect/url",
807+
restorePreviousSession: true,
808+
});
809+
810+
// Silent auth should NOT have been attempted because client is expired
811+
expect(clientAuthentication.login).not.toHaveBeenCalled();
812+
// The function should resolve (not hang)
813+
expect(result).toBeUndefined();
814+
});
815+
764816
});
765817

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

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)