Skip to content

Commit faabd58

Browse files
authored
Merge pull request #3042 from Particular/fix-auth-blank-on-token-expiry
Re-authenticate instead of going blank when the session is lost
2 parents 8d87dc4 + 995c69d commit faabd58

2 files changed

Lines changed: 128 additions & 2 deletions

File tree

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { describe, test, expect, vi, beforeEach } from "vitest";
2+
import { setActivePinia, createPinia } from "pinia";
3+
import routeLinks from "@/router/routeLinks";
4+
5+
// Capture the OIDC event callbacks useAuth registers, plus a spy on signinRedirect, so the tests
6+
// can fire "token expired" / "silent renew error" and assert that recovery re-authenticates.
7+
const signinRedirect = vi.fn().mockResolvedValue(undefined);
8+
const captured: { expired?: () => void; renewError?: (error: unknown) => void } = {};
9+
10+
vi.mock("oidc-client-ts", () => ({
11+
UserManager: class {
12+
getUser = vi.fn().mockResolvedValue(null);
13+
signinRedirect = signinRedirect;
14+
signinCallback = vi.fn().mockResolvedValue(null);
15+
signinSilent = vi.fn().mockResolvedValue(null);
16+
signoutRedirect = vi.fn().mockResolvedValue(undefined);
17+
removeUser = vi.fn().mockResolvedValue(undefined);
18+
events = {
19+
addUserLoaded: vi.fn(),
20+
addUserUnloaded: vi.fn(),
21+
addAccessTokenExpiring: vi.fn(),
22+
addAccessTokenExpired: vi.fn((cb: () => void) => {
23+
captured.expired = cb;
24+
}),
25+
addSilentRenewError: vi.fn((cb: (error: unknown) => void) => {
26+
captured.renewError = cb;
27+
}),
28+
};
29+
},
30+
WebStorageStateStore: class {},
31+
}));
32+
33+
const config = { authority: "https://idp" } as never;
34+
35+
// Fresh module state per test (useAuth keeps a module-singleton UserManager), then run the initial
36+
// authenticate so the handlers are registered. getUser returns null, so this initial call performs
37+
// one signinRedirect and leaves isAuthenticating true (as in the real redirect-away flow).
38+
async function initAuth() {
39+
const { useAuth } = await import("@/composables/useAuth");
40+
const { useAuthStore } = await import("@/stores/AuthStore");
41+
const auth = useAuth();
42+
await auth.authenticate(config);
43+
return useAuthStore();
44+
}
45+
46+
beforeEach(() => {
47+
vi.resetModules();
48+
signinRedirect.mockClear();
49+
captured.expired = undefined;
50+
captured.renewError = undefined;
51+
setActivePinia(createPinia());
52+
window.location.hash = "";
53+
});
54+
55+
describe("useAuth recovers a lost session from OIDC events", () => {
56+
test("re-authenticates and clears the stale token when the access token expires", async () => {
57+
const store = await initAuth();
58+
store.setAuthenticating(false); // initial redirect 'returned'
59+
signinRedirect.mockClear();
60+
61+
captured.expired!();
62+
63+
expect(signinRedirect).toHaveBeenCalledTimes(1);
64+
expect(store.token).toBeNull();
65+
});
66+
67+
test("re-authenticates when silent renewal errors", async () => {
68+
const store = await initAuth();
69+
store.setAuthenticating(false);
70+
signinRedirect.mockClear();
71+
72+
captured.renewError!(new Error("silent renew failed"));
73+
74+
expect(signinRedirect).toHaveBeenCalledTimes(1);
75+
});
76+
77+
test("does not re-authenticate while an auth flow is already running", async () => {
78+
const store = await initAuth();
79+
expect(store.isAuthenticating).toBe(true); // left true by the initial redirect
80+
signinRedirect.mockClear();
81+
82+
captured.expired!();
83+
84+
expect(signinRedirect).not.toHaveBeenCalled();
85+
});
86+
87+
test("does not re-authenticate on the logged-out route", async () => {
88+
const store = await initAuth();
89+
store.setAuthenticating(false);
90+
window.location.hash = `#${routeLinks.loggedOut}`;
91+
signinRedirect.mockClear();
92+
93+
captured.expired!();
94+
95+
expect(signinRedirect).not.toHaveBeenCalled();
96+
});
97+
});

src/Frontend/src/composables/useAuth.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useAuthStore } from "@/stores/AuthStore";
22
import type { AuthConfig } from "@/types/auth";
33
import { UserManager, type User } from "oidc-client-ts";
4+
import routeLinks from "@/router/routeLinks";
45
import logger from "@/logger";
56

67
let userManager: UserManager | null = null;
@@ -12,6 +13,28 @@ let userManager: UserManager | null = null;
1213
export function useAuth() {
1314
const authStore = useAuthStore();
1415

16+
// The session was lost mid-run (access token expired or silent renewal failed). Re-authenticate
17+
// instead of leaving the app blank. With a live identity-provider session this is a silent
18+
// redirect round-trip; otherwise the user lands on the provider's login page. Skip it when an
19+
// auth flow is already running, or when a logout left us on the anonymous logged-out route.
20+
async function reauthenticate() {
21+
if (authStore.isAuthenticating) {
22+
return;
23+
}
24+
if (window.location.hash === `#${routeLinks.loggedOut}`) {
25+
return;
26+
}
27+
authStore.setAuthenticating(true);
28+
try {
29+
await userManager?.signinRedirect();
30+
} catch (error) {
31+
logger.error("Re-authentication after session loss failed:", error);
32+
authStore.setAuthError(error instanceof Error ? error.message : "Re-authentication after session loss failed");
33+
} finally {
34+
authStore.setAuthenticating(false);
35+
}
36+
}
37+
1538
function initializeUserManager(config: AuthConfig): UserManager {
1639
if (!userManager) {
1740
userManager = new UserManager(config);
@@ -33,12 +56,18 @@ export function useAuth() {
3356
}
3457
});
3558

36-
userManager.events.addAccessTokenExpired(() => {
59+
// Token fully expired, or silent renewal errored: clear the stale token and re-authenticate
60+
// rather than rendering a blank app. Reacting to the OIDC events directly keeps recovery in
61+
// the auth domain and distinguishes session loss from an intentional logout, which arrives
62+
// as addUserUnloaded and must not re-trigger authentication.
63+
userManager.events.addAccessTokenExpired(async () => {
3764
authStore.clearToken();
65+
await reauthenticate();
3866
});
3967

40-
userManager.events.addSilentRenewError((error) => {
68+
userManager.events.addSilentRenewError(async (error) => {
4169
logger.error("Silent renew error:", error);
70+
await reauthenticate();
4271
});
4372
}
4473

0 commit comments

Comments
 (0)