@workos-inc/authkit-nextjs 2.6.0 → 2.7.1
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 +124 -29
- package/dist/esm/components/tokenStore.js +110 -11
- package/dist/esm/components/tokenStore.js.map +1 -1
- package/dist/esm/components/useAccessToken.js +6 -1
- package/dist/esm/components/useAccessToken.js.map +1 -1
- package/dist/esm/cookie.js +51 -0
- package/dist/esm/cookie.js.map +1 -1
- package/dist/esm/middleware.js +2 -2
- package/dist/esm/middleware.js.map +1 -1
- package/dist/esm/session.js +35 -2
- package/dist/esm/session.js.map +1 -1
- package/dist/esm/test-helpers.js +57 -0
- package/dist/esm/test-helpers.js.map +1 -0
- package/dist/esm/types/components/tokenStore.d.ts +7 -2
- package/dist/esm/types/cookie.d.ts +1 -0
- package/dist/esm/types/interfaces.d.ts +2 -0
- package/dist/esm/types/middleware.d.ts +1 -1
- package/dist/esm/types/session.d.ts +1 -1
- package/dist/esm/types/test-helpers.d.ts +3 -0
- package/dist/esm/types/workos.d.ts +1 -1
- package/dist/esm/workos.js +1 -1
- package/package.json +4 -3
- package/src/actions.spec.ts +100 -0
- package/src/auth.spec.ts +347 -0
- package/src/authkit-callback-route.spec.ts +258 -0
- package/src/components/authkit-provider.spec.tsx +471 -0
- package/src/components/button.spec.tsx +46 -0
- package/src/components/impersonation.spec.tsx +134 -0
- package/src/components/min-max-button.spec.tsx +60 -0
- package/src/components/tokenStore.spec.ts +816 -0
- package/src/components/tokenStore.ts +147 -12
- package/src/components/useAccessToken.spec.tsx +731 -0
- package/src/components/useAccessToken.ts +6 -1
- package/src/components/useTokenClaims.spec.tsx +194 -0
- package/src/cookie.spec.ts +276 -0
- package/src/cookie.ts +56 -0
- package/src/get-authorization-url.spec.ts +60 -0
- package/src/interfaces.ts +2 -0
- package/src/jwt.spec.ts +159 -0
- package/src/middleware.ts +2 -1
- package/src/session.spec.ts +1162 -0
- package/src/session.ts +41 -1
- package/src/test-helpers.ts +70 -0
- package/src/utils.spec.ts +142 -0
- package/src/workos.spec.ts +67 -0
- package/src/workos.ts +1 -1
|
@@ -51,8 +51,13 @@ export function useAccessToken(): UseAccessTokenReturn {
|
|
|
51
51
|
|
|
52
52
|
useEffect(() => {
|
|
53
53
|
if (!user) {
|
|
54
|
-
tokenStore.clearToken();
|
|
55
54
|
setIsInitialTokenLoading(false);
|
|
55
|
+
// Clear token when user logs out
|
|
56
|
+
if (prevUserIdRef.current !== undefined) {
|
|
57
|
+
tokenStore.clearToken();
|
|
58
|
+
}
|
|
59
|
+
prevUserIdRef.current = undefined;
|
|
60
|
+
prevSessionRef.current = undefined;
|
|
56
61
|
return;
|
|
57
62
|
}
|
|
58
63
|
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import '@testing-library/jest-dom';
|
|
2
|
+
import { render, waitFor } from '@testing-library/react';
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { useAuth } from './authkit-provider.js';
|
|
5
|
+
|
|
6
|
+
jest.mock('../actions.js', () => ({
|
|
7
|
+
getAccessTokenAction: jest.fn(),
|
|
8
|
+
refreshAccessTokenAction: jest.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
jest.mock('./authkit-provider.js', () => {
|
|
12
|
+
const originalModule = jest.requireActual('./authkit-provider.js');
|
|
13
|
+
return {
|
|
14
|
+
...originalModule,
|
|
15
|
+
useAuth: jest.fn(),
|
|
16
|
+
};
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
jest.mock('./useAccessToken.js', () => ({
|
|
20
|
+
useAccessToken: jest.fn(() => ({ accessToken: undefined })),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
jest.mock('jose', () => ({
|
|
24
|
+
decodeJwt: jest.fn((token: string) => {
|
|
25
|
+
if (token === 'malformed-token' || token === 'throw-error-token') {
|
|
26
|
+
throw new Error('Invalid JWT');
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const parts = token.split('.');
|
|
30
|
+
if (parts.length !== 3) throw new Error('Invalid JWT');
|
|
31
|
+
const payload = JSON.parse(atob(parts[1]));
|
|
32
|
+
return payload;
|
|
33
|
+
} catch {
|
|
34
|
+
throw new Error('Invalid JWT');
|
|
35
|
+
}
|
|
36
|
+
}),
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
// Import after mocks are set up
|
|
40
|
+
import { useAccessToken } from './useAccessToken.js';
|
|
41
|
+
import { useTokenClaims } from './useTokenClaims.js';
|
|
42
|
+
|
|
43
|
+
describe('useTokenClaims', () => {
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
jest.clearAllMocks();
|
|
46
|
+
jest.useFakeTimers();
|
|
47
|
+
|
|
48
|
+
(useAuth as jest.Mock).mockImplementation(() => ({
|
|
49
|
+
user: { id: 'user_123' },
|
|
50
|
+
sessionId: 'session_123',
|
|
51
|
+
refreshAuth: jest.fn().mockResolvedValue({}),
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
// Reset useAccessToken mock to default
|
|
55
|
+
(useAccessToken as jest.Mock).mockReturnValue({ accessToken: undefined });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
jest.useRealTimers();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const TokenClaimsTestComponent = () => {
|
|
63
|
+
const tokenClaims = useTokenClaims();
|
|
64
|
+
return (
|
|
65
|
+
<div>
|
|
66
|
+
<div data-testid="claims">{JSON.stringify(tokenClaims)}</div>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
it('should return empty object when no access token is available', async () => {
|
|
72
|
+
(useAccessToken as jest.Mock).mockReturnValue({ accessToken: undefined });
|
|
73
|
+
|
|
74
|
+
const { getByTestId } = render(<TokenClaimsTestComponent />);
|
|
75
|
+
|
|
76
|
+
await waitFor(() => {
|
|
77
|
+
expect(getByTestId('claims')).toHaveTextContent('{}');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should return all token claims when access token is available', async () => {
|
|
82
|
+
const payload = {
|
|
83
|
+
aud: 'audience',
|
|
84
|
+
exp: 9999999999,
|
|
85
|
+
iat: 1234567800,
|
|
86
|
+
iss: 'issuer',
|
|
87
|
+
sub: 'user_123',
|
|
88
|
+
sid: 'session_123',
|
|
89
|
+
org_id: 'org_123',
|
|
90
|
+
role: 'admin',
|
|
91
|
+
permissions: ['read', 'write'],
|
|
92
|
+
entitlements: ['feature_a'],
|
|
93
|
+
feature_flags: ['device-authorization-grant'],
|
|
94
|
+
jti: 'jwt_123',
|
|
95
|
+
nbf: 1234567800,
|
|
96
|
+
// Custom claims
|
|
97
|
+
customField1: 'value1',
|
|
98
|
+
customField2: 42,
|
|
99
|
+
customObject: { nested: 'data' },
|
|
100
|
+
};
|
|
101
|
+
const token = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify(payload))}.mock-signature`;
|
|
102
|
+
|
|
103
|
+
(useAccessToken as jest.Mock).mockReturnValue({ accessToken: token });
|
|
104
|
+
|
|
105
|
+
const { getByTestId } = render(<TokenClaimsTestComponent />);
|
|
106
|
+
|
|
107
|
+
await waitFor(() => {
|
|
108
|
+
expect(getByTestId('claims')).toHaveTextContent(JSON.stringify(payload));
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should return all standard claims when token has only standard claims', async () => {
|
|
113
|
+
const payload = {
|
|
114
|
+
aud: 'audience',
|
|
115
|
+
exp: 9999999999,
|
|
116
|
+
iat: 1234567800,
|
|
117
|
+
iss: 'issuer',
|
|
118
|
+
sub: 'user_123',
|
|
119
|
+
sid: 'session_123',
|
|
120
|
+
org_id: 'org_123',
|
|
121
|
+
role: 'admin',
|
|
122
|
+
permissions: ['read', 'write'],
|
|
123
|
+
entitlements: ['feature_a'],
|
|
124
|
+
feature_flags: ['device-authorization-grant'],
|
|
125
|
+
jti: 'jwt_123',
|
|
126
|
+
nbf: 1234567800,
|
|
127
|
+
};
|
|
128
|
+
const token = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify(payload))}.mock-signature`;
|
|
129
|
+
|
|
130
|
+
(useAccessToken as jest.Mock).mockReturnValue({ accessToken: token });
|
|
131
|
+
|
|
132
|
+
const { getByTestId } = render(<TokenClaimsTestComponent />);
|
|
133
|
+
|
|
134
|
+
await waitFor(() => {
|
|
135
|
+
expect(getByTestId('claims')).toHaveTextContent(JSON.stringify(payload));
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('should handle partial claims', async () => {
|
|
140
|
+
const payload = {
|
|
141
|
+
sub: 'user_123',
|
|
142
|
+
exp: 9999999999,
|
|
143
|
+
customField: 'value',
|
|
144
|
+
anotherCustom: true,
|
|
145
|
+
};
|
|
146
|
+
const token = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify(payload))}.mock-signature`;
|
|
147
|
+
|
|
148
|
+
(useAccessToken as jest.Mock).mockReturnValue({ accessToken: token });
|
|
149
|
+
|
|
150
|
+
const { getByTestId } = render(<TokenClaimsTestComponent />);
|
|
151
|
+
|
|
152
|
+
await waitFor(() => {
|
|
153
|
+
expect(getByTestId('claims')).toHaveTextContent(JSON.stringify(payload));
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should handle complex nested claims', async () => {
|
|
158
|
+
const payload = {
|
|
159
|
+
sub: 'user_123',
|
|
160
|
+
exp: 9999999999,
|
|
161
|
+
metadata: {
|
|
162
|
+
preferences: {
|
|
163
|
+
theme: 'dark',
|
|
164
|
+
language: 'en',
|
|
165
|
+
},
|
|
166
|
+
settings: ['setting1', 'setting2'],
|
|
167
|
+
},
|
|
168
|
+
tags: ['tag1', 'tag2'],
|
|
169
|
+
permissions_custom: {
|
|
170
|
+
read: true,
|
|
171
|
+
write: false,
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
const token = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify(payload))}.mock-signature`;
|
|
175
|
+
|
|
176
|
+
(useAccessToken as jest.Mock).mockReturnValue({ accessToken: token });
|
|
177
|
+
|
|
178
|
+
const { getByTestId } = render(<TokenClaimsTestComponent />);
|
|
179
|
+
|
|
180
|
+
await waitFor(() => {
|
|
181
|
+
expect(getByTestId('claims')).toHaveTextContent(JSON.stringify(payload));
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('should return empty object when decodeJwt throws an error', async () => {
|
|
186
|
+
(useAccessToken as jest.Mock).mockReturnValue({ accessToken: 'malformed-token' });
|
|
187
|
+
|
|
188
|
+
const { getByTestId } = render(<TokenClaimsTestComponent />);
|
|
189
|
+
|
|
190
|
+
await waitFor(() => {
|
|
191
|
+
expect(getByTestId('claims')).toHaveTextContent('{}');
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { describe, it, expect } from '@jest/globals';
|
|
2
|
+
|
|
3
|
+
// Mock at the top of the file
|
|
4
|
+
jest.mock('./env-variables');
|
|
5
|
+
|
|
6
|
+
describe('cookie.ts', () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
// Clear all mocks before each test
|
|
9
|
+
jest.clearAllMocks();
|
|
10
|
+
// Reset modules
|
|
11
|
+
jest.resetModules();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe('getCookieOptions', () => {
|
|
15
|
+
it('should return the default cookie options', async () => {
|
|
16
|
+
const { getCookieOptions } = await import('./cookie');
|
|
17
|
+
|
|
18
|
+
const options = getCookieOptions();
|
|
19
|
+
expect(options).toEqual(
|
|
20
|
+
expect.objectContaining({
|
|
21
|
+
path: '/',
|
|
22
|
+
httpOnly: true,
|
|
23
|
+
secure: false,
|
|
24
|
+
sameSite: 'lax',
|
|
25
|
+
maxAge: 400 * 24 * 60 * 60,
|
|
26
|
+
domain: 'example.com',
|
|
27
|
+
}),
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should return the cookie options with custom values', async () => {
|
|
32
|
+
// Import the mocked module
|
|
33
|
+
const envVars = await import('./env-variables');
|
|
34
|
+
|
|
35
|
+
// Set the mock values
|
|
36
|
+
Object.defineProperty(envVars, 'WORKOS_COOKIE_MAX_AGE', { value: '1000' });
|
|
37
|
+
Object.defineProperty(envVars, 'WORKOS_COOKIE_DOMAIN', { value: 'foobar.com' });
|
|
38
|
+
|
|
39
|
+
const { getCookieOptions } = await import('./cookie');
|
|
40
|
+
const options = getCookieOptions('http://example.com');
|
|
41
|
+
|
|
42
|
+
expect(options).toEqual(
|
|
43
|
+
expect.objectContaining({
|
|
44
|
+
secure: false,
|
|
45
|
+
maxAge: 1000,
|
|
46
|
+
domain: 'foobar.com',
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
Object.defineProperty(envVars, 'WORKOS_COOKIE_DOMAIN', { value: '' });
|
|
51
|
+
|
|
52
|
+
const options2 = getCookieOptions('http://example.com');
|
|
53
|
+
expect(options2).toEqual(
|
|
54
|
+
expect.objectContaining({
|
|
55
|
+
secure: false,
|
|
56
|
+
maxAge: 1000,
|
|
57
|
+
domain: '',
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const options3 = getCookieOptions('https://example.com', true);
|
|
62
|
+
// Domain should not be included when WORKOS_COOKIE_DOMAIN is empty
|
|
63
|
+
expect(options3).toEqual(expect.not.stringContaining('Domain='));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should return the cookie options with expired set to true', async () => {
|
|
67
|
+
const { getCookieOptions } = await import('./cookie');
|
|
68
|
+
const options = getCookieOptions('http://example.com', false, true);
|
|
69
|
+
expect(options).toEqual(expect.objectContaining({ maxAge: 0 }));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should return the cookie options as a string', async () => {
|
|
73
|
+
const { getCookieOptions } = await import('./cookie');
|
|
74
|
+
const options = getCookieOptions('http://example.com', true, false);
|
|
75
|
+
expect(options).toEqual(expect.stringContaining('HttpOnly; SameSite=Lax; Max-Age=34560000; Domain=example.com'));
|
|
76
|
+
expect(options).toEqual(expect.not.stringContaining('Secure'));
|
|
77
|
+
|
|
78
|
+
const options2 = getCookieOptions('https://example.com', true, true);
|
|
79
|
+
expect(options2).toEqual(expect.stringContaining('HttpOnly'));
|
|
80
|
+
expect(options2).toEqual(expect.stringContaining('Secure'));
|
|
81
|
+
expect(options2).toEqual(expect.stringContaining('SameSite=Lax'));
|
|
82
|
+
expect(options2).toEqual(expect.stringContaining('Max-Age=0'));
|
|
83
|
+
expect(options2).toEqual(expect.stringContaining('Domain=example.com'));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('allows the sameSite config to be set by the WORKOS_COOKIE_SAMESITE env variable', async () => {
|
|
87
|
+
const envVars = await import('./env-variables');
|
|
88
|
+
Object.defineProperty(envVars, 'WORKOS_COOKIE_SAMESITE', { value: 'none' });
|
|
89
|
+
|
|
90
|
+
const { getCookieOptions } = await import('./cookie');
|
|
91
|
+
const options = getCookieOptions('http://example.com');
|
|
92
|
+
expect(options).toEqual(expect.objectContaining({ sameSite: 'none' }));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('throws an error if the sameSite value is invalid', async () => {
|
|
96
|
+
const envVars = await import('./env-variables');
|
|
97
|
+
Object.defineProperty(envVars, 'WORKOS_COOKIE_SAMESITE', { value: 'invalid' });
|
|
98
|
+
|
|
99
|
+
const { getCookieOptions } = await import('./cookie');
|
|
100
|
+
expect(() => getCookieOptions('http://example.com')).toThrow('Invalid SameSite value: invalid');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('defaults to secure=true when no URL is available', async () => {
|
|
104
|
+
const envVars = await import('./env-variables');
|
|
105
|
+
Object.defineProperty(envVars, 'WORKOS_REDIRECT_URI', { value: undefined });
|
|
106
|
+
|
|
107
|
+
const { getCookieOptions } = await import('./cookie');
|
|
108
|
+
const options = getCookieOptions();
|
|
109
|
+
expect(options).toEqual(expect.objectContaining({ secure: true }));
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('defaults to secure=true when no URL is available with lax sameSite', async () => {
|
|
113
|
+
const envVars = await import('./env-variables');
|
|
114
|
+
Object.defineProperty(envVars, 'WORKOS_REDIRECT_URI', { value: undefined });
|
|
115
|
+
Object.defineProperty(envVars, 'WORKOS_COOKIE_SAMESITE', { value: 'lax' });
|
|
116
|
+
|
|
117
|
+
const { getCookieOptions } = await import('./cookie');
|
|
118
|
+
const options = getCookieOptions();
|
|
119
|
+
expect(options).toEqual(expect.objectContaining({ secure: true, sameSite: 'lax' }));
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('handles invalid URLs gracefully by defaulting to secure=true', async () => {
|
|
123
|
+
const { getCookieOptions } = await import('./cookie');
|
|
124
|
+
const options = getCookieOptions('not-a-valid-url');
|
|
125
|
+
expect(options).toEqual(expect.objectContaining({ secure: true }));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('handles invalid WORKOS_COOKIE_MAX_AGE gracefully', async () => {
|
|
129
|
+
const envVars = await import('./env-variables');
|
|
130
|
+
Object.defineProperty(envVars, 'WORKOS_COOKIE_MAX_AGE', { value: 'invalid-number' });
|
|
131
|
+
|
|
132
|
+
const { getCookieOptions } = await import('./cookie');
|
|
133
|
+
const options = getCookieOptions();
|
|
134
|
+
expect(options).toEqual(expect.objectContaining({ maxAge: 34560000 })); // Falls back to default
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('properly formats cookie string without Domain when not set', async () => {
|
|
138
|
+
const envVars = await import('./env-variables');
|
|
139
|
+
Object.defineProperty(envVars, 'WORKOS_COOKIE_DOMAIN', { value: '' });
|
|
140
|
+
|
|
141
|
+
const { getCookieOptions } = await import('./cookie');
|
|
142
|
+
const cookieString = getCookieOptions('https://example.com', true);
|
|
143
|
+
expect(cookieString).not.toContain('Domain=');
|
|
144
|
+
expect(cookieString).toContain('Secure');
|
|
145
|
+
expect(cookieString).toContain('SameSite=Lax'); // Capitalized
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe('getJwtCookie', () => {
|
|
150
|
+
beforeEach(() => {
|
|
151
|
+
// Reset NODE_ENV for each test
|
|
152
|
+
delete process.env.NODE_ENV;
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('should create JWT cookie with Secure flag for HTTPS URLs', async () => {
|
|
156
|
+
const { getJwtCookie } = await import('./cookie');
|
|
157
|
+
|
|
158
|
+
const cookie = getJwtCookie('test-token', 'https://example.com');
|
|
159
|
+
|
|
160
|
+
expect(cookie).toBe('workos-access-token=test-token; SameSite=Lax; Max-Age=30; Secure');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('should create JWT cookie without Secure flag for HTTP URLs', async () => {
|
|
164
|
+
const { getJwtCookie } = await import('./cookie');
|
|
165
|
+
|
|
166
|
+
const cookie = getJwtCookie('test-token', 'http://localhost:3000');
|
|
167
|
+
|
|
168
|
+
expect(cookie).toBe('workos-access-token=test-token; SameSite=Lax; Max-Age=30');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('should force Secure in production except for localhost', async () => {
|
|
172
|
+
process.env.NODE_ENV = 'production';
|
|
173
|
+
|
|
174
|
+
const { getJwtCookie } = await import('./cookie');
|
|
175
|
+
|
|
176
|
+
// Production with regular domain should be secure
|
|
177
|
+
const prodCookie = getJwtCookie('prod-token', 'http://example.com');
|
|
178
|
+
expect(prodCookie).toContain('Secure');
|
|
179
|
+
|
|
180
|
+
// Production with localhost should not be secure
|
|
181
|
+
const localhostCookie = getJwtCookie('local-token', 'http://localhost:3000');
|
|
182
|
+
expect(localhostCookie).not.toContain('Secure');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('should handle invalid URLs with no fallback URL', async () => {
|
|
186
|
+
process.env.NODE_ENV = 'production';
|
|
187
|
+
|
|
188
|
+
// Mock no WORKOS_REDIRECT_URI
|
|
189
|
+
const envVars = await import('./env-variables');
|
|
190
|
+
Object.defineProperty(envVars, 'WORKOS_REDIRECT_URI', { value: '' });
|
|
191
|
+
|
|
192
|
+
const { getJwtCookie } = await import('./cookie');
|
|
193
|
+
|
|
194
|
+
const cookie = getJwtCookie('token', 'invalid-url');
|
|
195
|
+
|
|
196
|
+
expect(cookie).toContain('Secure'); // Should default to secure in production when no fallback
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('should fall back to WORKOS_REDIRECT_URI when invalid URL provided', async () => {
|
|
200
|
+
const envVars = await import('./env-variables');
|
|
201
|
+
Object.defineProperty(envVars, 'WORKOS_REDIRECT_URI', { value: 'https://app.workos.com/callback' });
|
|
202
|
+
|
|
203
|
+
const { getJwtCookie } = await import('./cookie');
|
|
204
|
+
|
|
205
|
+
const cookie = getJwtCookie('token', 'invalid-url');
|
|
206
|
+
|
|
207
|
+
expect(cookie).toContain('Secure'); // Should use HTTPS from fallback URL
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('should set secure to false when WORKOS_REDIRECT_URI parsing fails', async () => {
|
|
211
|
+
process.env.NODE_ENV = 'development'; // Not production
|
|
212
|
+
|
|
213
|
+
const envVars = await import('./env-variables');
|
|
214
|
+
Object.defineProperty(envVars, 'WORKOS_REDIRECT_URI', { value: 'also-invalid-url' });
|
|
215
|
+
|
|
216
|
+
const { getJwtCookie } = await import('./cookie');
|
|
217
|
+
|
|
218
|
+
const cookie = getJwtCookie('token', null); // This triggers the WORKOS_REDIRECT_URI path
|
|
219
|
+
|
|
220
|
+
expect(cookie).not.toContain('Secure'); // Should be false when URL parsing fails (line 128)
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('should handle both main URL and fallback URL parsing failures', async () => {
|
|
224
|
+
const envVars = await import('./env-variables');
|
|
225
|
+
Object.defineProperty(envVars, 'WORKOS_REDIRECT_URI', { value: 'invalid-fallback-url' });
|
|
226
|
+
|
|
227
|
+
const { getJwtCookie } = await import('./cookie');
|
|
228
|
+
|
|
229
|
+
// Invalid main URL with invalid fallback URL - should hit line 118
|
|
230
|
+
const cookie = getJwtCookie('token', 'invalid-main-url');
|
|
231
|
+
|
|
232
|
+
expect(cookie).not.toContain('Secure'); // Line 118: secure = false when fallback parsing fails
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('should use WORKOS_REDIRECT_URI when no URL provided', async () => {
|
|
236
|
+
const envVars = await import('./env-variables');
|
|
237
|
+
Object.defineProperty(envVars, 'WORKOS_REDIRECT_URI', { value: 'https://secure.example.com' });
|
|
238
|
+
|
|
239
|
+
const { getJwtCookie } = await import('./cookie');
|
|
240
|
+
|
|
241
|
+
const cookie = getJwtCookie('token', null);
|
|
242
|
+
|
|
243
|
+
expect(cookie).toContain('Secure'); // Should use HTTPS from WORKOS_REDIRECT_URI
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('should create expired JWT cookie for deletion', async () => {
|
|
247
|
+
const { getJwtCookie } = await import('./cookie');
|
|
248
|
+
|
|
249
|
+
const cookie = getJwtCookie('token', 'https://example.com', true);
|
|
250
|
+
|
|
251
|
+
expect(cookie).toBe(
|
|
252
|
+
'workos-access-token=; SameSite=Lax; Max-Age=0; Secure; Expires=Thu, 01 Jan 1970 00:00:00 GMT',
|
|
253
|
+
);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('should handle null token body', async () => {
|
|
257
|
+
const { getJwtCookie } = await import('./cookie');
|
|
258
|
+
|
|
259
|
+
const cookie = getJwtCookie(null, 'https://example.com');
|
|
260
|
+
|
|
261
|
+
expect(cookie).toBe('workos-access-token=; SameSite=Lax; Max-Age=30; Secure');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('should handle localhost vs 127.0.0.1 in production', async () => {
|
|
265
|
+
process.env.NODE_ENV = 'production';
|
|
266
|
+
|
|
267
|
+
const { getJwtCookie } = await import('./cookie');
|
|
268
|
+
|
|
269
|
+
const localhostCookie = getJwtCookie('token', 'http://localhost:3000');
|
|
270
|
+
const ipCookie = getJwtCookie('token', 'http://127.0.0.1:3000');
|
|
271
|
+
|
|
272
|
+
expect(localhostCookie).not.toContain('Secure');
|
|
273
|
+
expect(ipCookie).not.toContain('Secure');
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
});
|
package/src/cookie.ts
CHANGED
|
@@ -8,6 +8,9 @@ import { CookieOptions } from './interfaces.js';
|
|
|
8
8
|
|
|
9
9
|
type ValidSameSite = CookieOptions['sameSite'];
|
|
10
10
|
|
|
11
|
+
const JWT_COOKIE_MAX_AGE = 30; // seconds
|
|
12
|
+
const JWT_COOKIE_NAME = 'workos-access-token';
|
|
13
|
+
|
|
11
14
|
function assertValidSamSite(sameSite: string): asserts sameSite is ValidSameSite {
|
|
12
15
|
if (!['lax', 'strict', 'none'].includes(sameSite.toLowerCase())) {
|
|
13
16
|
throw new Error(`Invalid SameSite value: ${sameSite}`);
|
|
@@ -88,3 +91,56 @@ export function getCookieOptions(
|
|
|
88
91
|
domain: WORKOS_COOKIE_DOMAIN || '',
|
|
89
92
|
};
|
|
90
93
|
}
|
|
94
|
+
|
|
95
|
+
export function getJwtCookie(body: string | null, requestUrlOrRedirectUri?: string | null, expired?: boolean): string {
|
|
96
|
+
const cookie = `${JWT_COOKIE_NAME}=${expired ? '' : (body ?? '')}`;
|
|
97
|
+
|
|
98
|
+
// Force Secure in production, except for localhost
|
|
99
|
+
let secure = false;
|
|
100
|
+
const isProduction = process.env.NODE_ENV === 'production';
|
|
101
|
+
|
|
102
|
+
if (requestUrlOrRedirectUri) {
|
|
103
|
+
try {
|
|
104
|
+
const url = new URL(requestUrlOrRedirectUri);
|
|
105
|
+
const isLocalhost = url.hostname === 'localhost' || url.hostname === '127.0.0.1';
|
|
106
|
+
// In production, always use Secure unless explicitly on localhost
|
|
107
|
+
secure = isProduction ? !isLocalhost : url.protocol === 'https:';
|
|
108
|
+
} catch {
|
|
109
|
+
// If URL parsing fails, default to secure in production
|
|
110
|
+
secure = isProduction;
|
|
111
|
+
// If it's not a valid URL, fall back to WORKOS_REDIRECT_URI
|
|
112
|
+
const fallbackUrl = WORKOS_REDIRECT_URI;
|
|
113
|
+
if (fallbackUrl) {
|
|
114
|
+
try {
|
|
115
|
+
const url = new URL(fallbackUrl);
|
|
116
|
+
secure = url.protocol === 'https:';
|
|
117
|
+
} catch {
|
|
118
|
+
secure = false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
} else if (WORKOS_REDIRECT_URI) {
|
|
123
|
+
// No URL provided, check WORKOS_REDIRECT_URI
|
|
124
|
+
try {
|
|
125
|
+
const url = new URL(WORKOS_REDIRECT_URI);
|
|
126
|
+
secure = url.protocol === 'https:';
|
|
127
|
+
} catch {
|
|
128
|
+
secure = false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const maxAge = expired ? 0 : JWT_COOKIE_MAX_AGE;
|
|
133
|
+
|
|
134
|
+
const parts = [cookie, 'SameSite=Lax', `Max-Age=${maxAge}`];
|
|
135
|
+
|
|
136
|
+
// Only add Secure flag if on HTTPS
|
|
137
|
+
if (secure) {
|
|
138
|
+
parts.push('Secure');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (expired) {
|
|
142
|
+
parts.push(`Expires=${new Date(0).toUTCString()}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return parts.join('; ');
|
|
146
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
|
2
|
+
import { getAuthorizationUrl } from './get-authorization-url.js';
|
|
3
|
+
import { headers } from 'next/headers';
|
|
4
|
+
import { getWorkOS } from './workos.js';
|
|
5
|
+
|
|
6
|
+
jest.mock('next/headers');
|
|
7
|
+
|
|
8
|
+
// Mock dependencies
|
|
9
|
+
const fakeWorkosInstance = {
|
|
10
|
+
userManagement: {
|
|
11
|
+
getAuthorizationUrl: jest.fn(),
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
jest.mock('./workos', () => ({
|
|
16
|
+
getWorkOS: jest.fn(() => fakeWorkosInstance),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
describe('getAuthorizationUrl', () => {
|
|
20
|
+
const workos = getWorkOS();
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
jest.clearAllMocks();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('uses x-redirect-uri header when redirectUri option is not provided', async () => {
|
|
26
|
+
const nextHeaders = await headers();
|
|
27
|
+
nextHeaders.set('x-redirect-uri', 'http://test-redirect.com');
|
|
28
|
+
|
|
29
|
+
// Mock workos.userManagement.getAuthorizationUrl
|
|
30
|
+
jest.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
|
|
31
|
+
|
|
32
|
+
await getAuthorizationUrl({});
|
|
33
|
+
|
|
34
|
+
expect(workos.userManagement.getAuthorizationUrl).toHaveBeenCalledWith(
|
|
35
|
+
expect.objectContaining({
|
|
36
|
+
redirectUri: 'http://test-redirect.com',
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('works when called with no arguments', async () => {
|
|
42
|
+
jest.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
|
|
43
|
+
|
|
44
|
+
await getAuthorizationUrl(); // Call with no arguments
|
|
45
|
+
|
|
46
|
+
expect(workos.userManagement.getAuthorizationUrl).toHaveBeenCalled();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('works when prompt is provided', async () => {
|
|
50
|
+
jest.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
|
|
51
|
+
|
|
52
|
+
await getAuthorizationUrl({ prompt: 'consent' });
|
|
53
|
+
|
|
54
|
+
expect(workos.userManagement.getAuthorizationUrl).toHaveBeenCalledWith(
|
|
55
|
+
expect.objectContaining({
|
|
56
|
+
prompt: 'consent',
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
});
|
package/src/interfaces.ts
CHANGED
|
@@ -76,9 +76,11 @@ export interface AuthkitMiddlewareOptions {
|
|
|
76
76
|
middlewareAuth?: AuthkitMiddlewareAuth;
|
|
77
77
|
redirectUri?: string;
|
|
78
78
|
signUpPaths?: string[];
|
|
79
|
+
eagerAuth?: boolean;
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
export interface AuthkitOptions {
|
|
83
|
+
eagerAuth?: boolean;
|
|
82
84
|
debug?: boolean;
|
|
83
85
|
redirectUri?: string;
|
|
84
86
|
screenHint?: 'sign-up' | 'sign-in';
|