@workos-inc/authkit-nextjs 2.10.0 → 2.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +93 -39
- package/dist/esm/auth.js +9 -1
- package/dist/esm/auth.js.map +1 -1
- package/dist/esm/authkit-callback-route.js +15 -6
- package/dist/esm/authkit-callback-route.js.map +1 -1
- package/dist/esm/components/impersonation.js +8 -6
- package/dist/esm/components/impersonation.js.map +1 -1
- package/dist/esm/index.js +2 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/session.js +56 -2
- package/dist/esm/session.js.map +1 -1
- package/dist/esm/test-helpers.js +1 -0
- package/dist/esm/test-helpers.js.map +1 -1
- package/dist/esm/types/components/impersonation.d.ts +2 -1
- package/dist/esm/types/index.d.ts +2 -1
- package/dist/esm/types/utils.d.ts +5 -0
- package/dist/esm/types/validate-api-key.d.ts +1 -0
- package/dist/esm/types/workos.d.ts +1 -1
- package/dist/esm/utils.js +10 -0
- package/dist/esm/utils.js.map +1 -1
- package/dist/esm/validate-api-key.js +17 -0
- package/dist/esm/validate-api-key.js.map +1 -0
- package/dist/esm/workos.js +1 -1
- package/package.json +4 -4
- package/src/auth.ts +11 -1
- package/src/authkit-callback-route.spec.ts +1 -0
- package/src/authkit-callback-route.ts +17 -6
- package/src/components/impersonation.spec.tsx +136 -20
- package/src/components/impersonation.tsx +8 -6
- package/src/index.ts +2 -0
- package/src/session.spec.ts +2 -4
- package/src/session.ts +73 -2
- package/src/test-helpers.ts +1 -0
- package/src/utils.ts +11 -0
- package/src/validate-api-key.spec.ts +113 -0
- package/src/validate-api-key.ts +19 -0
- package/src/workos.ts +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { WorkOS } from '@workos-inc/node';
|
|
2
|
-
export declare const VERSION = "2.
|
|
2
|
+
export declare const VERSION = "2.11.1";
|
|
3
3
|
/**
|
|
4
4
|
* Create a WorkOS instance with the provided API key and options.
|
|
5
5
|
* If an instance already exists, it returns the existing instance.
|
package/dist/esm/utils.js
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
|
+
/**
|
|
3
|
+
* Sets cache prevention headers to prevent CDN/proxy caching.
|
|
4
|
+
* @param headers - The Headers object to set the cache prevention headers on.
|
|
5
|
+
*/
|
|
6
|
+
export function setCachePreventionHeaders(headers) {
|
|
7
|
+
headers.set('Cache-Control', 'private, no-cache, no-store, must-revalidate, max-age=0');
|
|
8
|
+
headers.set('Pragma', 'no-cache');
|
|
9
|
+
headers.set('Expires', '0');
|
|
10
|
+
headers.set('x-middleware-cache', 'no-cache');
|
|
11
|
+
}
|
|
2
12
|
export function redirectWithFallback(redirectUri, headers) {
|
|
3
13
|
const newHeaders = headers ? new Headers(headers) : new Headers();
|
|
4
14
|
newHeaders.set('Location', redirectUri);
|
package/dist/esm/utils.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"utils.js","sourceRoot":"","sources":["../../src/utils.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C,MAAM,UAAU,oBAAoB,CAAC,WAAmB,EAAE,OAAiB;IACzE,MAAM,UAAU,GAAG,OAAO,CAAC,CAAC,CAAC,IAAI,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,IAAI,OAAO,EAAE,CAAC;IAClE,UAAU,CAAC,GAAG,CAAC,UAAU,EAAE,WAAW,CAAC,CAAC;IAExC,mEAAmE;IACnE,iCAAiC;IACjC,OAAO,CAAA,YAAY,aAAZ,YAAY,uBAAZ,YAAY,CAAE,QAAQ;QAC3B,CAAC,CAAC,YAAY,CAAC,QAAQ,CAAC,WAAW,EAAE,EAAE,OAAO,EAAE,CAAC;QACjD,CAAC,CAAC,IAAI,QAAQ,CAAC,IAAI,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,UAAU,EAAE,CAAC,CAAC;AAC/D,CAAC;AAED,MAAM,UAAU,yBAAyB,CAAC,SAA8D;IACtG,mEAAmE;IACnE,iCAAiC;IACjC,OAAO,CAAA,YAAY,aAAZ,YAAY,uBAAZ,YAAY,CAAE,IAAI;QACvB,CAAC,CAAC,YAAY,CAAC,IAAI,CAAC,SAAS,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC;QAC/C,CAAC,CAAC,IAAI,QAAQ,CAAC,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC,EAAE;YACtC,MAAM,EAAE,GAAG;YACX,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;SAChD,CAAC,CAAC;AACT,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,IAAI,CAAI,EAAW;IACjC,IAAI,MAAM,GAAG,KAAK,CAAC;IACnB,IAAI,MAAS,CAAC;IACd,OAAO,GAAG,EAAE;QACV,IAAI,CAAC,MAAM,EAAE,CAAC;YACZ,MAAM,GAAG,EAAE,EAAE,CAAC;YACd,MAAM,GAAG,IAAI,CAAC;QAChB,CAAC;QACD,OAAO,MAAM,CAAC;IAChB,CAAC,CAAC;AACJ,CAAC"}
|
|
1
|
+
{"version":3,"file":"utils.js","sourceRoot":"","sources":["../../src/utils.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C;;;GAGG;AACH,MAAM,UAAU,yBAAyB,CAAC,OAAgB;IACxD,OAAO,CAAC,GAAG,CAAC,eAAe,EAAE,yDAAyD,CAAC,CAAC;IACxF,OAAO,CAAC,GAAG,CAAC,QAAQ,EAAE,UAAU,CAAC,CAAC;IAClC,OAAO,CAAC,GAAG,CAAC,SAAS,EAAE,GAAG,CAAC,CAAC;IAC5B,OAAO,CAAC,GAAG,CAAC,oBAAoB,EAAE,UAAU,CAAC,CAAC;AAChD,CAAC;AAED,MAAM,UAAU,oBAAoB,CAAC,WAAmB,EAAE,OAAiB;IACzE,MAAM,UAAU,GAAG,OAAO,CAAC,CAAC,CAAC,IAAI,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,IAAI,OAAO,EAAE,CAAC;IAClE,UAAU,CAAC,GAAG,CAAC,UAAU,EAAE,WAAW,CAAC,CAAC;IAExC,mEAAmE;IACnE,iCAAiC;IACjC,OAAO,CAAA,YAAY,aAAZ,YAAY,uBAAZ,YAAY,CAAE,QAAQ;QAC3B,CAAC,CAAC,YAAY,CAAC,QAAQ,CAAC,WAAW,EAAE,EAAE,OAAO,EAAE,CAAC;QACjD,CAAC,CAAC,IAAI,QAAQ,CAAC,IAAI,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,UAAU,EAAE,CAAC,CAAC;AAC/D,CAAC;AAED,MAAM,UAAU,yBAAyB,CAAC,SAA8D;IACtG,mEAAmE;IACnE,iCAAiC;IACjC,OAAO,CAAA,YAAY,aAAZ,YAAY,uBAAZ,YAAY,CAAE,IAAI;QACvB,CAAC,CAAC,YAAY,CAAC,IAAI,CAAC,SAAS,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC;QAC/C,CAAC,CAAC,IAAI,QAAQ,CAAC,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC,EAAE;YACtC,MAAM,EAAE,GAAG;YACX,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;SAChD,CAAC,CAAC;AACT,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,IAAI,CAAI,EAAW;IACjC,IAAI,MAAM,GAAG,KAAK,CAAC;IACnB,IAAI,MAAS,CAAC;IACd,OAAO,GAAG,EAAE;QACV,IAAI,CAAC,MAAM,EAAE,CAAC;YACZ,MAAM,GAAG,EAAE,EAAE,CAAC;YACd,MAAM,GAAG,IAAI,CAAC;QAChB,CAAC;QACD,OAAO,MAAM,CAAC;IAChB,CAAC,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
import { getWorkOS } from './workos.js';
|
|
3
|
+
import { headers } from 'next/headers';
|
|
4
|
+
export async function validateApiKey() {
|
|
5
|
+
var _a;
|
|
6
|
+
const headersList = await headers();
|
|
7
|
+
const authorizationHeader = headersList.get('authorization');
|
|
8
|
+
if (!authorizationHeader) {
|
|
9
|
+
return { apiKey: null };
|
|
10
|
+
}
|
|
11
|
+
const value = (_a = authorizationHeader.match(/Bearer\s+(.*)/i)) === null || _a === void 0 ? void 0 : _a[1];
|
|
12
|
+
if (!value) {
|
|
13
|
+
return { apiKey: null };
|
|
14
|
+
}
|
|
15
|
+
return getWorkOS().apiKeys.validateApiKey({ value });
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=validate-api-key.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validate-api-key.js","sourceRoot":"","sources":["../../src/validate-api-key.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AACxC,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAEvC,MAAM,CAAC,KAAK,UAAU,cAAc;;IAClC,MAAM,WAAW,GAAG,MAAM,OAAO,EAAE,CAAC;IACpC,MAAM,mBAAmB,GAAG,WAAW,CAAC,GAAG,CAAC,eAAe,CAAC,CAAC;IAC7D,IAAI,CAAC,mBAAmB,EAAE,CAAC;QACzB,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC;IAC1B,CAAC;IAED,MAAM,KAAK,GAAG,MAAA,mBAAmB,CAAC,KAAK,CAAC,gBAAgB,CAAC,0CAAG,CAAC,CAAC,CAAC;IAC/D,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC;IAC1B,CAAC;IAED,OAAO,SAAS,EAAE,CAAC,OAAO,CAAC,cAAc,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC;AACvD,CAAC"}
|
package/dist/esm/workos.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
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
|
-
export const VERSION = '2.
|
|
4
|
+
export const VERSION = '2.11.1';
|
|
5
5
|
const options = {
|
|
6
6
|
apiHostname: WORKOS_API_HOSTNAME,
|
|
7
7
|
https: WORKOS_API_HTTPS ? WORKOS_API_HTTPS === 'true' : true,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@workos-inc/authkit-nextjs",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.1",
|
|
4
4
|
"description": "Authentication and session helpers for using WorkOS & AuthKit with Next.js",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"type": "module",
|
|
@@ -35,13 +35,13 @@
|
|
|
35
35
|
"type-check": "tsc --project tsconfig.json --noEmit"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@workos-inc/node": "^7.
|
|
38
|
+
"@workos-inc/node": "^7.72.0",
|
|
39
39
|
"iron-session": "^8.0.1",
|
|
40
40
|
"jose": "^5.2.3",
|
|
41
41
|
"path-to-regexp": "^6.2.2"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
|
-
"next": "^13.5.9 || ^14.2.26 || ^15.2.3",
|
|
44
|
+
"next": "^13.5.9 || ^14.2.26 || ^15.2.3 || ^16",
|
|
45
45
|
"react": "^18.0 || ^19.0.0",
|
|
46
46
|
"react-dom": "^18.0 || ^19.0.0"
|
|
47
47
|
},
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"eslint-plugin-require-extensions": "^0.1.3",
|
|
58
58
|
"jest": "^29.7.0",
|
|
59
59
|
"jest-environment-jsdom": "^29.7.0",
|
|
60
|
-
"next": "^
|
|
60
|
+
"next": "^16.0.1",
|
|
61
61
|
"prettier": "^3.3.3",
|
|
62
62
|
"ts-jest": "^29.2.5",
|
|
63
63
|
"ts-node": "^10.9.2",
|
package/src/auth.ts
CHANGED
|
@@ -10,6 +10,16 @@ import { getAuthorizationUrl } from './get-authorization-url.js';
|
|
|
10
10
|
import type { AccessToken, SwitchToOrganizationOptions, UserInfo } from './interfaces.js';
|
|
11
11
|
import { getSessionFromCookie, refreshSession, withAuth } from './session.js';
|
|
12
12
|
import { getWorkOS } from './workos.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A wrapper around revalidateTag to provide compatibility with previous versions.
|
|
16
|
+
* @param tag The tag to revalidate.
|
|
17
|
+
*/
|
|
18
|
+
function revalidateTagCompat(tag: string): void {
|
|
19
|
+
const fn = revalidateTag as (tag: string, profile: string) => void;
|
|
20
|
+
return fn(tag, 'max');
|
|
21
|
+
}
|
|
22
|
+
|
|
13
23
|
export async function getSignInUrl({
|
|
14
24
|
organizationId,
|
|
15
25
|
loginHint,
|
|
@@ -111,7 +121,7 @@ export async function switchToOrganization(
|
|
|
111
121
|
break;
|
|
112
122
|
case 'tag':
|
|
113
123
|
for (const tag of revalidationTags) {
|
|
114
|
-
|
|
124
|
+
revalidateTagCompat(tag);
|
|
115
125
|
}
|
|
116
126
|
break;
|
|
117
127
|
}
|
|
@@ -2,9 +2,14 @@ import { NextRequest } from 'next/server';
|
|
|
2
2
|
import { WORKOS_CLIENT_ID } from './env-variables.js';
|
|
3
3
|
import { HandleAuthOptions } from './interfaces.js';
|
|
4
4
|
import { saveSession } from './session.js';
|
|
5
|
-
import { errorResponseWithFallback, redirectWithFallback } from './utils.js';
|
|
5
|
+
import { errorResponseWithFallback, redirectWithFallback, setCachePreventionHeaders } from './utils.js';
|
|
6
6
|
import { getWorkOS } from './workos.js';
|
|
7
7
|
|
|
8
|
+
function preventCaching(headers: Headers): void {
|
|
9
|
+
headers.set('Vary', 'Cookie');
|
|
10
|
+
setCachePreventionHeaders(headers);
|
|
11
|
+
}
|
|
12
|
+
|
|
8
13
|
function handleState(state: string | null) {
|
|
9
14
|
let returnPathname: string | undefined = undefined;
|
|
10
15
|
let userState: string | undefined;
|
|
@@ -90,6 +95,7 @@ export function handleAuth(options: HandleAuthOptions = {}) {
|
|
|
90
95
|
// Fall back to standard Response if NextResponse is not available.
|
|
91
96
|
// This is to support Next.js 13.
|
|
92
97
|
const response = redirectWithFallback(url.toString());
|
|
98
|
+
preventCaching(response.headers);
|
|
93
99
|
|
|
94
100
|
if (!accessToken || !refreshToken) throw new Error('response is missing tokens');
|
|
95
101
|
|
|
@@ -116,23 +122,28 @@ export function handleAuth(options: HandleAuthOptions = {}) {
|
|
|
116
122
|
|
|
117
123
|
console.error(errorRes);
|
|
118
124
|
|
|
119
|
-
return errorResponse(request, error);
|
|
125
|
+
return await errorResponse(request, error);
|
|
120
126
|
}
|
|
121
127
|
}
|
|
122
128
|
|
|
123
|
-
return errorResponse(request);
|
|
129
|
+
return await errorResponse(request);
|
|
124
130
|
};
|
|
125
131
|
|
|
126
|
-
function errorResponse(request: NextRequest, error?: unknown) {
|
|
132
|
+
async function errorResponse(request: NextRequest, error?: unknown) {
|
|
127
133
|
if (onError) {
|
|
128
|
-
|
|
134
|
+
const response = await onError({ error, request });
|
|
135
|
+
preventCaching(response.headers);
|
|
136
|
+
return response;
|
|
129
137
|
}
|
|
130
138
|
|
|
131
|
-
|
|
139
|
+
const response = errorResponseWithFallback({
|
|
132
140
|
error: {
|
|
133
141
|
message: 'Something went wrong',
|
|
134
142
|
description: "Couldn't sign in. If you are not sure what happened, please contact your organization admin.",
|
|
135
143
|
},
|
|
136
144
|
});
|
|
145
|
+
|
|
146
|
+
preventCaching(response.headers);
|
|
147
|
+
return response;
|
|
137
148
|
}
|
|
138
149
|
}
|
|
@@ -27,19 +27,6 @@ describe('Impersonation', () => {
|
|
|
27
27
|
impersonator: null,
|
|
28
28
|
user: { id: '123', email: 'user@example.com' },
|
|
29
29
|
organizationId: null,
|
|
30
|
-
loading: false,
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
const { container } = render(<Impersonation />);
|
|
34
|
-
expect(container).toBeEmptyDOMElement();
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it('should return null if loading', () => {
|
|
38
|
-
(useAuth as jest.Mock).mockReturnValue({
|
|
39
|
-
impersonator: { email: 'admin@example.com' },
|
|
40
|
-
user: { id: '123', email: 'user@example.com' },
|
|
41
|
-
organizationId: null,
|
|
42
|
-
loading: true,
|
|
43
30
|
});
|
|
44
31
|
|
|
45
32
|
const { container } = render(<Impersonation />);
|
|
@@ -51,7 +38,6 @@ describe('Impersonation', () => {
|
|
|
51
38
|
impersonator: { email: 'admin@example.com' },
|
|
52
39
|
user: { id: '123', email: 'user@example.com' },
|
|
53
40
|
organizationId: null,
|
|
54
|
-
loading: false,
|
|
55
41
|
});
|
|
56
42
|
|
|
57
43
|
const { container } = render(<Impersonation />);
|
|
@@ -63,7 +49,6 @@ describe('Impersonation', () => {
|
|
|
63
49
|
impersonator: { email: 'admin@example.com' },
|
|
64
50
|
user: { id: '123', email: 'user@example.com' },
|
|
65
51
|
organizationId: 'org_123',
|
|
66
|
-
loading: false,
|
|
67
52
|
});
|
|
68
53
|
|
|
69
54
|
(getOrganizationAction as jest.Mock).mockResolvedValue({
|
|
@@ -83,7 +68,6 @@ describe('Impersonation', () => {
|
|
|
83
68
|
impersonator: { email: 'admin@example.com' },
|
|
84
69
|
user: { id: '123', email: 'user@example.com' },
|
|
85
70
|
organizationId: null,
|
|
86
|
-
loading: false,
|
|
87
71
|
});
|
|
88
72
|
|
|
89
73
|
const { container } = render(<Impersonation />);
|
|
@@ -96,7 +80,6 @@ describe('Impersonation', () => {
|
|
|
96
80
|
impersonator: { email: 'admin@example.com' },
|
|
97
81
|
user: { id: '123', email: 'user@example.com' },
|
|
98
82
|
organizationId: null,
|
|
99
|
-
loading: false,
|
|
100
83
|
});
|
|
101
84
|
|
|
102
85
|
const { container } = render(<Impersonation side="top" />);
|
|
@@ -109,7 +92,6 @@ describe('Impersonation', () => {
|
|
|
109
92
|
impersonator: { email: 'admin@example.com' },
|
|
110
93
|
user: { id: '123', email: 'user@example.com' },
|
|
111
94
|
organizationId: null,
|
|
112
|
-
loading: false,
|
|
113
95
|
});
|
|
114
96
|
|
|
115
97
|
const customStyle = { backgroundColor: 'red' };
|
|
@@ -123,12 +105,146 @@ describe('Impersonation', () => {
|
|
|
123
105
|
impersonator: { email: 'admin@example.com' },
|
|
124
106
|
user: { id: '123', email: 'user@example.com' },
|
|
125
107
|
organizationId: null,
|
|
126
|
-
loading: false,
|
|
127
108
|
});
|
|
128
109
|
|
|
129
110
|
render(<Impersonation />);
|
|
130
111
|
const stopButton = await screen.findByText('Stop');
|
|
131
112
|
stopButton.click();
|
|
132
|
-
expect(handleSignOutAction).
|
|
113
|
+
expect(handleSignOutAction).toHaveBeenCalledWith({});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('should pass returnTo prop to handleSignOutAction when provided', async () => {
|
|
117
|
+
(useAuth as jest.Mock).mockReturnValue({
|
|
118
|
+
impersonator: { email: 'admin@example.com' },
|
|
119
|
+
user: { id: '123', email: 'user@example.com' },
|
|
120
|
+
organizationId: null,
|
|
121
|
+
loading: false,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const returnTo = '/dashboard';
|
|
125
|
+
render(<Impersonation returnTo={returnTo} />);
|
|
126
|
+
const stopButton = await screen.findByText('Stop');
|
|
127
|
+
stopButton.click();
|
|
128
|
+
expect(handleSignOutAction).toHaveBeenCalledWith({ returnTo });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should not call getOrganizationAction when organizationId is not provided', () => {
|
|
132
|
+
(useAuth as jest.Mock).mockReturnValue({
|
|
133
|
+
impersonator: { email: 'admin@example.com' },
|
|
134
|
+
user: { id: '123', email: 'user@example.com' },
|
|
135
|
+
organizationId: null,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
render(<Impersonation />);
|
|
139
|
+
expect(getOrganizationAction).not.toHaveBeenCalled();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should not call getOrganizationAction when impersonator is not present', () => {
|
|
143
|
+
(useAuth as jest.Mock).mockReturnValue({
|
|
144
|
+
impersonator: null,
|
|
145
|
+
user: { id: '123', email: 'user@example.com' },
|
|
146
|
+
organizationId: 'org_123',
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
render(<Impersonation />);
|
|
150
|
+
expect(getOrganizationAction).not.toHaveBeenCalled();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should not call getOrganizationAction when user is not present', () => {
|
|
154
|
+
(useAuth as jest.Mock).mockReturnValue({
|
|
155
|
+
impersonator: { email: 'admin@example.com' },
|
|
156
|
+
user: null,
|
|
157
|
+
organizationId: 'org_123',
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
render(<Impersonation />);
|
|
161
|
+
expect(getOrganizationAction).not.toHaveBeenCalled();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('should not call getOrganizationAction again when organization is already loaded with same ID', async () => {
|
|
165
|
+
const mockOrg = {
|
|
166
|
+
id: 'org_123',
|
|
167
|
+
name: 'Test Org',
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
(getOrganizationAction as jest.Mock).mockResolvedValue(mockOrg);
|
|
171
|
+
|
|
172
|
+
const { rerender } = await act(async () => {
|
|
173
|
+
(useAuth as jest.Mock).mockReturnValue({
|
|
174
|
+
impersonator: { email: 'admin@example.com' },
|
|
175
|
+
user: { id: '123', email: 'user@example.com' },
|
|
176
|
+
organizationId: 'org_123',
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
return render(<Impersonation />);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Wait for the initial call to complete
|
|
183
|
+
await act(async () => {
|
|
184
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
expect(getOrganizationAction).toHaveBeenCalledTimes(1);
|
|
188
|
+
|
|
189
|
+
// Rerender with the same organizationId
|
|
190
|
+
await act(async () => {
|
|
191
|
+
(useAuth as jest.Mock).mockReturnValue({
|
|
192
|
+
impersonator: { email: 'admin@example.com' },
|
|
193
|
+
user: { id: '123', email: 'user@example.com' },
|
|
194
|
+
organizationId: 'org_123',
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
rerender(<Impersonation />);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Should still be called only once
|
|
201
|
+
expect(getOrganizationAction).toHaveBeenCalledTimes(1);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('should call getOrganizationAction again when organizationId changes', async () => {
|
|
205
|
+
const mockOrg1 = {
|
|
206
|
+
id: 'org_123',
|
|
207
|
+
name: 'Test Org 1',
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const mockOrg2 = {
|
|
211
|
+
id: 'org_456',
|
|
212
|
+
name: 'Test Org 2',
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
(getOrganizationAction as jest.Mock).mockResolvedValueOnce(mockOrg1).mockResolvedValueOnce(mockOrg2);
|
|
216
|
+
|
|
217
|
+
const { rerender } = await act(async () => {
|
|
218
|
+
(useAuth as jest.Mock).mockReturnValue({
|
|
219
|
+
impersonator: { email: 'admin@example.com' },
|
|
220
|
+
user: { id: '123', email: 'user@example.com' },
|
|
221
|
+
organizationId: 'org_123',
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
return render(<Impersonation />);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// Wait for the initial call to complete
|
|
228
|
+
await act(async () => {
|
|
229
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
expect(getOrganizationAction).toHaveBeenCalledTimes(1);
|
|
233
|
+
expect(getOrganizationAction).toHaveBeenCalledWith('org_123');
|
|
234
|
+
|
|
235
|
+
// Rerender with a different organizationId
|
|
236
|
+
await act(async () => {
|
|
237
|
+
(useAuth as jest.Mock).mockReturnValue({
|
|
238
|
+
impersonator: { email: 'admin@example.com' },
|
|
239
|
+
user: { id: '123', email: 'user@example.com' },
|
|
240
|
+
organizationId: 'org_456',
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
rerender(<Impersonation />);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// Should be called again with the new ID
|
|
247
|
+
expect(getOrganizationAction).toHaveBeenCalledTimes(2);
|
|
248
|
+
expect(getOrganizationAction).toHaveBeenCalledWith('org_456');
|
|
133
249
|
});
|
|
134
250
|
});
|
|
@@ -9,19 +9,21 @@ import { useAuth } from './authkit-provider.js';
|
|
|
9
9
|
|
|
10
10
|
interface ImpersonationProps extends React.ComponentPropsWithoutRef<'div'> {
|
|
11
11
|
side?: 'top' | 'bottom';
|
|
12
|
+
returnTo?: string;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
export function Impersonation({ side = 'bottom', ...props }: ImpersonationProps) {
|
|
15
|
-
const { user, impersonator, organizationId
|
|
15
|
+
export function Impersonation({ side = 'bottom', returnTo, ...props }: ImpersonationProps) {
|
|
16
|
+
const { user, impersonator, organizationId } = useAuth();
|
|
16
17
|
|
|
17
18
|
const [organization, setOrganization] = React.useState<Organization | null>(null);
|
|
18
19
|
|
|
19
20
|
React.useEffect(() => {
|
|
20
|
-
if (!organizationId) return;
|
|
21
|
+
if (!organizationId || !impersonator || !user) return;
|
|
22
|
+
if (organization && organization.id === organizationId) return;
|
|
21
23
|
getOrganizationAction(organizationId).then(setOrganization);
|
|
22
|
-
}, [organizationId]);
|
|
24
|
+
}, [organizationId, impersonator, user]);
|
|
23
25
|
|
|
24
|
-
if (
|
|
26
|
+
if (!impersonator || !user) return null;
|
|
25
27
|
|
|
26
28
|
return (
|
|
27
29
|
<div
|
|
@@ -78,7 +80,7 @@ export function Impersonation({ side = 'bottom', ...props }: ImpersonationProps)
|
|
|
78
80
|
<form
|
|
79
81
|
onSubmit={async (event) => {
|
|
80
82
|
event.preventDefault();
|
|
81
|
-
await handleSignOutAction();
|
|
83
|
+
await handleSignOutAction({ returnTo });
|
|
82
84
|
}}
|
|
83
85
|
style={{
|
|
84
86
|
display: 'flex',
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { getSignInUrl, getSignUpUrl, signOut, switchToOrganization } from './aut
|
|
|
2
2
|
import { handleAuth } from './authkit-callback-route.js';
|
|
3
3
|
import { authkit, authkitMiddleware } from './middleware.js';
|
|
4
4
|
import { getTokenClaims, refreshSession, saveSession, withAuth } from './session.js';
|
|
5
|
+
import { validateApiKey } from './validate-api-key.js';
|
|
5
6
|
import { getWorkOS } from './workos.js';
|
|
6
7
|
|
|
7
8
|
export * from './interfaces.js';
|
|
@@ -19,4 +20,5 @@ export {
|
|
|
19
20
|
switchToOrganization,
|
|
20
21
|
withAuth,
|
|
21
22
|
getTokenClaims,
|
|
23
|
+
validateApiKey,
|
|
22
24
|
};
|
package/src/session.spec.ts
CHANGED
|
@@ -116,7 +116,7 @@ describe('session.ts', () => {
|
|
|
116
116
|
await expect(async () => {
|
|
117
117
|
await withAuth();
|
|
118
118
|
}).rejects.toThrow(
|
|
119
|
-
|
|
119
|
+
/You are calling 'withAuth' on https:\/\/example\.com\/ that isn't covered by the AuthKit middleware/,
|
|
120
120
|
);
|
|
121
121
|
});
|
|
122
122
|
|
|
@@ -126,9 +126,7 @@ describe('session.ts', () => {
|
|
|
126
126
|
|
|
127
127
|
await expect(async () => {
|
|
128
128
|
await withAuth({ ensureSignedIn: true });
|
|
129
|
-
}).rejects.toThrow(
|
|
130
|
-
"You are calling 'withAuth' on a route that isn’t covered by the AuthKit middleware. Make sure it is running on all paths you are calling 'withAuth' from by updating your middleware config in 'middleware.(js|ts)'.",
|
|
131
|
-
);
|
|
129
|
+
}).rejects.toThrow(/You are calling 'withAuth' on a route that isn't covered by the AuthKit middleware/);
|
|
132
130
|
});
|
|
133
131
|
|
|
134
132
|
it('should throw an error if the URL is not found in the headers', async () => {
|
package/src/session.ts
CHANGED
|
@@ -21,7 +21,7 @@ import { getWorkOS } from './workos.js';
|
|
|
21
21
|
|
|
22
22
|
import type { AuthenticationResponse } from '@workos-inc/node';
|
|
23
23
|
import { parse, tokensToRegexp } from 'path-to-regexp';
|
|
24
|
-
import { lazy, redirectWithFallback } from './utils.js';
|
|
24
|
+
import { lazy, redirectWithFallback, setCachePreventionHeaders } from './utils.js';
|
|
25
25
|
|
|
26
26
|
const sessionHeaderName = 'x-workos-session';
|
|
27
27
|
const middlewareHeaderName = 'x-workos-middleware';
|
|
@@ -30,6 +30,49 @@ const jwtCookieName = 'workos-access-token';
|
|
|
30
30
|
|
|
31
31
|
const JWKS = lazy(() => createRemoteJWKSet(new URL(getWorkOS().userManagement.getJwksUrl(WORKOS_CLIENT_ID))));
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Applies cache security headers with Vary header deduplication.
|
|
35
|
+
* Only applies headers if the request is authenticated (has session, cookie, or Authorization header).
|
|
36
|
+
* Used in middleware where existing Vary headers may already be present.
|
|
37
|
+
* @param headers - The Headers object to set the cache security headers on.
|
|
38
|
+
* @param request - The NextRequest object to check for authentication.
|
|
39
|
+
* @param sessionData - Optional session data to check for authentication.
|
|
40
|
+
*/
|
|
41
|
+
function applyCacheSecurityHeaders(
|
|
42
|
+
headers: Headers,
|
|
43
|
+
request: NextRequest,
|
|
44
|
+
sessionData?: { accessToken?: string } | Session,
|
|
45
|
+
): void {
|
|
46
|
+
const cookieName = WORKOS_COOKIE_NAME || 'wos-session';
|
|
47
|
+
|
|
48
|
+
// Only apply cache headers for authenticated requests
|
|
49
|
+
if (!sessionData?.accessToken && !request.cookies.has(cookieName) && !request.headers.has('authorization')) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const varyValues = new Set<string>(['cookie']);
|
|
54
|
+
if (request.headers.has('authorization')) {
|
|
55
|
+
varyValues.add('authorization');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const currentVary = headers.get('Vary');
|
|
59
|
+
if (currentVary) {
|
|
60
|
+
currentVary.split(',').forEach((v) => {
|
|
61
|
+
const trimmed = v.trim().toLowerCase();
|
|
62
|
+
if (trimmed) varyValues.add(trimmed);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
headers.set(
|
|
67
|
+
'Vary',
|
|
68
|
+
Array.from(varyValues)
|
|
69
|
+
.map((v) => v.charAt(0).toUpperCase() + v.slice(1))
|
|
70
|
+
.join(', '),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
setCachePreventionHeaders(headers);
|
|
74
|
+
}
|
|
75
|
+
|
|
33
76
|
/**
|
|
34
77
|
* Determines if a request is for an initial document load (not API/RSC/prefetch)
|
|
35
78
|
*/
|
|
@@ -120,7 +163,33 @@ async function updateSessionMiddleware(
|
|
|
120
163
|
headers.set(signUpPathsHeaderName, signUpPaths.join(','));
|
|
121
164
|
}
|
|
122
165
|
|
|
166
|
+
applyCacheSecurityHeaders(headers, request, session);
|
|
167
|
+
|
|
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
|
+
}
|
|
178
|
+
|
|
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);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Remove session header from response headers to prevent leakage
|
|
187
|
+
headers.delete(sessionHeaderName);
|
|
188
|
+
|
|
123
189
|
return NextResponse.next({
|
|
190
|
+
request: {
|
|
191
|
+
headers: requestHeaders,
|
|
192
|
+
},
|
|
124
193
|
headers,
|
|
125
194
|
});
|
|
126
195
|
}
|
|
@@ -172,6 +241,8 @@ async function updateSession(
|
|
|
172
241
|
|
|
173
242
|
const cookieName = WORKOS_COOKIE_NAME || 'wos-session';
|
|
174
243
|
|
|
244
|
+
applyCacheSecurityHeaders(newRequestHeaders, request, session);
|
|
245
|
+
|
|
175
246
|
if (hasValidSession) {
|
|
176
247
|
newRequestHeaders.set(sessionHeaderName, request.cookies.get(cookieName)!.value);
|
|
177
248
|
|
|
@@ -488,7 +559,7 @@ async function getSessionFromHeader(): Promise<Session | undefined> {
|
|
|
488
559
|
if (!hasMiddleware) {
|
|
489
560
|
const url = headersList.get('x-url');
|
|
490
561
|
throw new Error(
|
|
491
|
-
`You are calling 'withAuth' on ${url ?? 'a route'} that isn
|
|
562
|
+
`You are calling 'withAuth' on ${url ?? 'a route'} that isn't covered by the AuthKit middleware. Make sure it is running on all paths you are calling 'withAuth' from by updating your middleware config in 'middleware.(js|ts)'.`,
|
|
492
563
|
);
|
|
493
564
|
}
|
|
494
565
|
|
package/src/test-helpers.ts
CHANGED
package/src/utils.ts
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Sets cache prevention headers to prevent CDN/proxy caching.
|
|
5
|
+
* @param headers - The Headers object to set the cache prevention headers on.
|
|
6
|
+
*/
|
|
7
|
+
export function setCachePreventionHeaders(headers: Headers): void {
|
|
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
|
+
headers.set('x-middleware-cache', 'no-cache');
|
|
12
|
+
}
|
|
13
|
+
|
|
3
14
|
export function redirectWithFallback(redirectUri: string, headers?: Headers) {
|
|
4
15
|
const newHeaders = headers ? new Headers(headers) : new Headers();
|
|
5
16
|
newHeaders.set('Location', redirectUri);
|