@workos-inc/authkit-nextjs 3.0.0-beta.1 → 3.0.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 +276 -102
- package/dist/esm/actions.js +35 -4
- package/dist/esm/actions.js.map +1 -1
- package/dist/esm/auth.js +51 -20
- package/dist/esm/auth.js.map +1 -1
- package/dist/esm/authkit-callback-route.js +82 -93
- package/dist/esm/authkit-callback-route.js.map +1 -1
- package/dist/esm/components/authkit-provider.js +36 -15
- package/dist/esm/components/authkit-provider.js.map +1 -1
- package/dist/esm/components/impersonation.js +17 -15
- package/dist/esm/components/impersonation.js.map +1 -1
- package/dist/esm/components/min-max-button.js +1 -1
- package/dist/esm/components/min-max-button.js.map +1 -1
- package/dist/esm/components/tokenStore.js +28 -19
- package/dist/esm/components/tokenStore.js.map +1 -1
- package/dist/esm/components/useAccessToken.js +1 -1
- package/dist/esm/components/useAccessToken.js.map +1 -1
- package/dist/esm/components/useTokenClaims.js +1 -1
- package/dist/esm/components/useTokenClaims.js.map +1 -1
- package/dist/esm/cookie.js +16 -5
- package/dist/esm/cookie.js.map +1 -1
- package/dist/esm/env-variables.js +6 -6
- package/dist/esm/env-variables.js.map +1 -1
- package/dist/esm/errors.js +36 -0
- package/dist/esm/errors.js.map +1 -0
- package/dist/esm/get-authorization-url.js +51 -12
- package/dist/esm/get-authorization-url.js.map +1 -1
- package/dist/esm/index.js +5 -2
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/interfaces.js +7 -1
- package/dist/esm/interfaces.js.map +1 -1
- package/dist/esm/middleware-helpers.js +102 -0
- package/dist/esm/middleware-helpers.js.map +1 -0
- package/dist/esm/middleware.js +3 -1
- package/dist/esm/middleware.js.map +1 -1
- package/dist/esm/pkce.js +38 -0
- package/dist/esm/pkce.js.map +1 -0
- package/dist/esm/session.js +73 -35
- package/dist/esm/session.js.map +1 -1
- package/dist/esm/test-helpers.js +1 -1
- package/dist/esm/test-helpers.js.map +1 -1
- package/dist/esm/types/actions.d.ts +34 -5
- package/dist/esm/types/auth.d.ts +7 -15
- package/dist/esm/types/components/authkit-provider.d.ts +6 -2
- package/dist/esm/types/components/impersonation.d.ts +2 -1
- package/dist/esm/types/cookie.d.ts +8 -0
- package/dist/esm/types/env-variables.d.ts +2 -1
- package/dist/esm/types/errors.d.ts +15 -0
- package/dist/esm/types/get-authorization-url.d.ts +2 -2
- package/dist/esm/types/index.d.ts +5 -2
- package/dist/esm/types/interfaces.d.ts +12 -0
- package/dist/esm/types/jwt.d.ts +9 -9
- package/dist/esm/types/middleware-helpers.d.ts +27 -0
- package/dist/esm/types/middleware.d.ts +3 -1
- package/dist/esm/types/pkce.d.ts +12 -0
- package/dist/esm/types/session.d.ts +1 -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 -2
- package/dist/esm/utils.js.map +1 -1
- package/dist/esm/validate-api-key.js +16 -0
- package/dist/esm/validate-api-key.js.map +1 -0
- package/dist/esm/workos.js +1 -1
- package/package.json +32 -34
- package/src/actions.spec.ts +94 -17
- package/src/actions.ts +44 -5
- package/src/auth.spec.ts +60 -29
- package/src/auth.ts +55 -41
- package/src/authkit-callback-route.spec.ts +310 -58
- package/src/authkit-callback-route.ts +106 -103
- package/src/components/authkit-provider.spec.tsx +264 -70
- package/src/components/authkit-provider.tsx +40 -15
- package/src/components/button.spec.tsx +4 -6
- package/src/components/impersonation.spec.tsx +152 -35
- package/src/components/impersonation.tsx +37 -30
- package/src/components/min-max-button.spec.tsx +2 -1
- package/src/components/tokenStore.spec.ts +59 -44
- package/src/components/tokenStore.ts +11 -3
- package/src/components/useAccessToken.spec.tsx +82 -83
- package/src/components/useTokenClaims.spec.tsx +23 -22
- package/src/cookie.spec.ts +14 -9
- package/src/cookie.ts +29 -0
- package/src/env-variables.ts +2 -0
- package/src/errors.spec.ts +108 -0
- package/src/errors.ts +46 -0
- package/src/get-authorization-url.spec.ts +170 -15
- package/src/get-authorization-url.ts +69 -23
- package/src/index.ts +20 -2
- package/src/interfaces.ts +15 -0
- package/src/jwt.ts +9 -9
- package/src/middleware-helpers.spec.ts +238 -0
- package/src/middleware-helpers.ts +134 -0
- package/src/middleware.spec.ts +25 -0
- package/src/middleware.ts +4 -1
- package/src/pkce.spec.ts +125 -0
- package/src/pkce.ts +42 -0
- package/src/session.spec.ts +87 -89
- package/src/session.ts +91 -27
- package/src/test-helpers.ts +1 -1
- package/src/utils.spec.ts +14 -31
- package/src/utils.ts +9 -0
- package/src/validate-api-key.spec.ts +111 -0
- package/src/validate-api-key.ts +19 -0
- package/src/workos.spec.ts +2 -2
- package/src/workos.ts +1 -1
package/src/cookie.ts
CHANGED
|
@@ -92,6 +92,35 @@ export function getCookieOptions(
|
|
|
92
92
|
};
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Cookie options for the PKCE verifier cookie.
|
|
97
|
+
* 'strict' blocks the cookie on the cross-site redirect back from WorkOS; downgrade to 'lax'.
|
|
98
|
+
* 'none' is more permissive and must be preserved for iframe/cross-origin embed flows.
|
|
99
|
+
*/
|
|
100
|
+
export function getPKCECookieOptions(): CookieOptions;
|
|
101
|
+
export function getPKCECookieOptions(redirectUri: string | null | undefined, asString: true, expired?: boolean): string;
|
|
102
|
+
export function getPKCECookieOptions(
|
|
103
|
+
redirectUri?: string | null,
|
|
104
|
+
asString?: boolean,
|
|
105
|
+
expired?: boolean,
|
|
106
|
+
): CookieOptions | string;
|
|
107
|
+
export function getPKCECookieOptions(
|
|
108
|
+
redirectUri?: string | null,
|
|
109
|
+
asString: boolean = false,
|
|
110
|
+
expired: boolean = false,
|
|
111
|
+
): CookieOptions | string {
|
|
112
|
+
if (asString) {
|
|
113
|
+
const options = getCookieOptions(redirectUri, true, expired);
|
|
114
|
+
return options.replace(/SameSite=Strict/i, 'SameSite=Lax');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const options = getCookieOptions(redirectUri);
|
|
118
|
+
return {
|
|
119
|
+
...options,
|
|
120
|
+
sameSite: options.sameSite.toLowerCase() === 'strict' ? 'lax' : options.sameSite,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
95
124
|
export function getJwtCookie(body: string | null, requestUrlOrRedirectUri?: string | null, expired?: boolean): string {
|
|
96
125
|
const cookie = `${JWT_COOKIE_NAME}=${expired ? '' : (body ?? '')}`;
|
|
97
126
|
|
package/src/env-variables.ts
CHANGED
|
@@ -12,6 +12,7 @@ const WORKOS_COOKIE_DOMAIN = getEnvVariable('WORKOS_COOKIE_DOMAIN');
|
|
|
12
12
|
const WORKOS_COOKIE_MAX_AGE = getEnvVariable('WORKOS_COOKIE_MAX_AGE');
|
|
13
13
|
const WORKOS_COOKIE_NAME = getEnvVariable('WORKOS_COOKIE_NAME');
|
|
14
14
|
const WORKOS_COOKIE_SAMESITE = getEnvVariable('WORKOS_COOKIE_SAMESITE') as 'lax' | 'strict' | 'none' | undefined;
|
|
15
|
+
const WORKOS_CLAIM_TOKEN = getEnvVariable('WORKOS_CLAIM_TOKEN');
|
|
15
16
|
|
|
16
17
|
// Required env variables
|
|
17
18
|
const WORKOS_API_KEY = getEnvVariable('WORKOS_API_KEY') ?? '';
|
|
@@ -24,6 +25,7 @@ export {
|
|
|
24
25
|
WORKOS_API_HTTPS,
|
|
25
26
|
WORKOS_API_KEY,
|
|
26
27
|
WORKOS_API_PORT,
|
|
28
|
+
WORKOS_CLAIM_TOKEN,
|
|
27
29
|
WORKOS_CLIENT_ID,
|
|
28
30
|
WORKOS_COOKIE_DOMAIN,
|
|
29
31
|
WORKOS_COOKIE_MAX_AGE,
|
|
@@ -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,33 +1,45 @@
|
|
|
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
|
-
|
|
6
|
-
jest.mock('next/headers');
|
|
4
|
+
import { getStateFromPKCECookieValue } from './pkce.js';
|
|
7
5
|
|
|
8
6
|
// Mock dependencies
|
|
9
|
-
const fakeWorkosInstance = {
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
const { fakeWorkosInstance } = vi.hoisted(() => ({
|
|
8
|
+
fakeWorkosInstance: {
|
|
9
|
+
baseURL: 'https://api.workos.com',
|
|
10
|
+
userManagement: {
|
|
11
|
+
getAuthorizationUrl: vi.fn(),
|
|
12
|
+
},
|
|
13
|
+
pkce: {
|
|
14
|
+
generate: vi.fn().mockResolvedValue({
|
|
15
|
+
codeVerifier: 'test-code-verifier',
|
|
16
|
+
codeChallenge: 'test-code-challenge',
|
|
17
|
+
codeChallengeMethod: 'S256' as const,
|
|
18
|
+
}),
|
|
19
|
+
},
|
|
12
20
|
},
|
|
13
|
-
};
|
|
21
|
+
}));
|
|
14
22
|
|
|
15
|
-
|
|
16
|
-
getWorkOS:
|
|
23
|
+
vi.mock('./workos', () => ({
|
|
24
|
+
getWorkOS: vi.fn(() => fakeWorkosInstance),
|
|
17
25
|
}));
|
|
18
26
|
|
|
19
27
|
describe('getAuthorizationUrl', () => {
|
|
20
28
|
const workos = getWorkOS();
|
|
21
29
|
beforeEach(() => {
|
|
22
|
-
|
|
30
|
+
vi.clearAllMocks();
|
|
31
|
+
fakeWorkosInstance.pkce.generate.mockResolvedValue({
|
|
32
|
+
codeVerifier: 'test-code-verifier',
|
|
33
|
+
codeChallenge: 'test-code-challenge',
|
|
34
|
+
codeChallengeMethod: 'S256' as const,
|
|
35
|
+
});
|
|
23
36
|
});
|
|
24
37
|
|
|
25
38
|
it('uses x-redirect-uri header when redirectUri option is not provided', async () => {
|
|
26
39
|
const nextHeaders = await headers();
|
|
27
40
|
nextHeaders.set('x-redirect-uri', 'http://test-redirect.com');
|
|
28
41
|
|
|
29
|
-
|
|
30
|
-
jest.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
|
|
42
|
+
vi.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
|
|
31
43
|
|
|
32
44
|
await getAuthorizationUrl({});
|
|
33
45
|
|
|
@@ -39,15 +51,15 @@ describe('getAuthorizationUrl', () => {
|
|
|
39
51
|
});
|
|
40
52
|
|
|
41
53
|
it('works when called with no arguments', async () => {
|
|
42
|
-
|
|
54
|
+
vi.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
|
|
43
55
|
|
|
44
|
-
await getAuthorizationUrl();
|
|
56
|
+
await getAuthorizationUrl();
|
|
45
57
|
|
|
46
58
|
expect(workos.userManagement.getAuthorizationUrl).toHaveBeenCalled();
|
|
47
59
|
});
|
|
48
60
|
|
|
49
61
|
it('works when prompt is provided', async () => {
|
|
50
|
-
|
|
62
|
+
vi.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
|
|
51
63
|
|
|
52
64
|
await getAuthorizationUrl({ prompt: 'consent' });
|
|
53
65
|
|
|
@@ -57,4 +69,147 @@ describe('getAuthorizationUrl', () => {
|
|
|
57
69
|
}),
|
|
58
70
|
);
|
|
59
71
|
});
|
|
72
|
+
|
|
73
|
+
describe('claim nonce', () => {
|
|
74
|
+
afterEach(() => {
|
|
75
|
+
delete process.env.WORKOS_CLAIM_TOKEN;
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('does not fetch nonce when WORKOS_CLAIM_TOKEN is not set', async () => {
|
|
79
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
80
|
+
vi.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
|
|
81
|
+
|
|
82
|
+
await getAuthorizationUrl({});
|
|
83
|
+
|
|
84
|
+
expect(fetchSpy).not.toHaveBeenCalledWith(expect.stringContaining('claim-nonces'), expect.anything());
|
|
85
|
+
expect(workos.userManagement.getAuthorizationUrl).toHaveBeenCalledWith(
|
|
86
|
+
expect.not.objectContaining({ claimNonce: expect.any(String) }),
|
|
87
|
+
);
|
|
88
|
+
fetchSpy.mockRestore();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('fetches nonce and passes claimNonce when WORKOS_CLAIM_TOKEN is set', async () => {
|
|
92
|
+
process.env.WORKOS_CLAIM_TOKEN = 'test-claim-token';
|
|
93
|
+
const fetchSpy = vi
|
|
94
|
+
.spyOn(globalThis, 'fetch')
|
|
95
|
+
.mockResolvedValueOnce(new Response(JSON.stringify({ nonce: 'test-nonce' }), { status: 201 }));
|
|
96
|
+
|
|
97
|
+
vi.resetModules();
|
|
98
|
+
const { getAuthorizationUrl: freshGetAuthorizationUrl } = await import('./get-authorization-url.js');
|
|
99
|
+
vi.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
|
|
100
|
+
|
|
101
|
+
await freshGetAuthorizationUrl({});
|
|
102
|
+
|
|
103
|
+
expect(fetchSpy).toHaveBeenCalledWith(
|
|
104
|
+
'https://api.workos.com/x/one-shot-environments/claim-nonces',
|
|
105
|
+
expect.objectContaining({
|
|
106
|
+
method: 'POST',
|
|
107
|
+
headers: { 'Content-Type': 'application/json' },
|
|
108
|
+
body: JSON.stringify({ client_id: 'client_1234567890', claim_token: 'test-claim-token' }),
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
expect(workos.userManagement.getAuthorizationUrl).toHaveBeenCalledWith(
|
|
112
|
+
expect.objectContaining({ claimNonce: 'test-nonce' }),
|
|
113
|
+
);
|
|
114
|
+
fetchSpy.mockRestore();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('proceeds without nonce on network error', async () => {
|
|
118
|
+
process.env.WORKOS_CLAIM_TOKEN = 'test-claim-token';
|
|
119
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('Network error'));
|
|
120
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
121
|
+
|
|
122
|
+
vi.resetModules();
|
|
123
|
+
const { getAuthorizationUrl: freshGetAuthorizationUrl } = await import('./get-authorization-url.js');
|
|
124
|
+
vi.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
|
|
125
|
+
|
|
126
|
+
await freshGetAuthorizationUrl({});
|
|
127
|
+
|
|
128
|
+
expect(workos.userManagement.getAuthorizationUrl).toHaveBeenCalledWith(
|
|
129
|
+
expect.not.objectContaining({ claimNonce: expect.any(String) }),
|
|
130
|
+
);
|
|
131
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
132
|
+
'[authkit-nextjs]: Failed to exchange WORKOS_CLAIM_TOKEN. Try removing WORKOS_CLAIM_TOKEN from your environment variables.',
|
|
133
|
+
expect.any(Error),
|
|
134
|
+
);
|
|
135
|
+
fetchSpy.mockRestore();
|
|
136
|
+
warnSpy.mockRestore();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('proceeds without nonce on non-OK response', async () => {
|
|
140
|
+
process.env.WORKOS_CLAIM_TOKEN = 'test-claim-token';
|
|
141
|
+
const fetchSpy = vi
|
|
142
|
+
.spyOn(globalThis, 'fetch')
|
|
143
|
+
.mockResolvedValueOnce(new Response('Unauthorized', { status: 401 }));
|
|
144
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
145
|
+
|
|
146
|
+
vi.resetModules();
|
|
147
|
+
const { getAuthorizationUrl: freshGetAuthorizationUrl } = await import('./get-authorization-url.js');
|
|
148
|
+
vi.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
|
|
149
|
+
|
|
150
|
+
await freshGetAuthorizationUrl({});
|
|
151
|
+
|
|
152
|
+
expect(workos.userManagement.getAuthorizationUrl).toHaveBeenCalledWith(
|
|
153
|
+
expect.not.objectContaining({ claimNonce: expect.any(String) }),
|
|
154
|
+
);
|
|
155
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
156
|
+
'[authkit-nextjs]: Failed to exchange WORKOS_CLAIM_TOKEN (401). Try removing WORKOS_CLAIM_TOKEN from your environment variables.',
|
|
157
|
+
);
|
|
158
|
+
fetchSpy.mockRestore();
|
|
159
|
+
warnSpy.mockRestore();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('includes PKCE and claim nonce together', async () => {
|
|
163
|
+
process.env.WORKOS_CLAIM_TOKEN = 'test-claim-token';
|
|
164
|
+
const fetchSpy = vi
|
|
165
|
+
.spyOn(globalThis, 'fetch')
|
|
166
|
+
.mockResolvedValueOnce(new Response(JSON.stringify({ nonce: 'test-nonce' }), { status: 201 }));
|
|
167
|
+
|
|
168
|
+
vi.resetModules();
|
|
169
|
+
const { getAuthorizationUrl: freshGetAuthorizationUrl } = await import('./get-authorization-url.js');
|
|
170
|
+
vi.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
|
|
171
|
+
|
|
172
|
+
const result = await freshGetAuthorizationUrl({});
|
|
173
|
+
|
|
174
|
+
expect(workos.userManagement.getAuthorizationUrl).toHaveBeenCalledWith(
|
|
175
|
+
expect.objectContaining({
|
|
176
|
+
claimNonce: 'test-nonce',
|
|
177
|
+
codeChallenge: 'test-code-challenge',
|
|
178
|
+
codeChallengeMethod: 'S256',
|
|
179
|
+
}),
|
|
180
|
+
);
|
|
181
|
+
expect(result.sealedState).toBeDefined();
|
|
182
|
+
fetchSpy.mockRestore();
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe('PKCE', () => {
|
|
187
|
+
it('always generates PKCE pair and includes code challenge', async () => {
|
|
188
|
+
vi.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
|
|
189
|
+
|
|
190
|
+
const result = await getAuthorizationUrl({});
|
|
191
|
+
|
|
192
|
+
expect(fakeWorkosInstance.pkce.generate).toHaveBeenCalled();
|
|
193
|
+
expect(workos.userManagement.getAuthorizationUrl).toHaveBeenCalledWith(
|
|
194
|
+
expect.objectContaining({
|
|
195
|
+
codeChallenge: 'test-code-challenge',
|
|
196
|
+
codeChallengeMethod: 'S256',
|
|
197
|
+
}),
|
|
198
|
+
);
|
|
199
|
+
expect(result.url).toBe('mock-url');
|
|
200
|
+
expect(result.sealedState).toBeDefined();
|
|
201
|
+
expect(result.sealedState).not.toBe('');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('seals codeVerifier and nonce into state', async () => {
|
|
205
|
+
vi.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
|
|
206
|
+
|
|
207
|
+
const result = await getAuthorizationUrl({});
|
|
208
|
+
|
|
209
|
+
const { codeVerifier, nonce } = await getStateFromPKCECookieValue(result.sealedState);
|
|
210
|
+
expect(codeVerifier).toBe('test-code-verifier');
|
|
211
|
+
expect(nonce).toBeDefined();
|
|
212
|
+
expect(typeof nonce).toBe('string');
|
|
213
|
+
});
|
|
214
|
+
});
|
|
60
215
|
});
|
|
@@ -1,37 +1,83 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { WORKOS_CLIENT_ID, WORKOS_REDIRECT_URI } from './env-variables.js';
|
|
3
|
-
import { GetAuthURLOptions } from './interfaces.js';
|
|
1
|
+
import { sealData } from 'iron-session';
|
|
4
2
|
import { headers } from 'next/headers';
|
|
3
|
+
import { WORKOS_CLAIM_TOKEN, WORKOS_CLIENT_ID, WORKOS_COOKIE_PASSWORD, WORKOS_REDIRECT_URI } from './env-variables.js';
|
|
4
|
+
import { GetAuthURLOptions, GetAuthURLResult, State } from './interfaces.js';
|
|
5
|
+
import { getWorkOS } from './workos.js';
|
|
5
6
|
|
|
6
|
-
async function
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
7
|
+
async function fetchClaimNonce(baseURL: string): Promise<string | null> {
|
|
8
|
+
try {
|
|
9
|
+
const response = await fetch(`${baseURL}/x/one-shot-environments/claim-nonces`, {
|
|
10
|
+
method: 'POST',
|
|
11
|
+
headers: { 'Content-Type': 'application/json' },
|
|
12
|
+
body: JSON.stringify({
|
|
13
|
+
client_id: WORKOS_CLIENT_ID,
|
|
14
|
+
claim_token: WORKOS_CLAIM_TOKEN,
|
|
15
|
+
}),
|
|
16
|
+
});
|
|
17
|
+
if (!response.ok) {
|
|
18
|
+
if (response.status !== 409) {
|
|
19
|
+
console.warn(
|
|
20
|
+
`[authkit-nextjs]: Failed to exchange WORKOS_CLAIM_TOKEN (${response.status}). Try removing WORKOS_CLAIM_TOKEN from your environment variables.`,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
const data = await response.json();
|
|
26
|
+
return data.nonce;
|
|
27
|
+
} catch (error) {
|
|
28
|
+
console.warn(
|
|
29
|
+
'[authkit-nextjs]: Failed to exchange WORKOS_CLAIM_TOKEN. Try removing WORKOS_CLAIM_TOKEN from your environment variables.',
|
|
30
|
+
error,
|
|
31
|
+
);
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function getAuthorizationUrl({
|
|
37
|
+
returnPathname,
|
|
38
|
+
screenHint,
|
|
39
|
+
organizationId,
|
|
40
|
+
loginHint,
|
|
41
|
+
prompt,
|
|
42
|
+
state: customState,
|
|
43
|
+
redirectUri,
|
|
44
|
+
}: GetAuthURLOptions = {}): Promise<GetAuthURLResult> {
|
|
45
|
+
const redirectUriToUse = await (async () => {
|
|
46
|
+
if (redirectUri) {
|
|
47
|
+
return redirectUri;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const headersList = await headers();
|
|
51
|
+
return headersList.get('x-redirect-uri') ?? undefined;
|
|
52
|
+
})();
|
|
17
53
|
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
: null;
|
|
54
|
+
const pkce = await getWorkOS().pkce.generate();
|
|
55
|
+
const claimNonce = WORKOS_CLAIM_TOKEN ? await fetchClaimNonce(getWorkOS().baseURL) : null;
|
|
21
56
|
|
|
22
|
-
const
|
|
23
|
-
|
|
57
|
+
const state = {
|
|
58
|
+
nonce: crypto.randomUUID(),
|
|
59
|
+
codeVerifier: pkce.codeVerifier,
|
|
60
|
+
customState,
|
|
61
|
+
returnPathname,
|
|
62
|
+
} satisfies State;
|
|
63
|
+
|
|
64
|
+
const sealedState = await sealData(state, { password: WORKOS_COOKIE_PASSWORD, ttl: 600 });
|
|
24
65
|
|
|
25
|
-
|
|
26
|
-
provider: 'authkit',
|
|
66
|
+
const url = getWorkOS().userManagement.getAuthorizationUrl({
|
|
67
|
+
provider: 'authkit' as const,
|
|
27
68
|
clientId: WORKOS_CLIENT_ID,
|
|
28
|
-
redirectUri:
|
|
29
|
-
state: finalState,
|
|
69
|
+
redirectUri: redirectUriToUse ?? WORKOS_REDIRECT_URI,
|
|
30
70
|
screenHint,
|
|
31
71
|
organizationId,
|
|
32
72
|
loginHint,
|
|
33
73
|
prompt,
|
|
74
|
+
state: sealedState,
|
|
75
|
+
codeChallenge: pkce.codeChallenge,
|
|
76
|
+
codeChallengeMethod: pkce.codeChallengeMethod,
|
|
77
|
+
...(claimNonce && { claimNonce }),
|
|
34
78
|
});
|
|
79
|
+
|
|
80
|
+
return { url, sealedState };
|
|
35
81
|
}
|
|
36
82
|
|
|
37
83
|
export { getAuthorizationUrl };
|
package/src/index.ts
CHANGED
|
@@ -1,22 +1,40 @@
|
|
|
1
1
|
import { getSignInUrl, getSignUpUrl, signOut, switchToOrganization } from './auth.js';
|
|
2
2
|
import { handleAuth } from './authkit-callback-route.js';
|
|
3
|
-
import {
|
|
3
|
+
import { AuthKitError, TokenRefreshError } from './errors.js';
|
|
4
|
+
import { authkit, authkitMiddleware, authkitProxy } from './middleware.js';
|
|
5
|
+
export {
|
|
6
|
+
applyResponseHeaders,
|
|
7
|
+
handleAuthkitHeaders,
|
|
8
|
+
handleAuthkitProxy,
|
|
9
|
+
partitionAuthkitHeaders,
|
|
10
|
+
isAuthkitRequestHeader,
|
|
11
|
+
AUTHKIT_REQUEST_HEADERS,
|
|
12
|
+
type AuthkitHeadersResult,
|
|
13
|
+
type AuthkitRedirectStatus,
|
|
14
|
+
type AuthkitRequestHeader,
|
|
15
|
+
type HandleAuthkitHeadersOptions,
|
|
16
|
+
} from './middleware-helpers.js';
|
|
4
17
|
import { getTokenClaims, refreshSession, saveSession, withAuth } from './session.js';
|
|
18
|
+
import { validateApiKey } from './validate-api-key.js';
|
|
5
19
|
import { getWorkOS } from './workos.js';
|
|
6
20
|
|
|
7
21
|
export * from './interfaces.js';
|
|
8
22
|
|
|
9
23
|
export {
|
|
24
|
+
AuthKitError,
|
|
25
|
+
TokenRefreshError,
|
|
10
26
|
authkit,
|
|
11
27
|
authkitMiddleware,
|
|
28
|
+
authkitProxy,
|
|
12
29
|
getSignInUrl,
|
|
13
30
|
getSignUpUrl,
|
|
31
|
+
getTokenClaims,
|
|
14
32
|
getWorkOS,
|
|
15
33
|
handleAuth,
|
|
16
34
|
refreshSession,
|
|
17
35
|
saveSession,
|
|
18
36
|
signOut,
|
|
19
37
|
switchToOrganization,
|
|
38
|
+
validateApiKey,
|
|
20
39
|
withAuth,
|
|
21
|
-
getTokenClaims,
|
|
22
40
|
};
|
package/src/interfaces.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AuthenticationResponse, OauthTokens, User } from '@workos-inc/node';
|
|
2
2
|
import { type NextRequest } from 'next/server';
|
|
3
|
+
import * as v from 'valibot';
|
|
3
4
|
|
|
4
5
|
export interface HandleAuthOptions {
|
|
5
6
|
returnPathname?: string;
|
|
@@ -61,6 +62,20 @@ export interface AccessToken {
|
|
|
61
62
|
feature_flags?: string[];
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
export const StateSchema = v.object({
|
|
66
|
+
nonce: v.string(),
|
|
67
|
+
customState: v.optional(v.string()),
|
|
68
|
+
returnPathname: v.optional(v.string()),
|
|
69
|
+
codeVerifier: v.string(),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
export type State = v.InferOutput<typeof StateSchema>;
|
|
73
|
+
|
|
74
|
+
export interface GetAuthURLResult {
|
|
75
|
+
url: string;
|
|
76
|
+
sealedState: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
64
79
|
export interface GetAuthURLOptions {
|
|
65
80
|
screenHint?: 'sign-up' | 'sign-in';
|
|
66
81
|
returnPathname?: string;
|
package/src/jwt.ts
CHANGED
|
@@ -2,16 +2,16 @@
|
|
|
2
2
|
* JWT (JSON Web Token) Interface Definitions
|
|
3
3
|
*/
|
|
4
4
|
export interface JWTHeader {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
5
|
+
alg: string;
|
|
6
|
+
typ?: string | undefined;
|
|
7
|
+
cty?: string | undefined;
|
|
8
|
+
crit?: Array<string | Exclude<keyof JWTHeader, 'crit'>> | undefined;
|
|
9
|
+
kid?: string | undefined;
|
|
10
|
+
jku?: string | undefined;
|
|
11
|
+
x5u?: string | string[] | undefined;
|
|
12
12
|
'x5t#S256'?: string | undefined;
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
x5t?: string | undefined;
|
|
14
|
+
x5c?: string | string[] | undefined;
|
|
15
15
|
}
|
|
16
16
|
/**
|
|
17
17
|
* JWT Payload Interface
|