Skip to content

Commit 7a40c93

Browse files
authored
fix: persist auth across browser windows using localStorage (#557)
* fix: persist auth across browser windows using localStorage Switch JWT token storage from sessionStorage to localStorage so users stay signed in when opening new windows or tabs. Add cross-tab sync via the storage event so sign-in/out in one tab updates all others. * fix: update E2E tests to use localStorage for JWT token Update all E2E and accessibility tests to read/write the JWT token from localStorage instead of sessionStorage, matching the production code change. * fix: update remaining E2E tests for localStorage JWT - Fix try-flow.spec.ts to use localStorage for JWT token - Simplify cross-navigation persistence test to avoid Playwright quirk with /try page bundle initialization
1 parent 1d56c16 commit 7a40c93

21 files changed

+254
-257
lines changed

src/try/api/api-client.test.ts

Lines changed: 34 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -31,16 +31,16 @@ describe("API Client - callISBAPI", () => {
3131
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlRlc3QgVXNlciIsImlhdCI6MTUxNjIzOTAyMn0.test"
3232

3333
beforeEach(() => {
34-
// Clear mocks and sessionStorage before each test
34+
// Clear mocks and localStorage before each test
3535
mockFetch.mockReset()
3636
mockFetch.mockResolvedValue(createMockResponse())
37-
sessionStorage.clear()
37+
localStorage.clear()
3838
})
3939

4040
describe("AC #1: Authorization Header Injection", () => {
41-
it("should add Authorization header when JWT token exists in sessionStorage", async () => {
42-
// Arrange: Set token in sessionStorage
43-
sessionStorage.setItem(_internal.JWT_TOKEN_KEY, TEST_TOKEN)
41+
it("should add Authorization header when JWT token exists in localStorage", async () => {
42+
// Arrange: Set token in localStorage
43+
localStorage.setItem(_internal.JWT_TOKEN_KEY, TEST_TOKEN)
4444

4545
// Act: Make API call
4646
await callISBAPI("/api/leases")
@@ -53,7 +53,7 @@ describe("API Client - callISBAPI", () => {
5353

5454
it("should use correct Bearer token format", async () => {
5555
// Arrange
56-
sessionStorage.setItem(_internal.JWT_TOKEN_KEY, TEST_TOKEN)
56+
localStorage.setItem(_internal.JWT_TOKEN_KEY, TEST_TOKEN)
5757

5858
// Act
5959
await callISBAPI("/api/test")
@@ -68,7 +68,7 @@ describe("API Client - callISBAPI", () => {
6868
describe("AC #2: Conditional Header Behavior", () => {
6969
it("should preserve custom headers alongside Authorization header", async () => {
7070
// Arrange
71-
sessionStorage.setItem(_internal.JWT_TOKEN_KEY, TEST_TOKEN)
71+
localStorage.setItem(_internal.JWT_TOKEN_KEY, TEST_TOKEN)
7272
const customHeaders = {
7373
"X-Custom-Header": "custom-value",
7474
"X-Another-Header": "another-value",
@@ -86,7 +86,7 @@ describe("API Client - callISBAPI", () => {
8686

8787
it("should include default Content-Type header", async () => {
8888
// Arrange
89-
sessionStorage.setItem(_internal.JWT_TOKEN_KEY, TEST_TOKEN)
89+
localStorage.setItem(_internal.JWT_TOKEN_KEY, TEST_TOKEN)
9090

9191
// Act
9292
await callISBAPI("/api/test")
@@ -98,7 +98,7 @@ describe("API Client - callISBAPI", () => {
9898

9999
it("should allow custom Content-Type to override default", async () => {
100100
// Arrange
101-
sessionStorage.setItem(_internal.JWT_TOKEN_KEY, TEST_TOKEN)
101+
localStorage.setItem(_internal.JWT_TOKEN_KEY, TEST_TOKEN)
102102

103103
// Act
104104
await callISBAPI("/api/test", {
@@ -112,7 +112,7 @@ describe("API Client - callISBAPI", () => {
112112

113113
it("should preserve other fetch options (method, body, etc.)", async () => {
114114
// Arrange
115-
sessionStorage.setItem(_internal.JWT_TOKEN_KEY, TEST_TOKEN)
115+
localStorage.setItem(_internal.JWT_TOKEN_KEY, TEST_TOKEN)
116116
const requestBody = JSON.stringify({ productId: "123" })
117117

118118
// Act
@@ -131,8 +131,8 @@ describe("API Client - callISBAPI", () => {
131131

132132
describe("AC #3: No Token Behavior", () => {
133133
it("should NOT add Authorization header when token is missing", async () => {
134-
// Arrange: No token in sessionStorage
135-
sessionStorage.clear()
134+
// Arrange: No token in localStorage
135+
localStorage.clear()
136136

137137
// Act
138138
await callISBAPI("/api/public")
@@ -145,7 +145,7 @@ describe("API Client - callISBAPI", () => {
145145

146146
it("should NOT add Authorization header when token is empty string", async () => {
147147
// Arrange: Empty string token
148-
sessionStorage.setItem(_internal.JWT_TOKEN_KEY, "")
148+
localStorage.setItem(_internal.JWT_TOKEN_KEY, "")
149149

150150
// Act
151151
await callISBAPI("/api/test")
@@ -157,15 +157,15 @@ describe("API Client - callISBAPI", () => {
157157

158158
it("should not throw error when token is missing", async () => {
159159
// Arrange
160-
sessionStorage.clear()
160+
localStorage.clear()
161161

162162
// Act & Assert: Should not throw
163163
await expect(callISBAPI("/api/test")).resolves.toBeDefined()
164164
})
165165

166166
it("should still include Content-Type when unauthenticated", async () => {
167167
// Arrange
168-
sessionStorage.clear()
168+
localStorage.clear()
169169

170170
// Act
171171
await callISBAPI("/api/test")
@@ -202,7 +202,7 @@ describe("API Client - callISBAPI", () => {
202202
describe("Headers extraction", () => {
203203
it("should handle Headers object input", async () => {
204204
// Arrange
205-
sessionStorage.setItem(_internal.JWT_TOKEN_KEY, TEST_TOKEN)
205+
localStorage.setItem(_internal.JWT_TOKEN_KEY, TEST_TOKEN)
206206
const headers = new Headers()
207207
headers.set("X-Custom", "value")
208208

@@ -216,7 +216,7 @@ describe("API Client - callISBAPI", () => {
216216

217217
it("should handle array of header pairs", async () => {
218218
// Arrange
219-
sessionStorage.setItem(_internal.JWT_TOKEN_KEY, TEST_TOKEN)
219+
localStorage.setItem(_internal.JWT_TOKEN_KEY, TEST_TOKEN)
220220
const headers: [string, string][] = [
221221
["X-First", "first-value"],
222222
["X-Second", "second-value"],
@@ -236,11 +236,11 @@ describe("API Client - callISBAPI", () => {
236236
describe("Internal helpers", () => {
237237
describe("getToken", () => {
238238
beforeEach(() => {
239-
sessionStorage.clear()
239+
localStorage.clear()
240240
})
241241

242242
it("should return token when present", () => {
243-
sessionStorage.setItem(_internal.JWT_TOKEN_KEY, "test-token")
243+
localStorage.setItem(_internal.JWT_TOKEN_KEY, "test-token")
244244
expect(_internal.getToken()).toBe("test-token")
245245
})
246246

@@ -249,7 +249,7 @@ describe("Internal helpers", () => {
249249
})
250250

251251
it("should return null for empty string token", () => {
252-
sessionStorage.setItem(_internal.JWT_TOKEN_KEY, "")
252+
localStorage.setItem(_internal.JWT_TOKEN_KEY, "")
253253
expect(_internal.getToken()).toBeNull()
254254
})
255255
})
@@ -294,9 +294,9 @@ describe("API Client - checkAuthStatus", () => {
294294

295295
beforeEach(() => {
296296
mockFetch.mockReset()
297-
sessionStorage.clear()
297+
localStorage.clear()
298298
// Set token for authenticated requests
299-
sessionStorage.setItem(_internal.JWT_TOKEN_KEY, TEST_TOKEN)
299+
localStorage.setItem(_internal.JWT_TOKEN_KEY, TEST_TOKEN)
300300
// Suppress console.error for cleaner test output
301301
jest.spyOn(console, "error").mockImplementation(() => {})
302302
})
@@ -464,9 +464,9 @@ describe("API Client - checkAuthStatus", () => {
464464
})
465465

466466
describe("Edge cases", () => {
467-
it("should work when sessionStorage has no token", async () => {
467+
it("should work when localStorage has no token", async () => {
468468
// Arrange: Clear token
469-
sessionStorage.clear()
469+
localStorage.clear()
470470
mockFetch.mockResolvedValueOnce(createMockResponse('{"error": "Unauthorized"}', 401))
471471

472472
// Act
@@ -529,8 +529,8 @@ describe("API Client - 401 Handling (Story 5.8)", () => {
529529
beforeEach(() => {
530530
mockFetch.mockReset()
531531
mockFetch.mockResolvedValue(createMockResponse())
532-
sessionStorage.clear()
533-
sessionStorage.setItem(_internal.JWT_TOKEN_KEY, TEST_TOKEN)
532+
localStorage.clear()
533+
localStorage.setItem(_internal.JWT_TOKEN_KEY, TEST_TOKEN)
534534

535535
// Set up location href spy using our custom jsdom environment helper
536536
locationSpy = setupLocationHrefSpy()
@@ -542,7 +542,7 @@ describe("API Client - 401 Handling (Story 5.8)", () => {
542542
})
543543

544544
describe("AC #1: Automatic 401 Detection", () => {
545-
it("should clear sessionStorage token when 401 received", async () => {
545+
it("should clear localStorage token when 401 received", async () => {
546546
// Arrange
547547
mockFetch.mockResolvedValueOnce(createMockResponse('{"error": "Unauthorized"}', 401))
548548

@@ -554,7 +554,7 @@ describe("API Client - 401 Handling (Story 5.8)", () => {
554554
}
555555

556556
// Assert: Token should be cleared
557-
expect(sessionStorage.getItem(_internal.JWT_TOKEN_KEY)).toBeNull()
557+
expect(localStorage.getItem(_internal.JWT_TOKEN_KEY)).toBeNull()
558558
})
559559

560560
it("should detect 401 status code", async () => {
@@ -601,7 +601,7 @@ describe("API Client - 401 Handling (Story 5.8)", () => {
601601
describe("AC #4: No Infinite Loops", () => {
602602
it("should clear token before redirect (order verification)", async () => {
603603
// This test verifies the implementation order by checking the code flow:
604-
// 1. Token is cleared first (via sessionStorage.removeItem)
604+
// 1. Token is cleared first (via localStorage.removeItem)
605605
// 2. Then redirect happens (via window.location.href)
606606
// We verify both happen, and since the code is sequential, clear happens first
607607

@@ -617,7 +617,7 @@ describe("API Client - 401 Handling (Story 5.8)", () => {
617617

618618
// Assert: Both actions happened
619619
// Token is cleared
620-
expect(sessionStorage.getItem(_internal.JWT_TOKEN_KEY)).toBeNull()
620+
expect(localStorage.getItem(_internal.JWT_TOKEN_KEY)).toBeNull()
621621
// Redirect happened
622622
expect(locationSpy?.getRedirectUrl()).toBe("/api/auth/login")
623623
// The implementation clears before redirect (verified by code review)
@@ -635,7 +635,7 @@ describe("API Client - 401 Handling (Story 5.8)", () => {
635635
}
636636

637637
// Assert: Token cleared before redirect
638-
expect(sessionStorage.getItem(_internal.JWT_TOKEN_KEY)).toBeNull()
638+
expect(localStorage.getItem(_internal.JWT_TOKEN_KEY)).toBeNull()
639639
expect(locationSpy?.getRedirectUrl()).toBe("/api/auth/login")
640640
})
641641
})
@@ -651,7 +651,7 @@ describe("API Client - 401 Handling (Story 5.8)", () => {
651651

652652
// Assert: Returns response normally
653653
expect(response).toBe(successResponse)
654-
expect(sessionStorage.getItem(_internal.JWT_TOKEN_KEY)).toBe(TEST_TOKEN) // Token not cleared
654+
expect(localStorage.getItem(_internal.JWT_TOKEN_KEY)).toBe(TEST_TOKEN) // Token not cleared
655655
})
656656

657657
it("should preserve existing behavior for 500 errors", async () => {
@@ -677,7 +677,7 @@ describe("API Client - 401 Handling (Story 5.8)", () => {
677677
// Assert: Should NOT redirect, should return response
678678
expect(response.status).toBe(401)
679679
expect(locationSpy?.getRedirectUrl()).toBe("") // No redirect
680-
expect(sessionStorage.getItem(_internal.JWT_TOKEN_KEY)).toBe(TEST_TOKEN) // Token not cleared
680+
expect(localStorage.getItem(_internal.JWT_TOKEN_KEY)).toBe(TEST_TOKEN) // Token not cleared
681681
})
682682
})
683683

@@ -702,7 +702,7 @@ describe("API Client - 401 Handling (Story 5.8)", () => {
702702
await checkAuthStatus()
703703

704704
// Assert: Token still present (checkAuthStatus uses skipAuthRedirect)
705-
expect(sessionStorage.getItem(_internal.JWT_TOKEN_KEY)).toBe(TEST_TOKEN)
705+
expect(localStorage.getItem(_internal.JWT_TOKEN_KEY)).toBe(TEST_TOKEN)
706706
})
707707
})
708708
})

src/try/api/api-client.ts

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,11 @@ export interface ISBAPIOptions extends RequestInit {
6969
* Makes an authenticated API call to the Innovation Sandbox API.
7070
*
7171
* Automatically includes Authorization header with Bearer token if JWT exists
72-
* in sessionStorage. If no token exists, the request proceeds without
72+
* in localStorage. If no token exists, the request proceeds without
7373
* authentication (for public endpoints or pre-auth flows).
7474
*
7575
* Story 5.8: Automatically handles 401 responses by:
76-
* 1. Clearing the invalid token from sessionStorage
76+
* 1. Clearing the invalid token from localStorage
7777
* 2. Redirecting to OAuth login
7878
* Use `skipAuthRedirect: true` to disable this behavior (e.g., for checkAuthStatus).
7979
*
@@ -140,19 +140,18 @@ export async function callISBAPI(endpoint: string, options: ISBAPIOptions = {}):
140140
}
141141

142142
/**
143-
* Clears JWT token from sessionStorage.
143+
* Clears JWT token from localStorage.
144144
* Used on 401 to remove invalid token before redirect.
145145
* @internal
146146
*/
147147
function clearToken(): void {
148-
// Guard against SSR environments
149-
if (typeof sessionStorage === "undefined") {
148+
if (typeof localStorage === "undefined") {
150149
return
151150
}
152151
try {
153-
sessionStorage.removeItem(JWT_TOKEN_KEY)
152+
localStorage.removeItem(JWT_TOKEN_KEY)
154153
} catch {
155-
// Ignore sessionStorage errors
154+
// Ignore localStorage errors
156155
}
157156
}
158157

@@ -272,26 +271,25 @@ export async function checkAuthStatus(timeout = 5000): Promise<AuthStatusResult>
272271
}
273272

274273
/**
275-
* Retrieves JWT token from sessionStorage.
274+
* Retrieves JWT token from localStorage.
276275
*
277276
* @returns JWT token string or null if not found/unavailable
278277
* @internal
279278
*/
280279
function getToken(): string | null {
281-
// Guard against SSR environments where sessionStorage is unavailable
282-
if (typeof sessionStorage === "undefined") {
280+
if (typeof localStorage === "undefined") {
283281
return null
284282
}
285283

286284
try {
287-
const token = sessionStorage.getItem(JWT_TOKEN_KEY)
285+
const token = localStorage.getItem(JWT_TOKEN_KEY)
288286
// Treat empty string as no token
289287
if (token === null || token === "") {
290288
return null
291289
}
292290
return token
293291
} catch {
294-
// Handle any sessionStorage access errors (e.g., security restrictions)
292+
// Handle any localStorage access errors (e.g., security restrictions)
295293
return null
296294
}
297295
}

0 commit comments

Comments
 (0)