@actuate-media/cms-core 0.37.0 → 0.38.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.
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=totp-login.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"totp-login.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/api/totp-login.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,115 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest';
2
+ import { handleActuateAPI } from '../../api/index.js';
3
+ import { initDB } from '../../db.js';
4
+ import { encryptSecret, encryptStringArray } from '../../security/secret-storage.js';
5
+ import { createMfaPendingToken, computeRequestFingerprint } from '../../auth/mfa-pending.js';
6
+ import { generateTOTPSecret } from '../../auth/totp.js';
7
+ /**
8
+ * Contract test for the backup-code recovery path of POST /auth/totp/login.
9
+ *
10
+ * Backup codes are generated and shown at enrollment but, before this, were
11
+ * never consumed anywhere — a lost authenticator meant permanent lockout. This
12
+ * proves a valid code signs the user in, is single-use (consumed on success),
13
+ * and that an unknown code is rejected.
14
+ */
15
+ const SECRET = 'a'.repeat(48);
16
+ const ENCRYPTION_KEY = 'b'.repeat(64); // 32 bytes hex
17
+ const BACKUP_CODES = ['aaaa1111', 'bbbb2222', 'cccc3333'];
18
+ async function createMockDB(state) {
19
+ const totpSecret = await encryptSecret(generateTOTPSecret());
20
+ return {
21
+ user: {
22
+ findUnique: async () => ({
23
+ id: 'user-1',
24
+ email: 'admin@example.com',
25
+ name: 'Admin',
26
+ role: 'ADMIN',
27
+ totpSecret,
28
+ totpEnabled: true,
29
+ backupCodes: await encryptStringArray(state.backupCodes),
30
+ isActive: true,
31
+ }),
32
+ update: async ({ data }) => {
33
+ // The handler re-encrypts the remaining codes; decrypt-by-count is
34
+ // enough for the assertion (we only check how many remain).
35
+ state.backupCodes = state.backupCodes.slice(0, data.backupCodes.length);
36
+ return { id: 'user-1' };
37
+ },
38
+ },
39
+ document: {
40
+ findFirst: async () => ({ data: {} }),
41
+ },
42
+ session: {
43
+ findMany: async () => [],
44
+ create: async () => ({ id: 'sess-1' }),
45
+ updateMany: async () => ({ count: 0 }),
46
+ },
47
+ auditLog: {
48
+ create: async () => ({ id: 'log-1' }),
49
+ },
50
+ };
51
+ }
52
+ async function makePendingToken(ip, userAgent) {
53
+ const fingerprint = await computeRequestFingerprint(ip, userAgent);
54
+ return createMfaPendingToken({ userId: 'user-1', fingerprint }, SECRET);
55
+ }
56
+ beforeEach(() => {
57
+ delete globalThis.__actuateConfig;
58
+ process.env.CMS_SECRET = SECRET;
59
+ process.env.CMS_ENCRYPTION_KEY = ENCRYPTION_KEY;
60
+ });
61
+ describe('POST /auth/totp/login — backup code recovery', () => {
62
+ it('signs in with a valid backup code and consumes it (single-use)', async () => {
63
+ const ip = '203.0.113.10';
64
+ const ua = 'vitest-agent';
65
+ const state = { backupCodes: [...BACKUP_CODES] };
66
+ const db = await createMockDB(state);
67
+ initDB(db);
68
+ const handler = handleActuateAPI({ prismaClient: db, config: { collections: {} } });
69
+ const token = await makePendingToken(ip, ua);
70
+ const res = await handler(new Request('https://example.com/api/cms/auth/totp/login', {
71
+ method: 'POST',
72
+ headers: { 'x-real-ip': ip, 'user-agent': ua, 'content-type': 'application/json' },
73
+ body: JSON.stringify({ mfaPendingToken: token, code: 'BBBB-2222' }),
74
+ }));
75
+ expect(res.status).toBe(200);
76
+ expect(res.headers.get('set-cookie')).toContain('actuate_session=');
77
+ // The used code was removed — one fewer remains.
78
+ expect(state.backupCodes).toHaveLength(BACKUP_CODES.length - 1);
79
+ });
80
+ it('rejects an unknown code', async () => {
81
+ const ip = '203.0.113.11';
82
+ const ua = 'vitest-agent';
83
+ const state = { backupCodes: [...BACKUP_CODES] };
84
+ const db = await createMockDB(state);
85
+ initDB(db);
86
+ const handler = handleActuateAPI({ prismaClient: db, config: { collections: {} } });
87
+ const token = await makePendingToken(ip, ua);
88
+ const res = await handler(new Request('https://example.com/api/cms/auth/totp/login', {
89
+ method: 'POST',
90
+ headers: { 'x-real-ip': ip, 'user-agent': ua, 'content-type': 'application/json' },
91
+ body: JSON.stringify({ mfaPendingToken: token, code: 'deadbeef' }),
92
+ }));
93
+ expect(res.status).toBe(401);
94
+ expect(state.backupCodes).toHaveLength(BACKUP_CODES.length);
95
+ });
96
+ it('rejects a stale pending token from a different device fingerprint', async () => {
97
+ const state = { backupCodes: [...BACKUP_CODES] };
98
+ const db = await createMockDB(state);
99
+ initDB(db);
100
+ const handler = handleActuateAPI({ prismaClient: db, config: { collections: {} } });
101
+ // Token minted for one device, redeemed from another (different UA).
102
+ const token = await makePendingToken('203.0.113.12', 'device-a');
103
+ const res = await handler(new Request('https://example.com/api/cms/auth/totp/login', {
104
+ method: 'POST',
105
+ headers: {
106
+ 'x-real-ip': '203.0.113.12',
107
+ 'user-agent': 'device-b',
108
+ 'content-type': 'application/json',
109
+ },
110
+ body: JSON.stringify({ mfaPendingToken: token, code: 'aaaa1111' }),
111
+ }));
112
+ expect(res.status).toBe(401);
113
+ });
114
+ });
115
+ //# sourceMappingURL=totp-login.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"totp-login.test.js","sourceRoot":"","sources":["../../../src/__tests__/api/totp-login.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=totp.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"totp.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/auth/totp.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { generateBackupCodes, generateTOTPSecret, generateTOTPUri, verifyTOTP, normalizeBackupCode, findBackupCodeMatch, } from '../../auth/totp.js';
3
+ describe('totp helpers', () => {
4
+ it('generates a scannable otpauth URI for the given account', () => {
5
+ const secret = generateTOTPSecret();
6
+ const uri = generateTOTPUri(secret, 'admin@example.com');
7
+ expect(uri.startsWith('otpauth://totp/')).toBe(true);
8
+ expect(uri).toContain(`secret=${secret}`);
9
+ expect(uri).toContain('issuer=Actuate%20CMS');
10
+ expect(uri).toContain(encodeURIComponent('admin@example.com'));
11
+ });
12
+ it('verifies a TOTP code derived from the same secret', async () => {
13
+ // Derive the current code the same way the verifier does, then confirm it
14
+ // round-trips. Importing the private HOTP isn't exposed, so we lean on the
15
+ // window tolerance: a freshly generated secret should not validate a random
16
+ // 6-digit guess.
17
+ const secret = generateTOTPSecret();
18
+ expect(verifyTOTP('000000', secret)).toBe(false);
19
+ });
20
+ });
21
+ describe('backup codes', () => {
22
+ it('generates the requested number of unique 8-char hex codes', () => {
23
+ const codes = generateBackupCodes(8);
24
+ expect(codes).toHaveLength(8);
25
+ expect(new Set(codes).size).toBe(8);
26
+ for (const code of codes) {
27
+ expect(code).toMatch(/^[0-9a-f]{8}$/);
28
+ }
29
+ });
30
+ it('normalizes whitespace, dashes and case', () => {
31
+ expect(normalizeBackupCode('ABCD-1234')).toBe('abcd1234');
32
+ expect(normalizeBackupCode(' ab cd 12 34 ')).toBe('abcd1234');
33
+ expect(normalizeBackupCode('')).toBe('');
34
+ });
35
+ it('matches a stored code regardless of formatting and returns its index', () => {
36
+ const codes = ['aaaa1111', 'bbbb2222', 'cccc3333'];
37
+ expect(findBackupCodeMatch(codes, 'BBBB-2222')).toBe(1);
38
+ expect(findBackupCodeMatch(codes, ' cccc 3333 ')).toBe(2);
39
+ });
40
+ it('returns -1 for a non-matching or empty submission', () => {
41
+ const codes = ['aaaa1111', 'bbbb2222'];
42
+ expect(findBackupCodeMatch(codes, 'deadbeef')).toBe(-1);
43
+ expect(findBackupCodeMatch(codes, '')).toBe(-1);
44
+ expect(findBackupCodeMatch([], 'aaaa1111')).toBe(-1);
45
+ });
46
+ it('does not match a code that only shares a prefix (length-aware compare)', () => {
47
+ const codes = ['aaaa1111'];
48
+ expect(findBackupCodeMatch(codes, 'aaaa111')).toBe(-1);
49
+ expect(findBackupCodeMatch(codes, 'aaaa11110')).toBe(-1);
50
+ });
51
+ });
52
+ //# sourceMappingURL=totp.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"totp.test.js","sourceRoot":"","sources":["../../../src/__tests__/auth/totp.test.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"handlers.d.ts","sourceRoot":"","sources":["../../src/api/handlers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AA+/B5C,iFAAiF;AACjF,wBAAgB,wBAAwB,IAAI,IAAI,CAE/C;AA8JD,wBAAgB,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAS9E;AAiTD,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CA68UzD"}
1
+ {"version":3,"file":"handlers.d.ts","sourceRoot":"","sources":["../../src/api/handlers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AA+/B5C,iFAAiF;AACjF,wBAAgB,wBAAwB,IAAI,IAAI,CAE/C;AA8JD,wBAAgB,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAS9E;AAiTD,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAi/UzD"}
@@ -33,7 +33,7 @@ import { sendFormNotifications } from '../forms/notify.js';
33
33
  import { checkForUpdates } from '../upgrade/version-check.js';
34
34
  import { createUpgradePR } from '../upgrade/upgrade-pr.js';
35
35
  import { encryptField, decryptField } from '../security/encrypted-fields.js';
36
- import { encryptSecret, decryptSecret, encryptStringArray, } from '../security/secret-storage.js';
36
+ import { encryptSecret, decryptSecret, encryptStringArray, decryptStringArray, } from '../security/secret-storage.js';
37
37
  import { createRateLimiter } from '../security/rate-limit.js';
38
38
  import { generateOpenAPISpec } from './openapi.js';
39
39
  import { createSSEPresenceAdapter } from '../presence/index.js';
@@ -1741,7 +1741,14 @@ export function registerCMSRoutes(router) {
1741
1741
  return auth.error;
1742
1742
  const user = await db().user.findUnique({
1743
1743
  where: { id: auth.session.userId },
1744
- select: { id: true, email: true, name: true, role: true, isActive: true },
1744
+ select: {
1745
+ id: true,
1746
+ email: true,
1747
+ name: true,
1748
+ role: true,
1749
+ isActive: true,
1750
+ totpEnabled: true,
1751
+ },
1745
1752
  });
1746
1753
  if (!user) {
1747
1754
  return errorResponse('User not found', 404);
@@ -1915,6 +1922,7 @@ export function registerCMSRoutes(router) {
1915
1922
  role: true,
1916
1923
  totpSecret: true,
1917
1924
  totpEnabled: true,
1925
+ backupCodes: true,
1918
1926
  isActive: true,
1919
1927
  },
1920
1928
  });
@@ -1922,7 +1930,34 @@ export function registerCMSRoutes(router) {
1922
1930
  return errorResponse('Invalid request', 400);
1923
1931
  }
1924
1932
  const decryptedSecret = await decryptSecret(user.totpSecret);
1925
- const valid = verifyTOTP(body.code, decryptedSecret);
1933
+ let valid = verifyTOTP(body.code, decryptedSecret);
1934
+ let usedBackupCode = false;
1935
+ // Backup-code fallback. A user who has lost their authenticator can sign
1936
+ // in with one of the one-time recovery codes issued at enrollment. Each
1937
+ // code is single-use: on a match we remove it and re-persist the
1938
+ // remaining codes so it can never be replayed. We compare against every
1939
+ // stored code in constant time to avoid leaking which/whether one matched.
1940
+ const storedBackups = Array.isArray(user.backupCodes) ? user.backupCodes : [];
1941
+ if (!valid && storedBackups.length > 0) {
1942
+ try {
1943
+ const { findBackupCodeMatch } = await import('../auth/totp.js');
1944
+ const codes = await decryptStringArray(storedBackups);
1945
+ const matchIdx = findBackupCodeMatch(codes, body.code);
1946
+ if (matchIdx !== -1) {
1947
+ valid = true;
1948
+ usedBackupCode = true;
1949
+ const remaining = codes.filter((_, i) => i !== matchIdx);
1950
+ await db().user.update({
1951
+ where: { id: user.id },
1952
+ data: { backupCodes: await encryptStringArray(remaining) },
1953
+ });
1954
+ }
1955
+ }
1956
+ catch {
1957
+ // Decryption failure (e.g. the encryption key was rotated) — treat
1958
+ // as no match rather than throwing a 500 at the login screen.
1959
+ }
1960
+ }
1926
1961
  if (!valid) {
1927
1962
  await logEvent({
1928
1963
  event: 'login_failed',
@@ -1952,7 +1987,7 @@ export function registerCMSRoutes(router) {
1952
1987
  userId: user.id,
1953
1988
  ipAddress: isResolvedIp(ip) ? ip : undefined,
1954
1989
  userAgent: userAgent ?? undefined,
1955
- details: { mfa: 'totp' },
1990
+ details: { mfa: usedBackupCode ? 'backup_code' : 'totp' },
1956
1991
  });
1957
1992
  const isProduction = process.env.NODE_ENV === 'production';
1958
1993
  const sessionCookie = [