@workos-inc/authkit-nextjs 2.12.2 → 2.14.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 +138 -73
- package/dist/esm/errors.js +33 -0
- package/dist/esm/errors.js.map +1 -0
- package/dist/esm/get-authorization-url.js +7 -2
- package/dist/esm/get-authorization-url.js.map +1 -1
- package/dist/esm/index.js +3 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/middleware-helpers.js +99 -0
- package/dist/esm/middleware-helpers.js.map +1 -0
- package/dist/esm/session.js +11 -35
- package/dist/esm/session.js.map +1 -1
- package/dist/esm/types/errors.d.ts +15 -0
- package/dist/esm/types/index.d.ts +3 -1
- package/dist/esm/types/middleware-helpers.d.ts +25 -0
- package/dist/esm/types/session.d.ts +1 -1
- package/dist/esm/types/validate-api-key.d.ts +1 -1
- package/dist/esm/types/workos.d.ts +1 -1
- package/dist/esm/utils.js +0 -2
- package/dist/esm/utils.js.map +1 -1
- package/dist/esm/workos.js +1 -1
- package/package.json +20 -21
- package/src/actions.spec.ts +14 -12
- package/src/auth.spec.ts +27 -29
- package/src/authkit-callback-route.spec.ts +31 -29
- package/src/components/authkit-provider.spec.tsx +67 -71
- package/src/components/button.spec.tsx +4 -6
- package/src/components/impersonation.spec.tsx +25 -25
- package/src/components/min-max-button.spec.tsx +2 -1
- package/src/components/tokenStore.spec.ts +21 -21
- package/src/components/useAccessToken.spec.tsx +73 -77
- package/src/components/useTokenClaims.spec.tsx +22 -22
- package/src/cookie.spec.ts +14 -9
- package/src/errors.spec.ts +108 -0
- package/src/errors.ts +46 -0
- package/src/get-authorization-url.spec.ts +12 -13
- package/src/get-authorization-url.ts +6 -10
- package/src/index.ts +16 -2
- package/src/middleware-helpers.spec.ts +231 -0
- package/src/middleware-helpers.ts +130 -0
- package/src/session.spec.ts +81 -73
- package/src/session.ts +16 -38
- package/src/utils.spec.ts +14 -31
- package/src/utils.ts +0 -2
- package/src/validate-api-key.spec.ts +4 -6
- package/src/workos.spec.ts +2 -2
- package/src/workos.ts +1 -1
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { AuthKitError, TokenRefreshError, getSessionErrorContext } from './errors.js';
|
|
2
|
+
import type { Session } from './interfaces.js';
|
|
3
|
+
import type { User } from '@workos-inc/node';
|
|
4
|
+
|
|
5
|
+
describe('AuthKitError', () => {
|
|
6
|
+
it('creates error with message', () => {
|
|
7
|
+
const error = new AuthKitError('Test error');
|
|
8
|
+
|
|
9
|
+
expect(error.message).toBe('Test error');
|
|
10
|
+
expect(error.name).toBe('AuthKitError');
|
|
11
|
+
expect(error).toBeInstanceOf(Error);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('creates error with cause and data', () => {
|
|
15
|
+
const originalError = new Error('Original error');
|
|
16
|
+
const data = { userId: '123' };
|
|
17
|
+
const error = new AuthKitError('Test error', originalError, data);
|
|
18
|
+
|
|
19
|
+
expect(error.cause).toBe(originalError);
|
|
20
|
+
expect(error.data).toEqual(data);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('TokenRefreshError', () => {
|
|
25
|
+
it('creates error with correct name and inheritance', () => {
|
|
26
|
+
const error = new TokenRefreshError('Refresh failed');
|
|
27
|
+
|
|
28
|
+
expect(error.name).toBe('TokenRefreshError');
|
|
29
|
+
expect(error.message).toBe('Refresh failed');
|
|
30
|
+
expect(error).toBeInstanceOf(AuthKitError);
|
|
31
|
+
expect(error).toBeInstanceOf(Error);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('creates error with cause and context', () => {
|
|
35
|
+
const originalError = new Error('Network error');
|
|
36
|
+
const error = new TokenRefreshError('Refresh failed', originalError, {
|
|
37
|
+
userId: 'user_123',
|
|
38
|
+
sessionId: 'session_456',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(error.cause).toBe(originalError);
|
|
42
|
+
expect(error.userId).toBe('user_123');
|
|
43
|
+
expect(error.sessionId).toBe('session_456');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('has undefined properties when no context provided', () => {
|
|
47
|
+
const error = new TokenRefreshError('Refresh failed');
|
|
48
|
+
|
|
49
|
+
expect(error.userId).toBeUndefined();
|
|
50
|
+
expect(error.sessionId).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('getSessionErrorContext', () => {
|
|
55
|
+
function createTestJwt(payload: Record<string, unknown>): string {
|
|
56
|
+
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
|
|
57
|
+
const payloadStr = btoa(JSON.stringify(payload));
|
|
58
|
+
return `${header}.${payloadStr}.test-signature`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
it('returns empty object for missing session', () => {
|
|
62
|
+
expect(getSessionErrorContext(null)).toEqual({});
|
|
63
|
+
expect(getSessionErrorContext(undefined)).toEqual({});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('extracts userId and sessionId from access token', () => {
|
|
67
|
+
const session: Session = {
|
|
68
|
+
accessToken: createTestJwt({ sub: 'user_456', sid: 'session_123' }),
|
|
69
|
+
refreshToken: 'refresh_token',
|
|
70
|
+
user: {
|
|
71
|
+
id: 'user_456',
|
|
72
|
+
email: 'test@example.com',
|
|
73
|
+
emailVerified: true,
|
|
74
|
+
profilePictureUrl: null,
|
|
75
|
+
firstName: null,
|
|
76
|
+
lastName: null,
|
|
77
|
+
createdAt: '2024-01-01',
|
|
78
|
+
updatedAt: '2024-01-01',
|
|
79
|
+
object: 'user',
|
|
80
|
+
} as User,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const context = getSessionErrorContext(session);
|
|
84
|
+
expect(context.userId).toBe('user_456');
|
|
85
|
+
expect(context.sessionId).toBe('session_123');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('returns empty object for invalid JWT', () => {
|
|
89
|
+
const session: Session = {
|
|
90
|
+
accessToken: 'invalid-jwt',
|
|
91
|
+
refreshToken: 'refresh_token',
|
|
92
|
+
user: {
|
|
93
|
+
id: 'user_123',
|
|
94
|
+
email: 'test@example.com',
|
|
95
|
+
emailVerified: true,
|
|
96
|
+
profilePictureUrl: null,
|
|
97
|
+
firstName: null,
|
|
98
|
+
lastName: null,
|
|
99
|
+
createdAt: '2024-01-01',
|
|
100
|
+
updatedAt: '2024-01-01',
|
|
101
|
+
object: 'user',
|
|
102
|
+
} as User,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const context = getSessionErrorContext(session);
|
|
106
|
+
expect(context).toEqual({});
|
|
107
|
+
});
|
|
108
|
+
});
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Session } from './interfaces.js';
|
|
2
|
+
import { decodeJwt } from './jwt.js';
|
|
3
|
+
|
|
4
|
+
export class AuthKitError extends Error {
|
|
5
|
+
data?: Record<string, unknown>;
|
|
6
|
+
|
|
7
|
+
constructor(message: string, cause?: unknown, data?: Record<string, unknown>) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = 'AuthKitError';
|
|
10
|
+
this.cause = cause;
|
|
11
|
+
this.data = data;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface TokenRefreshErrorContext {
|
|
16
|
+
userId?: string;
|
|
17
|
+
sessionId?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class TokenRefreshError extends AuthKitError {
|
|
21
|
+
readonly userId?: string;
|
|
22
|
+
readonly sessionId?: string;
|
|
23
|
+
|
|
24
|
+
constructor(message: string, cause?: unknown, context?: TokenRefreshErrorContext) {
|
|
25
|
+
super(message, cause);
|
|
26
|
+
this.name = 'TokenRefreshError';
|
|
27
|
+
this.userId = context?.userId;
|
|
28
|
+
this.sessionId = context?.sessionId;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getSessionErrorContext(session?: Session | null): TokenRefreshErrorContext {
|
|
33
|
+
if (!session?.accessToken) {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const { payload } = decodeJwt(session.accessToken);
|
|
39
|
+
return {
|
|
40
|
+
userId: payload.sub,
|
|
41
|
+
sessionId: payload.sid,
|
|
42
|
+
};
|
|
43
|
+
} catch {
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -1,25 +1,24 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
|
2
1
|
import { getAuthorizationUrl } from './get-authorization-url.js';
|
|
3
2
|
import { headers } from 'next/headers';
|
|
4
3
|
import { getWorkOS } from './workos.js';
|
|
5
4
|
|
|
6
|
-
jest.mock('next/headers');
|
|
7
|
-
|
|
8
5
|
// Mock dependencies
|
|
9
|
-
const fakeWorkosInstance = {
|
|
10
|
-
|
|
11
|
-
|
|
6
|
+
const { fakeWorkosInstance } = vi.hoisted(() => ({
|
|
7
|
+
fakeWorkosInstance: {
|
|
8
|
+
userManagement: {
|
|
9
|
+
getAuthorizationUrl: vi.fn(),
|
|
10
|
+
},
|
|
12
11
|
},
|
|
13
|
-
};
|
|
12
|
+
}));
|
|
14
13
|
|
|
15
|
-
|
|
16
|
-
getWorkOS:
|
|
14
|
+
vi.mock('./workos', () => ({
|
|
15
|
+
getWorkOS: vi.fn(() => fakeWorkosInstance),
|
|
17
16
|
}));
|
|
18
17
|
|
|
19
18
|
describe('getAuthorizationUrl', () => {
|
|
20
19
|
const workos = getWorkOS();
|
|
21
20
|
beforeEach(() => {
|
|
22
|
-
|
|
21
|
+
vi.clearAllMocks();
|
|
23
22
|
});
|
|
24
23
|
|
|
25
24
|
it('uses x-redirect-uri header when redirectUri option is not provided', async () => {
|
|
@@ -27,7 +26,7 @@ describe('getAuthorizationUrl', () => {
|
|
|
27
26
|
nextHeaders.set('x-redirect-uri', 'http://test-redirect.com');
|
|
28
27
|
|
|
29
28
|
// Mock workos.userManagement.getAuthorizationUrl
|
|
30
|
-
|
|
29
|
+
vi.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
|
|
31
30
|
|
|
32
31
|
await getAuthorizationUrl({});
|
|
33
32
|
|
|
@@ -39,7 +38,7 @@ describe('getAuthorizationUrl', () => {
|
|
|
39
38
|
});
|
|
40
39
|
|
|
41
40
|
it('works when called with no arguments', async () => {
|
|
42
|
-
|
|
41
|
+
vi.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
|
|
43
42
|
|
|
44
43
|
await getAuthorizationUrl(); // Call with no arguments
|
|
45
44
|
|
|
@@ -47,7 +46,7 @@ describe('getAuthorizationUrl', () => {
|
|
|
47
46
|
});
|
|
48
47
|
|
|
49
48
|
it('works when prompt is provided', async () => {
|
|
50
|
-
|
|
49
|
+
vi.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
|
|
51
50
|
|
|
52
51
|
await getAuthorizationUrl({ prompt: 'consent' });
|
|
53
52
|
|
|
@@ -4,16 +4,12 @@ import { GetAuthURLOptions } from './interfaces.js';
|
|
|
4
4
|
import { headers } from 'next/headers';
|
|
5
5
|
|
|
6
6
|
async function getAuthorizationUrl(options: GetAuthURLOptions = {}) {
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
loginHint,
|
|
14
|
-
prompt,
|
|
15
|
-
state: customState,
|
|
16
|
-
} = options;
|
|
7
|
+
const { returnPathname, screenHint, organizationId, loginHint, prompt, state: customState } = options;
|
|
8
|
+
let redirectUri = options.redirectUri;
|
|
9
|
+
if (!redirectUri) {
|
|
10
|
+
const headersList = await headers();
|
|
11
|
+
redirectUri = headersList.get('x-redirect-uri') ?? undefined;
|
|
12
|
+
}
|
|
17
13
|
|
|
18
14
|
const internalState = returnPathname
|
|
19
15
|
? btoa(JSON.stringify({ returnPathname })).replace(/\+/g, '-').replace(/\//g, '_')
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import { getSignInUrl, getSignUpUrl, signOut, switchToOrganization } from './auth.js';
|
|
2
2
|
import { handleAuth } from './authkit-callback-route.js';
|
|
3
|
+
import { AuthKitError, TokenRefreshError } from './errors.js';
|
|
3
4
|
import { authkit, authkitMiddleware } from './middleware.js';
|
|
5
|
+
export {
|
|
6
|
+
applyResponseHeaders,
|
|
7
|
+
handleAuthkitHeaders,
|
|
8
|
+
partitionAuthkitHeaders,
|
|
9
|
+
isAuthkitRequestHeader,
|
|
10
|
+
AUTHKIT_REQUEST_HEADERS,
|
|
11
|
+
type AuthkitHeadersResult,
|
|
12
|
+
type AuthkitRedirectStatus,
|
|
13
|
+
type AuthkitRequestHeader,
|
|
14
|
+
type HandleAuthkitHeadersOptions,
|
|
15
|
+
} from './middleware-helpers.js';
|
|
4
16
|
import { getTokenClaims, refreshSession, saveSession, withAuth } from './session.js';
|
|
5
17
|
import { validateApiKey } from './validate-api-key.js';
|
|
6
18
|
import { getWorkOS } from './workos.js';
|
|
@@ -8,17 +20,19 @@ import { getWorkOS } from './workos.js';
|
|
|
8
20
|
export * from './interfaces.js';
|
|
9
21
|
|
|
10
22
|
export {
|
|
23
|
+
AuthKitError,
|
|
24
|
+
TokenRefreshError,
|
|
11
25
|
authkit,
|
|
12
26
|
authkitMiddleware,
|
|
13
27
|
getSignInUrl,
|
|
14
28
|
getSignUpUrl,
|
|
29
|
+
getTokenClaims,
|
|
15
30
|
getWorkOS,
|
|
16
31
|
handleAuth,
|
|
17
32
|
refreshSession,
|
|
18
33
|
saveSession,
|
|
19
34
|
signOut,
|
|
20
35
|
switchToOrganization,
|
|
21
|
-
withAuth,
|
|
22
|
-
getTokenClaims,
|
|
23
36
|
validateApiKey,
|
|
37
|
+
withAuth,
|
|
24
38
|
};
|
|
@@ -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
|
+
}
|