@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,3 +1,4 @@
|
|
|
1
|
+
import type { Mock } from 'vitest';
|
|
1
2
|
import '@testing-library/jest-dom';
|
|
2
3
|
import { act, render, waitFor } from '@testing-library/react';
|
|
3
4
|
import React from 'react';
|
|
@@ -89,7 +90,7 @@ describe('useAccessToken', () => {
|
|
|
89
90
|
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2lkIjoic2Vzc2lvbl8xMjMiLCJleHAiOjk5OTk5OTk5OTl9.mock-signature';
|
|
90
91
|
|
|
91
92
|
(getAccessTokenAction as Mock).mockResolvedValueOnce(expiringToken);
|
|
92
|
-
(refreshAccessTokenAction as Mock).mockResolvedValueOnce(refreshedToken);
|
|
93
|
+
(refreshAccessTokenAction as Mock).mockResolvedValueOnce({ accessToken: refreshedToken });
|
|
93
94
|
|
|
94
95
|
const { getByTestId } = render(<TestComponent />);
|
|
95
96
|
|
|
@@ -112,7 +113,7 @@ describe('useAccessToken', () => {
|
|
|
112
113
|
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJyZWZyZXNoZWQiLCJzaWQiOiJzZXNzaW9uXzEyMyIsImV4cCI6OTk5OTk5OTk5OX0.mock-signature-2';
|
|
113
114
|
|
|
114
115
|
(getAccessTokenAction as Mock).mockResolvedValueOnce(initialToken);
|
|
115
|
-
(refreshAccessTokenAction as Mock).mockResolvedValueOnce(refreshedToken);
|
|
116
|
+
(refreshAccessTokenAction as Mock).mockResolvedValueOnce({ accessToken: refreshedToken });
|
|
116
117
|
|
|
117
118
|
const { getByTestId } = render(<TestComponent />);
|
|
118
119
|
|
|
@@ -170,10 +171,12 @@ describe('useAccessToken', () => {
|
|
|
170
171
|
it('should handle errors during manual refresh', async () => {
|
|
171
172
|
const initialToken =
|
|
172
173
|
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2lkIjoic2Vzc2lvbl8xMjMiLCJleHAiOjk5OTk5OTk5OTl9.mock-signature';
|
|
173
|
-
const error = new Error('Failed to refresh token');
|
|
174
174
|
|
|
175
175
|
(getAccessTokenAction as Mock).mockResolvedValueOnce(initialToken);
|
|
176
|
-
(refreshAccessTokenAction as Mock).
|
|
176
|
+
(refreshAccessTokenAction as Mock).mockResolvedValueOnce({
|
|
177
|
+
accessToken: undefined,
|
|
178
|
+
error: 'Failed to refresh token',
|
|
179
|
+
});
|
|
177
180
|
|
|
178
181
|
const { getByTestId } = render(<TestComponent />);
|
|
179
182
|
|
|
@@ -277,10 +280,12 @@ describe('useAccessToken', () => {
|
|
|
277
280
|
iat: currentTimeInSeconds - 35,
|
|
278
281
|
};
|
|
279
282
|
const expiringToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify(payload))}.mock-signature`;
|
|
280
|
-
const error = new Error('Failed to refresh token');
|
|
281
283
|
|
|
282
284
|
(getAccessTokenAction as Mock).mockResolvedValueOnce(expiringToken);
|
|
283
|
-
(refreshAccessTokenAction as Mock).
|
|
285
|
+
(refreshAccessTokenAction as Mock).mockResolvedValueOnce({
|
|
286
|
+
accessToken: undefined,
|
|
287
|
+
error: 'Failed to refresh token',
|
|
288
|
+
});
|
|
284
289
|
|
|
285
290
|
const { getByTestId } = render(<TestComponent />);
|
|
286
291
|
|
|
@@ -410,7 +415,7 @@ describe('useAccessToken', () => {
|
|
|
410
415
|
|
|
411
416
|
(refreshAccessTokenAction as Mock).mockImplementation(() => {
|
|
412
417
|
refreshCalls++;
|
|
413
|
-
return refreshPromise;
|
|
418
|
+
return refreshPromise.then((t) => ({ accessToken: t }));
|
|
414
419
|
});
|
|
415
420
|
|
|
416
421
|
(getAccessTokenAction as Mock).mockImplementation(() => {
|
|
@@ -507,7 +512,7 @@ describe('useAccessToken', () => {
|
|
|
507
512
|
resolveRefreshPromise = resolve;
|
|
508
513
|
});
|
|
509
514
|
|
|
510
|
-
(refreshAccessTokenAction as Mock).mockReturnValue(refreshPromise);
|
|
515
|
+
(refreshAccessTokenAction as Mock).mockReturnValue(refreshPromise.then((t) => ({ accessToken: t })));
|
|
511
516
|
(getAccessTokenAction as Mock).mockResolvedValue(initialToken);
|
|
512
517
|
|
|
513
518
|
const { getByTestId } = render(<TestComponent />);
|
|
@@ -582,10 +587,8 @@ describe('useAccessToken', () => {
|
|
|
582
587
|
const stringError = 'String error directly'; // Not wrapped in Error object
|
|
583
588
|
|
|
584
589
|
(getAccessTokenAction as Mock).mockResolvedValueOnce(initialToken);
|
|
585
|
-
// Mock refreshAccessTokenAction to
|
|
586
|
-
(refreshAccessTokenAction as Mock).
|
|
587
|
-
return Promise.reject(stringError); // Directly reject with string
|
|
588
|
-
});
|
|
590
|
+
// Mock refreshAccessTokenAction to return an error result (as server actions do)
|
|
591
|
+
(refreshAccessTokenAction as Mock).mockResolvedValueOnce({ accessToken: undefined, error: stringError });
|
|
589
592
|
|
|
590
593
|
const { getByTestId } = render(<TestComponent />);
|
|
591
594
|
|
package/src/cookie.ts
CHANGED
|
@@ -92,6 +92,35 @@ export function getCookieOptions(
|
|
|
92
92
|
};
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Cookie options for the PKCE verifier cookie.
|
|
97
|
+
* 'strict' blocks the cookie on the cross-site redirect back from WorkOS; downgrade to 'lax'.
|
|
98
|
+
* 'none' is more permissive and must be preserved for iframe/cross-origin embed flows.
|
|
99
|
+
*/
|
|
100
|
+
export function getPKCECookieOptions(): CookieOptions;
|
|
101
|
+
export function getPKCECookieOptions(redirectUri: string | null | undefined, asString: true, expired?: boolean): string;
|
|
102
|
+
export function getPKCECookieOptions(
|
|
103
|
+
redirectUri?: string | null,
|
|
104
|
+
asString?: boolean,
|
|
105
|
+
expired?: boolean,
|
|
106
|
+
): CookieOptions | string;
|
|
107
|
+
export function getPKCECookieOptions(
|
|
108
|
+
redirectUri?: string | null,
|
|
109
|
+
asString: boolean = false,
|
|
110
|
+
expired: boolean = false,
|
|
111
|
+
): CookieOptions | string {
|
|
112
|
+
if (asString) {
|
|
113
|
+
const options = getCookieOptions(redirectUri, true, expired);
|
|
114
|
+
return options.replace(/SameSite=Strict/i, 'SameSite=Lax');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const options = getCookieOptions(redirectUri);
|
|
118
|
+
return {
|
|
119
|
+
...options,
|
|
120
|
+
sameSite: options.sameSite.toLowerCase() === 'strict' ? 'lax' : options.sameSite,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
95
124
|
export function getJwtCookie(body: string | null, requestUrlOrRedirectUri?: string | null, expired?: boolean): string {
|
|
96
125
|
const cookie = `${JWT_COOKIE_NAME}=${expired ? '' : (body ?? '')}`;
|
|
97
126
|
|
package/src/env-variables.ts
CHANGED
|
@@ -13,7 +13,6 @@ const WORKOS_COOKIE_MAX_AGE = getEnvVariable('WORKOS_COOKIE_MAX_AGE');
|
|
|
13
13
|
const WORKOS_COOKIE_NAME = getEnvVariable('WORKOS_COOKIE_NAME');
|
|
14
14
|
const WORKOS_COOKIE_SAMESITE = getEnvVariable('WORKOS_COOKIE_SAMESITE') as 'lax' | 'strict' | 'none' | undefined;
|
|
15
15
|
const WORKOS_CLAIM_TOKEN = getEnvVariable('WORKOS_CLAIM_TOKEN');
|
|
16
|
-
const WORKOS_ENABLE_PKCE = getEnvVariable('WORKOS_ENABLE_PKCE');
|
|
17
16
|
|
|
18
17
|
// Required env variables
|
|
19
18
|
const WORKOS_API_KEY = getEnvVariable('WORKOS_API_KEY') ?? '';
|
|
@@ -34,5 +33,4 @@ export {
|
|
|
34
33
|
WORKOS_COOKIE_PASSWORD,
|
|
35
34
|
WORKOS_REDIRECT_URI,
|
|
36
35
|
WORKOS_COOKIE_SAMESITE,
|
|
37
|
-
WORKOS_ENABLE_PKCE,
|
|
38
36
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getAuthorizationUrl } from './get-authorization-url.js';
|
|
2
2
|
import { headers } from 'next/headers';
|
|
3
3
|
import { getWorkOS } from './workos.js';
|
|
4
|
+
import { getStateFromPKCECookieValue } from './pkce.js';
|
|
4
5
|
|
|
5
6
|
// Mock dependencies
|
|
6
7
|
const { fakeWorkosInstance } = vi.hoisted(() => ({
|
|
@@ -27,7 +28,6 @@ describe('getAuthorizationUrl', () => {
|
|
|
27
28
|
const workos = getWorkOS();
|
|
28
29
|
beforeEach(() => {
|
|
29
30
|
vi.clearAllMocks();
|
|
30
|
-
delete process.env.WORKOS_ENABLE_PKCE;
|
|
31
31
|
fakeWorkosInstance.pkce.generate.mockResolvedValue({
|
|
32
32
|
codeVerifier: 'test-code-verifier',
|
|
33
33
|
codeChallenge: 'test-code-challenge',
|
|
@@ -39,7 +39,6 @@ describe('getAuthorizationUrl', () => {
|
|
|
39
39
|
const nextHeaders = await headers();
|
|
40
40
|
nextHeaders.set('x-redirect-uri', 'http://test-redirect.com');
|
|
41
41
|
|
|
42
|
-
// Mock workos.userManagement.getAuthorizationUrl
|
|
43
42
|
vi.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
|
|
44
43
|
|
|
45
44
|
await getAuthorizationUrl({});
|
|
@@ -54,7 +53,7 @@ describe('getAuthorizationUrl', () => {
|
|
|
54
53
|
it('works when called with no arguments', async () => {
|
|
55
54
|
vi.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
|
|
56
55
|
|
|
57
|
-
await getAuthorizationUrl();
|
|
56
|
+
await getAuthorizationUrl();
|
|
58
57
|
|
|
59
58
|
expect(workos.userManagement.getAuthorizationUrl).toHaveBeenCalled();
|
|
60
59
|
});
|
|
@@ -160,9 +159,8 @@ describe('getAuthorizationUrl', () => {
|
|
|
160
159
|
warnSpy.mockRestore();
|
|
161
160
|
});
|
|
162
161
|
|
|
163
|
-
it('
|
|
162
|
+
it('includes PKCE and claim nonce together', async () => {
|
|
164
163
|
process.env.WORKOS_CLAIM_TOKEN = 'test-claim-token';
|
|
165
|
-
process.env.WORKOS_DISABLE_PKCE = 'true';
|
|
166
164
|
const fetchSpy = vi
|
|
167
165
|
.spyOn(globalThis, 'fetch')
|
|
168
166
|
.mockResolvedValueOnce(new Response(JSON.stringify({ nonce: 'test-nonce' }), { status: 201 }));
|
|
@@ -174,39 +172,23 @@ describe('getAuthorizationUrl', () => {
|
|
|
174
172
|
const result = await freshGetAuthorizationUrl({});
|
|
175
173
|
|
|
176
174
|
expect(workos.userManagement.getAuthorizationUrl).toHaveBeenCalledWith(
|
|
177
|
-
expect.objectContaining({
|
|
175
|
+
expect.objectContaining({
|
|
176
|
+
claimNonce: 'test-nonce',
|
|
177
|
+
codeChallenge: 'test-code-challenge',
|
|
178
|
+
codeChallengeMethod: 'S256',
|
|
179
|
+
}),
|
|
178
180
|
);
|
|
179
|
-
expect(result.
|
|
181
|
+
expect(result.sealedState).toBeDefined();
|
|
180
182
|
fetchSpy.mockRestore();
|
|
181
183
|
});
|
|
182
184
|
});
|
|
183
185
|
|
|
184
186
|
describe('PKCE', () => {
|
|
185
|
-
it('
|
|
187
|
+
it('always generates PKCE pair and includes code challenge', async () => {
|
|
186
188
|
vi.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
|
|
187
189
|
|
|
188
190
|
const result = await getAuthorizationUrl({});
|
|
189
191
|
|
|
190
|
-
expect(fakeWorkosInstance.pkce.generate).not.toHaveBeenCalled();
|
|
191
|
-
expect(workos.userManagement.getAuthorizationUrl).toHaveBeenCalledWith(
|
|
192
|
-
expect.not.objectContaining({
|
|
193
|
-
codeChallenge: expect.any(String),
|
|
194
|
-
}),
|
|
195
|
-
);
|
|
196
|
-
expect(result.pkceCookieValue).toBeUndefined();
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
it('generates PKCE pair when WORKOS_ENABLE_PKCE is set to true', async () => {
|
|
200
|
-
process.env.WORKOS_ENABLE_PKCE = 'true';
|
|
201
|
-
|
|
202
|
-
// Re-import to pick up the new env var
|
|
203
|
-
vi.resetModules();
|
|
204
|
-
const { getAuthorizationUrl: freshGetAuthorizationUrl } = await import('./get-authorization-url.js');
|
|
205
|
-
|
|
206
|
-
vi.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
|
|
207
|
-
|
|
208
|
-
const result = await freshGetAuthorizationUrl({});
|
|
209
|
-
|
|
210
192
|
expect(fakeWorkosInstance.pkce.generate).toHaveBeenCalled();
|
|
211
193
|
expect(workos.userManagement.getAuthorizationUrl).toHaveBeenCalledWith(
|
|
212
194
|
expect.objectContaining({
|
|
@@ -215,23 +197,19 @@ describe('getAuthorizationUrl', () => {
|
|
|
215
197
|
}),
|
|
216
198
|
);
|
|
217
199
|
expect(result.url).toBe('mock-url');
|
|
218
|
-
expect(result.
|
|
219
|
-
expect(result.
|
|
200
|
+
expect(result.sealedState).toBeDefined();
|
|
201
|
+
expect(result.sealedState).not.toBe('');
|
|
220
202
|
});
|
|
221
203
|
|
|
222
|
-
it('
|
|
223
|
-
process.env.WORKOS_ENABLE_PKCE = 'true';
|
|
224
|
-
|
|
225
|
-
vi.resetModules();
|
|
226
|
-
const { getAuthorizationUrl: freshGetAuthorizationUrl } = await import('./get-authorization-url.js');
|
|
227
|
-
|
|
204
|
+
it('seals codeVerifier and nonce into state', async () => {
|
|
228
205
|
vi.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
|
|
229
206
|
|
|
230
|
-
const result = await
|
|
207
|
+
const result = await getAuthorizationUrl({});
|
|
231
208
|
|
|
232
|
-
|
|
233
|
-
expect(
|
|
234
|
-
expect(
|
|
209
|
+
const { codeVerifier, nonce } = await getStateFromPKCECookieValue(result.sealedState);
|
|
210
|
+
expect(codeVerifier).toBe('test-code-verifier');
|
|
211
|
+
expect(nonce).toBeDefined();
|
|
212
|
+
expect(typeof nonce).toBe('string');
|
|
235
213
|
});
|
|
236
214
|
});
|
|
237
215
|
});
|
|
@@ -1,13 +1,7 @@
|
|
|
1
1
|
import { sealData } from 'iron-session';
|
|
2
2
|
import { headers } from 'next/headers';
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
WORKOS_CLIENT_ID,
|
|
6
|
-
WORKOS_COOKIE_PASSWORD,
|
|
7
|
-
WORKOS_ENABLE_PKCE,
|
|
8
|
-
WORKOS_REDIRECT_URI,
|
|
9
|
-
} from './env-variables.js';
|
|
10
|
-
import { GetAuthURLOptions, GetAuthURLResult } from './interfaces.js';
|
|
3
|
+
import { WORKOS_CLAIM_TOKEN, WORKOS_CLIENT_ID, WORKOS_COOKIE_PASSWORD, WORKOS_REDIRECT_URI } from './env-variables.js';
|
|
4
|
+
import { GetAuthURLOptions, GetAuthURLResult, State } from './interfaces.js';
|
|
11
5
|
import { getWorkOS } from './workos.js';
|
|
12
6
|
|
|
13
7
|
async function fetchClaimNonce(baseURL: string): Promise<string | null> {
|
|
@@ -39,51 +33,51 @@ async function fetchClaimNonce(baseURL: string): Promise<string | null> {
|
|
|
39
33
|
}
|
|
40
34
|
}
|
|
41
35
|
|
|
42
|
-
async function getAuthorizationUrl(
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
36
|
+
async function getAuthorizationUrl({
|
|
37
|
+
returnPathname,
|
|
38
|
+
screenHint,
|
|
39
|
+
organizationId,
|
|
40
|
+
loginHint,
|
|
41
|
+
prompt,
|
|
42
|
+
state: customState,
|
|
43
|
+
redirectUri,
|
|
44
|
+
}: GetAuthURLOptions = {}): Promise<GetAuthURLResult> {
|
|
45
|
+
const redirectUriToUse = await (async () => {
|
|
46
|
+
if (redirectUri) {
|
|
47
|
+
return redirectUri;
|
|
48
|
+
}
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
const headersList = await headers();
|
|
51
|
+
return headersList.get('x-redirect-uri') ?? undefined;
|
|
52
|
+
})();
|
|
53
53
|
|
|
54
|
+
const pkce = await getWorkOS().pkce.generate();
|
|
54
55
|
const claimNonce = WORKOS_CLAIM_TOKEN ? await fetchClaimNonce(getWorkOS().baseURL) : null;
|
|
55
56
|
|
|
56
|
-
const
|
|
57
|
-
|
|
57
|
+
const state = {
|
|
58
|
+
nonce: crypto.randomUUID(),
|
|
59
|
+
codeVerifier: pkce.codeVerifier,
|
|
60
|
+
customState,
|
|
61
|
+
returnPathname,
|
|
62
|
+
} satisfies State;
|
|
63
|
+
|
|
64
|
+
const sealedState = await sealData(state, { password: WORKOS_COOKIE_PASSWORD, ttl: 600 });
|
|
58
65
|
|
|
59
|
-
const
|
|
66
|
+
const url = getWorkOS().userManagement.getAuthorizationUrl({
|
|
60
67
|
provider: 'authkit' as const,
|
|
61
68
|
clientId: WORKOS_CLIENT_ID,
|
|
62
|
-
redirectUri:
|
|
63
|
-
state: finalState,
|
|
69
|
+
redirectUri: redirectUriToUse ?? WORKOS_REDIRECT_URI,
|
|
64
70
|
screenHint,
|
|
65
71
|
organizationId,
|
|
66
72
|
loginHint,
|
|
67
73
|
prompt,
|
|
74
|
+
state: sealedState,
|
|
75
|
+
codeChallenge: pkce.codeChallenge,
|
|
76
|
+
codeChallengeMethod: pkce.codeChallengeMethod,
|
|
68
77
|
...(claimNonce && { claimNonce }),
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
if (WORKOS_ENABLE_PKCE === 'true') {
|
|
72
|
-
const pkce = await getWorkOS().pkce.generate();
|
|
73
|
-
const pkceCookieValue = await sealData(
|
|
74
|
-
{ codeVerifier: pkce.codeVerifier },
|
|
75
|
-
{ password: WORKOS_COOKIE_PASSWORD, ttl: 600 },
|
|
76
|
-
);
|
|
77
|
-
const url = getWorkOS().userManagement.getAuthorizationUrl({
|
|
78
|
-
...baseOptions,
|
|
79
|
-
codeChallenge: pkce.codeChallenge,
|
|
80
|
-
codeChallengeMethod: pkce.codeChallengeMethod,
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
return { url, pkceCookieValue };
|
|
84
|
-
}
|
|
78
|
+
});
|
|
85
79
|
|
|
86
|
-
return { url
|
|
80
|
+
return { url, sealedState };
|
|
87
81
|
}
|
|
88
82
|
|
|
89
83
|
export { getAuthorizationUrl };
|
package/src/index.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { getSignInUrl, getSignUpUrl, signOut, switchToOrganization } from './auth.js';
|
|
2
2
|
import { handleAuth } from './authkit-callback-route.js';
|
|
3
3
|
import { AuthKitError, TokenRefreshError } from './errors.js';
|
|
4
|
-
import { authkit, authkitMiddleware } from './middleware.js';
|
|
4
|
+
import { authkit, authkitMiddleware, authkitProxy } from './middleware.js';
|
|
5
5
|
export {
|
|
6
6
|
applyResponseHeaders,
|
|
7
7
|
handleAuthkitHeaders,
|
|
8
|
+
handleAuthkitProxy,
|
|
8
9
|
partitionAuthkitHeaders,
|
|
9
10
|
isAuthkitRequestHeader,
|
|
10
11
|
AUTHKIT_REQUEST_HEADERS,
|
|
@@ -24,6 +25,7 @@ export {
|
|
|
24
25
|
TokenRefreshError,
|
|
25
26
|
authkit,
|
|
26
27
|
authkitMiddleware,
|
|
28
|
+
authkitProxy,
|
|
27
29
|
getSignInUrl,
|
|
28
30
|
getSignUpUrl,
|
|
29
31
|
getTokenClaims,
|
package/src/interfaces.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AuthenticationResponse, OauthTokens, User } from '@workos-inc/node';
|
|
2
2
|
import { type NextRequest } from 'next/server';
|
|
3
|
+
import * as v from 'valibot';
|
|
3
4
|
|
|
4
5
|
export interface HandleAuthOptions {
|
|
5
6
|
returnPathname?: string;
|
|
@@ -61,9 +62,18 @@ export interface AccessToken {
|
|
|
61
62
|
feature_flags?: string[];
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
export const StateSchema = v.object({
|
|
66
|
+
nonce: v.string(),
|
|
67
|
+
customState: v.optional(v.string()),
|
|
68
|
+
returnPathname: v.optional(v.string()),
|
|
69
|
+
codeVerifier: v.string(),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
export type State = v.InferOutput<typeof StateSchema>;
|
|
73
|
+
|
|
64
74
|
export interface GetAuthURLResult {
|
|
65
75
|
url: string;
|
|
66
|
-
|
|
76
|
+
sealedState: string;
|
|
67
77
|
}
|
|
68
78
|
|
|
69
79
|
export interface GetAuthURLOptions {
|
package/src/jwt.ts
CHANGED
|
@@ -2,16 +2,16 @@
|
|
|
2
2
|
* JWT (JSON Web Token) Interface Definitions
|
|
3
3
|
*/
|
|
4
4
|
export interface JWTHeader {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
5
|
+
alg: string;
|
|
6
|
+
typ?: string | undefined;
|
|
7
|
+
cty?: string | undefined;
|
|
8
|
+
crit?: Array<string | Exclude<keyof JWTHeader, 'crit'>> | undefined;
|
|
9
|
+
kid?: string | undefined;
|
|
10
|
+
jku?: string | undefined;
|
|
11
|
+
x5u?: string | string[] | undefined;
|
|
12
12
|
'x5t#S256'?: string | undefined;
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
x5t?: string | undefined;
|
|
14
|
+
x5c?: string | string[] | undefined;
|
|
15
15
|
}
|
|
16
16
|
/**
|
|
17
17
|
* JWT Payload Interface
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from 'next/server';
|
|
2
2
|
import {
|
|
3
3
|
handleAuthkitHeaders,
|
|
4
|
+
handleAuthkitProxy,
|
|
4
5
|
partitionAuthkitHeaders,
|
|
5
6
|
applyResponseHeaders,
|
|
6
7
|
isAuthkitRequestHeader,
|
|
@@ -228,4 +229,10 @@ describe('middleware-helpers', () => {
|
|
|
228
229
|
expect(response.headers.getSetCookie()).toHaveLength(2);
|
|
229
230
|
});
|
|
230
231
|
});
|
|
232
|
+
|
|
233
|
+
describe('handleAuthkitHeaders (deprecated alias)', () => {
|
|
234
|
+
it('should be the same function reference as handleAuthkitProxy', () => {
|
|
235
|
+
expect(handleAuthkitHeaders).toBe(handleAuthkitProxy);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
231
238
|
});
|
|
@@ -56,8 +56,9 @@ export function partitionAuthkitHeaders(request: NextRequest, authkitHeaders: He
|
|
|
56
56
|
const headers = new Headers(authkitHeaders);
|
|
57
57
|
const requestHeaders = new Headers(request.headers);
|
|
58
58
|
|
|
59
|
-
//
|
|
60
|
-
|
|
59
|
+
// Snapshot keys before iterating, since we delete headers during the loop
|
|
60
|
+
const requestHeaderKeys = Array.from(requestHeaders.keys());
|
|
61
|
+
for (const name of requestHeaderKeys) {
|
|
61
62
|
if (isAuthkitRequestHeader(name)) {
|
|
62
63
|
requestHeaders.delete(name);
|
|
63
64
|
}
|
|
@@ -106,7 +107,7 @@ export interface HandleAuthkitHeadersOptions {
|
|
|
106
107
|
/**
|
|
107
108
|
* Creates a NextResponse with properly merged AuthKit headers.
|
|
108
109
|
*/
|
|
109
|
-
export function
|
|
110
|
+
export function handleAuthkitProxy(
|
|
110
111
|
request: NextRequest,
|
|
111
112
|
authkitHeaders: Headers,
|
|
112
113
|
options: HandleAuthkitHeadersOptions = {},
|
|
@@ -128,3 +129,6 @@ export function handleAuthkitHeaders(
|
|
|
128
129
|
|
|
129
130
|
return applyResponseHeaders(NextResponse.next({ request: { headers: requestHeaders } }), responseHeaders);
|
|
130
131
|
}
|
|
132
|
+
|
|
133
|
+
/** @deprecated Use `handleAuthkitProxy` instead. */
|
|
134
|
+
export const handleAuthkitHeaders: typeof handleAuthkitProxy = handleAuthkitProxy;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { authkitMiddleware, authkitProxy } from './middleware.js';
|
|
2
|
+
|
|
3
|
+
describe('middleware', () => {
|
|
4
|
+
describe('authkitProxy', () => {
|
|
5
|
+
it('should return a middleware function when called with no options', () => {
|
|
6
|
+
const middleware = authkitProxy();
|
|
7
|
+
expect(typeof middleware).toBe('function');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('should accept the same options as authkitMiddleware', () => {
|
|
11
|
+
const middleware = authkitProxy({
|
|
12
|
+
debug: true,
|
|
13
|
+
middlewareAuth: { enabled: true, unauthenticatedPaths: ['/public'] },
|
|
14
|
+
signUpPaths: ['/sign-up'],
|
|
15
|
+
});
|
|
16
|
+
expect(typeof middleware).toBe('function');
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('authkitMiddleware (deprecated alias)', () => {
|
|
21
|
+
it('should be the same function reference as authkitProxy', () => {
|
|
22
|
+
expect(authkitMiddleware).toBe(authkitProxy);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
});
|
package/src/middleware.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { updateSessionMiddleware, updateSession } from './session.js';
|
|
|
3
3
|
import { AuthkitMiddlewareOptions, AuthkitOptions, AuthkitResponse } from './interfaces.js';
|
|
4
4
|
import { WORKOS_REDIRECT_URI } from './env-variables.js';
|
|
5
5
|
|
|
6
|
-
export function
|
|
6
|
+
export function authkitProxy({
|
|
7
7
|
debug = false,
|
|
8
8
|
middlewareAuth = { enabled: false, unauthenticatedPaths: [] },
|
|
9
9
|
redirectUri = WORKOS_REDIRECT_URI,
|
|
@@ -15,6 +15,9 @@ export function authkitMiddleware({
|
|
|
15
15
|
};
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/** @deprecated Use `authkitProxy` instead. */
|
|
19
|
+
export const authkitMiddleware: typeof authkitProxy = authkitProxy;
|
|
20
|
+
|
|
18
21
|
export async function authkit(request: NextRequest, options: AuthkitOptions = {}): Promise<AuthkitResponse> {
|
|
19
22
|
return await updateSession(request, options);
|
|
20
23
|
}
|
package/src/pkce.spec.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { sealData } from 'iron-session';
|
|
2
|
+
import { getStateFromPKCECookieValue } from './pkce.js';
|
|
3
|
+
|
|
4
|
+
const PASSWORD = process.env.WORKOS_COOKIE_PASSWORD!;
|
|
5
|
+
|
|
6
|
+
describe('setPKCECookie SameSite override', () => {
|
|
7
|
+
const mockSet = vi.fn();
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
vi.clearAllMocks();
|
|
11
|
+
vi.resetModules();
|
|
12
|
+
vi.doMock('next/headers', () => ({
|
|
13
|
+
cookies: async () => ({ set: mockSet, get: vi.fn(), getAll: vi.fn(), delete: vi.fn() }),
|
|
14
|
+
headers: async () => ({ get: vi.fn(), set: vi.fn(), delete: vi.fn() }),
|
|
15
|
+
}));
|
|
16
|
+
vi.doMock('./env-variables', async (importOriginal) => {
|
|
17
|
+
return { ...(await importOriginal<typeof import('./env-variables')>()) };
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('should downgrade strict to lax', async () => {
|
|
22
|
+
const envVars = await import('./env-variables');
|
|
23
|
+
Object.defineProperty(envVars, 'WORKOS_COOKIE_SAMESITE', { value: 'strict' });
|
|
24
|
+
|
|
25
|
+
const { setPKCECookie } = await import('./pkce');
|
|
26
|
+
await setPKCECookie('sealed-state');
|
|
27
|
+
|
|
28
|
+
expect(mockSet).toHaveBeenCalledWith(
|
|
29
|
+
'wos-auth-verifier',
|
|
30
|
+
'sealed-state',
|
|
31
|
+
expect.objectContaining({ sameSite: 'lax' }),
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should preserve none for iframe/cross-origin flows', async () => {
|
|
36
|
+
const envVars = await import('./env-variables');
|
|
37
|
+
Object.defineProperty(envVars, 'WORKOS_COOKIE_SAMESITE', { value: 'none' });
|
|
38
|
+
|
|
39
|
+
const { setPKCECookie } = await import('./pkce');
|
|
40
|
+
await setPKCECookie('sealed-state');
|
|
41
|
+
|
|
42
|
+
expect(mockSet).toHaveBeenCalledWith(
|
|
43
|
+
'wos-auth-verifier',
|
|
44
|
+
'sealed-state',
|
|
45
|
+
expect.objectContaining({ sameSite: 'none' }),
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should downgrade mixed-case Strict to lax', async () => {
|
|
50
|
+
const envVars = await import('./env-variables');
|
|
51
|
+
Object.defineProperty(envVars, 'WORKOS_COOKIE_SAMESITE', { value: 'Strict' });
|
|
52
|
+
|
|
53
|
+
const { setPKCECookie } = await import('./pkce');
|
|
54
|
+
await setPKCECookie('sealed-state');
|
|
55
|
+
|
|
56
|
+
expect(mockSet).toHaveBeenCalledWith(
|
|
57
|
+
'wos-auth-verifier',
|
|
58
|
+
'sealed-state',
|
|
59
|
+
expect.objectContaining({ sameSite: 'lax' }),
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should default to lax when no SameSite configured', async () => {
|
|
64
|
+
const { setPKCECookie } = await import('./pkce');
|
|
65
|
+
await setPKCECookie('sealed-state');
|
|
66
|
+
|
|
67
|
+
expect(mockSet).toHaveBeenCalledWith(
|
|
68
|
+
'wos-auth-verifier',
|
|
69
|
+
'sealed-state',
|
|
70
|
+
expect.objectContaining({ sameSite: 'lax' }),
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('getStateFromPKCECookieValue', () => {
|
|
76
|
+
it('should unseal and validate a valid state', async () => {
|
|
77
|
+
const sealed = await sealData(
|
|
78
|
+
{ nonce: 'test-nonce', codeVerifier: 'verifier-abc', returnPathname: '/dashboard', customState: 'custom' },
|
|
79
|
+
{ password: PASSWORD },
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const state = await getStateFromPKCECookieValue(sealed);
|
|
83
|
+
|
|
84
|
+
expect(state.nonce).toBe('test-nonce');
|
|
85
|
+
expect(state.returnPathname).toBe('/dashboard');
|
|
86
|
+
expect(state.customState).toBe('custom');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should unseal state with codeVerifier', async () => {
|
|
90
|
+
const sealed = await sealData({ nonce: 'test-nonce', codeVerifier: 'verifier-123' }, { password: PASSWORD });
|
|
91
|
+
|
|
92
|
+
const state = await getStateFromPKCECookieValue(sealed);
|
|
93
|
+
|
|
94
|
+
expect(state.nonce).toBe('test-nonce');
|
|
95
|
+
expect(state.codeVerifier).toBe('verifier-123');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should throw when codeVerifier is missing', async () => {
|
|
99
|
+
const sealed = await sealData({ nonce: 'test-nonce', returnPathname: '/dashboard' }, { password: PASSWORD });
|
|
100
|
+
|
|
101
|
+
await expect(getStateFromPKCECookieValue(sealed)).rejects.toThrow();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should throw when nonce is missing', async () => {
|
|
105
|
+
const sealed = await sealData(
|
|
106
|
+
{ codeVerifier: 'verifier-abc', returnPathname: '/dashboard' },
|
|
107
|
+
{ password: PASSWORD },
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
await expect(getStateFromPKCECookieValue(sealed)).rejects.toThrow();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should throw when sealed data is corrupted', async () => {
|
|
114
|
+
await expect(getStateFromPKCECookieValue('not-a-valid-sealed-value')).rejects.toThrow();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should throw when sealed with a different password', async () => {
|
|
118
|
+
const sealed = await sealData(
|
|
119
|
+
{ nonce: 'test-nonce', codeVerifier: 'verifier-abc' },
|
|
120
|
+
{ password: 'a-different-password-that-is-32-chars!' },
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
await expect(getStateFromPKCECookieValue(sealed)).rejects.toThrow();
|
|
124
|
+
});
|
|
125
|
+
});
|