@workos-inc/authkit-nextjs 2.17.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +40 -11
  2. package/dist/esm/actions.js +35 -4
  3. package/dist/esm/actions.js.map +1 -1
  4. package/dist/esm/auth.js +13 -22
  5. package/dist/esm/auth.js.map +1 -1
  6. package/dist/esm/authkit-callback-route.js +71 -95
  7. package/dist/esm/authkit-callback-route.js.map +1 -1
  8. package/dist/esm/components/authkit-provider.js +31 -13
  9. package/dist/esm/components/authkit-provider.js.map +1 -1
  10. package/dist/esm/components/impersonation.js +9 -9
  11. package/dist/esm/components/impersonation.js.map +1 -1
  12. package/dist/esm/components/min-max-button.js +1 -1
  13. package/dist/esm/components/min-max-button.js.map +1 -1
  14. package/dist/esm/components/tokenStore.js +28 -19
  15. package/dist/esm/components/tokenStore.js.map +1 -1
  16. package/dist/esm/components/useAccessToken.js +1 -1
  17. package/dist/esm/components/useAccessToken.js.map +1 -1
  18. package/dist/esm/components/useTokenClaims.js +1 -1
  19. package/dist/esm/components/useTokenClaims.js.map +1 -1
  20. package/dist/esm/cookie.js +16 -5
  21. package/dist/esm/cookie.js.map +1 -1
  22. package/dist/esm/env-variables.js +5 -7
  23. package/dist/esm/env-variables.js.map +1 -1
  24. package/dist/esm/errors.js +7 -4
  25. package/dist/esm/errors.js.map +1 -1
  26. package/dist/esm/get-authorization-url.js +23 -27
  27. package/dist/esm/get-authorization-url.js.map +1 -1
  28. package/dist/esm/index.js +3 -3
  29. package/dist/esm/index.js.map +1 -1
  30. package/dist/esm/interfaces.js +7 -1
  31. package/dist/esm/interfaces.js.map +1 -1
  32. package/dist/esm/middleware-helpers.js +8 -5
  33. package/dist/esm/middleware-helpers.js.map +1 -1
  34. package/dist/esm/middleware.js +3 -1
  35. package/dist/esm/middleware.js.map +1 -1
  36. package/dist/esm/pkce.js +17 -22
  37. package/dist/esm/pkce.js.map +1 -1
  38. package/dist/esm/session.js +19 -23
  39. package/dist/esm/session.js.map +1 -1
  40. package/dist/esm/types/actions.d.ts +34 -5
  41. package/dist/esm/types/auth.d.ts +6 -16
  42. package/dist/esm/types/cookie.d.ts +8 -0
  43. package/dist/esm/types/env-variables.d.ts +1 -2
  44. package/dist/esm/types/get-authorization-url.d.ts +1 -1
  45. package/dist/esm/types/index.d.ts +3 -3
  46. package/dist/esm/types/interfaces.d.ts +9 -1
  47. package/dist/esm/types/jwt.d.ts +9 -9
  48. package/dist/esm/types/middleware-helpers.d.ts +3 -1
  49. package/dist/esm/types/middleware.d.ts +3 -1
  50. package/dist/esm/types/pkce.d.ts +6 -5
  51. package/dist/esm/utils.js +2 -2
  52. package/dist/esm/utils.js.map +1 -1
  53. package/dist/esm/validate-api-key.js +1 -2
  54. package/dist/esm/validate-api-key.js.map +1 -1
  55. package/package.json +12 -13
  56. package/src/actions.spec.ts +81 -6
  57. package/src/actions.ts +44 -5
  58. package/src/auth.spec.ts +3 -2
  59. package/src/auth.ts +12 -43
  60. package/src/authkit-callback-route.spec.ts +210 -60
  61. package/src/authkit-callback-route.ts +94 -107
  62. package/src/components/authkit-provider.spec.tsx +89 -6
  63. package/src/components/authkit-provider.tsx +20 -1
  64. package/src/components/impersonation.spec.tsx +1 -0
  65. package/src/components/impersonation.tsx +29 -24
  66. package/src/components/tokenStore.spec.ts +35 -20
  67. package/src/components/tokenStore.ts +11 -3
  68. package/src/components/useAccessToken.spec.tsx +15 -12
  69. package/src/components/useTokenClaims.spec.tsx +1 -0
  70. package/src/cookie.ts +29 -0
  71. package/src/env-variables.ts +0 -2
  72. package/src/get-authorization-url.spec.ts +18 -40
  73. package/src/get-authorization-url.ts +34 -40
  74. package/src/index.ts +3 -1
  75. package/src/interfaces.ts +11 -1
  76. package/src/jwt.ts +9 -9
  77. package/src/middleware-helpers.spec.ts +7 -0
  78. package/src/middleware-helpers.ts +7 -3
  79. package/src/middleware.spec.ts +25 -0
  80. package/src/middleware.ts +4 -1
  81. package/src/pkce.spec.ts +125 -0
  82. package/src/pkce.ts +19 -19
  83. package/src/session.spec.ts +18 -22
  84. package/src/session.ts +10 -12
@@ -1,8 +1,8 @@
1
1
  import { NextRequest } from 'next/server';
2
- import { getCookieOptions } from './cookie.js';
2
+ import { getPKCECookieOptions } from './cookie.js';
3
3
  import { WORKOS_CLIENT_ID } from './env-variables.js';
4
4
  import { HandleAuthOptions } from './interfaces.js';
5
- import { PKCE_COOKIE_NAME, getPKCECodeVerifier } from './pkce.js';
5
+ import { PKCE_COOKIE_NAME, getStateFromPKCECookieValue } from './pkce.js';
6
6
  import { saveSession } from './session.js';
7
7
  import { errorResponseWithFallback, redirectWithFallback, setCachePreventionHeaders } from './utils.js';
8
8
  import { getWorkOS } from './workos.js';
@@ -12,37 +12,6 @@ function preventCaching(headers: Headers): void {
12
12
  setCachePreventionHeaders(headers);
13
13
  }
14
14
 
15
- function handleState(state: string | null) {
16
- let returnPathname: string | undefined = undefined;
17
- let userState: string | undefined;
18
- if (state?.includes('.')) {
19
- const [internal, ...rest] = state.split('.');
20
- userState = rest.join('.');
21
- try {
22
- // Reverse URL-safe base64 encoding
23
- const decoded = internal.replace(/-/g, '+').replace(/_/g, '/');
24
- returnPathname = JSON.parse(atob(decoded)).returnPathname;
25
- } catch {
26
- // Malformed internal part, ignore it
27
- }
28
- } else if (state) {
29
- try {
30
- const decoded = JSON.parse(atob(state));
31
- if (decoded.returnPathname) {
32
- returnPathname = decoded.returnPathname;
33
- } else {
34
- userState = state;
35
- }
36
- } catch {
37
- userState = state;
38
- }
39
- }
40
- return {
41
- returnPathname,
42
- state: userState,
43
- };
44
- }
45
-
46
15
  export function handleAuth(options: HandleAuthOptions = {}) {
47
16
  const { returnPathname: returnPathnameOption = '/', baseURL, onSuccess, onError } = options;
48
17
 
@@ -56,82 +25,100 @@ export function handleAuth(options: HandleAuthOptions = {}) {
56
25
  }
57
26
 
58
27
  return async function GET(request: NextRequest) {
59
- // Fall back to standard URL parsing when nextUrl is not available (e.g., vinext)
60
- const requestUrl = request.nextUrl ?? new URL(request.url);
61
- const code = requestUrl.searchParams.get('code');
62
- const state = requestUrl.searchParams.get('state');
63
-
64
- const { state: customState, returnPathname: returnPathnameState } = handleState(state);
65
-
66
- if (code) {
67
- try {
68
- const pkceCookie = request.cookies.get(PKCE_COOKIE_NAME);
69
- const codeVerifier = await getPKCECodeVerifier(pkceCookie?.value);
70
-
71
- // Use the code returned to us by AuthKit and authenticate the user with WorkOS
72
- const { accessToken, refreshToken, user, impersonator, oauthTokens, authenticationMethod, organizationId } =
73
- await getWorkOS().userManagement.authenticateWithCode({
74
- clientId: WORKOS_CLIENT_ID,
75
- code,
76
- codeVerifier,
77
- });
78
-
79
- // If baseURL is provided, use it instead of request.nextUrl
80
- // This is useful if the app is being run in a container like docker where
81
- // the hostname can be different from the one in the request
82
- const url = baseURL ? new URL(baseURL) : new URL(requestUrl.toString());
83
-
84
- // Cleanup params
85
- url.searchParams.delete('code');
86
- url.searchParams.delete('state');
87
-
88
- // Redirect to the requested path and store the session
89
- const returnPathname = returnPathnameState ?? returnPathnameOption;
90
-
91
- // Extract pathname and search params from returnPathname
92
- const parsedReturnUrl = new URL(returnPathname, 'https://placeholder.com');
93
- url.pathname = parsedReturnUrl.pathname;
94
- url.search = parsedReturnUrl.search;
95
-
96
- // Fall back to standard Response if NextResponse is not available.
97
- // This is to support Next.js 13.
98
- const response = redirectWithFallback(url.toString());
99
- preventCaching(response.headers);
100
-
101
- if (pkceCookie) {
102
- response.headers.append('Set-Cookie', `${PKCE_COOKIE_NAME}=; ${getCookieOptions(request.url, true, true)}`);
103
- }
104
-
105
- if (!accessToken || !refreshToken) throw new Error('response is missing tokens');
106
-
107
- await saveSession({ accessToken, refreshToken, user, impersonator }, request);
108
-
109
- if (onSuccess) {
110
- await onSuccess({
111
- accessToken,
112
- refreshToken,
113
- user,
114
- impersonator,
115
- oauthTokens,
116
- authenticationMethod,
117
- organizationId,
118
- state: customState,
119
- });
120
- }
121
-
122
- return response;
123
- } catch (error) {
124
- const errorRes = {
125
- error: error instanceof Error ? error.message : String(error),
126
- };
127
-
128
- console.error(errorRes);
129
-
130
- return await errorResponse(request, error);
28
+ // Always delete the PKCE cookie after handling the callback, regardless of success or error
29
+ // to avoid stale cookies affecting future auth attempts & prevent replays
30
+ const deleteCookie = `${PKCE_COOKIE_NAME}=; ${getPKCECookieOptions(request.url, true, true)}`;
31
+
32
+ // We want to catch any & all errors and respond the same way
33
+ // Firstly, by destroying the 1-use PKCE cookie to prevent replay attacks
34
+ // or stale cookies affecting future auth attempts
35
+ try {
36
+ // Fall back to standard URL parsing when nextUrl is not available (e.g., vinext)
37
+ const requestUrl = request.nextUrl ?? new URL(request.url);
38
+
39
+ // Gather mandatory information
40
+ const code = requestUrl.searchParams.get('code');
41
+ const state = requestUrl.searchParams.get('state');
42
+ const pkceCookie = request.cookies.get(PKCE_COOKIE_NAME)?.value;
43
+
44
+ if (!code || !state) {
45
+ throw new Error('Missing required auth parameter');
46
+ }
47
+
48
+ // CSRF verification: both channels (cookie + URL state) must be present and match
49
+ if (!pkceCookie) {
50
+ throw new Error(
51
+ 'Auth cookie missing cannot verify OAuth state. Ensure Set-Cookie headers are propagated on redirects.',
52
+ );
131
53
  }
132
- }
133
54
 
134
- return await errorResponse(request);
55
+ if (state !== pkceCookie) {
56
+ throw new Error('OAuth state mismatch');
57
+ }
58
+
59
+ const {
60
+ codeVerifier,
61
+ customState,
62
+ returnPathname: returnPathnameState,
63
+ } = await getStateFromPKCECookieValue(pkceCookie);
64
+
65
+ // Use the code returned to us by AuthKit and authenticate the user with WorkOS
66
+ const { accessToken, refreshToken, user, impersonator, oauthTokens, authenticationMethod, organizationId } =
67
+ await getWorkOS().userManagement.authenticateWithCode({
68
+ clientId: WORKOS_CLIENT_ID,
69
+ code,
70
+ codeVerifier,
71
+ });
72
+
73
+ if (!accessToken || !refreshToken) {
74
+ throw new Error('response is missing tokens');
75
+ }
76
+
77
+ // If baseURL is provided, use it instead of request.nextUrl
78
+ // This is useful if the app is being run in a container like docker where
79
+ // the hostname can be different from the one in the request
80
+ const url = baseURL ? new URL(baseURL) : new URL(requestUrl.toString());
81
+
82
+ // Cleanup params
83
+ url.searchParams.delete('code');
84
+ url.searchParams.delete('state');
85
+
86
+ // Redirect to the requested path and store the session
87
+ const returnPathname = returnPathnameState ?? returnPathnameOption;
88
+
89
+ // Extract pathname and search params from returnPathname
90
+ const parsedReturnUrl = new URL(returnPathname, 'https://placeholder.com');
91
+ url.pathname = parsedReturnUrl.pathname;
92
+ url.search = parsedReturnUrl.search;
93
+
94
+ // Fall back to standard Response if NextResponse is not available.
95
+ // This is to support Next.js 13.
96
+ const response = redirectWithFallback(url.toString());
97
+ preventCaching(response.headers);
98
+ response.headers.append('Set-Cookie', deleteCookie);
99
+
100
+ await saveSession({ accessToken, refreshToken, user, impersonator }, request);
101
+
102
+ if (onSuccess) {
103
+ await onSuccess({
104
+ accessToken,
105
+ refreshToken,
106
+ user,
107
+ impersonator,
108
+ oauthTokens,
109
+ authenticationMethod,
110
+ organizationId,
111
+ state: customState,
112
+ });
113
+ }
114
+
115
+ return response;
116
+ } catch (error) {
117
+ console.error('[AuthKit callback error]', error);
118
+ const response = await errorResponse(request, error);
119
+ response.headers.append('Set-Cookie', deleteCookie);
120
+ return response;
121
+ }
135
122
  };
136
123
 
137
124
  async function errorResponse(request: NextRequest, error?: unknown) {
@@ -1,3 +1,4 @@
1
+ import type { Mock } from 'vitest';
1
2
  import React from 'react';
2
3
  import { render, waitFor, act } from '@testing-library/react';
3
4
  import '@testing-library/jest-dom';
@@ -228,17 +229,20 @@ describe('AuthKitProvider', () => {
228
229
  });
229
230
 
230
231
  describe('window.location.reload behavior', () => {
231
- let originalLocation: Location;
232
+ let originalLocationDescriptor: PropertyDescriptor | undefined;
232
233
 
233
234
  beforeEach(() => {
234
- originalLocation = window.location;
235
- // @ts-expect-error - deleting window.location to mock it
236
- delete window.location;
237
- window.location = { reload: vi.fn() } as unknown as Location;
235
+ originalLocationDescriptor = Object.getOwnPropertyDescriptor(window, 'location');
236
+ Object.defineProperty(window, 'location', {
237
+ writable: true,
238
+ value: { reload: vi.fn() },
239
+ });
238
240
  });
239
241
 
240
242
  afterEach(() => {
241
- window.location = originalLocation;
243
+ if (originalLocationDescriptor) {
244
+ Object.defineProperty(window, 'location', originalLocationDescriptor);
245
+ }
242
246
  });
243
247
 
244
248
  it('should reload the page when session is expired and no onSessionExpired handler is provided', async () => {
@@ -312,6 +316,85 @@ describe('useAuth', () => {
312
316
  });
313
317
  });
314
318
 
319
+ describe('client-side redirect for ensureSignedIn', () => {
320
+ let originalLocationDescriptor: PropertyDescriptor | undefined;
321
+
322
+ beforeEach(() => {
323
+ originalLocationDescriptor = Object.getOwnPropertyDescriptor(window, 'location');
324
+ Object.defineProperty(window, 'location', {
325
+ writable: true,
326
+ value: { href: '' },
327
+ });
328
+ });
329
+
330
+ afterEach(() => {
331
+ if (originalLocationDescriptor) {
332
+ Object.defineProperty(window, 'location', originalLocationDescriptor);
333
+ }
334
+ });
335
+
336
+ it('should redirect via window.location.href when getAuthAction returns signInUrl', async () => {
337
+ // First call (initial load): no user, no signInUrl
338
+ // Second call (ensureSignedIn triggered): no user, signInUrl returned
339
+ (getAuthAction as Mock)
340
+ .mockResolvedValueOnce({ user: null })
341
+ .mockResolvedValueOnce({ user: null, signInUrl: 'https://api.workos.com/authorize?client_id=test' });
342
+
343
+ const TestComponent = () => {
344
+ const auth = useAuth({ ensureSignedIn: true });
345
+ return <div data-testid="loading">{auth.loading.toString()}</div>;
346
+ };
347
+
348
+ render(
349
+ <AuthKitProvider>
350
+ <TestComponent />
351
+ </AuthKitProvider>,
352
+ );
353
+
354
+ await waitFor(() => {
355
+ expect(window.location.href).toBe('https://api.workos.com/authorize?client_id=test');
356
+ });
357
+ });
358
+
359
+ it('should redirect via window.location.href when refreshAuthAction returns signInUrl', async () => {
360
+ (getAuthAction as Mock).mockResolvedValueOnce({
361
+ user: { email: 'test@example.com' },
362
+ sessionId: 'test-session',
363
+ });
364
+ (refreshAuthAction as Mock).mockResolvedValueOnce({
365
+ user: null,
366
+ signInUrl: 'https://api.workos.com/authorize?client_id=refresh_test',
367
+ });
368
+
369
+ const TestComponent = () => {
370
+ const auth = useAuth();
371
+ return (
372
+ <div>
373
+ <button onClick={() => auth.refreshAuth({ ensureSignedIn: true })}>Refresh</button>
374
+ </div>
375
+ );
376
+ };
377
+
378
+ const { getByRole } = render(
379
+ <AuthKitProvider>
380
+ <TestComponent />
381
+ </AuthKitProvider>,
382
+ );
383
+
384
+ await waitFor(() => {
385
+ expect(getAuthAction).toHaveBeenCalledTimes(1);
386
+ });
387
+
388
+ act(() => {
389
+ getByRole('button').click();
390
+ });
391
+
392
+ await waitFor(() => {
393
+ expect(window.location.href).toBe('https://api.workos.com/authorize?client_id=refresh_test');
394
+ });
395
+ });
396
+ });
397
+
315
398
  it('should throw error when used outside of AuthKitProvider', () => {
316
399
  const TestComponent = () => {
317
400
  const auth = useAuth();
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import React, { createContext, ReactNode, useCallback, useContext, useEffect, useState } from 'react';
3
+ import React, { createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react';
4
4
  import {
5
5
  checkSessionAction,
6
6
  getAuthAction,
@@ -57,11 +57,27 @@ export const AuthKitProvider = ({ children, onSessionExpired, initialAuth }: Aut
57
57
  const [featureFlags, setFeatureFlags] = useState<string[] | undefined>(initialAuth?.featureFlags);
58
58
  const [impersonator, setImpersonator] = useState<Impersonator | undefined>(initialAuth?.impersonator);
59
59
  const [loading, setLoading] = useState(!initialAuth);
60
+ const redirectingRef = useRef(false);
61
+
62
+ // Redirect client-side to avoid CORS errors that occur when redirect()
63
+ // is called from a server action to an external URL.
64
+ const handleSignInRedirect = useCallback((auth: Record<string, unknown>): boolean => {
65
+ if ('signInUrl' in auth && auth.signInUrl) {
66
+ redirectingRef.current = true;
67
+ window.location.href = auth.signInUrl as string;
68
+ return true;
69
+ }
70
+ return false;
71
+ }, []);
60
72
 
61
73
  const getAuth = useCallback(async ({ ensureSignedIn = false }: { ensureSignedIn?: boolean } = {}) => {
74
+ if (redirectingRef.current) return;
62
75
  setLoading(true);
63
76
  try {
64
77
  const auth = await getAuthAction({ ensureSignedIn });
78
+
79
+ if (handleSignInRedirect(auth)) return;
80
+
65
81
  setUser(auth.user);
66
82
  setSessionId(auth.sessionId);
67
83
  setOrganizationId(auth.organizationId);
@@ -105,10 +121,13 @@ export const AuthKitProvider = ({ children, onSessionExpired, initialAuth }: Aut
105
121
 
106
122
  const refreshAuth = useCallback(
107
123
  async ({ ensureSignedIn = false, organizationId }: { ensureSignedIn?: boolean; organizationId?: string } = {}) => {
124
+ if (redirectingRef.current) return;
108
125
  try {
109
126
  setLoading(true);
110
127
  const auth = await refreshAuthAction({ ensureSignedIn, organizationId });
111
128
 
129
+ if (handleSignInRedirect(auth)) return;
130
+
112
131
  setUser(auth.user);
113
132
  setSessionId(auth.sessionId);
114
133
  setOrganizationId(auth.organizationId);
@@ -1,3 +1,4 @@
1
+ import type { Mock } from 'vitest';
1
2
  import { render, act, screen } from '@testing-library/react';
2
3
  import '@testing-library/jest-dom';
3
4
  import { Impersonation } from './impersonation.js';
@@ -29,35 +29,40 @@ export function Impersonation({ side = 'bottom', returnTo, ...props }: Impersona
29
29
  <div
30
30
  {...props}
31
31
  data-workos-impersonation-root=""
32
- style={{
33
- 'position': 'fixed',
34
- 'inset': 0,
35
- 'pointerEvents': 'none',
36
- 'zIndex': 9999,
37
-
38
- // short properties with defaults for authoring convenience
39
- '--wi-minimized': '0',
40
- '--wi-s': 'min(max(var(--workos-impersonation-size, 4px), 2px), 15px)',
41
- '--wi-bgc': 'var(--workos-impersonation-background-color, #fce654)',
42
- '--wi-c': 'var(--workos-impersonation-color, #1a1600)',
43
- '--wi-bc': 'var(--workos-impersonation-border-color, #e0c36c)',
44
- '--wi-bw': 'var(--workos-impersonation-border-width, 1px)',
45
-
46
- ...props.style,
47
- }}
32
+ style={
33
+ {
34
+ position: 'fixed',
35
+ inset: 0,
36
+ pointerEvents: 'none',
37
+ zIndex: 9999,
38
+
39
+ // short properties with defaults for authoring convenience
40
+ '--wi-minimized': '0',
41
+ '--wi-s': 'min(max(var(--workos-impersonation-size, 4px), 2px), 15px)',
42
+ '--wi-bgc': 'var(--workos-impersonation-background-color, #fce654)',
43
+ '--wi-c': 'var(--workos-impersonation-color, #1a1600)',
44
+ '--wi-bc': 'var(--workos-impersonation-border-color, #e0c36c)',
45
+ '--wi-bw': 'var(--workos-impersonation-border-width, 1px)',
46
+
47
+ ...props.style,
48
+ } as React.CSSProperties
49
+ }
48
50
  >
49
51
  <div
50
- style={{
51
- '--wi-frame-size': 'calc(var(--wi-s) * (1 - var(--wi-minimized)) + var(--wi-minimized) * var(--wi-bw) * -1)',
52
- 'position': 'absolute',
53
- 'inset': 'calc(var(--wi-frame-size) * -1)',
54
- 'borderRadius': 'calc(var(--wi-frame-size) * 3)',
55
- 'boxShadow': `
52
+ style={
53
+ {
54
+ '--wi-frame-size':
55
+ 'calc(var(--wi-s) * (1 - var(--wi-minimized)) + var(--wi-minimized) * var(--wi-bw) * -1)',
56
+ position: 'absolute',
57
+ inset: 'calc(var(--wi-frame-size) * -1)',
58
+ borderRadius: 'calc(var(--wi-frame-size) * 3)',
59
+ boxShadow: `
56
60
  inset 0 0 0 calc(var(--wi-frame-size) * 2) var(--wi-bgc),
57
61
  inset 0 0 0 calc(var(--wi-frame-size) * 2 + var(--wi-bw)) var(--wi-bc)
58
62
  `,
59
- 'transition': 'all 500ms cubic-bezier(0.16, 1, 0.3, 1)',
60
- }}
63
+ transition: 'all 500ms cubic-bezier(0.16, 1, 0.3, 1)',
64
+ } as React.CSSProperties
65
+ }
61
66
  />
62
67
 
63
68
  <div
@@ -1,3 +1,4 @@
1
+ import type { Mock } from 'vitest';
1
2
  import { tokenStore, TokenStore } from './tokenStore.js';
2
3
  import { getAccessTokenAction, refreshAccessTokenAction } from '../actions.js';
3
4
 
@@ -54,7 +55,7 @@ describe('tokenStore', () => {
54
55
  resolvePromise = resolve;
55
56
  });
56
57
 
57
- mockRefreshAccessTokenAction.mockReturnValue(slowPromise);
58
+ mockRefreshAccessTokenAction.mockReturnValue(slowPromise.then((t) => ({ accessToken: t })));
58
59
 
59
60
  expect(tokenStore.isRefreshing()).toBe(false);
60
61
 
@@ -124,18 +125,25 @@ describe('tokenStore', () => {
124
125
  const refreshedToken =
125
126
  'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJyZWZyZXNoZWQiLCJzaWQiOiJzZXNzaW9uXzEyMyIsImV4cCI6OTk5OTk5OTk5OX0.mock-signature-2';
126
127
 
127
- // Set expiring token first
128
+ // Set expiring token first — also set refresh mock since getAccessTokenSilently
129
+ // will trigger refresh for expiring tokens
128
130
  mockGetAccessTokenAction.mockResolvedValue(expiringToken);
131
+ mockRefreshAccessTokenAction.mockResolvedValueOnce({ accessToken: expiringToken });
129
132
  await tokenStore.getAccessTokenSilently();
130
133
 
131
- // Setup refresh mock
132
- mockRefreshAccessTokenAction.mockResolvedValue(refreshedToken);
134
+ // Clear mocks to track subsequent calls
135
+ mockGetAccessTokenAction.mockClear();
136
+ mockRefreshAccessTokenAction.mockClear();
133
137
 
134
- // Now call getAccessToken - should trigger refresh
138
+ // Setup refresh to return new token
139
+ mockRefreshAccessTokenAction.mockResolvedValue({ accessToken: refreshedToken });
140
+
141
+ // Now call getAccessToken - should trigger refresh due to expiring token
135
142
  const token = await tokenStore.getAccessToken();
136
143
 
137
- expect(token).toBe(refreshedToken);
144
+ // Should have called refresh since existing token was expiring
138
145
  expect(mockRefreshAccessTokenAction).toHaveBeenCalled();
146
+ expect(token).toBe(refreshedToken);
139
147
  });
140
148
 
141
149
  it('should refresh when no token exists', async () => {
@@ -192,7 +200,7 @@ describe('tokenStore', () => {
192
200
  'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJyZWZyZXNoZWQiLCJzaWQiOiJzZXNzaW9uXzEyMyIsImV4cCI6OTk5OTk5OTk5OX0.mock-signature-2';
193
201
 
194
202
  mockGetAccessTokenAction.mockResolvedValue(expiredToken);
195
- mockRefreshAccessTokenAction.mockResolvedValue(refreshedToken);
203
+ mockRefreshAccessTokenAction.mockResolvedValue({ accessToken: refreshedToken });
196
204
 
197
205
  const token = await tokenStore.getAccessTokenSilently();
198
206
 
@@ -287,15 +295,18 @@ describe('tokenStore', () => {
287
295
  const refreshedToken =
288
296
  'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJyZWZyZXNoZWQiLCJzaWQiOiJzZXNzaW9uXzEyMyIsImV4cCI6OTk5OTk5OTk5OX0.mock-signature-2';
289
297
 
290
- // First set an expiring token
298
+ // First set an expiring token — also set refresh mock since getAccessTokenSilently
299
+ // will trigger refresh for expiring tokens
291
300
  mockGetAccessTokenAction.mockResolvedValue(expiringToken);
301
+ mockRefreshAccessTokenAction.mockResolvedValueOnce({ accessToken: expiringToken });
292
302
  await tokenStore.getAccessTokenSilently();
293
303
 
294
304
  // Clear mocks
295
305
  mockGetAccessTokenAction.mockClear();
306
+ mockRefreshAccessTokenAction.mockClear();
296
307
 
297
308
  // Setup refresh to return new token
298
- mockRefreshAccessTokenAction.mockResolvedValue(refreshedToken);
309
+ mockRefreshAccessTokenAction.mockResolvedValue({ accessToken: refreshedToken });
299
310
 
300
311
  // Call getAccessToken again - should trigger refresh due to expiring token
301
312
  const token = await tokenStore.getAccessToken();
@@ -526,7 +537,10 @@ describe('tokenStore', () => {
526
537
  await tokenStore.getAccessTokenSilently();
527
538
 
528
539
  // Now simulate network error during refresh
529
- mockRefreshAccessTokenAction.mockRejectedValue(new Error('Network error'));
540
+ mockRefreshAccessTokenAction.mockResolvedValue({
541
+ accessToken: undefined,
542
+ error: 'Failed to refresh access token',
543
+ });
530
544
 
531
545
  try {
532
546
  await tokenStore.refreshToken();
@@ -543,7 +557,7 @@ describe('tokenStore', () => {
543
557
  it('should convert non-Error objects to Error instances', async () => {
544
558
  const errorString = 'network timeout';
545
559
 
546
- mockRefreshAccessTokenAction.mockRejectedValue(errorString);
560
+ mockRefreshAccessTokenAction.mockResolvedValue({ accessToken: undefined, error: errorString });
547
561
 
548
562
  try {
549
563
  await tokenStore.refreshToken();
@@ -649,7 +663,7 @@ describe('tokenStore', () => {
649
663
 
650
664
  mockRefreshAccessTokenAction.mockImplementation(() => {
651
665
  callCount++;
652
- return slowPromise;
666
+ return slowPromise.then((t) => ({ accessToken: t }));
653
667
  });
654
668
 
655
669
  // Clear any existing refresh promise
@@ -691,11 +705,11 @@ describe('tokenStore', () => {
691
705
  });
692
706
 
693
707
  describe('refresh state management', () => {
694
- it('should preserve Error instances without conversion', async () => {
695
- const errorInstance = new Error('actual error instance');
708
+ it('should create Error from server action error string', async () => {
709
+ const errorMessage = 'actual error instance';
696
710
 
697
- // Mock refresh to throw an Error instance
698
- mockRefreshAccessTokenAction.mockRejectedValue(errorInstance);
711
+ // Mock refresh to return an error result (as server actions do)
712
+ mockRefreshAccessTokenAction.mockResolvedValue({ accessToken: undefined, error: errorMessage });
699
713
 
700
714
  try {
701
715
  await tokenStore.refreshToken();
@@ -703,9 +717,10 @@ describe('tokenStore', () => {
703
717
  // Expected to throw
704
718
  }
705
719
 
706
- // Verify the Error instance was preserved without conversion
720
+ // Verify an Error was created from the error string
707
721
  const state = tokenStore.getSnapshot();
708
- expect(state.error).toBe(errorInstance); // Same instance, not a new one
722
+ expect(state.error).toBeInstanceOf(Error);
723
+ expect(state.error?.message).toBe(errorMessage);
709
724
  });
710
725
 
711
726
  it('should update state for manual refresh', async () => {
@@ -717,7 +732,7 @@ describe('tokenStore', () => {
717
732
  await tokenStore.getAccessTokenSilently();
718
733
 
719
734
  // Mock refresh to return new token
720
- mockRefreshAccessTokenAction.mockResolvedValue(newToken);
735
+ mockRefreshAccessTokenAction.mockResolvedValue({ accessToken: newToken });
721
736
 
722
737
  // Call manual refresh which should update state
723
738
  const result = await tokenStore.refreshToken();
@@ -736,7 +751,7 @@ describe('tokenStore', () => {
736
751
 
737
752
  // Clear mocks and set up spy on setState
738
753
  mockGetAccessTokenAction.mockClear();
739
- mockRefreshAccessTokenAction.mockResolvedValue(existingToken); // Same token
754
+ mockRefreshAccessTokenAction.mockResolvedValue({ accessToken: existingToken }); // Same token
740
755
 
741
756
  const listener = vi.fn();
742
757
  tokenStore.subscribe(listener);
@@ -1,6 +1,14 @@
1
1
  import { getAccessTokenAction, refreshAccessTokenAction } from '../actions.js';
2
+ import type { RefreshAccessTokenActionResult } from '../actions.js';
2
3
  import { decodeJwt } from '../jwt.js';
3
4
 
5
+ function unwrapRefreshResult(result: RefreshAccessTokenActionResult): string | undefined {
6
+ if (result.error) {
7
+ throw new Error(result.error);
8
+ }
9
+ return result.accessToken;
10
+ }
11
+
4
12
  interface TokenState {
5
13
  token: string | undefined;
6
14
  loading: boolean;
@@ -323,7 +331,7 @@ export class TokenStore {
323
331
 
324
332
  if (!silent) {
325
333
  // Manual refresh - always force refresh
326
- token = await refreshAccessTokenAction();
334
+ token = unwrapRefreshResult(await refreshAccessTokenAction());
327
335
  } else {
328
336
  // Silent refresh - only fetch from server if we don't have a local token
329
337
  if (!previousToken) {
@@ -342,14 +350,14 @@ export class TokenStore {
342
350
 
343
351
  // If the token from server is expiring, refresh it
344
352
  if (!token || (tokenData && tokenData.isExpiring)) {
345
- const refreshedToken = await refreshAccessTokenAction();
353
+ const refreshedToken = unwrapRefreshResult(await refreshAccessTokenAction());
346
354
  if (refreshedToken) {
347
355
  token = refreshedToken;
348
356
  }
349
357
  }
350
358
  } else {
351
359
  // We have a local token that needs refreshing (already checked by getAccessTokenSilently)
352
- token = await refreshAccessTokenAction();
360
+ token = unwrapRefreshResult(await refreshAccessTokenAction());
353
361
  }
354
362
  }
355
363