@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.
- package/README.md +40 -11
- package/dist/esm/actions.js +35 -4
- package/dist/esm/actions.js.map +1 -1
- package/dist/esm/auth.js +13 -22
- package/dist/esm/auth.js.map +1 -1
- package/dist/esm/authkit-callback-route.js +71 -95
- package/dist/esm/authkit-callback-route.js.map +1 -1
- package/dist/esm/components/authkit-provider.js +31 -13
- package/dist/esm/components/authkit-provider.js.map +1 -1
- package/dist/esm/components/impersonation.js +9 -9
- package/dist/esm/components/impersonation.js.map +1 -1
- package/dist/esm/components/min-max-button.js +1 -1
- package/dist/esm/components/min-max-button.js.map +1 -1
- package/dist/esm/components/tokenStore.js +28 -19
- package/dist/esm/components/tokenStore.js.map +1 -1
- package/dist/esm/components/useAccessToken.js +1 -1
- package/dist/esm/components/useAccessToken.js.map +1 -1
- package/dist/esm/components/useTokenClaims.js +1 -1
- package/dist/esm/components/useTokenClaims.js.map +1 -1
- package/dist/esm/cookie.js +16 -5
- package/dist/esm/cookie.js.map +1 -1
- package/dist/esm/env-variables.js +5 -7
- package/dist/esm/env-variables.js.map +1 -1
- package/dist/esm/errors.js +7 -4
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/get-authorization-url.js +23 -27
- package/dist/esm/get-authorization-url.js.map +1 -1
- package/dist/esm/index.js +3 -3
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/interfaces.js +7 -1
- package/dist/esm/interfaces.js.map +1 -1
- package/dist/esm/middleware-helpers.js +8 -5
- package/dist/esm/middleware-helpers.js.map +1 -1
- package/dist/esm/middleware.js +3 -1
- package/dist/esm/middleware.js.map +1 -1
- package/dist/esm/pkce.js +17 -22
- package/dist/esm/pkce.js.map +1 -1
- package/dist/esm/session.js +19 -23
- package/dist/esm/session.js.map +1 -1
- package/dist/esm/types/actions.d.ts +34 -5
- package/dist/esm/types/auth.d.ts +6 -16
- package/dist/esm/types/cookie.d.ts +8 -0
- package/dist/esm/types/env-variables.d.ts +1 -2
- package/dist/esm/types/get-authorization-url.d.ts +1 -1
- package/dist/esm/types/index.d.ts +3 -3
- package/dist/esm/types/interfaces.d.ts +9 -1
- package/dist/esm/types/jwt.d.ts +9 -9
- package/dist/esm/types/middleware-helpers.d.ts +3 -1
- package/dist/esm/types/middleware.d.ts +3 -1
- package/dist/esm/types/pkce.d.ts +6 -5
- package/dist/esm/utils.js +2 -2
- package/dist/esm/utils.js.map +1 -1
- package/dist/esm/validate-api-key.js +1 -2
- package/dist/esm/validate-api-key.js.map +1 -1
- package/package.json +12 -13
- package/src/actions.spec.ts +81 -6
- package/src/actions.ts +44 -5
- package/src/auth.spec.ts +3 -2
- package/src/auth.ts +12 -43
- package/src/authkit-callback-route.spec.ts +210 -60
- package/src/authkit-callback-route.ts +94 -107
- package/src/components/authkit-provider.spec.tsx +89 -6
- package/src/components/authkit-provider.tsx +20 -1
- package/src/components/impersonation.spec.tsx +1 -0
- package/src/components/impersonation.tsx +29 -24
- package/src/components/tokenStore.spec.ts +35 -20
- package/src/components/tokenStore.ts +11 -3
- package/src/components/useAccessToken.spec.tsx +15 -12
- package/src/components/useTokenClaims.spec.tsx +1 -0
- package/src/cookie.ts +29 -0
- package/src/env-variables.ts +0 -2
- package/src/get-authorization-url.spec.ts +18 -40
- package/src/get-authorization-url.ts +34 -40
- package/src/index.ts +3 -1
- package/src/interfaces.ts +11 -1
- package/src/jwt.ts +9 -9
- package/src/middleware-helpers.spec.ts +7 -0
- package/src/middleware-helpers.ts +7 -3
- package/src/middleware.spec.ts +25 -0
- package/src/middleware.ts +4 -1
- package/src/pkce.spec.ts +125 -0
- package/src/pkce.ts +19 -19
- package/src/session.spec.ts +18 -22
- package/src/session.ts +10 -12
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { NextRequest } from 'next/server';
|
|
2
|
-
import {
|
|
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,
|
|
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
|
-
//
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
|
232
|
+
let originalLocationDescriptor: PropertyDescriptor | undefined;
|
|
232
233
|
|
|
233
234
|
beforeEach(() => {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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);
|
|
@@ -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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
132
|
-
|
|
134
|
+
// Clear mocks to track subsequent calls
|
|
135
|
+
mockGetAccessTokenAction.mockClear();
|
|
136
|
+
mockRefreshAccessTokenAction.mockClear();
|
|
133
137
|
|
|
134
|
-
//
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
695
|
-
const
|
|
708
|
+
it('should create Error from server action error string', async () => {
|
|
709
|
+
const errorMessage = 'actual error instance';
|
|
696
710
|
|
|
697
|
-
// Mock refresh to
|
|
698
|
-
mockRefreshAccessTokenAction.
|
|
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
|
|
720
|
+
// Verify an Error was created from the error string
|
|
707
721
|
const state = tokenStore.getSnapshot();
|
|
708
|
-
expect(state.error).
|
|
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
|
|