@workos-inc/authkit-nextjs 3.0.0-beta.1 → 3.0.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.
Files changed (106) hide show
  1. package/README.md +305 -102
  2. package/dist/esm/actions.js +35 -5
  3. package/dist/esm/actions.js.map +1 -1
  4. package/dist/esm/auth.js +71 -21
  5. package/dist/esm/auth.js.map +1 -1
  6. package/dist/esm/authkit-callback-route.js +90 -92
  7. package/dist/esm/authkit-callback-route.js.map +1 -1
  8. package/dist/esm/components/authkit-provider.js +36 -15
  9. package/dist/esm/components/authkit-provider.js.map +1 -1
  10. package/dist/esm/components/impersonation.js +17 -15
  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 +20 -5
  21. package/dist/esm/cookie.js.map +1 -1
  22. package/dist/esm/env-variables.js +6 -6
  23. package/dist/esm/env-variables.js.map +1 -1
  24. package/dist/esm/errors.js +36 -0
  25. package/dist/esm/errors.js.map +1 -0
  26. package/dist/esm/get-authorization-url.js +51 -12
  27. package/dist/esm/get-authorization-url.js.map +1 -1
  28. package/dist/esm/index.js +5 -2
  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 +102 -0
  33. package/dist/esm/middleware-helpers.js.map +1 -0
  34. package/dist/esm/middleware.js +3 -1
  35. package/dist/esm/middleware.js.map +1 -1
  36. package/dist/esm/pkce.js +52 -0
  37. package/dist/esm/pkce.js.map +1 -0
  38. package/dist/esm/session.js +82 -35
  39. package/dist/esm/session.js.map +1 -1
  40. package/dist/esm/test-helpers.js +1 -1
  41. package/dist/esm/test-helpers.js.map +1 -1
  42. package/dist/esm/types/actions.d.ts +34 -5
  43. package/dist/esm/types/auth.d.ts +7 -15
  44. package/dist/esm/types/components/authkit-provider.d.ts +6 -2
  45. package/dist/esm/types/components/impersonation.d.ts +2 -1
  46. package/dist/esm/types/cookie.d.ts +9 -0
  47. package/dist/esm/types/env-variables.d.ts +2 -1
  48. package/dist/esm/types/errors.d.ts +15 -0
  49. package/dist/esm/types/get-authorization-url.d.ts +2 -2
  50. package/dist/esm/types/index.d.ts +5 -2
  51. package/dist/esm/types/interfaces.d.ts +12 -0
  52. package/dist/esm/types/jwt.d.ts +9 -9
  53. package/dist/esm/types/middleware-helpers.d.ts +27 -0
  54. package/dist/esm/types/middleware.d.ts +3 -1
  55. package/dist/esm/types/pkce.d.ts +17 -0
  56. package/dist/esm/types/session.d.ts +1 -1
  57. package/dist/esm/types/utils.d.ts +5 -0
  58. package/dist/esm/types/validate-api-key.d.ts +1 -0
  59. package/dist/esm/types/workos.d.ts +1 -1
  60. package/dist/esm/utils.js +10 -2
  61. package/dist/esm/utils.js.map +1 -1
  62. package/dist/esm/validate-api-key.js +16 -0
  63. package/dist/esm/validate-api-key.js.map +1 -0
  64. package/dist/esm/workos.js +1 -1
  65. package/package.json +33 -34
  66. package/src/actions.spec.ts +91 -18
  67. package/src/actions.ts +44 -6
  68. package/src/auth.spec.ts +79 -29
  69. package/src/auth.ts +74 -42
  70. package/src/authkit-callback-route.spec.ts +372 -58
  71. package/src/authkit-callback-route.ts +121 -103
  72. package/src/components/authkit-provider.spec.tsx +264 -70
  73. package/src/components/authkit-provider.tsx +40 -15
  74. package/src/components/button.spec.tsx +4 -6
  75. package/src/components/impersonation.spec.tsx +152 -35
  76. package/src/components/impersonation.tsx +37 -30
  77. package/src/components/min-max-button.spec.tsx +2 -1
  78. package/src/components/tokenStore.spec.ts +59 -44
  79. package/src/components/tokenStore.ts +11 -3
  80. package/src/components/useAccessToken.spec.tsx +82 -83
  81. package/src/components/useTokenClaims.spec.tsx +23 -22
  82. package/src/cookie.spec.ts +63 -9
  83. package/src/cookie.ts +35 -0
  84. package/src/env-variables.ts +2 -0
  85. package/src/errors.spec.ts +108 -0
  86. package/src/errors.ts +46 -0
  87. package/src/get-authorization-url.spec.ts +170 -15
  88. package/src/get-authorization-url.ts +69 -23
  89. package/src/index.ts +20 -2
  90. package/src/interfaces.ts +15 -0
  91. package/src/jwt.ts +9 -9
  92. package/src/middleware-helpers.spec.ts +238 -0
  93. package/src/middleware-helpers.ts +134 -0
  94. package/src/middleware.spec.ts +25 -0
  95. package/src/middleware.ts +4 -1
  96. package/src/pkce.spec.ts +146 -0
  97. package/src/pkce.ts +59 -0
  98. package/src/session.spec.ts +87 -89
  99. package/src/session.ts +104 -27
  100. package/src/test-helpers.ts +1 -1
  101. package/src/utils.spec.ts +14 -31
  102. package/src/utils.ts +9 -0
  103. package/src/validate-api-key.spec.ts +111 -0
  104. package/src/validate-api-key.ts +19 -0
  105. package/src/workos.spec.ts +2 -2
  106. package/src/workos.ts +1 -1
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,6 +62,20 @@ 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
+
74
+ export interface GetAuthURLResult {
75
+ url: string;
76
+ sealedState: string;
77
+ }
78
+
64
79
  export interface GetAuthURLOptions {
65
80
  screenHint?: 'sign-up' | 'sign-in';
66
81
  returnPathname?: string;
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
- '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;
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
- 'x5t'?: string | undefined;
14
- 'x5c'?: string | string[] | undefined;
13
+ x5t?: string | undefined;
14
+ x5c?: string | string[] | undefined;
15
15
  }
16
16
  /**
17
17
  * JWT Payload Interface
@@ -0,0 +1,238 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import {
3
+ handleAuthkitHeaders,
4
+ handleAuthkitProxy,
5
+ partitionAuthkitHeaders,
6
+ applyResponseHeaders,
7
+ isAuthkitRequestHeader,
8
+ AUTHKIT_REQUEST_HEADERS,
9
+ } from './middleware-helpers.js';
10
+
11
+ describe('middleware-helpers', () => {
12
+ function createMockRequest(url = 'https://example.com/test', method = 'GET'): NextRequest {
13
+ return new NextRequest(url, { method });
14
+ }
15
+
16
+ function createAuthkitHeaders(): Headers {
17
+ const headers = new Headers();
18
+ headers.set('x-workos-middleware', 'true');
19
+ headers.set('x-workos-session', 'encrypted-session-data');
20
+ headers.set('x-url', 'https://example.com/test');
21
+ headers.set('set-cookie', 'wos-session=abc123; Path=/; HttpOnly');
22
+ headers.set('cache-control', 'private, no-cache');
23
+ headers.set('vary', 'Cookie');
24
+ return headers;
25
+ }
26
+
27
+ describe('isAuthkitRequestHeader', () => {
28
+ it('should recognize known headers and x-workos-* pattern', () => {
29
+ expect(isAuthkitRequestHeader('x-workos-middleware')).toBe(true);
30
+ expect(isAuthkitRequestHeader('x-workos-session')).toBe(true);
31
+ expect(isAuthkitRequestHeader('x-url')).toBe(true);
32
+ expect(isAuthkitRequestHeader('x-workos-future-header')).toBe(true);
33
+ // Case insensitive
34
+ expect(isAuthkitRequestHeader('X-WorkOS-Session')).toBe(true);
35
+ });
36
+
37
+ it('should reject non-authkit headers', () => {
38
+ expect(isAuthkitRequestHeader('set-cookie')).toBe(false);
39
+ expect(isAuthkitRequestHeader('content-type')).toBe(false);
40
+ expect(isAuthkitRequestHeader('x-custom-header')).toBe(false);
41
+ });
42
+ });
43
+
44
+ describe('partitionAuthkitHeaders', () => {
45
+ it('should split headers into request-only and response headers', () => {
46
+ const request = createMockRequest();
47
+ const authkitHeaders = createAuthkitHeaders();
48
+
49
+ const { requestHeaders, responseHeaders } = partitionAuthkitHeaders(request, authkitHeaders);
50
+
51
+ // Request headers contain internal authkit headers
52
+ expect(requestHeaders.get('x-workos-session')).toBe('encrypted-session-data');
53
+ expect(requestHeaders.get('x-workos-middleware')).toBe('true');
54
+
55
+ // Response headers contain browser-safe headers only
56
+ expect(responseHeaders.get('set-cookie')).toBe('wos-session=abc123; Path=/; HttpOnly');
57
+ expect(responseHeaders.get('cache-control')).toBe('private, no-cache');
58
+ expect(responseHeaders.get('vary')).toBe('Cookie');
59
+
60
+ // Internal headers NOT in response
61
+ expect(responseHeaders.get('x-workos-session')).toBeNull();
62
+ expect(responseHeaders.get('x-workos-middleware')).toBeNull();
63
+ });
64
+
65
+ it('should preserve original request headers while adding authkit headers', () => {
66
+ const request = createMockRequest();
67
+ request.headers.set('authorization', 'Bearer token');
68
+ request.headers.set('x-custom', 'value');
69
+
70
+ const { requestHeaders } = partitionAuthkitHeaders(request, createAuthkitHeaders());
71
+
72
+ expect(requestHeaders.get('authorization')).toBe('Bearer token');
73
+ expect(requestHeaders.get('x-custom')).toBe('value');
74
+ expect(requestHeaders.get('x-workos-session')).toBe('encrypted-session-data');
75
+ });
76
+
77
+ it('should filter response headers to allowlist only', () => {
78
+ const request = createMockRequest();
79
+ const authkitHeaders = new Headers();
80
+ authkitHeaders.set('set-cookie', 'session=abc');
81
+ authkitHeaders.set('x-dangerous-header', 'leaked');
82
+ authkitHeaders.set('location', 'https://evil.com');
83
+
84
+ const { responseHeaders } = partitionAuthkitHeaders(request, authkitHeaders);
85
+
86
+ expect(responseHeaders.get('set-cookie')).toBe('session=abc');
87
+ expect(responseHeaders.get('x-dangerous-header')).toBeNull();
88
+ expect(responseHeaders.get('location')).toBeNull();
89
+ });
90
+
91
+ it('should handle multiple Set-Cookie headers correctly', () => {
92
+ const request = createMockRequest();
93
+ const authkitHeaders = new Headers();
94
+ authkitHeaders.append('set-cookie', 'cookie1=value1');
95
+ authkitHeaders.append('set-cookie', 'cookie2=value2');
96
+
97
+ const { responseHeaders } = partitionAuthkitHeaders(request, authkitHeaders);
98
+
99
+ expect(responseHeaders.getSetCookie()).toHaveLength(2);
100
+ });
101
+
102
+ it('should auto-add cache-control: no-store when cookies present without cache-control', () => {
103
+ const request = createMockRequest();
104
+ const authkitHeaders = new Headers();
105
+ authkitHeaders.set('set-cookie', 'session=abc');
106
+
107
+ const { responseHeaders } = partitionAuthkitHeaders(request, authkitHeaders);
108
+
109
+ expect(responseHeaders.get('cache-control')).toBe('no-store');
110
+ });
111
+
112
+ it('should deduplicate and merge Vary header values', () => {
113
+ const request = createMockRequest();
114
+ const authkitHeaders = new Headers();
115
+ authkitHeaders.append('vary', 'Cookie');
116
+ authkitHeaders.append('vary', 'Cookie, Accept');
117
+
118
+ const { responseHeaders } = partitionAuthkitHeaders(request, authkitHeaders);
119
+
120
+ expect(responseHeaders.get('vary')).toBe('Cookie, Accept');
121
+ });
122
+
123
+ it('should forward x-middleware-cache header', () => {
124
+ const request = createMockRequest();
125
+ const authkitHeaders = new Headers();
126
+ authkitHeaders.set('x-middleware-cache', 'no-cache');
127
+
128
+ const { responseHeaders } = partitionAuthkitHeaders(request, authkitHeaders);
129
+
130
+ expect(responseHeaders.get('x-middleware-cache')).toBe('no-cache');
131
+ });
132
+
133
+ it('should strip client-injected x-workos-* headers and use trusted values', () => {
134
+ const request = createMockRequest();
135
+ request.headers.set('x-workos-session', 'malicious-session');
136
+ request.headers.set('x-workos-admin-bypass', 'true');
137
+
138
+ const authkitHeaders = new Headers();
139
+ authkitHeaders.set('x-workos-session', 'real-session');
140
+
141
+ const { requestHeaders } = partitionAuthkitHeaders(request, authkitHeaders);
142
+
143
+ expect(requestHeaders.get('x-workos-session')).toBe('real-session');
144
+ expect(requestHeaders.get('x-workos-admin-bypass')).toBeNull();
145
+ });
146
+ });
147
+
148
+ describe('handleAuthkitHeaders', () => {
149
+ it('should return NextResponse with response headers applied', () => {
150
+ const request = createMockRequest();
151
+ const response = handleAuthkitHeaders(request, createAuthkitHeaders());
152
+
153
+ expect(response).toBeInstanceOf(NextResponse);
154
+ expect(response.status).toBe(200);
155
+ expect(response.headers.get('set-cookie')).toBe('wos-session=abc123; Path=/; HttpOnly');
156
+ expect(response.headers.get('vary')).toBe('Cookie');
157
+
158
+ // Internal headers NOT leaked
159
+ for (const header of AUTHKIT_REQUEST_HEADERS) {
160
+ expect(response.headers.get(header)).toBeNull();
161
+ }
162
+ });
163
+
164
+ it('should redirect with normalized absolute URL', () => {
165
+ const request = createMockRequest('https://example.com/page');
166
+ const response = handleAuthkitHeaders(request, createAuthkitHeaders(), {
167
+ redirect: '/login',
168
+ });
169
+
170
+ expect(response.status).toBe(307);
171
+ expect(response.headers.get('location')).toBe('https://example.com/login');
172
+ expect(response.headers.get('set-cookie')).toBe('wos-session=abc123; Path=/; HttpOnly');
173
+ });
174
+
175
+ it('should use 307 for GET and 303 for POST redirects by default', () => {
176
+ const getRequest = createMockRequest('https://example.com/test', 'GET');
177
+ const postRequest = createMockRequest('https://example.com/test', 'POST');
178
+ const headers = createAuthkitHeaders();
179
+
180
+ const getResponse = handleAuthkitHeaders(getRequest, headers, { redirect: '/login' });
181
+ const postResponse = handleAuthkitHeaders(postRequest, headers, { redirect: '/login' });
182
+
183
+ expect(getResponse.status).toBe(307);
184
+ expect(postResponse.status).toBe(303);
185
+ });
186
+
187
+ it('should allow overriding redirect status', () => {
188
+ const request = createMockRequest();
189
+ const response = handleAuthkitHeaders(request, createAuthkitHeaders(), {
190
+ redirect: '/login',
191
+ redirectStatus: 302,
192
+ });
193
+
194
+ expect(response.status).toBe(302);
195
+ });
196
+
197
+ it('should throw clear error on invalid redirect URL', () => {
198
+ const request = createMockRequest();
199
+
200
+ expect(() =>
201
+ handleAuthkitHeaders(request, createAuthkitHeaders(), {
202
+ redirect: 'http://[invalid',
203
+ }),
204
+ ).toThrow('Invalid redirect URL: "http://[invalid". Must be a valid absolute or relative URL.');
205
+ });
206
+
207
+ it('should treat empty/undefined redirect as no redirect', () => {
208
+ const request = createMockRequest();
209
+ const headers = createAuthkitHeaders();
210
+
211
+ expect(handleAuthkitHeaders(request, headers, { redirect: '' }).status).toBe(200);
212
+ expect(handleAuthkitHeaders(request, headers, { redirect: undefined }).status).toBe(200);
213
+ });
214
+ });
215
+
216
+ describe('applyResponseHeaders', () => {
217
+ it('should merge headers onto existing response', () => {
218
+ const response = NextResponse.next();
219
+ response.headers.set('vary', 'Accept');
220
+ response.headers.set('set-cookie', 'existing=value');
221
+
222
+ const headers = new Headers();
223
+ headers.set('vary', 'Cookie');
224
+ headers.set('set-cookie', 'new=value');
225
+
226
+ applyResponseHeaders(response, headers);
227
+
228
+ expect(response.headers.get('vary')).toBe('Accept, Cookie');
229
+ expect(response.headers.getSetCookie()).toHaveLength(2);
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
+ });
238
+ });
@@ -0,0 +1,134 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+
3
+ /** Internal AuthKit headers - forwarded to downstream requests but never sent to browser. */
4
+ export const AUTHKIT_REQUEST_HEADERS = [
5
+ 'x-workos-middleware',
6
+ 'x-url',
7
+ 'x-redirect-uri',
8
+ 'x-sign-up-paths',
9
+ 'x-workos-session',
10
+ ] as const;
11
+
12
+ export type AuthkitRequestHeader = (typeof AUTHKIT_REQUEST_HEADERS)[number];
13
+
14
+ const ALLOWED_RESPONSE_HEADERS: readonly string[] = [
15
+ 'set-cookie',
16
+ 'cache-control',
17
+ 'vary',
18
+ 'www-authenticate',
19
+ 'proxy-authenticate',
20
+ 'link',
21
+ 'x-middleware-cache',
22
+ ];
23
+
24
+ const MULTI_VALUE_HEADERS: readonly string[] = ['set-cookie', 'www-authenticate', 'proxy-authenticate', 'link'];
25
+
26
+ export function isAuthkitRequestHeader(name: string): boolean {
27
+ const lower = name.toLowerCase();
28
+ return (AUTHKIT_REQUEST_HEADERS as readonly string[]).includes(lower) || lower.startsWith('x-workos-');
29
+ }
30
+
31
+ function setHeader(headers: Headers, name: string, value: string): void {
32
+ const lower = name.toLowerCase();
33
+ if (MULTI_VALUE_HEADERS.includes(lower)) {
34
+ headers.append(name, value);
35
+ } else if (lower === 'vary') {
36
+ const existing = headers.get(name);
37
+ const merged = new Set([
38
+ ...(existing ? existing.split(',').map((v) => v.trim()) : []),
39
+ ...value.split(',').map((v) => v.trim()),
40
+ ]);
41
+ headers.set(name, [...merged].join(', '));
42
+ } else {
43
+ headers.set(name, value);
44
+ }
45
+ }
46
+
47
+ export interface AuthkitHeadersResult {
48
+ requestHeaders: Headers;
49
+ responseHeaders: Headers;
50
+ }
51
+
52
+ /**
53
+ * Partitions AuthKit headers into request headers (for withAuth) and response headers (for browser).
54
+ */
55
+ export function partitionAuthkitHeaders(request: NextRequest, authkitHeaders: Headers): AuthkitHeadersResult {
56
+ const headers = new Headers(authkitHeaders);
57
+ const requestHeaders = new Headers(request.headers);
58
+
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) {
62
+ if (isAuthkitRequestHeader(name)) {
63
+ requestHeaders.delete(name);
64
+ }
65
+ }
66
+ for (const headerName of AUTHKIT_REQUEST_HEADERS) {
67
+ const value = headers.get(headerName);
68
+ if (value != null) {
69
+ requestHeaders.set(headerName, value);
70
+ }
71
+ }
72
+
73
+ // Build response headers from allowlist only
74
+ const responseHeaders = new Headers();
75
+ for (const [name, value] of headers) {
76
+ const lower = name.toLowerCase();
77
+ if (!isAuthkitRequestHeader(lower) && ALLOWED_RESPONSE_HEADERS.includes(lower)) {
78
+ setHeader(responseHeaders, name, value);
79
+ }
80
+ }
81
+
82
+ // Auto-add cache-control when setting cookies
83
+ if (responseHeaders.has('set-cookie') && !responseHeaders.has('cache-control')) {
84
+ responseHeaders.set('cache-control', 'no-store');
85
+ }
86
+
87
+ return { requestHeaders, responseHeaders };
88
+ }
89
+
90
+ export function applyResponseHeaders(response: NextResponse, responseHeaders: Headers): NextResponse {
91
+ for (const [name, value] of responseHeaders) {
92
+ setHeader(response.headers, name, value);
93
+ }
94
+ return response;
95
+ }
96
+
97
+ export type AuthkitRedirectStatus = 302 | 303 | 307 | 308;
98
+
99
+ export interface HandleAuthkitHeadersOptions {
100
+ /** URL to redirect to (relative or absolute). */
101
+ redirect?: string | URL;
102
+
103
+ /** Redirect status code. @default 307 for GET/HEAD, 303 for POST/PUT/DELETE */
104
+ redirectStatus?: AuthkitRedirectStatus;
105
+ }
106
+
107
+ /**
108
+ * Creates a NextResponse with properly merged AuthKit headers.
109
+ */
110
+ export function handleAuthkitProxy(
111
+ request: NextRequest,
112
+ authkitHeaders: Headers,
113
+ options: HandleAuthkitHeadersOptions = {},
114
+ ): NextResponse {
115
+ const { requestHeaders, responseHeaders } = partitionAuthkitHeaders(request, authkitHeaders);
116
+ const { redirect, redirectStatus } = options;
117
+
118
+ if (redirect != null && redirect !== '') {
119
+ let redirectUrl: URL;
120
+ try {
121
+ redirectUrl = redirect instanceof URL ? redirect : new URL(redirect, request.url);
122
+ } catch {
123
+ throw new Error(`Invalid redirect URL: "${redirect}". Must be a valid absolute or relative URL.`);
124
+ }
125
+ const method = request.method.toUpperCase();
126
+ const status = redirectStatus ?? (method === 'GET' || method === 'HEAD' ? 307 : 303);
127
+ return applyResponseHeaders(NextResponse.redirect(redirectUrl, status), responseHeaders);
128
+ }
129
+
130
+ return applyResponseHeaders(NextResponse.next({ request: { headers: requestHeaders } }), responseHeaders);
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 authkitMiddleware({
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
  }
@@ -0,0 +1,146 @@
1
+ import { sealData } from 'iron-session';
2
+ import { getStateFromPKCECookieValue, getPKCECookieNameForState } from './pkce.js';
3
+
4
+ const PASSWORD = process.env.WORKOS_COOKIE_PASSWORD!;
5
+
6
+ describe('getPKCECookieNameForState', () => {
7
+ it('should derive a cookie name prefixed with the base name', () => {
8
+ const state = 'any-string-at-all';
9
+
10
+ expect(getPKCECookieNameForState(state)).toMatch(/^wos-auth-verifier-[0-9a-f]{8}$/);
11
+ });
12
+
13
+ it('should produce different names for different states', () => {
14
+ const stateA = 'first-sealed-state-value';
15
+ const stateB = 'second-sealed-state-value';
16
+
17
+ expect(getPKCECookieNameForState(stateA)).not.toBe(getPKCECookieNameForState(stateB));
18
+ });
19
+
20
+ it('should be deterministic for the same input', () => {
21
+ const state = 'some-sealed-state';
22
+
23
+ expect(getPKCECookieNameForState(state)).toBe(getPKCECookieNameForState(state));
24
+ });
25
+ });
26
+
27
+ describe('setPKCECookie SameSite override', () => {
28
+ const mockSet = vi.fn();
29
+
30
+ beforeEach(() => {
31
+ vi.clearAllMocks();
32
+ vi.resetModules();
33
+ vi.doMock('next/headers', () => ({
34
+ cookies: async () => ({ set: mockSet, get: vi.fn(), getAll: vi.fn(), delete: vi.fn() }),
35
+ headers: async () => ({ get: vi.fn(), set: vi.fn(), delete: vi.fn() }),
36
+ }));
37
+ vi.doMock('./env-variables', async (importOriginal) => {
38
+ return { ...(await importOriginal<typeof import('./env-variables')>()) };
39
+ });
40
+ });
41
+
42
+ it('should downgrade strict to lax', async () => {
43
+ const envVars = await import('./env-variables');
44
+ Object.defineProperty(envVars, 'WORKOS_COOKIE_SAMESITE', { value: 'strict' });
45
+
46
+ const { setPKCECookie } = await import('./pkce');
47
+ await setPKCECookie('sealed-state');
48
+
49
+ expect(mockSet).toHaveBeenCalledWith(
50
+ getPKCECookieNameForState('sealed-state'),
51
+ 'sealed-state',
52
+ expect.objectContaining({ sameSite: 'lax' }),
53
+ );
54
+ });
55
+
56
+ it('should preserve none for iframe/cross-origin flows', async () => {
57
+ const envVars = await import('./env-variables');
58
+ Object.defineProperty(envVars, 'WORKOS_COOKIE_SAMESITE', { value: 'none' });
59
+
60
+ const { setPKCECookie } = await import('./pkce');
61
+ await setPKCECookie('sealed-state');
62
+
63
+ expect(mockSet).toHaveBeenCalledWith(
64
+ getPKCECookieNameForState('sealed-state'),
65
+ 'sealed-state',
66
+ expect.objectContaining({ sameSite: 'none' }),
67
+ );
68
+ });
69
+
70
+ it('should downgrade mixed-case Strict to lax', async () => {
71
+ const envVars = await import('./env-variables');
72
+ Object.defineProperty(envVars, 'WORKOS_COOKIE_SAMESITE', { value: 'Strict' });
73
+
74
+ const { setPKCECookie } = await import('./pkce');
75
+ await setPKCECookie('sealed-state');
76
+
77
+ expect(mockSet).toHaveBeenCalledWith(
78
+ getPKCECookieNameForState('sealed-state'),
79
+ 'sealed-state',
80
+ expect.objectContaining({ sameSite: 'lax' }),
81
+ );
82
+ });
83
+
84
+ it('should default to lax when no SameSite configured', async () => {
85
+ const { setPKCECookie } = await import('./pkce');
86
+ await setPKCECookie('sealed-state');
87
+
88
+ expect(mockSet).toHaveBeenCalledWith(
89
+ getPKCECookieNameForState('sealed-state'),
90
+ 'sealed-state',
91
+ expect.objectContaining({ sameSite: 'lax' }),
92
+ );
93
+ });
94
+ });
95
+
96
+ describe('getStateFromPKCECookieValue', () => {
97
+ it('should unseal and validate a valid state', async () => {
98
+ const sealed = await sealData(
99
+ { nonce: 'test-nonce', codeVerifier: 'verifier-abc', returnPathname: '/dashboard', customState: 'custom' },
100
+ { password: PASSWORD },
101
+ );
102
+
103
+ const state = await getStateFromPKCECookieValue(sealed);
104
+
105
+ expect(state.nonce).toBe('test-nonce');
106
+ expect(state.returnPathname).toBe('/dashboard');
107
+ expect(state.customState).toBe('custom');
108
+ });
109
+
110
+ it('should unseal state with codeVerifier', async () => {
111
+ const sealed = await sealData({ nonce: 'test-nonce', codeVerifier: 'verifier-123' }, { password: PASSWORD });
112
+
113
+ const state = await getStateFromPKCECookieValue(sealed);
114
+
115
+ expect(state.nonce).toBe('test-nonce');
116
+ expect(state.codeVerifier).toBe('verifier-123');
117
+ });
118
+
119
+ it('should throw when codeVerifier is missing', async () => {
120
+ const sealed = await sealData({ nonce: 'test-nonce', returnPathname: '/dashboard' }, { password: PASSWORD });
121
+
122
+ await expect(getStateFromPKCECookieValue(sealed)).rejects.toThrow();
123
+ });
124
+
125
+ it('should throw when nonce is missing', async () => {
126
+ const sealed = await sealData(
127
+ { codeVerifier: 'verifier-abc', returnPathname: '/dashboard' },
128
+ { password: PASSWORD },
129
+ );
130
+
131
+ await expect(getStateFromPKCECookieValue(sealed)).rejects.toThrow();
132
+ });
133
+
134
+ it('should throw when sealed data is corrupted', async () => {
135
+ await expect(getStateFromPKCECookieValue('not-a-valid-sealed-value')).rejects.toThrow();
136
+ });
137
+
138
+ it('should throw when sealed with a different password', async () => {
139
+ const sealed = await sealData(
140
+ { nonce: 'test-nonce', codeVerifier: 'verifier-abc' },
141
+ { password: 'a-different-password-that-is-32-chars!' },
142
+ );
143
+
144
+ await expect(getStateFromPKCECookieValue(sealed)).rejects.toThrow();
145
+ });
146
+ });
package/src/pkce.ts ADDED
@@ -0,0 +1,59 @@
1
+ import fnv1a from '@sindresorhus/fnv1a';
2
+ import { unsealData } from 'iron-session';
3
+ import { cookies } from 'next/headers';
4
+ import * as v from 'valibot';
5
+ import { getPKCECookieOptions } from './cookie.js';
6
+ import { WORKOS_COOKIE_PASSWORD } from './env-variables.js';
7
+ import { State, StateSchema } from './interfaces.js';
8
+
9
+ export const PKCE_COOKIE_NAME = 'wos-auth-verifier';
10
+
11
+ /**
12
+ * Short, deterministic hex fingerprint of an arbitrary string.
13
+ * Used to give each PKCE flow its own cookie name without depending
14
+ * on the internal format of the sealed state value
15
+ */
16
+ function shortHash(input: string): string {
17
+ // fnv1a returns a BigInt — use 32-bit variant so it fits safely in a Number
18
+ const hash = Number(fnv1a(input, { size: 32 }));
19
+
20
+ // Hex-encode and pad to a fixed 8-char width
21
+ return hash.toString(16).padStart(8, '0');
22
+ }
23
+
24
+ /**
25
+ * Derive a flow-specific cookie name so concurrent auth flows don't overwrite
26
+ * each other's PKCE cookies. Uses an FNV-1a hash of the full sealed state
27
+ */
28
+ export function getPKCECookieNameForState(state: string): string {
29
+ return `${PKCE_COOKIE_NAME}-${shortHash(state)}`;
30
+ }
31
+
32
+ /**
33
+ * Set the PKCE verifier cookie in server action context.
34
+ * In middleware context, callers must set the cookie via Set-Cookie headers instead.
35
+ */
36
+ export async function setPKCECookie(sealedState: string): Promise<void> {
37
+ const nextCookies = await cookies();
38
+ const options = getPKCECookieOptions();
39
+
40
+ nextCookies.set(getPKCECookieNameForState(sealedState), sealedState, {
41
+ ...options,
42
+ httpOnly: true,
43
+ });
44
+ }
45
+
46
+ /**
47
+ * Read and unseal the auth cookie containing PKCE code verifier and OAuth state.
48
+ * Throws if the cookie is not in the required state
49
+ */
50
+ export async function getStateFromPKCECookieValue(cookieValue: string): Promise<State> {
51
+ // NOTE: TypeScript compiler won't flag if we Seal different data in than we Unseal
52
+ // Also, this function is not in a critically-high-performance path, so runtime validation
53
+ // is an acceptable tradeoff for increased security and type-safety
54
+ const unsealed = await unsealData(cookieValue, {
55
+ password: WORKOS_COOKIE_PASSWORD,
56
+ });
57
+
58
+ return v.parse(StateSchema, unsealed);
59
+ }