@actuate-media/cms-core 0.11.2 → 0.13.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.
Files changed (115) hide show
  1. package/dist/__tests__/api/cron-routes.test.d.ts +2 -0
  2. package/dist/__tests__/api/cron-routes.test.d.ts.map +1 -0
  3. package/dist/__tests__/api/cron-routes.test.js +67 -0
  4. package/dist/__tests__/api/cron-routes.test.js.map +1 -0
  5. package/dist/__tests__/api/health.test.d.ts +2 -0
  6. package/dist/__tests__/api/health.test.d.ts.map +1 -0
  7. package/dist/__tests__/api/health.test.js +140 -0
  8. package/dist/__tests__/api/health.test.js.map +1 -0
  9. package/dist/__tests__/auth/oauth.test.d.ts +2 -0
  10. package/dist/__tests__/auth/oauth.test.d.ts.map +1 -0
  11. package/dist/__tests__/auth/oauth.test.js +406 -0
  12. package/dist/__tests__/auth/oauth.test.js.map +1 -0
  13. package/dist/__tests__/auth/password.test.js +82 -3
  14. package/dist/__tests__/auth/password.test.js.map +1 -1
  15. package/dist/__tests__/auth/reset.test.d.ts +2 -0
  16. package/dist/__tests__/auth/reset.test.d.ts.map +1 -0
  17. package/dist/__tests__/auth/reset.test.js +303 -0
  18. package/dist/__tests__/auth/reset.test.js.map +1 -0
  19. package/dist/__tests__/auth/session.test.js +54 -1
  20. package/dist/__tests__/auth/session.test.js.map +1 -1
  21. package/dist/__tests__/cron/cron.test.d.ts +2 -0
  22. package/dist/__tests__/cron/cron.test.d.ts.map +1 -0
  23. package/dist/__tests__/cron/cron.test.js +262 -0
  24. package/dist/__tests__/cron/cron.test.js.map +1 -0
  25. package/dist/__tests__/diagnostics/env.test.d.ts +2 -0
  26. package/dist/__tests__/diagnostics/env.test.d.ts.map +1 -0
  27. package/dist/__tests__/diagnostics/env.test.js +119 -0
  28. package/dist/__tests__/diagnostics/env.test.js.map +1 -0
  29. package/dist/__tests__/diagnostics/logger.test.d.ts +2 -0
  30. package/dist/__tests__/diagnostics/logger.test.d.ts.map +1 -0
  31. package/dist/__tests__/diagnostics/logger.test.js +111 -0
  32. package/dist/__tests__/diagnostics/logger.test.js.map +1 -0
  33. package/dist/__tests__/security/encrypted-fields.test.d.ts +2 -0
  34. package/dist/__tests__/security/encrypted-fields.test.d.ts.map +1 -0
  35. package/dist/__tests__/security/encrypted-fields.test.js +60 -0
  36. package/dist/__tests__/security/encrypted-fields.test.js.map +1 -0
  37. package/dist/__tests__/security/rate-limit.test.js +42 -0
  38. package/dist/__tests__/security/rate-limit.test.js.map +1 -1
  39. package/dist/__tests__/security/safe-fetch.test.d.ts +2 -0
  40. package/dist/__tests__/security/safe-fetch.test.d.ts.map +1 -0
  41. package/dist/__tests__/security/safe-fetch.test.js +97 -0
  42. package/dist/__tests__/security/safe-fetch.test.js.map +1 -0
  43. package/dist/__tests__/security/ssrf.test.d.ts +2 -0
  44. package/dist/__tests__/security/ssrf.test.d.ts.map +1 -0
  45. package/dist/__tests__/security/ssrf.test.js +209 -0
  46. package/dist/__tests__/security/ssrf.test.js.map +1 -0
  47. package/dist/actions.d.ts.map +1 -1
  48. package/dist/actions.js +7 -6
  49. package/dist/actions.js.map +1 -1
  50. package/dist/api/handler-factory.d.ts.map +1 -1
  51. package/dist/api/handler-factory.js +15 -6
  52. package/dist/api/handler-factory.js.map +1 -1
  53. package/dist/api/handlers.d.ts.map +1 -1
  54. package/dist/api/handlers.js +165 -26
  55. package/dist/api/handlers.js.map +1 -1
  56. package/dist/auth/oauth.d.ts +8 -0
  57. package/dist/auth/oauth.d.ts.map +1 -1
  58. package/dist/auth/oauth.js +44 -2
  59. package/dist/auth/oauth.js.map +1 -1
  60. package/dist/auth/password.d.ts +35 -2
  61. package/dist/auth/password.d.ts.map +1 -1
  62. package/dist/auth/password.js +97 -7
  63. package/dist/auth/password.js.map +1 -1
  64. package/dist/auth/reset.d.ts.map +1 -1
  65. package/dist/auth/reset.js +2 -1
  66. package/dist/auth/reset.js.map +1 -1
  67. package/dist/auth/session.d.ts +9 -0
  68. package/dist/auth/session.d.ts.map +1 -1
  69. package/dist/auth/session.js +54 -1
  70. package/dist/auth/session.js.map +1 -1
  71. package/dist/config/runtime.d.ts +99 -0
  72. package/dist/config/runtime.d.ts.map +1 -0
  73. package/dist/config/runtime.js +43 -0
  74. package/dist/config/runtime.js.map +1 -0
  75. package/dist/config/types.d.ts +21 -0
  76. package/dist/config/types.d.ts.map +1 -1
  77. package/dist/cron/index.d.ts +72 -0
  78. package/dist/cron/index.d.ts.map +1 -0
  79. package/dist/cron/index.js +222 -0
  80. package/dist/cron/index.js.map +1 -0
  81. package/dist/diagnostics/env.d.ts +44 -0
  82. package/dist/diagnostics/env.d.ts.map +1 -0
  83. package/dist/diagnostics/env.js +293 -0
  84. package/dist/diagnostics/env.js.map +1 -0
  85. package/dist/diagnostics/logger.d.ts +38 -0
  86. package/dist/diagnostics/logger.d.ts.map +1 -0
  87. package/dist/diagnostics/logger.js +89 -0
  88. package/dist/diagnostics/logger.js.map +1 -0
  89. package/dist/page-builder/blocks.d.ts.map +1 -1
  90. package/dist/page-builder/blocks.js +6 -1
  91. package/dist/page-builder/blocks.js.map +1 -1
  92. package/dist/security/audit.d.ts.map +1 -1
  93. package/dist/security/audit.js +3 -1
  94. package/dist/security/audit.js.map +1 -1
  95. package/dist/security/encrypted-fields.d.ts +9 -0
  96. package/dist/security/encrypted-fields.d.ts.map +1 -1
  97. package/dist/security/encrypted-fields.js +52 -1
  98. package/dist/security/encrypted-fields.js.map +1 -1
  99. package/dist/security/ip-canon.d.ts +71 -0
  100. package/dist/security/ip-canon.d.ts.map +1 -0
  101. package/dist/security/ip-canon.js +352 -0
  102. package/dist/security/ip-canon.js.map +1 -0
  103. package/dist/security/rate-limit.d.ts +8 -0
  104. package/dist/security/rate-limit.d.ts.map +1 -1
  105. package/dist/security/rate-limit.js +81 -3
  106. package/dist/security/rate-limit.js.map +1 -1
  107. package/dist/security/safe-fetch.d.ts +30 -8
  108. package/dist/security/safe-fetch.d.ts.map +1 -1
  109. package/dist/security/safe-fetch.js +32 -6
  110. package/dist/security/safe-fetch.js.map +1 -1
  111. package/dist/security/webhook.d.ts +20 -2
  112. package/dist/security/webhook.d.ts.map +1 -1
  113. package/dist/security/webhook.js +100 -30
  114. package/dist/security/webhook.js.map +1 -1
  115. package/package.json +1 -1
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=cron-routes.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cron-routes.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/api/cron-routes.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,67 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+ import { handleActuateAPI } from '../../api/index.js';
3
+ import { initDB } from '../../db.js';
4
+ const SECRET = 'test-secret-for-cron-routes-aaaaaaaaaaa';
5
+ function createMockDB() {
6
+ return {
7
+ document: {
8
+ findMany: async () => [],
9
+ updateMany: async () => ({ count: 0 }),
10
+ deleteMany: async () => ({ count: 0 }),
11
+ },
12
+ session: { deleteMany: async () => ({ count: 0 }) },
13
+ auditLog: { deleteMany: async () => ({ count: 0 }) },
14
+ passwordResetToken: { deleteMany: async () => ({ count: 0 }) },
15
+ };
16
+ }
17
+ describe('cron route HTTP method (Vercel Cron compatibility)', () => {
18
+ const originalCronSecret = process.env.CRON_SECRET;
19
+ beforeEach(() => {
20
+ process.env.CMS_SECRET = SECRET;
21
+ process.env.CRON_SECRET = 'cron-secret-for-test-aaaaaaaaaaaaaaaa';
22
+ initDB(createMockDB());
23
+ });
24
+ afterEach(() => {
25
+ if (originalCronSecret === undefined)
26
+ delete process.env.CRON_SECRET;
27
+ else
28
+ process.env.CRON_SECRET = originalCronSecret;
29
+ });
30
+ // Bugbot review (PR #40, post-fix commit): Vercel Cron sends GET requests
31
+ // (https://vercel.com/docs/cron-jobs). The original implementation used
32
+ // router.post(...) which would have returned 405/404 for every Vercel
33
+ // invocation, leaving the cron jobs silently non-functional.
34
+ it.each(['/api/cms/cron/publish', '/api/cms/cron/cleanup', '/api/cms/cron/seo-scan'])('accepts GET %s with valid CRON_SECRET (Vercel Cron path)', async (path) => {
35
+ const handler = handleActuateAPI({ prismaClient: createMockDB() });
36
+ const response = await handler(new Request(`https://example.com${path}`, {
37
+ method: 'GET',
38
+ headers: { authorization: `Bearer ${process.env.CRON_SECRET}` },
39
+ }));
40
+ // Either 200 (handler ran) or 500 (handler ran but mock DB couldn't
41
+ // satisfy something) — both prove the route was matched. We must NOT
42
+ // get 404 or 405.
43
+ expect([200, 500]).toContain(response.status);
44
+ });
45
+ it.each(['/api/cms/cron/publish', '/api/cms/cron/cleanup', '/api/cms/cron/seo-scan'])('still accepts POST %s for self-hosted schedulers', async (path) => {
46
+ const handler = handleActuateAPI({ prismaClient: createMockDB() });
47
+ const response = await handler(new Request(`https://example.com${path}`, {
48
+ method: 'POST',
49
+ headers: { authorization: `Bearer ${process.env.CRON_SECRET}` },
50
+ }));
51
+ expect([200, 500]).toContain(response.status);
52
+ });
53
+ it.each(['/api/cms/cron/publish', '/api/cms/cron/cleanup', '/api/cms/cron/seo-scan'])('rejects GET %s without CRON_SECRET (401, not 405)', async (path) => {
54
+ const handler = handleActuateAPI({ prismaClient: createMockDB() });
55
+ const response = await handler(new Request(`https://example.com${path}`, { method: 'GET' }));
56
+ expect(response.status).toBe(401);
57
+ });
58
+ it.each(['/api/cms/cron/publish', '/api/cms/cron/cleanup', '/api/cms/cron/seo-scan'])('rejects GET %s with wrong CRON_SECRET (401)', async (path) => {
59
+ const handler = handleActuateAPI({ prismaClient: createMockDB() });
60
+ const response = await handler(new Request(`https://example.com${path}`, {
61
+ method: 'GET',
62
+ headers: { authorization: 'Bearer wrong-secret' },
63
+ }));
64
+ expect(response.status).toBe(401);
65
+ });
66
+ });
67
+ //# sourceMappingURL=cron-routes.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cron-routes.test.js","sourceRoot":"","sources":["../../../src/__tests__/api/cron-routes.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=health.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"health.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/api/health.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,140 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+ import { handleActuateAPI } from '../../api/index.js';
3
+ import { setActuateConfig } from '../../config/runtime.js';
4
+ import { initDB } from '../../db.js';
5
+ const VALID_SECRET = 'a-real-cms-secret-of-at-least-32-characters';
6
+ describe('/api/cms/health status derivation', () => {
7
+ // Snapshot every env var any test in this file mutates so we can restore
8
+ // them in `afterEach`. NOTE: `CMS_SESSION_SECRET` is included even though
9
+ // only one test sets it — Bugbot caught the original test where cleanup
10
+ // ran inline and was therefore skipped on assertion failure, leaking the
11
+ // legacy secret into the next `it.each` iteration and causing the
12
+ // config-file path test to pass for the wrong reason.
13
+ const original = {
14
+ CMS_SECRET: process.env.CMS_SECRET,
15
+ CMS_SESSION_SECRET: process.env.CMS_SESSION_SECRET,
16
+ CMS_ENCRYPTION_KEY: process.env.CMS_ENCRYPTION_KEY,
17
+ CRON_SECRET: process.env.CRON_SECRET,
18
+ DATABASE_URL: process.env.DATABASE_URL,
19
+ };
20
+ beforeEach(() => {
21
+ process.env.CMS_SECRET = VALID_SECRET;
22
+ process.env.DATABASE_URL = 'postgres://x';
23
+ delete process.env.CMS_SESSION_SECRET;
24
+ delete process.env.CMS_ENCRYPTION_KEY;
25
+ delete process.env.CRON_SECRET;
26
+ });
27
+ afterEach(() => {
28
+ for (const [k, v] of Object.entries(original)) {
29
+ if (v === undefined)
30
+ delete process.env[k];
31
+ else
32
+ process.env[k] = v;
33
+ }
34
+ // Tests within this file share `globalThis`, so explicitly clear any
35
+ // config a single test stashed (see `runtime.ts` doc — "Tests reset
36
+ // between suites").
37
+ delete globalThis.__actuateConfig;
38
+ });
39
+ // Bugbot review (PR #41): the early-return DB-failure path hardcoded
40
+ // `status: 'degraded'`, ignoring the env validation result computed two
41
+ // lines above. A deploy with both a broken DB *and* a malformed CMS_SECRET
42
+ // would report `degraded` even though it's actually `unhealthy` —
43
+ // defeating M6's whole point of catching env misconfig at /health time.
44
+ it('returns "unhealthy" when env is misconfigured AND db() throws', async () => {
45
+ process.env.CMS_SECRET = 'too-short'; // triggers env error
46
+ // Force db() to throw by initializing with an object that the route's
47
+ // model probes will treat as unusable while leaving db() itself callable.
48
+ // The simplest way is to trip the catch by leaving DB uninitialized.
49
+ initDB(null); // db() will throw "Prisma client not initialised"
50
+ const handler = handleActuateAPI({ prismaClient: null });
51
+ const response = await handler(new Request('https://example.com/api/cms/health', { method: 'GET' }));
52
+ expect(response.status).toBe(200);
53
+ const payload = (await response.json());
54
+ expect(payload.data.databaseConnected).toBe(false);
55
+ expect(payload.data.status).toBe('unhealthy');
56
+ });
57
+ it('returns "degraded" when only db() throws (env is fine)', async () => {
58
+ initDB(null);
59
+ const handler = handleActuateAPI({ prismaClient: null });
60
+ const response = await handler(new Request('https://example.com/api/cms/health', { method: 'GET' }));
61
+ const payload = (await response.json());
62
+ expect(payload.data.status).toBe('degraded');
63
+ });
64
+ // Bugbot review (PR #41): the unauthenticated /health response embeds the
65
+ // full env validation, so any "ok" message that mentions the configured
66
+ // secret length leaks that length to anonymous callers. Validate the
67
+ // serialized response here so a regression in either the env module or the
68
+ // health route is caught.
69
+ it('does not leak exact secret lengths in the /health JSON', async () => {
70
+ process.env.CMS_SECRET = 'a'.repeat(45);
71
+ process.env.CRON_SECRET = 'a-real-cron-secret-with-known-length';
72
+ process.env.CMS_ENCRYPTION_KEY = 'a1b2'.repeat(16);
73
+ initDB({
74
+ document: { count: async () => 0 },
75
+ user: { count: async () => 0 },
76
+ media: { count: async () => 0 },
77
+ });
78
+ const handler = handleActuateAPI({
79
+ prismaClient: {
80
+ document: { count: async () => 0 },
81
+ user: { count: async () => 0 },
82
+ media: { count: async () => 0 },
83
+ },
84
+ });
85
+ const response = await handler(new Request('https://example.com/api/cms/health', { method: 'GET' }));
86
+ const text = await response.text();
87
+ // No "Configured (N chars)." anywhere. Use a regex broad enough to catch
88
+ // both "42 chars" and "42 chars." variants.
89
+ expect(text).not.toMatch(/\d+\s*chars?/i);
90
+ // And specifically, the configured lengths must not appear next to the
91
+ // secret names in the response body.
92
+ expect(text).not.toContain('45');
93
+ });
94
+ // Bugbot review (PR #43): `validateEnvShape()` only inspected
95
+ // `process.env.CMS_SECRET`, but `getSessionSecret()` also accepts the
96
+ // secret from the legacy `CMS_SESSION_SECRET` env var or `actuate.config.ts
97
+ // → secret`. Without the wrapping the route now applies, a deploy that
98
+ // configures the secret via the config file would get the contradictory
99
+ // response `secretConfigured: true` AND `status: "unhealthy"`, tripping
100
+ // monitoring / load-balancer health probes for no real fault.
101
+ it.each([
102
+ [
103
+ 'CMS_SESSION_SECRET (legacy env var)',
104
+ () => {
105
+ delete process.env.CMS_SECRET;
106
+ process.env.CMS_SESSION_SECRET = 'a'.repeat(40);
107
+ },
108
+ ],
109
+ [
110
+ 'actuate.config.ts → secret (config-file path)',
111
+ () => {
112
+ delete process.env.CMS_SECRET;
113
+ setActuateConfig({ secret: 'a'.repeat(40) });
114
+ },
115
+ ],
116
+ ])('does not report unhealthy when CMS_SECRET is supplied via %s', async (_label, setup) => {
117
+ setup();
118
+ initDB({
119
+ document: { count: async () => 0 },
120
+ user: { count: async () => 0 },
121
+ media: { count: async () => 0 },
122
+ });
123
+ const handler = handleActuateAPI({
124
+ prismaClient: {
125
+ document: { count: async () => 0 },
126
+ user: { count: async () => 0 },
127
+ media: { count: async () => 0 },
128
+ },
129
+ });
130
+ const response = await handler(new Request('https://example.com/api/cms/health', { method: 'GET' }));
131
+ const payload = (await response.json());
132
+ // Both signals must agree — no contradictory `secretConfigured: true`
133
+ // alongside `status: "unhealthy"`.
134
+ expect(payload.data.secretConfigured).toBe(true);
135
+ expect(payload.data.status).not.toBe('unhealthy');
136
+ const cmsSecretCheck = payload.data.env.checks.find((c) => c.name === 'CMS_SECRET');
137
+ expect(cmsSecretCheck?.status).toBe('ok');
138
+ });
139
+ });
140
+ //# sourceMappingURL=health.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"health.test.js","sourceRoot":"","sources":["../../../src/__tests__/api/health.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=oauth.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"oauth.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/auth/oauth.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,406 @@
1
+ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
2
+ import * as jose from 'jose';
3
+ import { generateCodeVerifier, generateCodeChallenge, generateState, generateOAuthNonce, verifyState, getAuthorizationUrl, handleOAuthCallback, InvalidOAuthStateError, } from '../../auth/oauth.js';
4
+ const TEST_SECRET = 'a-secret-key-that-is-at-least-32-chars-long!!';
5
+ const RETURN_TO = '/admin/dashboard';
6
+ const PROVIDER_CONFIG = {
7
+ clientId: 'client-id-123',
8
+ clientSecret: 'client-secret-abc',
9
+ redirectUri: 'https://example.com/api/cms/auth/oauth/google/callback',
10
+ };
11
+ // ─── PKCE primitives ────────────────────────────────────────────────────
12
+ describe('generateCodeVerifier', () => {
13
+ it('returns a base64url string with no padding', () => {
14
+ const v = generateCodeVerifier();
15
+ // RFC 7636 §4.1: verifiers are 43..128 chars from the unreserved alphabet.
16
+ expect(v).toMatch(/^[A-Za-z0-9_-]{43,128}$/);
17
+ expect(v).not.toContain('=');
18
+ expect(v).not.toContain('+');
19
+ expect(v).not.toContain('/');
20
+ });
21
+ it('produces a unique value per call', () => {
22
+ const a = generateCodeVerifier();
23
+ const b = generateCodeVerifier();
24
+ const c = generateCodeVerifier();
25
+ expect(new Set([a, b, c]).size).toBe(3);
26
+ });
27
+ });
28
+ describe('generateCodeChallenge', () => {
29
+ it('returns a base64url SHA-256 of the verifier (RFC 7636 §4.2 S256 method)', async () => {
30
+ // Test vector from RFC 7636 §4.2: verifier "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
31
+ // → challenge "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
32
+ const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
33
+ const challenge = await generateCodeChallenge(verifier);
34
+ expect(challenge).toBe('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM');
35
+ });
36
+ it('produces base64url output (no padding)', async () => {
37
+ const challenge = await generateCodeChallenge('any-verifier-value-for-testing-only');
38
+ expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/);
39
+ expect(challenge).not.toContain('=');
40
+ });
41
+ });
42
+ describe('generateOAuthNonce', () => {
43
+ it('returns a base64url 16-byte nonce', () => {
44
+ const n = generateOAuthNonce();
45
+ expect(n).toMatch(/^[A-Za-z0-9_-]+$/);
46
+ // 16 bytes → 22 base64url chars (no padding)
47
+ expect(n.length).toBe(22);
48
+ });
49
+ it('produces a unique value per call', () => {
50
+ const set = new Set(Array.from({ length: 50 }, () => generateOAuthNonce()));
51
+ expect(set.size).toBe(50);
52
+ });
53
+ });
54
+ // ─── State token (signed JWT with PKCE verifier) ────────────────────────
55
+ describe('generateState / verifyState', () => {
56
+ it('round-trips a valid state through sign + verify', async () => {
57
+ const verifier = generateCodeVerifier();
58
+ const state = await generateState('google', verifier, RETURN_TO, TEST_SECRET);
59
+ const decoded = await verifyState(state, TEST_SECRET);
60
+ expect(decoded.provider).toBe('google');
61
+ expect(decoded.codeVerifier).toBe(verifier);
62
+ expect(decoded.returnTo).toBe(RETURN_TO);
63
+ expect(decoded.nonce).toBeUndefined();
64
+ });
65
+ it('round-trips a nonce when provided', async () => {
66
+ const nonce = generateOAuthNonce();
67
+ const state = await generateState('github', generateCodeVerifier(), '/admin', TEST_SECRET, nonce);
68
+ const decoded = await verifyState(state, TEST_SECRET);
69
+ expect(decoded.nonce).toBe(nonce);
70
+ });
71
+ it('strips standard JWT claims from the returned payload', async () => {
72
+ const verifier = generateCodeVerifier();
73
+ const state = await generateState('google', verifier, RETURN_TO, TEST_SECRET);
74
+ const decoded = await verifyState(state, TEST_SECRET);
75
+ // jose injects iat/exp/iss into the JWT but verifyState should only return
76
+ // the validated OAuthState fields.
77
+ expect(decoded.iat).toBeUndefined();
78
+ expect(decoded.exp).toBeUndefined();
79
+ expect(decoded.iss).toBeUndefined();
80
+ });
81
+ it('rejects a token signed with a different secret', async () => {
82
+ const state = await generateState('google', generateCodeVerifier(), RETURN_TO, TEST_SECRET);
83
+ await expect(verifyState(state, 'a-different-secret-of-at-least-32-chars-x')).rejects.toThrow();
84
+ });
85
+ it('rejects a tampered token', async () => {
86
+ const state = await generateState('google', generateCodeVerifier(), RETURN_TO, TEST_SECRET);
87
+ // Flip a character in the signature (last segment)
88
+ const parts = state.split('.');
89
+ parts[2] = parts[2].replace(/[A-Za-z0-9_-]/, (c) => (c === 'a' ? 'b' : 'a'));
90
+ const tampered = parts.join('.');
91
+ await expect(verifyState(tampered, TEST_SECRET)).rejects.toThrow();
92
+ });
93
+ it('rejects a token issued by a different application (issuer mismatch)', async () => {
94
+ // Sign a state-shaped JWT with the right key but a wrong `iss`.
95
+ const key = new TextEncoder().encode(TEST_SECRET);
96
+ const evil = await new jose.SignJWT({
97
+ provider: 'google',
98
+ codeVerifier: 'x'.repeat(43),
99
+ returnTo: '/',
100
+ })
101
+ .setProtectedHeader({ alg: 'HS256' })
102
+ .setIssuedAt()
103
+ .setExpirationTime('10m')
104
+ .setIssuer('not-actuate-cms')
105
+ .sign(key);
106
+ await expect(verifyState(evil, TEST_SECRET)).rejects.toThrow();
107
+ });
108
+ it('rejects a state with a missing provider field', async () => {
109
+ const key = new TextEncoder().encode(TEST_SECRET);
110
+ const malformed = await new jose.SignJWT({
111
+ codeVerifier: 'x'.repeat(43),
112
+ returnTo: '/',
113
+ })
114
+ .setProtectedHeader({ alg: 'HS256' })
115
+ .setIssuedAt()
116
+ .setExpirationTime('10m')
117
+ .setIssuer('actuate-cms')
118
+ .sign(key);
119
+ await expect(verifyState(malformed, TEST_SECRET)).rejects.toThrow(InvalidOAuthStateError);
120
+ });
121
+ it('rejects a state with a missing codeVerifier field', async () => {
122
+ const key = new TextEncoder().encode(TEST_SECRET);
123
+ const malformed = await new jose.SignJWT({
124
+ provider: 'google',
125
+ returnTo: '/',
126
+ })
127
+ .setProtectedHeader({ alg: 'HS256' })
128
+ .setIssuedAt()
129
+ .setExpirationTime('10m')
130
+ .setIssuer('actuate-cms')
131
+ .sign(key);
132
+ await expect(verifyState(malformed, TEST_SECRET)).rejects.toThrow(InvalidOAuthStateError);
133
+ });
134
+ it('rejects a state where nonce is not a string', async () => {
135
+ const key = new TextEncoder().encode(TEST_SECRET);
136
+ const malformed = await new jose.SignJWT({
137
+ provider: 'google',
138
+ codeVerifier: 'x'.repeat(43),
139
+ returnTo: '/',
140
+ nonce: 12345,
141
+ })
142
+ .setProtectedHeader({ alg: 'HS256' })
143
+ .setIssuedAt()
144
+ .setExpirationTime('10m')
145
+ .setIssuer('actuate-cms')
146
+ .sign(key);
147
+ await expect(verifyState(malformed, TEST_SECRET)).rejects.toThrow(InvalidOAuthStateError);
148
+ });
149
+ });
150
+ // ─── Authorization URL builder ──────────────────────────────────────────
151
+ describe('getAuthorizationUrl', () => {
152
+ it('builds a Google authorize URL with PKCE params', () => {
153
+ const url = new URL(getAuthorizationUrl('google', PROVIDER_CONFIG, 'state-jwt', 'challenge-x'));
154
+ expect(url.origin + url.pathname).toBe('https://accounts.google.com/o/oauth2/v2/auth');
155
+ expect(url.searchParams.get('response_type')).toBe('code');
156
+ expect(url.searchParams.get('client_id')).toBe(PROVIDER_CONFIG.clientId);
157
+ expect(url.searchParams.get('redirect_uri')).toBe(PROVIDER_CONFIG.redirectUri);
158
+ expect(url.searchParams.get('state')).toBe('state-jwt');
159
+ expect(url.searchParams.get('code_challenge')).toBe('challenge-x');
160
+ expect(url.searchParams.get('code_challenge_method')).toBe('S256');
161
+ expect(url.searchParams.get('scope')).toContain('openid');
162
+ });
163
+ it('uses a github-specific authorize endpoint', () => {
164
+ const url = new URL(getAuthorizationUrl('github', PROVIDER_CONFIG, 's', 'c'));
165
+ expect(url.origin + url.pathname).toBe('https://github.com/login/oauth/authorize');
166
+ });
167
+ });
168
+ function createFakeDb(initial = {}) {
169
+ const users = initial.users ?? [];
170
+ const oauthAccounts = initial.oauthAccounts ?? [];
171
+ const sessions = [];
172
+ return {
173
+ users,
174
+ oauthAccounts,
175
+ sessions,
176
+ user: {
177
+ findFirst: vi.fn(async ({ where }) => {
178
+ const target = where.email.equals.toLowerCase();
179
+ return users.find((u) => u.email.toLowerCase() === target) ?? null;
180
+ }),
181
+ create: vi.fn(async ({ data }) => {
182
+ const u = { id: `u_${users.length + 1}`, ...data };
183
+ users.push(u);
184
+ return u;
185
+ }),
186
+ },
187
+ oAuthAccount: {
188
+ findUnique: vi.fn(async ({ where, }) => {
189
+ const w = where.provider_providerAccountId;
190
+ const acc = oauthAccounts.find((a) => a.provider === w.provider && a.providerAccountId === w.providerAccountId);
191
+ if (!acc)
192
+ return null;
193
+ const user = users.find((u) => u.id === acc.userId) ?? null;
194
+ return { ...acc, user };
195
+ }),
196
+ upsert: vi.fn(async ({ where, create }) => {
197
+ const w = where.provider_providerAccountId;
198
+ const existing = oauthAccounts.find((a) => a.provider === w.provider && a.providerAccountId === w.providerAccountId);
199
+ if (existing)
200
+ return existing;
201
+ oauthAccounts.push({
202
+ provider: w.provider,
203
+ providerAccountId: w.providerAccountId,
204
+ userId: create.userId,
205
+ });
206
+ return oauthAccounts[oauthAccounts.length - 1];
207
+ }),
208
+ },
209
+ session: {
210
+ create: vi.fn(async ({ data }) => {
211
+ const s = { id: `s_${sessions.length + 1}`, ...data };
212
+ sessions.push(s);
213
+ return s;
214
+ }),
215
+ },
216
+ };
217
+ }
218
+ describe('handleOAuthCallback', () => {
219
+ const PROVIDERS = { google: PROVIDER_CONFIG };
220
+ const originalFetch = globalThis.fetch;
221
+ const originalEncryptionKey = process.env.CMS_ENCRYPTION_KEY;
222
+ beforeEach(() => {
223
+ // Set a 64-hex-char encryption key so encryptSecret() doesn't throw.
224
+ process.env.CMS_ENCRYPTION_KEY = 'a'.repeat(64);
225
+ });
226
+ afterEach(() => {
227
+ globalThis.fetch = originalFetch;
228
+ if (originalEncryptionKey === undefined) {
229
+ delete process.env.CMS_ENCRYPTION_KEY;
230
+ }
231
+ else {
232
+ process.env.CMS_ENCRYPTION_KEY = originalEncryptionKey;
233
+ }
234
+ vi.restoreAllMocks();
235
+ });
236
+ function mockProviderResponses(opts) {
237
+ globalThis.fetch = vi.fn(async (input) => {
238
+ const url = typeof input === 'string' ? input : (input.url ?? input.toString());
239
+ if (url.includes('oauth2.googleapis.com/token')) {
240
+ return new Response(JSON.stringify({ access_token: opts.accessToken ?? 'access-tok', token_type: 'Bearer' }), { status: 200, headers: { 'Content-Type': 'application/json' } });
241
+ }
242
+ if (url.includes('googleapis.com/oauth2/v3/userinfo')) {
243
+ return new Response(JSON.stringify({
244
+ sub: opts.profile.id,
245
+ email: opts.profile.email,
246
+ name: opts.profile.name,
247
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } });
248
+ }
249
+ throw new Error(`Unexpected fetch in test: ${url}`);
250
+ });
251
+ }
252
+ it('rejects when the state.provider does not match the URL provider', async () => {
253
+ const state = await generateState('github', generateCodeVerifier(), RETURN_TO, TEST_SECRET);
254
+ const db = createFakeDb();
255
+ await expect(handleOAuthCallback('google', 'code-x', state, PROVIDERS, TEST_SECRET, db)).rejects.toThrow(/Provider mismatch/);
256
+ });
257
+ it('rejects when the nonce in state does not match the cookie nonce', async () => {
258
+ const nonce = generateOAuthNonce();
259
+ const state = await generateState('google', generateCodeVerifier(), RETURN_TO, TEST_SECRET, nonce);
260
+ const db = createFakeDb();
261
+ await expect(handleOAuthCallback('google', 'code-x', state, PROVIDERS, TEST_SECRET, db, {
262
+ expectedNonce: 'a-different-nonce',
263
+ })).rejects.toThrow(/nonce mismatch/i);
264
+ });
265
+ it('rejects when state has a nonce but no cookie nonce was provided', async () => {
266
+ const nonce = generateOAuthNonce();
267
+ const state = await generateState('google', generateCodeVerifier(), RETURN_TO, TEST_SECRET, nonce);
268
+ const db = createFakeDb();
269
+ await expect(handleOAuthCallback('google', 'code-x', state, PROVIDERS, TEST_SECRET, db, {
270
+ expectedNonce: null,
271
+ })).rejects.toThrow(/nonce mismatch/i);
272
+ });
273
+ it('rejects when the provider is not configured', async () => {
274
+ const state = await generateState('github', generateCodeVerifier(), RETURN_TO, TEST_SECRET);
275
+ const db = createFakeDb();
276
+ await expect(handleOAuthCallback('github', 'code-x', state, PROVIDERS, TEST_SECRET, db)).rejects.toThrow(/not configured/);
277
+ });
278
+ it('rejects when the OAuth provider returns no email', async () => {
279
+ mockProviderResponses({ profile: { id: '111', email: '', name: 'Nobody' } });
280
+ const state = await generateState('google', generateCodeVerifier(), RETURN_TO, TEST_SECRET);
281
+ const db = createFakeDb();
282
+ await expect(handleOAuthCallback('google', 'code-x', state, PROVIDERS, TEST_SECRET, db)).rejects.toThrow(/email/);
283
+ });
284
+ it('rejects self-signup when allowSelfSignup is false (default)', async () => {
285
+ mockProviderResponses({ profile: { id: '222', email: 'new@example.com', name: 'New User' } });
286
+ const state = await generateState('google', generateCodeVerifier(), RETURN_TO, TEST_SECRET);
287
+ const db = createFakeDb();
288
+ await expect(handleOAuthCallback('google', 'code-x', state, PROVIDERS, TEST_SECRET, db)).rejects.toThrow(/No account found/);
289
+ expect(db.user.create).not.toHaveBeenCalled();
290
+ });
291
+ it('refuses to silently link an OAuth login to a password-protected account', async () => {
292
+ mockProviderResponses({
293
+ profile: { id: '333', email: 'pwd@example.com', name: 'Pwd User' },
294
+ });
295
+ const state = await generateState('google', generateCodeVerifier(), RETURN_TO, TEST_SECRET);
296
+ const db = createFakeDb({
297
+ users: [
298
+ {
299
+ id: 'u_existing',
300
+ email: 'pwd@example.com',
301
+ name: 'Pwd User',
302
+ role: 'CLIENT',
303
+ passwordHash: 'pbkdf2:600000:abc:def',
304
+ },
305
+ ],
306
+ });
307
+ await expect(handleOAuthCallback('google', 'code-x', state, PROVIDERS, TEST_SECRET, db)).rejects.toThrow(/already exists/);
308
+ });
309
+ it('reuses an existing OAuth-only account (passwordHash null) when emails match', async () => {
310
+ mockProviderResponses({
311
+ profile: { id: '444', email: 'oauth@example.com', name: 'OAuth User' },
312
+ });
313
+ const state = await generateState('google', generateCodeVerifier(), RETURN_TO, TEST_SECRET);
314
+ const db = createFakeDb({
315
+ users: [
316
+ {
317
+ id: 'u_oauth_only',
318
+ email: 'oauth@example.com',
319
+ name: 'OAuth User',
320
+ role: 'CLIENT',
321
+ passwordHash: null,
322
+ },
323
+ ],
324
+ });
325
+ const result = await handleOAuthCallback('google', 'code-x', state, PROVIDERS, TEST_SECRET, db);
326
+ expect(result.user.id).toBe('u_oauth_only');
327
+ expect(db.user.create).not.toHaveBeenCalled();
328
+ expect(db.session.create).toHaveBeenCalledOnce();
329
+ });
330
+ it('provisions a new user when allowSelfSignup is true and no account exists', async () => {
331
+ mockProviderResponses({
332
+ profile: { id: '555', email: 'fresh@example.com', name: 'Fresh User' },
333
+ });
334
+ const state = await generateState('google', generateCodeVerifier(), RETURN_TO, TEST_SECRET);
335
+ const db = createFakeDb();
336
+ const onProvision = vi.fn();
337
+ const result = await handleOAuthCallback('google', 'code-x', state, PROVIDERS, TEST_SECRET, db, { allowSelfSignup: true, onProvision });
338
+ expect(onProvision).toHaveBeenCalledOnce();
339
+ expect(db.user.create).toHaveBeenCalledOnce();
340
+ expect(db.user.create.mock.calls[0][0].data.passwordHash).toBeNull();
341
+ expect(result.user.email).toBe('fresh@example.com');
342
+ expect(result.user.role).toBe('CLIENT');
343
+ });
344
+ it('lets onProvision throw to reject self-signup', async () => {
345
+ mockProviderResponses({
346
+ profile: { id: '666', email: 'blocked@evil.com', name: 'Blocked' },
347
+ });
348
+ const state = await generateState('google', generateCodeVerifier(), RETURN_TO, TEST_SECRET);
349
+ const db = createFakeDb();
350
+ const onProvision = vi.fn(() => {
351
+ throw new Error('Email domain not allowed');
352
+ });
353
+ await expect(handleOAuthCallback('google', 'code-x', state, PROVIDERS, TEST_SECRET, db, {
354
+ allowSelfSignup: true,
355
+ onProvision,
356
+ })).rejects.toThrow(/domain not allowed/);
357
+ expect(db.user.create).not.toHaveBeenCalled();
358
+ });
359
+ it('returns an existing user found via OAuthAccount even if email differs', async () => {
360
+ mockProviderResponses({
361
+ profile: { id: '777', email: 'now@example.com', name: 'Linked User' },
362
+ });
363
+ const state = await generateState('google', generateCodeVerifier(), RETURN_TO, TEST_SECRET);
364
+ const db = createFakeDb({
365
+ users: [
366
+ {
367
+ id: 'u_linked',
368
+ email: 'old@example.com', // different from current OAuth email
369
+ name: 'Linked',
370
+ role: 'ADMIN',
371
+ passwordHash: 'pbkdf2:600000:a:b',
372
+ },
373
+ ],
374
+ oauthAccounts: [{ provider: 'google', providerAccountId: '777', userId: 'u_linked' }],
375
+ });
376
+ const result = await handleOAuthCallback('google', 'code-x', state, PROVIDERS, TEST_SECRET, db);
377
+ // Even though the email matches a *different* user, the existing
378
+ // OAuthAccount link wins and we return the linked user.
379
+ expect(result.user.id).toBe('u_linked');
380
+ expect(result.user.role).toBe('ADMIN');
381
+ });
382
+ it('creates a session row for the authenticated user', async () => {
383
+ mockProviderResponses({
384
+ profile: { id: '888', email: 'session@example.com', name: 'Session User' },
385
+ });
386
+ const state = await generateState('google', generateCodeVerifier(), RETURN_TO, TEST_SECRET);
387
+ const db = createFakeDb({
388
+ users: [
389
+ {
390
+ id: 'u_sess',
391
+ email: 'session@example.com',
392
+ name: 'Session',
393
+ role: 'CLIENT',
394
+ passwordHash: null,
395
+ },
396
+ ],
397
+ });
398
+ await handleOAuthCallback('google', 'code-x', state, PROVIDERS, TEST_SECRET, db);
399
+ expect(db.session.create).toHaveBeenCalledOnce();
400
+ const call = db.session.create.mock.calls[0][0];
401
+ expect(call.data.userId).toBe('u_sess');
402
+ // Default 7-day expiry — verify the date is in the future
403
+ expect(call.data.expiresAt.getTime()).toBeGreaterThan(Date.now());
404
+ });
405
+ });
406
+ //# sourceMappingURL=oauth.test.js.map