@workos-inc/authkit-nextjs 2.12.2 → 2.13.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.
@@ -0,0 +1,231 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import {
3
+ handleAuthkitHeaders,
4
+ partitionAuthkitHeaders,
5
+ applyResponseHeaders,
6
+ isAuthkitRequestHeader,
7
+ AUTHKIT_REQUEST_HEADERS,
8
+ } from './middleware-helpers.js';
9
+
10
+ describe('middleware-helpers', () => {
11
+ function createMockRequest(url = 'https://example.com/test', method = 'GET'): NextRequest {
12
+ return new NextRequest(url, { method });
13
+ }
14
+
15
+ function createAuthkitHeaders(): Headers {
16
+ const headers = new Headers();
17
+ headers.set('x-workos-middleware', 'true');
18
+ headers.set('x-workos-session', 'encrypted-session-data');
19
+ headers.set('x-url', 'https://example.com/test');
20
+ headers.set('set-cookie', 'wos-session=abc123; Path=/; HttpOnly');
21
+ headers.set('cache-control', 'private, no-cache');
22
+ headers.set('vary', 'Cookie');
23
+ return headers;
24
+ }
25
+
26
+ describe('isAuthkitRequestHeader', () => {
27
+ it('should recognize known headers and x-workos-* pattern', () => {
28
+ expect(isAuthkitRequestHeader('x-workos-middleware')).toBe(true);
29
+ expect(isAuthkitRequestHeader('x-workos-session')).toBe(true);
30
+ expect(isAuthkitRequestHeader('x-url')).toBe(true);
31
+ expect(isAuthkitRequestHeader('x-workos-future-header')).toBe(true);
32
+ // Case insensitive
33
+ expect(isAuthkitRequestHeader('X-WorkOS-Session')).toBe(true);
34
+ });
35
+
36
+ it('should reject non-authkit headers', () => {
37
+ expect(isAuthkitRequestHeader('set-cookie')).toBe(false);
38
+ expect(isAuthkitRequestHeader('content-type')).toBe(false);
39
+ expect(isAuthkitRequestHeader('x-custom-header')).toBe(false);
40
+ });
41
+ });
42
+
43
+ describe('partitionAuthkitHeaders', () => {
44
+ it('should split headers into request-only and response headers', () => {
45
+ const request = createMockRequest();
46
+ const authkitHeaders = createAuthkitHeaders();
47
+
48
+ const { requestHeaders, responseHeaders } = partitionAuthkitHeaders(request, authkitHeaders);
49
+
50
+ // Request headers contain internal authkit headers
51
+ expect(requestHeaders.get('x-workos-session')).toBe('encrypted-session-data');
52
+ expect(requestHeaders.get('x-workos-middleware')).toBe('true');
53
+
54
+ // Response headers contain browser-safe headers only
55
+ expect(responseHeaders.get('set-cookie')).toBe('wos-session=abc123; Path=/; HttpOnly');
56
+ expect(responseHeaders.get('cache-control')).toBe('private, no-cache');
57
+ expect(responseHeaders.get('vary')).toBe('Cookie');
58
+
59
+ // Internal headers NOT in response
60
+ expect(responseHeaders.get('x-workos-session')).toBeNull();
61
+ expect(responseHeaders.get('x-workos-middleware')).toBeNull();
62
+ });
63
+
64
+ it('should preserve original request headers while adding authkit headers', () => {
65
+ const request = createMockRequest();
66
+ request.headers.set('authorization', 'Bearer token');
67
+ request.headers.set('x-custom', 'value');
68
+
69
+ const { requestHeaders } = partitionAuthkitHeaders(request, createAuthkitHeaders());
70
+
71
+ expect(requestHeaders.get('authorization')).toBe('Bearer token');
72
+ expect(requestHeaders.get('x-custom')).toBe('value');
73
+ expect(requestHeaders.get('x-workos-session')).toBe('encrypted-session-data');
74
+ });
75
+
76
+ it('should filter response headers to allowlist only', () => {
77
+ const request = createMockRequest();
78
+ const authkitHeaders = new Headers();
79
+ authkitHeaders.set('set-cookie', 'session=abc');
80
+ authkitHeaders.set('x-dangerous-header', 'leaked');
81
+ authkitHeaders.set('location', 'https://evil.com');
82
+
83
+ const { responseHeaders } = partitionAuthkitHeaders(request, authkitHeaders);
84
+
85
+ expect(responseHeaders.get('set-cookie')).toBe('session=abc');
86
+ expect(responseHeaders.get('x-dangerous-header')).toBeNull();
87
+ expect(responseHeaders.get('location')).toBeNull();
88
+ });
89
+
90
+ it('should handle multiple Set-Cookie headers correctly', () => {
91
+ const request = createMockRequest();
92
+ const authkitHeaders = new Headers();
93
+ authkitHeaders.append('set-cookie', 'cookie1=value1');
94
+ authkitHeaders.append('set-cookie', 'cookie2=value2');
95
+
96
+ const { responseHeaders } = partitionAuthkitHeaders(request, authkitHeaders);
97
+
98
+ expect(responseHeaders.getSetCookie()).toHaveLength(2);
99
+ });
100
+
101
+ it('should auto-add cache-control: no-store when cookies present without cache-control', () => {
102
+ const request = createMockRequest();
103
+ const authkitHeaders = new Headers();
104
+ authkitHeaders.set('set-cookie', 'session=abc');
105
+
106
+ const { responseHeaders } = partitionAuthkitHeaders(request, authkitHeaders);
107
+
108
+ expect(responseHeaders.get('cache-control')).toBe('no-store');
109
+ });
110
+
111
+ it('should deduplicate and merge Vary header values', () => {
112
+ const request = createMockRequest();
113
+ const authkitHeaders = new Headers();
114
+ authkitHeaders.append('vary', 'Cookie');
115
+ authkitHeaders.append('vary', 'Cookie, Accept');
116
+
117
+ const { responseHeaders } = partitionAuthkitHeaders(request, authkitHeaders);
118
+
119
+ expect(responseHeaders.get('vary')).toBe('Cookie, Accept');
120
+ });
121
+
122
+ it('should forward x-middleware-cache header', () => {
123
+ const request = createMockRequest();
124
+ const authkitHeaders = new Headers();
125
+ authkitHeaders.set('x-middleware-cache', 'no-cache');
126
+
127
+ const { responseHeaders } = partitionAuthkitHeaders(request, authkitHeaders);
128
+
129
+ expect(responseHeaders.get('x-middleware-cache')).toBe('no-cache');
130
+ });
131
+
132
+ it('should strip client-injected x-workos-* headers and use trusted values', () => {
133
+ const request = createMockRequest();
134
+ request.headers.set('x-workos-session', 'malicious-session');
135
+ request.headers.set('x-workos-admin-bypass', 'true');
136
+
137
+ const authkitHeaders = new Headers();
138
+ authkitHeaders.set('x-workos-session', 'real-session');
139
+
140
+ const { requestHeaders } = partitionAuthkitHeaders(request, authkitHeaders);
141
+
142
+ expect(requestHeaders.get('x-workos-session')).toBe('real-session');
143
+ expect(requestHeaders.get('x-workos-admin-bypass')).toBeNull();
144
+ });
145
+ });
146
+
147
+ describe('handleAuthkitHeaders', () => {
148
+ it('should return NextResponse with response headers applied', () => {
149
+ const request = createMockRequest();
150
+ const response = handleAuthkitHeaders(request, createAuthkitHeaders());
151
+
152
+ expect(response).toBeInstanceOf(NextResponse);
153
+ expect(response.status).toBe(200);
154
+ expect(response.headers.get('set-cookie')).toBe('wos-session=abc123; Path=/; HttpOnly');
155
+ expect(response.headers.get('vary')).toBe('Cookie');
156
+
157
+ // Internal headers NOT leaked
158
+ for (const header of AUTHKIT_REQUEST_HEADERS) {
159
+ expect(response.headers.get(header)).toBeNull();
160
+ }
161
+ });
162
+
163
+ it('should redirect with normalized absolute URL', () => {
164
+ const request = createMockRequest('https://example.com/page');
165
+ const response = handleAuthkitHeaders(request, createAuthkitHeaders(), {
166
+ redirect: '/login',
167
+ });
168
+
169
+ expect(response.status).toBe(307);
170
+ expect(response.headers.get('location')).toBe('https://example.com/login');
171
+ expect(response.headers.get('set-cookie')).toBe('wos-session=abc123; Path=/; HttpOnly');
172
+ });
173
+
174
+ it('should use 307 for GET and 303 for POST redirects by default', () => {
175
+ const getRequest = createMockRequest('https://example.com/test', 'GET');
176
+ const postRequest = createMockRequest('https://example.com/test', 'POST');
177
+ const headers = createAuthkitHeaders();
178
+
179
+ const getResponse = handleAuthkitHeaders(getRequest, headers, { redirect: '/login' });
180
+ const postResponse = handleAuthkitHeaders(postRequest, headers, { redirect: '/login' });
181
+
182
+ expect(getResponse.status).toBe(307);
183
+ expect(postResponse.status).toBe(303);
184
+ });
185
+
186
+ it('should allow overriding redirect status', () => {
187
+ const request = createMockRequest();
188
+ const response = handleAuthkitHeaders(request, createAuthkitHeaders(), {
189
+ redirect: '/login',
190
+ redirectStatus: 302,
191
+ });
192
+
193
+ expect(response.status).toBe(302);
194
+ });
195
+
196
+ it('should throw clear error on invalid redirect URL', () => {
197
+ const request = createMockRequest();
198
+
199
+ expect(() =>
200
+ handleAuthkitHeaders(request, createAuthkitHeaders(), {
201
+ redirect: 'http://[invalid',
202
+ }),
203
+ ).toThrow('Invalid redirect URL: "http://[invalid". Must be a valid absolute or relative URL.');
204
+ });
205
+
206
+ it('should treat empty/undefined redirect as no redirect', () => {
207
+ const request = createMockRequest();
208
+ const headers = createAuthkitHeaders();
209
+
210
+ expect(handleAuthkitHeaders(request, headers, { redirect: '' }).status).toBe(200);
211
+ expect(handleAuthkitHeaders(request, headers, { redirect: undefined }).status).toBe(200);
212
+ });
213
+ });
214
+
215
+ describe('applyResponseHeaders', () => {
216
+ it('should merge headers onto existing response', () => {
217
+ const response = NextResponse.next();
218
+ response.headers.set('vary', 'Accept');
219
+ response.headers.set('set-cookie', 'existing=value');
220
+
221
+ const headers = new Headers();
222
+ headers.set('vary', 'Cookie');
223
+ headers.set('set-cookie', 'new=value');
224
+
225
+ applyResponseHeaders(response, headers);
226
+
227
+ expect(response.headers.get('vary')).toBe('Accept, Cookie');
228
+ expect(response.headers.getSetCookie()).toHaveLength(2);
229
+ });
230
+ });
231
+ });
@@ -0,0 +1,130 @@
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
+ // Strip any client-injected authkit headers, then apply trusted ones
60
+ for (const name of [...requestHeaders.keys()]) {
61
+ if (isAuthkitRequestHeader(name)) {
62
+ requestHeaders.delete(name);
63
+ }
64
+ }
65
+ for (const headerName of AUTHKIT_REQUEST_HEADERS) {
66
+ const value = headers.get(headerName);
67
+ if (value != null) {
68
+ requestHeaders.set(headerName, value);
69
+ }
70
+ }
71
+
72
+ // Build response headers from allowlist only
73
+ const responseHeaders = new Headers();
74
+ for (const [name, value] of headers) {
75
+ const lower = name.toLowerCase();
76
+ if (!isAuthkitRequestHeader(lower) && ALLOWED_RESPONSE_HEADERS.includes(lower)) {
77
+ setHeader(responseHeaders, name, value);
78
+ }
79
+ }
80
+
81
+ // Auto-add cache-control when setting cookies
82
+ if (responseHeaders.has('set-cookie') && !responseHeaders.has('cache-control')) {
83
+ responseHeaders.set('cache-control', 'no-store');
84
+ }
85
+
86
+ return { requestHeaders, responseHeaders };
87
+ }
88
+
89
+ export function applyResponseHeaders(response: NextResponse, responseHeaders: Headers): NextResponse {
90
+ for (const [name, value] of responseHeaders) {
91
+ setHeader(response.headers, name, value);
92
+ }
93
+ return response;
94
+ }
95
+
96
+ export type AuthkitRedirectStatus = 302 | 303 | 307 | 308;
97
+
98
+ export interface HandleAuthkitHeadersOptions {
99
+ /** URL to redirect to (relative or absolute). */
100
+ redirect?: string | URL;
101
+
102
+ /** Redirect status code. @default 307 for GET/HEAD, 303 for POST/PUT/DELETE */
103
+ redirectStatus?: AuthkitRedirectStatus;
104
+ }
105
+
106
+ /**
107
+ * Creates a NextResponse with properly merged AuthKit headers.
108
+ */
109
+ export function handleAuthkitHeaders(
110
+ request: NextRequest,
111
+ authkitHeaders: Headers,
112
+ options: HandleAuthkitHeadersOptions = {},
113
+ ): NextResponse {
114
+ const { requestHeaders, responseHeaders } = partitionAuthkitHeaders(request, authkitHeaders);
115
+ const { redirect, redirectStatus } = options;
116
+
117
+ if (redirect != null && redirect !== '') {
118
+ let redirectUrl: URL;
119
+ try {
120
+ redirectUrl = redirect instanceof URL ? redirect : new URL(redirect, request.url);
121
+ } catch {
122
+ throw new Error(`Invalid redirect URL: "${redirect}". Must be a valid absolute or relative URL.`);
123
+ }
124
+ const method = request.method.toUpperCase();
125
+ const status = redirectStatus ?? (method === 'GET' || method === 'HEAD' ? 307 : 303);
126
+ return applyResponseHeaders(NextResponse.redirect(redirectUrl, status), responseHeaders);
127
+ }
128
+
129
+ return applyResponseHeaders(NextResponse.next({ request: { headers: requestHeaders } }), responseHeaders);
130
+ }
@@ -374,10 +374,7 @@ describe('session.ts', () => {
374
374
  );
375
375
  });
376
376
 
377
- it('should use Response if NextResponse.redirect is not available', async () => {
378
- const originalRedirect = NextResponse.redirect;
379
- (NextResponse as Partial<typeof NextResponse>).redirect = undefined;
380
-
377
+ it('should return a redirect response when middlewareAuth is enabled and user is not authenticated', async () => {
381
378
  const request = new NextRequest(new URL('http://example.com/protected'));
382
379
  const result = await updateSessionMiddleware(
383
380
  request,
@@ -390,10 +387,9 @@ describe('session.ts', () => {
390
387
  [],
391
388
  );
392
389
 
393
- expect(result).toBeInstanceOf(Response);
394
-
395
- // Restore the original redirect method
396
- (NextResponse as Partial<typeof NextResponse>).redirect = originalRedirect;
390
+ expect(result).toBeInstanceOf(NextResponse);
391
+ expect(result.status).toBe(307);
392
+ expect(result.headers.get('Location')).toContain('workos.com');
397
393
  });
398
394
 
399
395
  it('should automatically add the redirect URI to unauthenticatedPaths when middleware is enabled', async () => {
@@ -429,7 +425,7 @@ describe('session.ts', () => {
429
425
  expect(result.headers.get('Location')).toContain('screen_hint=sign-up');
430
426
  });
431
427
 
432
- it('should set the sign up paths in the headers', async () => {
428
+ it('should not leak sign-up paths header to the browser', async () => {
433
429
  const request = new NextRequest(new URL('http://example.com/protected-signup'));
434
430
  const result = await updateSessionMiddleware(
435
431
  request,
@@ -442,7 +438,8 @@ describe('session.ts', () => {
442
438
  ['/protected-signup'],
443
439
  );
444
440
 
445
- expect(result.headers.get('x-sign-up-paths')).toBe('/protected-signup');
441
+ // x-sign-up-paths is an internal header that should not leak to the browser
442
+ expect(result.headers.get('x-sign-up-paths')).toBeNull();
446
443
  });
447
444
 
448
445
  it('should allow logged out users on unauthenticated paths', async () => {
package/src/session.ts CHANGED
@@ -4,9 +4,10 @@ import { sealData, unsealData } from 'iron-session';
4
4
  import { JWTPayload, createRemoteJWKSet, decodeJwt, jwtVerify } from 'jose';
5
5
  import { cookies, headers } from 'next/headers';
6
6
  import { redirect } from 'next/navigation';
7
- import { NextRequest, NextResponse } from 'next/server';
7
+ import { NextRequest } from 'next/server';
8
8
  import { getCookieOptions, getJwtCookie } from './cookie.js';
9
9
  import { WORKOS_CLIENT_ID, WORKOS_COOKIE_NAME, WORKOS_COOKIE_PASSWORD, WORKOS_REDIRECT_URI } from './env-variables.js';
10
+ import { TokenRefreshError, getSessionErrorContext } from './errors.js';
10
11
  import { getAuthorizationUrl } from './get-authorization-url.js';
11
12
  import {
12
13
  AccessToken,
@@ -21,7 +22,8 @@ import { getWorkOS } from './workos.js';
21
22
 
22
23
  import type { AuthenticationResponse } from '@workos-inc/node';
23
24
  import { parse, tokensToRegexp } from 'path-to-regexp';
24
- import { lazy, redirectWithFallback, setCachePreventionHeaders } from './utils.js';
25
+ import { handleAuthkitHeaders } from './middleware-helpers.js';
26
+ import { lazy, setCachePreventionHeaders } from './utils.js';
25
27
 
26
28
  const sessionHeaderName = 'x-workos-session';
27
29
  const middlewareHeaderName = 'x-workos-middleware';
@@ -149,15 +151,6 @@ async function updateSessionMiddleware(
149
151
  eagerAuth,
150
152
  });
151
153
 
152
- // If the user is logged out and this path isn't on the allowlist for logged out paths, redirect to AuthKit.
153
- if (middlewareAuth.enabled && matchedPaths.length === 0 && !session.user) {
154
- if (debug) {
155
- console.log(`Unauthenticated user on protected route ${request.url}, redirecting to AuthKit`);
156
- }
157
-
158
- return redirectWithFallback(authorizationUrl as string, headers);
159
- }
160
-
161
154
  // Record the sign up paths so we can use them later
162
155
  if (signUpPaths.length > 0) {
163
156
  headers.set(signUpPathsHeaderName, signUpPaths.join(','));
@@ -165,33 +158,16 @@ async function updateSessionMiddleware(
165
158
 
166
159
  applyCacheSecurityHeaders(headers, request, session);
167
160
 
168
- // Create a new request with modified headers (for page handlers)
169
- const requestHeaders = new Headers(request.headers);
170
- requestHeaders.set(middlewareHeaderName, headers.get(middlewareHeaderName)!);
171
- requestHeaders.set('x-url', headers.get('x-url')!);
172
- if (headers.has('x-redirect-uri')) {
173
- requestHeaders.set('x-redirect-uri', headers.get('x-redirect-uri')!);
174
- }
175
- if (headers.has(signUpPathsHeaderName)) {
176
- requestHeaders.set(signUpPathsHeaderName, headers.get(signUpPathsHeaderName)!);
177
- }
161
+ // If the user is logged out and this path isn't on the allowlist for logged out paths, redirect to AuthKit.
162
+ if (middlewareAuth.enabled && matchedPaths.length === 0 && !session.user) {
163
+ if (debug) {
164
+ console.log(`Unauthenticated user on protected route ${request.url}, redirecting to AuthKit`);
165
+ }
178
166
 
179
- // Pass session to page handlers via request header
180
- // This ensures handlers see refreshed sessions immediately (before Set-Cookie reaches browser)
181
- const sessionHeader = headers.get(sessionHeaderName);
182
- if (sessionHeader) {
183
- requestHeaders.set(sessionHeaderName, sessionHeader);
167
+ return handleAuthkitHeaders(request, headers, { redirect: authorizationUrl as string });
184
168
  }
185
169
 
186
- // Remove session header from response headers to prevent leakage
187
- headers.delete(sessionHeaderName);
188
-
189
- return NextResponse.next({
190
- request: {
191
- headers: requestHeaders,
192
- },
193
- headers,
194
- });
170
+ return handleAuthkitHeaders(request, headers);
195
171
  }
196
172
 
197
173
  async function updateSession(
@@ -406,9 +382,11 @@ async function refreshSession({
406
382
  organizationId: nextOrganizationId ?? organizationIdFromAccessToken,
407
383
  });
408
384
  } catch (error) {
409
- throw new Error(`Failed to refresh session: ${error instanceof Error ? error.message : String(error)}`, {
410
- cause: error,
411
- });
385
+ throw new TokenRefreshError(
386
+ `Failed to refresh session: ${error instanceof Error ? error.message : String(error)}`,
387
+ error,
388
+ getSessionErrorContext(session),
389
+ );
412
390
  }
413
391
 
414
392
  const headersList = await headers();
package/src/utils.ts CHANGED
@@ -6,8 +6,6 @@ import { NextResponse } from 'next/server';
6
6
  */
7
7
  export function setCachePreventionHeaders(headers: Headers): void {
8
8
  headers.set('Cache-Control', 'private, no-cache, no-store, must-revalidate, max-age=0');
9
- headers.set('Pragma', 'no-cache');
10
- headers.set('Expires', '0');
11
9
  headers.set('x-middleware-cache', 'no-cache');
12
10
  }
13
11
 
package/src/workos.ts CHANGED
@@ -2,7 +2,7 @@ import { WorkOS } from '@workos-inc/node';
2
2
  import { WORKOS_API_HOSTNAME, WORKOS_API_KEY, WORKOS_API_HTTPS, WORKOS_API_PORT } from './env-variables.js';
3
3
  import { lazy } from './utils.js';
4
4
 
5
- export const VERSION = '2.12.2';
5
+ export const VERSION = '2.13.0';
6
6
 
7
7
  const options = {
8
8
  apiHostname: WORKOS_API_HOSTNAME,