Skip to content

Commit 528ee9c

Browse files
Merge pull request #501 from MeasureAuthoringTool/MAT-10041
MAT 10041 Okta SDK Cross-Tab Token Synchronization
2 parents 624f182 + c0e3e18 commit 528ee9c

7 files changed

Lines changed: 276 additions & 91 deletions

File tree

package-lock.json

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/okta/OktaSecurity.test.tsx

Lines changed: 87 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import React from "react";
22
import { render, screen, waitFor } from "@testing-library/react";
3-
import OktaSecurity from "./OktaSecurity";
3+
import OktaSecurity, {
4+
transformAuthState,
5+
resetSessionCheckCache,
6+
} from "./OktaSecurity";
47
import * as madieUtil from "@madie/madie-util";
8+
import { OktaAuth, toRelativeUrl } from "@okta/okta-auth-js";
59
import { Security } from "@okta/okta-react";
6-
import { toRelativeUrl } from "@okta/okta-auth-js";
710
import { MADIE_TIMEOUT_RETURN_URL } from "../services/timeoutReturnUrl";
811

912
// Mock dependencies
@@ -16,7 +19,7 @@ jest.mock("@okta/okta-react", () => ({
1619
)),
1720
}));
1821
jest.mock("@okta/okta-auth-js", () => ({
19-
OktaAuth: jest.fn().mockImplementation(() => ({})),
22+
OktaAuth: jest.fn().mockImplementation((config) => ({ options: config })),
2023
toRelativeUrl: jest.fn((uri) => uri),
2124
}));
2225
jest.mock("./../router/Router", () =>
@@ -93,6 +96,20 @@ describe("OktaSecurity", () => {
9396
redirectUri: "http://localhost:3000/login/callback",
9497
scopes: ["openid", "profile", "email"],
9598
});
99+
render(<OktaSecurity />);
100+
await waitFor(() => expect(OktaAuth).toHaveBeenCalled());
101+
102+
const config = (OktaAuth as unknown as jest.Mock).mock.calls[0][0];
103+
expect(config.tokenManager).toEqual({
104+
autoRenew: true,
105+
storage: "localStorage",
106+
});
107+
expect(config.services).toEqual({
108+
autoRenew: true,
109+
syncStorage: true,
110+
renewOnTabActivation: true,
111+
tabInactivityDuration: 1800,
112+
});
96113

97114
sessionStorage.setItem(MADIE_TIMEOUT_RETURN_URL, "/libraries");
98115
render(<OktaSecurity />);
@@ -153,4 +170,71 @@ describe("OktaSecurity", () => {
153170
window.location.origin
154171
);
155172
});
173+
174+
describe("transformAuthState", () => {
175+
const buildOktaAuth = (existsMock: jest.Mock) => ({
176+
session: { exists: existsMock },
177+
});
178+
179+
beforeEach(() => {
180+
resetSessionCheckCache();
181+
});
182+
183+
it("returns the auth state untouched when not authenticated, without a session check", async () => {
184+
const exists = jest.fn();
185+
const authState = { isAuthenticated: false };
186+
187+
const result = await transformAuthState(buildOktaAuth(exists), authState);
188+
189+
expect(result.isAuthenticated).toBe(false);
190+
expect(exists).not.toHaveBeenCalled();
191+
});
192+
193+
it("keeps the user authenticated when the Okta session exists", async () => {
194+
const exists = jest.fn().mockResolvedValue(true);
195+
const authState = { isAuthenticated: true };
196+
197+
const result = await transformAuthState(buildOktaAuth(exists), authState);
198+
199+
expect(result.isAuthenticated).toBe(true);
200+
expect(exists).toHaveBeenCalledTimes(1);
201+
});
202+
203+
it("trusts a recent successful session check instead of re-verifying", async () => {
204+
const exists = jest.fn().mockResolvedValue(true);
205+
const oktaAuth = buildOktaAuth(exists);
206+
207+
await transformAuthState(oktaAuth, { isAuthenticated: true });
208+
const result = await transformAuthState(oktaAuth, {
209+
isAuthenticated: true,
210+
});
211+
212+
// second call within the TTL should not hit the network again
213+
expect(exists).toHaveBeenCalledTimes(1);
214+
expect(result.isAuthenticated).toBe(true);
215+
});
216+
217+
it("retries once before dropping auth when the session check fails transiently", async () => {
218+
const exists = jest
219+
.fn()
220+
.mockResolvedValueOnce(false) // transient failure
221+
.mockResolvedValueOnce(true); // retry succeeds
222+
const authState = { isAuthenticated: true };
223+
224+
const result = await transformAuthState(buildOktaAuth(exists), authState);
225+
226+
expect(exists).toHaveBeenCalledTimes(2);
227+
expect(result.isAuthenticated).toBe(true);
228+
});
229+
230+
it("drops authentication when the Okta session is really gone", async () => {
231+
const exists = jest.fn().mockResolvedValue(false);
232+
const authState = { isAuthenticated: true };
233+
234+
const result = await transformAuthState(buildOktaAuth(exists), authState);
235+
236+
expect(exists).toHaveBeenCalledTimes(2);
237+
expect(result.isAuthenticated).toBe(false);
238+
});
239+
});
156240
});

src/okta/OktaSecurity.tsx

Lines changed: 84 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useState } from "react";
1+
import React, { useEffect, useMemo, useState } from "react";
22
import { Security } from "@okta/okta-react";
33
import { OktaAuth, toRelativeUrl } from "@okta/okta-auth-js";
44
import { getOktaConfig } from "@madie/madie-util";
@@ -15,6 +15,31 @@ interface OktaConfig {
1515
redirectUri: string;
1616
}
1717

18+
/**
19+
* How long one successful Okta session check is trusted before we verify
20+
* against the server again.
21+
*
22+
* Why this cache exists: `transformAuthState` runs on EVERY auth-state
23+
* recalculation — page load, token renewal, and (because `syncStorage` is on)
24+
* every token event mirrored from other tabs. Each run used to make a network
25+
* call to /api/v1/sessions/me, and `session.exists()` reports `false` for ANY
26+
* failure (network blip, 429 rate limit, aborted request), not just a dead
27+
* session. So a burst of refreshes/route changes could hammer that endpoint
28+
* and a single transient failure logged an active user out with no warning.
29+
* Trusting a recent positive result removes both the call volume and the
30+
* false-logout window.
31+
*/
32+
export const SESSION_CHECK_TTL_MS = 5 * 60 * 1000; // 5 minutes
33+
let lastSessionConfirmedAt = 0;
34+
35+
/**
36+
* Test-only: module state survives between test cases, so tests reset the
37+
* session-check cache here to keep each case independent.
38+
*/
39+
export const resetSessionCheckCache = (): void => {
40+
lastSessionConfirmedAt = 0;
41+
};
42+
1843
export const transformAuthState = async (oktaAuth, authState) => {
1944
// verifies unexpired tokens are available from the tokenManager (default behavior)
2045
if (localStorage.getItem("madieDebug") || (window as any).madieDebug) {
@@ -29,13 +54,25 @@ export const transformAuthState = async (oktaAuth, authState) => {
2954
if (!authState.isAuthenticated) {
3055
return authState;
3156
}
32-
// extra requirement: user must have valid Okta session
33-
authState.isAuthenticated = await oktaAuth.session.exists();
57+
const now = Date.now();
58+
if (now - lastSessionConfirmedAt < SESSION_CHECK_TTL_MS) {
59+
return authState;
60+
}
61+
let sessionExists = await oktaAuth.session.exists();
62+
if (!sessionExists) {
63+
// `session.exists()` returns false for BOTH "session is gone" and "the
64+
// request failed". Retry once so a transient network failure doesn't end
65+
// an otherwise-valid session.
66+
sessionExists = await oktaAuth.session.exists();
67+
}
68+
if (sessionExists) {
69+
lastSessionConfirmedAt = now;
70+
}
71+
authState.isAuthenticated = sessionExists;
3472
return authState;
3573
};
3674

3775
function OktaSecurity() {
38-
// const navigate = useNavigate();
3976
const [oktaConfig, setOktaConfig] = useState<OktaConfig>();
4077
const [oktaConfigErr, setOktaConfigErr] = useState<string>();
4178

@@ -58,20 +95,18 @@ function OktaSecurity() {
5895
);
5996
};
6097

61-
if (!oktaConfig && !oktaConfigErr) {
62-
(async () => {
63-
await getOktaConfig()
64-
.then((config) => {
65-
setOktaConfig(config);
66-
})
67-
.catch((err) => {
68-
console.error(err);
69-
setOktaConfigErr(
70-
"Unable to load Login page, Please contact administration"
71-
);
72-
});
73-
})();
74-
}
98+
useEffect(() => {
99+
getOktaConfig()
100+
.then((config) => {
101+
setOktaConfig(config);
102+
})
103+
.catch((err) => {
104+
console.error(err);
105+
setOktaConfigErr(
106+
"Unable to load Login page, Please contact administration"
107+
);
108+
});
109+
}, []);
75110

76111
const routerProps = {
77112
props: {
@@ -84,11 +119,38 @@ function OktaSecurity() {
84119
},
85120
};
86121

122+
// Memoized so the OktaAuth instance (and its token/renew/leader-election
123+
// services) is created exactly once per loaded config. Re-instantiating it
124+
// on a re-render restarts every service mid-flight, which destabilizes
125+
// renewals and cross-tab sync.
126+
const oktaAuth = useMemo(
127+
() =>
128+
oktaConfig
129+
? new OktaAuth({
130+
...oktaConfig, // other config
131+
transformAuthState,
132+
// Keep tokens valid and synchronized across all open tabs so background
133+
// tabs don't hit auth errors / unexpected logouts.
134+
// NOTE: `scopes` (incl. `offline_access` for refresh-token silent renewal)
135+
// is intentionally left to the env-provided oktaConfig for now — enabling
136+
// offline_access depends on the Okta/HARP app allowing refresh tokens and
137+
// is being decided separately.
138+
tokenManager: {
139+
autoRenew: true, // expired tokens are renewed, not removed
140+
storage: "localStorage", // required for cross-tab token sync
141+
},
142+
services: {
143+
autoRenew: true,
144+
syncStorage: true, // propagate renewed tokens to all tabs via storage events
145+
renewOnTabActivation: true, // refresh tokens when a background tab regains focus
146+
tabInactivityDuration: 1800, // seconds (30 min) — matches the idle timeout
147+
},
148+
})
149+
: null,
150+
[oktaConfig]
151+
);
152+
87153
if (!!oktaConfig) {
88-
const oktaAuth = new OktaAuth({
89-
...oktaConfig, // other config
90-
transformAuthState,
91-
});
92154
return (
93155
<Security
94156
oktaAuth={oktaAuth}

0 commit comments

Comments
 (0)