@workos-inc/authkit-nextjs 2.6.0 → 2.7.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 +124 -29
- package/dist/esm/components/tokenStore.js +110 -11
- package/dist/esm/components/tokenStore.js.map +1 -1
- package/dist/esm/components/useAccessToken.js +6 -1
- package/dist/esm/components/useAccessToken.js.map +1 -1
- package/dist/esm/cookie.js +51 -0
- package/dist/esm/cookie.js.map +1 -1
- package/dist/esm/middleware.js +2 -2
- package/dist/esm/middleware.js.map +1 -1
- package/dist/esm/session.js +35 -2
- package/dist/esm/session.js.map +1 -1
- package/dist/esm/test-helpers.js +57 -0
- package/dist/esm/test-helpers.js.map +1 -0
- package/dist/esm/types/components/tokenStore.d.ts +7 -2
- package/dist/esm/types/cookie.d.ts +1 -0
- package/dist/esm/types/interfaces.d.ts +2 -0
- package/dist/esm/types/middleware.d.ts +1 -1
- package/dist/esm/types/session.d.ts +1 -1
- package/dist/esm/types/test-helpers.d.ts +3 -0
- package/dist/esm/types/workos.d.ts +1 -1
- package/dist/esm/workos.js +1 -1
- package/package.json +4 -3
- package/src/actions.spec.ts +100 -0
- package/src/auth.spec.ts +347 -0
- package/src/authkit-callback-route.spec.ts +258 -0
- package/src/components/authkit-provider.spec.tsx +471 -0
- package/src/components/button.spec.tsx +46 -0
- package/src/components/impersonation.spec.tsx +134 -0
- package/src/components/min-max-button.spec.tsx +60 -0
- package/src/components/tokenStore.spec.ts +816 -0
- package/src/components/tokenStore.ts +147 -12
- package/src/components/useAccessToken.spec.tsx +731 -0
- package/src/components/useAccessToken.ts +6 -1
- package/src/components/useTokenClaims.spec.tsx +194 -0
- package/src/cookie.spec.ts +276 -0
- package/src/cookie.ts +56 -0
- package/src/get-authorization-url.spec.ts +60 -0
- package/src/interfaces.ts +2 -0
- package/src/jwt.spec.ts +159 -0
- package/src/middleware.ts +2 -1
- package/src/session.spec.ts +1162 -0
- package/src/session.ts +41 -1
- package/src/test-helpers.ts +70 -0
- package/src/utils.spec.ts +142 -0
- package/src/workos.spec.ts +67 -0
- package/src/workos.ts +1 -1
package/src/session.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { JWTPayload, createRemoteJWKSet, decodeJwt, jwtVerify } from 'jose';
|
|
|
5
5
|
import { cookies, headers } from 'next/headers';
|
|
6
6
|
import { redirect } from 'next/navigation';
|
|
7
7
|
import { NextRequest, NextResponse } from 'next/server';
|
|
8
|
-
import { getCookieOptions } from './cookie.js';
|
|
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
10
|
import { getAuthorizationUrl } from './get-authorization-url.js';
|
|
11
11
|
import {
|
|
@@ -26,9 +26,25 @@ import { lazy, redirectWithFallback } from './utils.js';
|
|
|
26
26
|
const sessionHeaderName = 'x-workos-session';
|
|
27
27
|
const middlewareHeaderName = 'x-workos-middleware';
|
|
28
28
|
const signUpPathsHeaderName = 'x-sign-up-paths';
|
|
29
|
+
const jwtCookieName = 'workos-access-token';
|
|
29
30
|
|
|
30
31
|
const JWKS = lazy(() => createRemoteJWKSet(new URL(getWorkOS().userManagement.getJwksUrl(WORKOS_CLIENT_ID))));
|
|
31
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Determines if a request is for an initial document load (not API/RSC/prefetch)
|
|
35
|
+
*/
|
|
36
|
+
function isInitialDocumentRequest(request: NextRequest): boolean {
|
|
37
|
+
const accept = request.headers.get('accept') || '';
|
|
38
|
+
const isDocumentRequest = accept.includes('text/html');
|
|
39
|
+
const isRSCRequest = request.headers.has('RSC') || request.headers.has('Next-Router-State-Tree');
|
|
40
|
+
const isPrefetch =
|
|
41
|
+
request.headers.get('Purpose') === 'prefetch' ||
|
|
42
|
+
request.headers.get('Sec-Purpose') === 'prefetch' ||
|
|
43
|
+
request.headers.has('Next-Router-Prefetch');
|
|
44
|
+
|
|
45
|
+
return isDocumentRequest && !isRSCRequest && !isPrefetch;
|
|
46
|
+
}
|
|
47
|
+
|
|
32
48
|
async function encryptSession(session: Session) {
|
|
33
49
|
return sealData(session, {
|
|
34
50
|
password: WORKOS_COOKIE_PASSWORD,
|
|
@@ -42,6 +58,7 @@ async function updateSessionMiddleware(
|
|
|
42
58
|
middlewareAuth: AuthkitMiddlewareAuth,
|
|
43
59
|
redirectUri: string,
|
|
44
60
|
signUpPaths: string[],
|
|
61
|
+
eagerAuth = false,
|
|
45
62
|
) {
|
|
46
63
|
if (!redirectUri && !WORKOS_REDIRECT_URI) {
|
|
47
64
|
throw new Error('You must provide a redirect URI in the AuthKit middleware or in the environment variables.');
|
|
@@ -86,6 +103,7 @@ async function updateSessionMiddleware(
|
|
|
86
103
|
debug,
|
|
87
104
|
redirectUri,
|
|
88
105
|
screenHint: getScreenHint(signUpPaths, request.nextUrl.pathname),
|
|
106
|
+
eagerAuth,
|
|
89
107
|
});
|
|
90
108
|
|
|
91
109
|
// If the user is logged out and this path isn't on the allowlist for logged out paths, redirect to AuthKit.
|
|
@@ -166,6 +184,16 @@ async function updateSession(
|
|
|
166
184
|
feature_flags: featureFlags,
|
|
167
185
|
} = decodeJwt<AccessToken>(session.accessToken);
|
|
168
186
|
|
|
187
|
+
// Set JWT cookie if eagerAuth is enabled
|
|
188
|
+
// Only set on document requests (initial page loads), not on API/RSC requests
|
|
189
|
+
if (options.eagerAuth && isInitialDocumentRequest(request)) {
|
|
190
|
+
const existingJwtCookie = request.cookies.get(jwtCookieName);
|
|
191
|
+
// Only set if cookie doesn't exist or has different value
|
|
192
|
+
if (!existingJwtCookie || existingJwtCookie.value !== session.accessToken) {
|
|
193
|
+
newRequestHeaders.append('Set-Cookie', getJwtCookie(session.accessToken, request.url));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
169
197
|
return {
|
|
170
198
|
session: {
|
|
171
199
|
sessionId,
|
|
@@ -213,6 +241,12 @@ async function updateSession(
|
|
|
213
241
|
newRequestHeaders.append('Set-Cookie', `${cookieName}=${encryptedSession}; ${getCookieOptions(request.url, true)}`);
|
|
214
242
|
newRequestHeaders.set(sessionHeaderName, encryptedSession);
|
|
215
243
|
|
|
244
|
+
// Set JWT cookie if eagerAuth is enabled
|
|
245
|
+
// Only set on document requests (initial page loads), not on API/RSC requests
|
|
246
|
+
if (options.eagerAuth && isInitialDocumentRequest(request)) {
|
|
247
|
+
newRequestHeaders.append('Set-Cookie', getJwtCookie(accessToken, request.url));
|
|
248
|
+
}
|
|
249
|
+
|
|
216
250
|
const {
|
|
217
251
|
sid: sessionId,
|
|
218
252
|
org_id: organizationId,
|
|
@@ -247,6 +281,12 @@ async function updateSession(
|
|
|
247
281
|
const deleteCookie = `${cookieName}=; Expires=${new Date(0).toUTCString()}; ${getCookieOptions(request.url, true, true)}`;
|
|
248
282
|
newRequestHeaders.append('Set-Cookie', deleteCookie);
|
|
249
283
|
|
|
284
|
+
// Delete JWT cookie if eagerAuth is enabled
|
|
285
|
+
if (options.eagerAuth) {
|
|
286
|
+
const deleteJwtCookie = getJwtCookie(null, request.url, true);
|
|
287
|
+
newRequestHeaders.append('Set-Cookie', deleteJwtCookie);
|
|
288
|
+
}
|
|
289
|
+
|
|
250
290
|
options.onSessionRefreshError?.({ error: e, request });
|
|
251
291
|
|
|
252
292
|
return {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// istanbul ignore file
|
|
2
|
+
|
|
3
|
+
import { sealData } from 'iron-session';
|
|
4
|
+
import { SignJWT } from 'jose';
|
|
5
|
+
import { WORKOS_COOKIE_NAME, WORKOS_COOKIE_PASSWORD } from './env-variables.js';
|
|
6
|
+
import { cookies } from 'next/headers';
|
|
7
|
+
import { User } from '@workos-inc/node';
|
|
8
|
+
|
|
9
|
+
export async function generateTestToken(payload = {}, expired = false) {
|
|
10
|
+
const defaultPayload = {
|
|
11
|
+
sid: 'session_123',
|
|
12
|
+
org_id: 'org_123',
|
|
13
|
+
role: 'member',
|
|
14
|
+
permissions: ['posts:create', 'posts:delete'],
|
|
15
|
+
entitlements: ['audit-logs'],
|
|
16
|
+
feature_flags: ['device-authorization-grant'],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const mergedPayload = { ...defaultPayload, ...payload };
|
|
20
|
+
|
|
21
|
+
const secret = new TextEncoder().encode(process.env.WORKOS_COOKIE_PASSWORD as string);
|
|
22
|
+
|
|
23
|
+
const token = await new SignJWT(mergedPayload)
|
|
24
|
+
.setProtectedHeader({ alg: 'HS256' })
|
|
25
|
+
.setIssuedAt()
|
|
26
|
+
.setIssuer('urn:example:issuer')
|
|
27
|
+
.setExpirationTime(expired ? '0s' : '2h')
|
|
28
|
+
.sign(secret);
|
|
29
|
+
|
|
30
|
+
return token;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function generateSession(overrides: Partial<User> = {}) {
|
|
34
|
+
const mockUser = {
|
|
35
|
+
id: 'user_123',
|
|
36
|
+
email: 'test@example.com',
|
|
37
|
+
emailVerified: true,
|
|
38
|
+
profilePictureUrl: null,
|
|
39
|
+
firstName: 'Test',
|
|
40
|
+
lastName: 'User',
|
|
41
|
+
object: 'user',
|
|
42
|
+
createdAt: '2024-01-01T00:00:00Z',
|
|
43
|
+
updatedAt: '2024-01-01T00:00:00Z',
|
|
44
|
+
lastSignInAt: '2024-01-01T00:00:00Z',
|
|
45
|
+
externalId: null,
|
|
46
|
+
metadata: {},
|
|
47
|
+
...overrides,
|
|
48
|
+
} satisfies User;
|
|
49
|
+
|
|
50
|
+
const accessToken = await generateTestToken({
|
|
51
|
+
sid: 'session_123',
|
|
52
|
+
org_id: 'org_123',
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Create and set a session cookie
|
|
56
|
+
const encryptedSession = await sealData(
|
|
57
|
+
{
|
|
58
|
+
accessToken,
|
|
59
|
+
refreshToken: 'refresh_token_123',
|
|
60
|
+
user: mockUser,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
password: WORKOS_COOKIE_PASSWORD as string,
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const cookieName = WORKOS_COOKIE_NAME || 'wos-session';
|
|
68
|
+
const nextCookies = await cookies();
|
|
69
|
+
nextCookies.set(cookieName, encryptedSession);
|
|
70
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { redirectWithFallback, errorResponseWithFallback } from './utils.js';
|
|
3
|
+
|
|
4
|
+
describe('utils', () => {
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
jest.resetModules();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
describe('redirectWithFallback', () => {
|
|
10
|
+
it('uses NextResponse.redirect when available', () => {
|
|
11
|
+
const redirectUrl = 'https://example.com';
|
|
12
|
+
const mockRedirect = jest.fn().mockReturnValue('redirected');
|
|
13
|
+
const originalRedirect = NextResponse.redirect;
|
|
14
|
+
|
|
15
|
+
NextResponse.redirect = mockRedirect;
|
|
16
|
+
|
|
17
|
+
const result = redirectWithFallback(redirectUrl);
|
|
18
|
+
|
|
19
|
+
expect(mockRedirect).toHaveBeenCalledWith(redirectUrl, { headers: undefined });
|
|
20
|
+
expect(result).toBe('redirected');
|
|
21
|
+
|
|
22
|
+
NextResponse.redirect = originalRedirect;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('uses headers when provided', () => {
|
|
26
|
+
const redirectUrl = 'https://example.com';
|
|
27
|
+
const headers = new Headers();
|
|
28
|
+
headers.set('Set-Cookie', 'test=1');
|
|
29
|
+
|
|
30
|
+
const result = redirectWithFallback(redirectUrl, headers);
|
|
31
|
+
|
|
32
|
+
expect(result.headers.get('Set-Cookie')).toBe('test=1');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('falls back to standard Response when NextResponse exists but redirect is undefined', async () => {
|
|
36
|
+
const redirectUrl = 'https://example.com';
|
|
37
|
+
|
|
38
|
+
jest.resetModules();
|
|
39
|
+
|
|
40
|
+
jest.mock('next/server', () => ({
|
|
41
|
+
NextResponse: {
|
|
42
|
+
// exists but has no redirect method
|
|
43
|
+
},
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
const { redirectWithFallback } = await import('./utils.js');
|
|
47
|
+
|
|
48
|
+
const result = redirectWithFallback(redirectUrl);
|
|
49
|
+
|
|
50
|
+
expect(result).toBeInstanceOf(Response);
|
|
51
|
+
expect(result.status).toBe(307);
|
|
52
|
+
expect(result.headers.get('Location')).toBe(redirectUrl);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('falls back to standard Response when NextResponse is undefined', async () => {
|
|
56
|
+
const redirectUrl = 'https://example.com';
|
|
57
|
+
|
|
58
|
+
jest.resetModules();
|
|
59
|
+
|
|
60
|
+
// Mock with undefined NextResponse
|
|
61
|
+
jest.mock('next/server', () => ({
|
|
62
|
+
NextResponse: undefined,
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
const { redirectWithFallback } = await import('./utils.js');
|
|
66
|
+
|
|
67
|
+
const result = redirectWithFallback(redirectUrl);
|
|
68
|
+
|
|
69
|
+
expect(result).toBeInstanceOf(Response);
|
|
70
|
+
expect(result.status).toBe(307);
|
|
71
|
+
expect(result.headers.get('Location')).toBe(redirectUrl);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('errorResponseWithFallback', () => {
|
|
76
|
+
const errorBody = {
|
|
77
|
+
error: {
|
|
78
|
+
message: 'Test error',
|
|
79
|
+
description: 'Test description',
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
it('uses NextResponse.json when available', () => {
|
|
84
|
+
const mockJson = jest.fn().mockReturnValue('error json response');
|
|
85
|
+
NextResponse.json = mockJson;
|
|
86
|
+
|
|
87
|
+
const result = errorResponseWithFallback(errorBody);
|
|
88
|
+
|
|
89
|
+
expect(mockJson).toHaveBeenCalledWith(errorBody, { status: 500 });
|
|
90
|
+
expect(result).toBe('error json response');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('falls back to standard Response when NextResponse is not available', () => {
|
|
94
|
+
const originalJson = NextResponse.json;
|
|
95
|
+
|
|
96
|
+
// @ts-expect-error - This is to test the fallback
|
|
97
|
+
delete NextResponse.json;
|
|
98
|
+
|
|
99
|
+
const result = errorResponseWithFallback(errorBody);
|
|
100
|
+
|
|
101
|
+
expect(result).toBeInstanceOf(Response);
|
|
102
|
+
expect(result.status).toBe(500);
|
|
103
|
+
expect(result.headers.get('Content-Type')).toBe('application/json');
|
|
104
|
+
|
|
105
|
+
NextResponse.json = originalJson;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('falls back to standard Response when NextResponse exists but json is undefined', async () => {
|
|
109
|
+
jest.resetModules();
|
|
110
|
+
|
|
111
|
+
jest.mock('next/server', () => ({
|
|
112
|
+
NextResponse: {
|
|
113
|
+
// exists but has no json method
|
|
114
|
+
},
|
|
115
|
+
}));
|
|
116
|
+
|
|
117
|
+
const { errorResponseWithFallback } = await import('./utils.js');
|
|
118
|
+
|
|
119
|
+
const result = errorResponseWithFallback(errorBody);
|
|
120
|
+
|
|
121
|
+
expect(result).toBeInstanceOf(Response);
|
|
122
|
+
expect(result.status).toBe(500);
|
|
123
|
+
expect(result.headers.get('Content-Type')).toBe('application/json');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('falls back to standard Response when NextResponse is undefined', async () => {
|
|
127
|
+
jest.resetModules();
|
|
128
|
+
|
|
129
|
+
jest.mock('next/server', () => ({
|
|
130
|
+
NextResponse: undefined,
|
|
131
|
+
}));
|
|
132
|
+
|
|
133
|
+
const { errorResponseWithFallback } = await import('./utils.js');
|
|
134
|
+
|
|
135
|
+
const result = errorResponseWithFallback(errorBody);
|
|
136
|
+
|
|
137
|
+
expect(result).toBeInstanceOf(Response);
|
|
138
|
+
expect(result.status).toBe(500);
|
|
139
|
+
expect(result.headers.get('Content-Type')).toBe('application/json');
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { WorkOS } from '@workos-inc/node';
|
|
2
|
+
import { getWorkOS, VERSION } from './workos.js';
|
|
3
|
+
|
|
4
|
+
describe('workos', () => {
|
|
5
|
+
const workos = getWorkOS();
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
jest.clearAllMocks();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('initializes WorkOS with the correct configuration', () => {
|
|
11
|
+
// Extracting the config to avoid a circular dependency error
|
|
12
|
+
const workosConfig = {
|
|
13
|
+
apiHostname: workos.options.apiHostname,
|
|
14
|
+
https: workos.options.https,
|
|
15
|
+
port: workos.options.port,
|
|
16
|
+
appInfo: workos.options.appInfo,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
expect(workosConfig).toEqual({
|
|
20
|
+
apiHostname: undefined,
|
|
21
|
+
https: true,
|
|
22
|
+
port: undefined,
|
|
23
|
+
appInfo: {
|
|
24
|
+
name: 'authkit/nextjs',
|
|
25
|
+
version: VERSION,
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('exports a WorkOS instance', () => {
|
|
31
|
+
expect(workos).toBeInstanceOf(WorkOS);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('with custom environment variables', () => {
|
|
35
|
+
const originalEnv = process.env;
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
jest.resetModules();
|
|
39
|
+
process.env = { ...originalEnv };
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
process.env = originalEnv;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('uses custom API hostname when provided', async () => {
|
|
47
|
+
process.env.WORKOS_API_HOSTNAME = 'custom.workos.com';
|
|
48
|
+
const { getWorkOS: customWorkos } = await import('./workos.js');
|
|
49
|
+
|
|
50
|
+
expect(customWorkos().options.apiHostname).toEqual('custom.workos.com');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('uses custom HTTPS setting when provided', async () => {
|
|
54
|
+
process.env.WORKOS_API_HTTPS = 'false';
|
|
55
|
+
const { getWorkOS: customWorkos } = await import('./workos.js');
|
|
56
|
+
|
|
57
|
+
expect(customWorkos().options.https).toEqual(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('uses custom port when provided', async () => {
|
|
61
|
+
process.env.WORKOS_API_PORT = '8080';
|
|
62
|
+
const { getWorkOS: customWorkos } = await import('./workos.js');
|
|
63
|
+
|
|
64
|
+
expect(customWorkos().options.port).toEqual(8080);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
});
|
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.
|
|
5
|
+
export const VERSION = '2.7.1';
|
|
6
6
|
|
|
7
7
|
const options = {
|
|
8
8
|
apiHostname: WORKOS_API_HOSTNAME,
|