@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.
Files changed (46) hide show
  1. package/README.md +124 -29
  2. package/dist/esm/components/tokenStore.js +110 -11
  3. package/dist/esm/components/tokenStore.js.map +1 -1
  4. package/dist/esm/components/useAccessToken.js +6 -1
  5. package/dist/esm/components/useAccessToken.js.map +1 -1
  6. package/dist/esm/cookie.js +51 -0
  7. package/dist/esm/cookie.js.map +1 -1
  8. package/dist/esm/middleware.js +2 -2
  9. package/dist/esm/middleware.js.map +1 -1
  10. package/dist/esm/session.js +35 -2
  11. package/dist/esm/session.js.map +1 -1
  12. package/dist/esm/test-helpers.js +57 -0
  13. package/dist/esm/test-helpers.js.map +1 -0
  14. package/dist/esm/types/components/tokenStore.d.ts +7 -2
  15. package/dist/esm/types/cookie.d.ts +1 -0
  16. package/dist/esm/types/interfaces.d.ts +2 -0
  17. package/dist/esm/types/middleware.d.ts +1 -1
  18. package/dist/esm/types/session.d.ts +1 -1
  19. package/dist/esm/types/test-helpers.d.ts +3 -0
  20. package/dist/esm/types/workos.d.ts +1 -1
  21. package/dist/esm/workos.js +1 -1
  22. package/package.json +4 -3
  23. package/src/actions.spec.ts +100 -0
  24. package/src/auth.spec.ts +347 -0
  25. package/src/authkit-callback-route.spec.ts +258 -0
  26. package/src/components/authkit-provider.spec.tsx +471 -0
  27. package/src/components/button.spec.tsx +46 -0
  28. package/src/components/impersonation.spec.tsx +134 -0
  29. package/src/components/min-max-button.spec.tsx +60 -0
  30. package/src/components/tokenStore.spec.ts +816 -0
  31. package/src/components/tokenStore.ts +147 -12
  32. package/src/components/useAccessToken.spec.tsx +731 -0
  33. package/src/components/useAccessToken.ts +6 -1
  34. package/src/components/useTokenClaims.spec.tsx +194 -0
  35. package/src/cookie.spec.ts +276 -0
  36. package/src/cookie.ts +56 -0
  37. package/src/get-authorization-url.spec.ts +60 -0
  38. package/src/interfaces.ts +2 -0
  39. package/src/jwt.spec.ts +159 -0
  40. package/src/middleware.ts +2 -1
  41. package/src/session.spec.ts +1162 -0
  42. package/src/session.ts +41 -1
  43. package/src/test-helpers.ts +70 -0
  44. package/src/utils.spec.ts +142 -0
  45. package/src/workos.spec.ts +67 -0
  46. 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.6.0';
5
+ export const VERSION = '2.7.1';
6
6
 
7
7
  const options = {
8
8
  apiHostname: WORKOS_API_HOSTNAME,