@cosmicdrift/kumiko-bundled-features 0.165.0 → 2.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.
Files changed (152) hide show
  1. package/package.json +10 -6
  2. package/src/__tests__/access-invalidation-consumer.integration.test.ts +279 -0
  3. package/src/__tests__/access-invalidation-stream.integration.test.ts +217 -0
  4. package/src/audit/__tests__/audit.integration.test.ts +50 -21
  5. package/src/audit/handlers/list.query.ts +1 -0
  6. package/src/auth-email-password/__tests__/auth.integration.test.ts +6 -0
  7. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +34 -0
  8. package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +9 -14
  9. package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +16 -5
  10. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +54 -0
  11. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -1
  12. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -1
  13. package/src/auth-email-password/handlers/login.write.ts +8 -0
  14. package/src/auth-email-password/handlers/self-registration-status.query.ts +6 -3
  15. package/src/auth-email-password/handlers/signup-request.write.ts +12 -1
  16. package/src/auth-email-password/i18n.ts +2 -0
  17. package/src/auth-email-password/seeding.ts +41 -23
  18. package/src/auth-email-password/web/__tests__/auth-client.test.ts +13 -0
  19. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +55 -8
  20. package/src/auth-email-password/web/__tests__/client-plugin.test.tsx +34 -0
  21. package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +8 -1
  22. package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +5 -1
  23. package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +3 -0
  24. package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +8 -2
  25. package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +5 -1
  26. package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +5 -1
  27. package/src/auth-email-password/web/auth-client.ts +32 -10
  28. package/src/auth-email-password/web/auth-gate.tsx +17 -21
  29. package/src/auth-email-password/web/client-plugin.ts +15 -2
  30. package/src/auth-email-password/web/index.ts +5 -1
  31. package/src/auth-email-password/web/login-screen.tsx +7 -3
  32. package/src/auth-email-password/web/request-account-unlock-screen.tsx +1 -1
  33. package/src/auth-foundation/__tests__/session-store.test.ts +2 -4
  34. package/src/auth-foundation/__tests__/tenant-providers.test.ts +24 -0
  35. package/src/auth-foundation/__tests__/token-verifier.test.ts +36 -3
  36. package/src/auth-foundation/boot-checks.ts +74 -69
  37. package/src/auth-foundation/resolve-anonymous-access.ts +13 -5
  38. package/src/auth-foundation/types.ts +3 -3
  39. package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +61 -1
  40. package/src/auth-mfa/__tests__/no-redis.integration.test.ts +209 -0
  41. package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +79 -1
  42. package/src/auth-mfa/handlers/disable.write.ts +16 -1
  43. package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +72 -42
  44. package/src/auth-mfa/handlers/enable-confirm.write.ts +23 -12
  45. package/src/auth-mfa/handlers/reencrypt.job.ts +26 -17
  46. package/src/auth-mfa/handlers/regenerate-recovery.write.ts +15 -1
  47. package/src/auth-mfa/handlers/verify.write.ts +28 -24
  48. package/src/auth-mfa/web/__tests__/mfa-client.test.ts +5 -1
  49. package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +10 -1
  50. package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +104 -1
  51. package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +5 -1
  52. package/src/auth-mfa/web/i18n.ts +4 -0
  53. package/src/auth-mfa/web/mfa-client.ts +13 -2
  54. package/src/auth-mfa/web/mfa-enable-screen.tsx +1 -1
  55. package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +25 -2
  56. package/src/billing-foundation/__tests__/subscription-provider-contract.ts +36 -12
  57. package/src/config/__tests__/backing-secrets.integration.test.ts +4 -0
  58. package/src/config/__tests__/cascade.integration.test.ts +13 -2
  59. package/src/config/__tests__/pii-encrypted.integration.test.ts +6 -6
  60. package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +78 -5
  61. package/src/config/db/queries/resolver.ts +3 -3
  62. package/src/config/handlers/reencrypt.job.ts +36 -24
  63. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +14 -0
  64. package/src/document-ingest-foundation/__tests__/feature.test.ts +38 -0
  65. package/src/document-ingest-foundation/__tests__/pages.test.ts +37 -0
  66. package/src/document-ingest-foundation/entity.ts +10 -3
  67. package/src/document-ingest-foundation/feature.ts +26 -8
  68. package/src/document-ingest-foundation/index.ts +1 -0
  69. package/src/document-ingest-foundation/pages.ts +34 -0
  70. package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +114 -11
  71. package/src/feature-toggles/compose-tier-resolver.ts +77 -8
  72. package/src/feature-toggles/global-feature-state-table.ts +15 -12
  73. package/src/files-provider-s3/s3-provider.ts +12 -1
  74. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +78 -1
  75. package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +58 -40
  76. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +9 -1
  77. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +22 -12
  78. package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +1 -1
  79. package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +35 -8
  80. package/src/inbound-provider-inmemory/__tests__/feature.test.ts +37 -33
  81. package/src/jobs/__tests__/job-instant.test.ts +15 -0
  82. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +19 -9
  83. package/src/jobs/feature.ts +6 -5
  84. package/src/jobs/job-instant.ts +9 -0
  85. package/src/mail-foundation/__tests__/mail-transport-contract.ts +27 -11
  86. package/src/mail-transport-inmemory/__tests__/feature.test.ts +10 -6
  87. package/src/mail-transport-smtp/__tests__/feature.test.ts +10 -5
  88. package/src/page-render/__tests__/markdown.test.ts +29 -0
  89. package/src/page-render/markdown.ts +1 -9
  90. package/src/personal-access-tokens/feature.ts +1 -1
  91. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +3 -1
  92. package/src/sessions/__tests__/sessions-screens.boot.test.ts +5 -10
  93. package/src/sessions/__tests__/sessions.integration.test.ts +72 -1
  94. package/src/sessions/feature.ts +12 -0
  95. package/src/sessions/handlers/revoke-all-for-user.write.ts +51 -12
  96. package/src/sessions/handlers/revoke-all-others.write.ts +19 -1
  97. package/src/sessions/handlers/revoke.write.ts +13 -0
  98. package/src/sessions/index.ts +7 -0
  99. package/src/sessions/session-callbacks.ts +58 -5
  100. package/src/sessions/session-revoked-event.ts +29 -0
  101. package/src/shared/__tests__/chunked-entity-migration.test.ts +4 -4
  102. package/src/shared/chunked-entity-migration.ts +2 -2
  103. package/src/shared/grace-period.test.ts +26 -0
  104. package/src/shared/grace-period.ts +20 -0
  105. package/src/shared/index.ts +2 -0
  106. package/src/shared/map-with-concurrency.test.ts +104 -0
  107. package/src/shared/map-with-concurrency.ts +38 -0
  108. package/src/subscription-mollie/__tests__/feature.test.ts +16 -6
  109. package/src/subscription-mollie/__tests__/verify-webhook.test.ts +21 -0
  110. package/src/subscription-mollie/verify-webhook.ts +1 -0
  111. package/src/subscription-stripe/__tests__/feature.test.ts +14 -7
  112. package/src/subscription-stripe/__tests__/verify-webhook.test.ts +20 -0
  113. package/src/subscription-stripe/verify-webhook.ts +3 -2
  114. package/src/tenant/__tests__/members-query-kms.integration.test.ts +2 -2
  115. package/src/tenant/__tests__/multi-tenant.integration.test.ts +1 -1
  116. package/src/tenant/handlers/active-tenant-ids.query.ts +8 -2
  117. package/src/tenant/handlers/invitations.query.ts +13 -15
  118. package/src/tenant/handlers/members.query.ts +12 -12
  119. package/src/tenant/seeding.ts +110 -16
  120. package/src/tenant-lifecycle/boot-checks.ts +11 -4
  121. package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +4 -6
  122. package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +115 -0
  123. package/src/tenant-settings/config.ts +44 -0
  124. package/src/tenant-settings/constants.ts +16 -0
  125. package/src/tenant-settings/feature.ts +49 -0
  126. package/src/tenant-settings/index.ts +8 -0
  127. package/src/tenant-settings/tenant-defaults.ts +67 -0
  128. package/src/user/handlers/find-for-auth.query.ts +1 -1
  129. package/src/user/seeding.ts +8 -0
  130. package/src/user-data-rights/COMPLIANCE.md +1 -1
  131. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +65 -0
  132. package/src/user-data-rights/__tests__/boot-checks.test.ts +15 -17
  133. package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +9 -5
  134. package/src/user-data-rights/__tests__/download.integration.test.ts +12 -41
  135. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +7 -10
  136. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +15 -1
  137. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +120 -0
  138. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +9 -4
  139. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +74 -8
  140. package/src/user-data-rights/db/queries/__tests__/forget-cleanup.integration.test.ts +69 -0
  141. package/src/user-data-rights/db/queries/forget-cleanup.ts +9 -2
  142. package/src/user-data-rights/feature.ts +45 -45
  143. package/src/user-data-rights/handlers/cancel-deletion.write.ts +8 -11
  144. package/src/user-data-rights/handlers/download-by-job.query.ts +6 -6
  145. package/src/user-data-rights/handlers/download-by-token.query.ts +6 -6
  146. package/src/user-data-rights/handlers/lift-restriction.write.ts +33 -2
  147. package/src/user-data-rights/handlers/list-download-attempts.query.ts +1 -0
  148. package/src/user-data-rights/handlers/my-audit-log.query.ts +1 -0
  149. package/src/user-data-rights/handlers/restrict-account.write.ts +33 -4
  150. package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +2 -0
  151. package/src/user-data-rights/lib/is-admin-actor.ts +19 -0
  152. package/src/user-data-rights/run-forget-cleanup.ts +27 -7
@@ -14,11 +14,14 @@
14
14
  import type {
15
15
  SessionChecker,
16
16
  SessionCreator,
17
+ SessionMassRevoker,
17
18
  SessionRevoker,
18
19
  } from "@cosmicdrift/kumiko-framework/api";
19
20
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
20
21
  import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
21
22
 
23
+ export type { SessionMassRevoker };
24
+
22
25
  export const EXT_TOKEN_VERIFIER = "tokenVerifier";
23
26
 
24
27
  /**
@@ -83,9 +86,6 @@ export function tokenShapeKey(shape: TokenShape): string {
83
86
 
84
87
  export const EXT_SESSION_STORE = "sessionStore";
85
88
 
86
- /** Mass-revoke every live session for a user. Used by password-change and "sign out everywhere". */
87
- export type SessionMassRevoker = (userId: string) => Promise<number>;
88
-
89
89
  /** Keep current sid (if any); revoke every other live session. Used by auth-mfa. */
90
90
  export type SessionRevokeAllOthers = (
91
91
  userId: string,
@@ -37,11 +37,16 @@ import { userEntity, userTable } from "../../user/schema/user";
37
37
  import { base32Decode } from "../base32";
38
38
  import { mfaRequiredConfigHandle } from "../config";
39
39
  import { AuthMfaHandlers } from "../constants";
40
- import { createAuthMfaFeature, mfaStatusCheckerFromFeature } from "../feature";
40
+ import {
41
+ bindMfaRevokeAllOtherSessionsFromFeature,
42
+ createAuthMfaFeature,
43
+ mfaStatusCheckerFromFeature,
44
+ } from "../feature";
41
45
  import { userMfaEntity, userMfaTable } from "../schema/user-mfa";
42
46
  import { currentTotpCode } from "../totp";
43
47
 
44
48
  let stack: TestStack;
49
+ const revokedForUserIds: string[] = [];
45
50
 
46
51
  const CHALLENGE_TOKEN_SECRET = "integration-test-challenge-token-secret-not-real-0001";
47
52
  const SETUP_TOKEN_SECRET = "integration-test-setup-token-secret-not-real-0002";
@@ -84,6 +89,20 @@ beforeAll(async () => {
84
89
  await unsafeCreateEntityTable(stack.db, tenantEntity);
85
90
  await unsafeCreateEntityTable(stack.db, userMfaEntity);
86
91
  await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
92
+
93
+ // Wire a spy in place of the real session-revoke callback (run-prod-app
94
+ // only binds this once the sessions feature is mounted — not the case in
95
+ // this test stack) so #1467's regression can observe whether
96
+ // revokeAllOtherSessions actually fires, instead of silently no-op'ing.
97
+ const bind = bindMfaRevokeAllOtherSessionsFromFeature(authMfaFeature);
98
+ // If the binder ever returns undefined, the spy below never wires up and
99
+ // the #1467 regression check further down would pass vacuously (nothing
100
+ // to revoke because nothing observed) instead of catching a real re-break.
101
+ expect(bind).toBeDefined();
102
+ bind?.((userId) => {
103
+ revokedForUserIds.push(userId);
104
+ return Promise.resolve(0);
105
+ });
87
106
  });
88
107
 
89
108
  afterAll(async () => {
@@ -93,6 +112,7 @@ afterAll(async () => {
93
112
  beforeEach(async () => {
94
113
  await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
95
114
  await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
115
+ revokedForUserIds.length = 0;
96
116
  });
97
117
 
98
118
  async function loginBlockedByEnforcement(
@@ -159,6 +179,10 @@ describe("POST /auth/mfa/preauth-confirm", () => {
159
179
  { Authorization: `Bearer ${body.token}` },
160
180
  );
161
181
  expect(meRes.status).toBe(200);
182
+
183
+ // Positive control for the #1467 regression below — proves the spy
184
+ // wiring actually observes a real revoke call, not just silent no-ops.
185
+ expect(revokedForUserIds).toContain(body.user.id);
162
186
  });
163
187
 
164
188
  test("wrong TOTP code → invalid_totp_code, no enrollment persisted", async () => {
@@ -247,6 +271,42 @@ describe("POST /auth/mfa/preauth-confirm", () => {
247
271
  expect(body.error.details.reason).toBe("invalid_setup_token");
248
272
  });
249
273
 
274
+ // #1467: findForAuth's status check + membership lookup used to run AFTER
275
+ // the entity-write and the revokeAllOtherSessions call — an admin
276
+ // restricting/deleting the account between preauth-enable-start and
277
+ // preauth-confirm still got invalid_setup_token back correctly (the
278
+ // handler's whole DB transaction rolls back on a non-success return, so
279
+ // the MFA row itself never actually persisted), but revokeAllOtherSessions
280
+ // is an external side effect outside that transaction — it fired
281
+ // regardless, logging the user's other devices out over an enrollment
282
+ // attempt that was rejected. Regression: with status/membership checked
283
+ // BEFORE the write, the revoke call must not fire at all for this case.
284
+ test("user restricted between start and confirm → invalid_setup_token, no session revoked (#1467)", async () => {
285
+ const email = "restricted-midflight@example.com";
286
+ const { preauthSetupToken, userId } = await loginBlockedByEnforcement(email);
287
+ const { setupToken, secret } = await startEnrollment(preauthSetupToken, email);
288
+
289
+ await asRawClient(stack.db).unsafe(
290
+ `UPDATE "${userTable.tableName}" SET "status" = 'restricted' WHERE "id" = $1`,
291
+ [userId],
292
+ );
293
+
294
+ const res = await stack.http.raw("POST", "/api/auth/mfa/preauth-confirm", {
295
+ setupToken,
296
+ code: currentTotpCode(secret),
297
+ });
298
+
299
+ expect(res.status).not.toBe(200);
300
+ const body = await res.json();
301
+ expect(body.error.details.reason).toBe("invalid_setup_token");
302
+ expect(revokedForUserIds).not.toContain(userId);
303
+ // Enrollment write itself rolls back with the handler's failure return —
304
+ // pinned here too so a future change to that transactional behavior
305
+ // doesn't silently reintroduce the original "row persists" half of the bug.
306
+ const rows = await selectMany(stack.db, userMfaTable, { userId });
307
+ expect(rows).toHaveLength(0);
308
+ });
309
+
250
310
  test("expired/garbage setupToken → invalid_setup_token, no session minted", async () => {
251
311
  const res = await stack.http.raw("POST", "/api/auth/mfa/preauth-confirm", {
252
312
  setupToken: "not-a-real-token",
@@ -0,0 +1,209 @@
1
+ // #1467: verify / enable-confirm / enable-confirm-preauth used to silently
2
+ // skip their brute-force cap and single-use burn when ctx.redis was
3
+ // unavailable, instead of failing closed. Real HTTP through setupTestStack
4
+ // with redis explicitly unwired (same pattern as
5
+ // auth-email-password/__tests__/account-lockout-no-redis.integration.test.ts),
6
+ // asserting each handler now returns an internal_error instead of silently
7
+ // degrading and continuing to the TOTP check.
8
+
9
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10
+ import { randomBytes } from "node:crypto";
11
+ import {
12
+ configureEntityFieldEncryption,
13
+ createEventStoreExecutor,
14
+ createTenantDb,
15
+ } from "@cosmicdrift/kumiko-framework/db";
16
+ import {
17
+ type SessionUser,
18
+ SYSTEM_TENANT_ID,
19
+ type TenantId,
20
+ } from "@cosmicdrift/kumiko-framework/engine";
21
+ import {
22
+ createTestUser,
23
+ setupTestStack,
24
+ type TestStack,
25
+ testTenantId,
26
+ unsafeCreateEntityTable,
27
+ } from "@cosmicdrift/kumiko-framework/stack";
28
+ import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
29
+ import { Temporal } from "temporal-polyfill";
30
+ import { createConfigFeature } from "../../config";
31
+ import { createConfigResolver } from "../../config/resolver";
32
+ import { createTenantFeature } from "../../tenant";
33
+ import { createUserFeature } from "../../user/feature";
34
+ import { base32Encode } from "../base32";
35
+ import { AuthMfaHandlers } from "../constants";
36
+ import { createAuthMfaFeature } from "../feature";
37
+ import { signMfaChallengeToken } from "../mfa-challenge-token";
38
+ import { signMfaSetupToken } from "../mfa-setup-token";
39
+ import { encodeRecoveryCodes, userMfaEntity, userMfaTable } from "../schema/user-mfa";
40
+ import { generateTotpSecret } from "../totp";
41
+
42
+ let stack: TestStack;
43
+
44
+ const CHALLENGE_TOKEN_SECRET = "no-redis-test-challenge-token-secret-not-real-0001";
45
+ const SETUP_TOKEN_SECRET = "no-redis-test-setup-token-secret-not-real-0002";
46
+ const TENANT_ID: TenantId = testTenantId(421);
47
+
48
+ // Mirrors framework's auth-routes.ts GUEST_USER — verify/enable-confirm-preauth
49
+ // are guest-callable (access: { roles: ["all"] }), the real identity comes
50
+ // entirely from the signed token, not from this dispatch-level user.
51
+ const GUEST_USER: SessionUser = {
52
+ id: "00000000-0000-0000-0000-000000000000",
53
+ tenantId: SYSTEM_TENANT_ID,
54
+ roles: ["all"],
55
+ };
56
+
57
+ beforeAll(async () => {
58
+ const encryption = createTestEnvelopeCipher(randomBytes(32).toString("base64"));
59
+ configureEntityFieldEncryption(encryption);
60
+ const resolver = createConfigResolver({ cipher: encryption });
61
+
62
+ stack = await setupTestStack({
63
+ features: [
64
+ createConfigFeature(),
65
+ createUserFeature(),
66
+ createTenantFeature(),
67
+ createAuthMfaFeature({
68
+ setupTokenSecret: SETUP_TOKEN_SECRET,
69
+ issuer: "Kumiko Test",
70
+ challengeTokenSecret: CHALLENGE_TOKEN_SECRET,
71
+ }),
72
+ ],
73
+ // extraContext runs after the default `redis: testRedis.redis` spread —
74
+ // setting redis:undefined here overrides it on the handler-facing
75
+ // AppContext (see account-lockout-no-redis.integration.test.ts).
76
+ extraContext: () => ({
77
+ configResolver: resolver,
78
+ configEncryption: encryption,
79
+ redis: undefined,
80
+ }),
81
+ });
82
+
83
+ await unsafeCreateEntityTable(stack.db, userMfaEntity);
84
+ });
85
+
86
+ afterAll(async () => {
87
+ await stack.cleanup();
88
+ });
89
+
90
+ // disable/regenerate-recovery are session-authed and only reachable once
91
+ // MFA is already enabled — but enable-confirm itself now requires
92
+ // ctx.redis (#1467), so it can't run against this no-redis stack to seed
93
+ // the row. Insert the row directly via the same executor.create the
94
+ // handler uses, bypassing the HTTP layer entirely (irrelevant to what
95
+ // this test is pinning: the handler's own ctx.redis gate).
96
+ const userMfaExecutor = createEventStoreExecutor(userMfaTable, userMfaEntity, {
97
+ entityName: "user-mfa",
98
+ });
99
+
100
+ async function seedEnabledMfa(user: SessionUser): Promise<void> {
101
+ const tdb = createTenantDb(stack.db, user.tenantId, "system");
102
+ const result = await userMfaExecutor.create(
103
+ {
104
+ userId: user.id,
105
+ totpSecret: base32Encode(generateTotpSecret()),
106
+ recoveryCodes: encodeRecoveryCodes([]),
107
+ enabledAt: Temporal.Now.instant(),
108
+ lastUsedAt: null,
109
+ },
110
+ user,
111
+ tdb,
112
+ );
113
+ if (!result.isSuccess) throw new Error(`seedEnabledMfa failed: ${result.error.code}`);
114
+ }
115
+
116
+ describe("auth-mfa handlers — ctx.redis unset (#1467)", () => {
117
+ test("verify: fails closed with internal_error instead of skipping the brute-force cap/burn", async () => {
118
+ const user = createTestUser({ tenantId: TENANT_ID });
119
+ const { token } = signMfaChallengeToken(
120
+ { userId: user.id, tenantId: TENANT_ID },
121
+ 5,
122
+ CHALLENGE_TOKEN_SECRET,
123
+ );
124
+
125
+ const err = await stack.http.writeErr(
126
+ AuthMfaHandlers.verify,
127
+ { challengeToken: token, code: "123456" },
128
+ GUEST_USER,
129
+ );
130
+ expect(err.httpStatus).toBeGreaterThanOrEqual(500);
131
+ expect(err.code).toBe("internal_error");
132
+ expect(err.message).toContain("requires ctx.redis");
133
+ });
134
+
135
+ test("enable-confirm-preauth: fails closed with internal_error instead of skipping the brute-force cap/burn", async () => {
136
+ const user = createTestUser({ tenantId: TENANT_ID });
137
+ const { token } = signMfaSetupToken(
138
+ {
139
+ userId: user.id,
140
+ tenantId: TENANT_ID,
141
+ totpSecretBase32: base32Encode(generateTotpSecret()),
142
+ recoveryCodeHashes: [],
143
+ },
144
+ 5,
145
+ SETUP_TOKEN_SECRET,
146
+ );
147
+
148
+ const err = await stack.http.writeErr(
149
+ AuthMfaHandlers.enableConfirmPreauth,
150
+ { setupToken: token, code: "123456" },
151
+ GUEST_USER,
152
+ );
153
+ expect(err.httpStatus).toBeGreaterThanOrEqual(500);
154
+ expect(err.code).toBe("internal_error");
155
+ expect(err.message).toContain("requires ctx.redis");
156
+ });
157
+
158
+ test("enable-confirm: fails closed with internal_error instead of skipping the single-use burn", async () => {
159
+ const user = createTestUser({ tenantId: TENANT_ID });
160
+ const { token } = signMfaSetupToken(
161
+ {
162
+ userId: user.id,
163
+ totpSecretBase32: base32Encode(generateTotpSecret()),
164
+ recoveryCodeHashes: [],
165
+ },
166
+ 5,
167
+ SETUP_TOKEN_SECRET,
168
+ );
169
+
170
+ const err = await stack.http.writeErr(
171
+ AuthMfaHandlers.enableConfirm,
172
+ { setupToken: token, code: "123456" },
173
+ user,
174
+ );
175
+ expect(err.httpStatus).toBeGreaterThanOrEqual(500);
176
+ expect(err.code).toBe("internal_error");
177
+ expect(err.message).toContain("requires ctx.redis");
178
+ });
179
+
180
+ // kumiko-framework#1534: disable/regenerate-recovery used to keep the
181
+ // old two-factor-authenticate-then-verify flow working even without
182
+ // ctx.redis (verifyMfaFactor's replay-burn silently no-ops), so an
183
+ // observed TOTP code could be replayed within the ±1-step window against
184
+ // a session-authed route — unlike verify/enable-confirm-preauth, no
185
+ // guest-token expiry limits the attack window.
186
+ test("disable: fails closed with internal_error instead of skipping TOTP replay protection", async () => {
187
+ const user = createTestUser({ id: 501, tenantId: TENANT_ID });
188
+ await seedEnabledMfa(user);
189
+
190
+ const err = await stack.http.writeErr(AuthMfaHandlers.disable, { code: "123456" }, user);
191
+ expect(err.httpStatus).toBeGreaterThanOrEqual(500);
192
+ expect(err.code).toBe("internal_error");
193
+ expect(err.message).toContain("requires ctx.redis");
194
+ });
195
+
196
+ test("regenerate-recovery: fails closed with internal_error instead of skipping TOTP replay protection", async () => {
197
+ const user = createTestUser({ id: 502, tenantId: TENANT_ID });
198
+ await seedEnabledMfa(user);
199
+
200
+ const err = await stack.http.writeErr(
201
+ AuthMfaHandlers.regenerateRecovery,
202
+ { code: "123456" },
203
+ user,
204
+ );
205
+ expect(err.httpStatus).toBeGreaterThanOrEqual(500);
206
+ expect(err.code).toBe("internal_error");
207
+ expect(err.message).toContain("requires ctx.redis");
208
+ });
209
+ });
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
15
15
  import { randomBytes } from "node:crypto";
16
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
16
+ import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
17
17
  import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
18
18
  import type { AppContext } from "@cosmicdrift/kumiko-framework/engine";
19
19
  import { rebuildProjection } from "@cosmicdrift/kumiko-framework/pipeline";
@@ -167,3 +167,81 @@ describe("auth-mfa KEK-rotation job — kumiko-framework#266 Step 8", () => {
167
167
  await stack.http.writeOk(AuthMfaHandlers.disable, { code: currentTotpCode(secret) }, user);
168
168
  });
169
169
  });
170
+
171
+ describe("auth-mfa KEK-rotation job — unrecognized values are not silently skipped (kumiko-framework#1541)", () => {
172
+ test("a non-envelope totpSecret is counted failed, never alreadyCurrent, and is left untouched", async () => {
173
+ // Reset provider to V1 so a freshly-enabled row is "current" (nothing to
174
+ // rotate) — the garbage field is the only failure we expect.
175
+ mutableProvider.replace(
176
+ createEnvMasterKeyProvider({
177
+ env: {
178
+ KUMIKO_SECRETS_MASTER_KEY_V1: v1Key,
179
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
180
+ },
181
+ }),
182
+ );
183
+
184
+ const user = createTestUser({ id: 2, roles: ["User"] });
185
+ const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
186
+ AuthMfaHandlers.enableStart,
187
+ { accountLabel: "user-2@example.com" },
188
+ user,
189
+ );
190
+ const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
191
+ const secret = base32Decode(secretParam);
192
+ await stack.http.writeOk(
193
+ AuthMfaHandlers.enableConfirm,
194
+ { setupToken: start.setupToken, code: currentTotpCode(secret) },
195
+ user,
196
+ );
197
+
198
+ const beforeRow = await readRawRow();
199
+ const garbage = "not-a-stored-envelope";
200
+ await asRawClient(stack.db).unsafe(`UPDATE read_user_mfa SET totp_secret = $1 WHERE id = $2`, [
201
+ garbage,
202
+ beforeRow.id,
203
+ ]);
204
+
205
+ type CapturedLog = { info: string[]; warn: string[] };
206
+ const captured: CapturedLog = { info: [], warn: [] };
207
+ const capturingLog = {
208
+ info: (msg: string) => {
209
+ captured.info.push(msg);
210
+ },
211
+ warn: (msg: string) => {
212
+ captured.warn.push(msg);
213
+ },
214
+ error: () => {},
215
+ debug: () => {},
216
+ child: () => capturingLog,
217
+ };
218
+
219
+ await mfaReencryptJob({}, {
220
+ db: stack.db,
221
+ registry: stack.registry,
222
+ masterKeyProvider: mutableProvider,
223
+ log: capturingLog,
224
+ } as AppContext);
225
+
226
+ const completeLine = captured.info.find((line) =>
227
+ line.includes("[auth-mfa:reencrypt] complete:"),
228
+ );
229
+ if (!completeLine) throw new Error("job did not log a completion summary");
230
+ const result = JSON.parse(completeLine.slice(completeLine.indexOf("{"))) as {
231
+ migrated: number;
232
+ failed: number;
233
+ alreadyCurrent: number;
234
+ };
235
+ expect(result.migrated).toBe(0);
236
+ expect(result.failed).toBe(1);
237
+ expect(result.alreadyCurrent).toBe(0);
238
+
239
+ const rejectedWarning = captured.warn.find((line) =>
240
+ line.includes("not a current-cipher envelope"),
241
+ );
242
+ expect(rejectedWarning).toBeDefined();
243
+
244
+ const afterRow = await readRawRow();
245
+ expect(afterRow.totpSecret).toBe(garbage);
246
+ });
247
+ });
@@ -1,5 +1,6 @@
1
1
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
4
  import { z } from "zod";
4
5
  import { findUserMfaRow } from "../db/queries";
5
6
  import { invalidTotpCode, mfaNotEnabled } from "../errors";
@@ -30,7 +31,21 @@ export function createDisableHandler(opts: DisableOptions) {
30
31
  const row = await findUserMfaRow(ctx.db, event.user);
31
32
  if (!row) return mfaNotEnabled();
32
33
 
33
- const replay = ctx.redis ? { redis: ctx.redis, userId: event.user.id } : undefined;
34
+ // Fail closed, matching verify.write.ts/enable-confirm-preauth.write.ts
35
+ // (#1467): without ctx.redis, verifyMfaFactor's TOTP-replay burn is
36
+ // skipped, and a code observed once (phishing proxy/shoulder-surfing)
37
+ // could be replayed within the ±1-step window to disable MFA outright.
38
+ // "no ctx.redis means no one gets through MFA login" doesn't cover
39
+ // this route — it's session-authed, and JWTs issued before Redis was
40
+ // unwired stay valid until they expire.
41
+ if (!ctx.redis) {
42
+ return writeFailure(
43
+ new InternalError({
44
+ message: "disable (mfa) requires ctx.redis for TOTP replay protection",
45
+ }),
46
+ );
47
+ }
48
+ const replay = { redis: ctx.redis, userId: event.user.id };
34
49
  const verify = await verifyMfaFactor(row, event.payload.code, replay);
35
50
  if (!verify.ok) return invalidTotpCode();
36
51
 
@@ -5,6 +5,7 @@ import {
5
5
  defineWriteHandler,
6
6
  type SessionUser,
7
7
  } from "@cosmicdrift/kumiko-framework/engine";
8
+ import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
8
9
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
9
10
  import { Temporal } from "temporal-polyfill";
10
11
  import { z } from "zod";
@@ -69,34 +70,45 @@ export function createEnableConfirmPreauthHandler(opts: EnableConfirmPreauthOpti
69
70
  const { userId, tenantId } = verify.payload;
70
71
  if (tenantId === undefined) return invalidSetupToken();
71
72
 
73
+ // Fail closed: unlike verify.write.ts/enable-confirm.write.ts, this
74
+ // endpoint has NO gate before it (access: { roles: ["all"] }, no JWT,
75
+ // no prior challenge-token from a successful login) — its only secret
76
+ // is a 6-digit TOTP code. Silently skipping the brute-force cap and
77
+ // single-use burn when ctx.redis is absent would let the setup token
78
+ // be guessed against unboundedly until its TTL expires. The route's
79
+ // IP rate limiter is not a substitute: it's IP-scoped and resets on
80
+ // every success.
81
+ if (!ctx.redis) {
82
+ return writeFailure(
83
+ new InternalError({
84
+ message:
85
+ "enable-confirm-preauth requires ctx.redis for the brute-force cap and single-use burn",
86
+ }),
87
+ );
88
+ }
89
+
72
90
  // Same guessing surface as /auth/mfa/verify (a short TOTP code) and
73
91
  // the same per-userId cap — deliberately shared counter, see
74
92
  // mfa-verify-attempts.ts.
75
- if (ctx.redis) {
76
- const state = await getMfaVerifyLockoutState(ctx.redis, userId);
77
- if (state?.lockedUntil !== null && state?.lockedUntil !== undefined) {
78
- const now = Date.now();
79
- if (state.lockedUntil > now) {
80
- const retryAfterSeconds = Math.max(1, Math.ceil((state.lockedUntil - now) / 1000));
81
- return tooManyAttempts(retryAfterSeconds);
82
- }
93
+ const state = await getMfaVerifyLockoutState(ctx.redis, userId);
94
+ if (state?.lockedUntil !== null && state?.lockedUntil !== undefined) {
95
+ const now = Date.now();
96
+ if (state.lockedUntil > now) {
97
+ const retryAfterSeconds = Math.max(1, Math.ceil((state.lockedUntil - now) / 1000));
98
+ return tooManyAttempts(retryAfterSeconds);
83
99
  }
84
100
  }
85
101
 
86
102
  const secret = base32Decode(verify.payload.totpSecretBase32);
87
103
  if (verifyTotp(secret, event.payload.code) === false) {
88
- if (ctx.redis) {
89
- await recordFailedMfaVerifyAttempt(ctx.redis, userId, maxAttempts, lockoutMinutes);
90
- }
104
+ await recordFailedMfaVerifyAttempt(ctx.redis, userId, maxAttempts, lockoutMinutes);
91
105
  return invalidTotpCode();
92
106
  }
93
107
 
94
108
  // Burn the setup token on the first successful confirm — same
95
109
  // single-use guarantee as enable-confirm.write.ts.
96
- if (ctx.redis) {
97
- const burnResult = await burnToken(ctx.redis, "mfa-setup", userId, verify.expiresAtMs);
98
- if (burnResult === "already-used") return invalidSetupToken();
99
- }
110
+ const burnResult = await burnToken(ctx.redis, "mfa-setup", userId, verify.expiresAtMs);
111
+ if (burnResult === "already-used") return invalidSetupToken();
100
112
 
101
113
  // "system" mode: no session exists yet, tenantId comes from the
102
114
  // verified token, mirroring enable-start-preauth.write.ts.
@@ -105,33 +117,20 @@ export function createEnableConfirmPreauthHandler(opts: EnableConfirmPreauthOpti
105
117
  const existing = await findUserMfaRow(scopedDb, scopedUser);
106
118
  if (existing) return mfaAlreadyEnabled();
107
119
 
108
- const result = await executor.create(
109
- {
110
- userId,
111
- totpSecret: verify.payload.totpSecretBase32,
112
- recoveryCodes: encodeRecoveryCodes(verify.payload.recoveryCodeHashes),
113
- enabledAt: Temporal.Now.instant(),
114
- lastUsedAt: null,
115
- },
116
- scopedUser,
117
- scopedDb,
118
- );
119
- if (!result.isSuccess) return result;
120
-
121
- if (ctx.redis) {
122
- await clearMfaVerifyAttempts(ctx.redis, userId);
123
- }
124
-
125
- // No currentSid to exclude — this is the first session for this
126
- // login, not a follow-up on an already-running one.
127
- if (opts.revokeAllOtherSessions) {
128
- await opts.revokeAllOtherSessions(userId, undefined);
129
- }
130
-
131
- // Re-derive the full session the way verify.write.ts does — the
132
- // setupToken only proves "password + new TOTP secret", not roles;
133
- // roles come from a fresh membership lookup, same as every other
134
- // login-completing handler.
120
+ // Status + membership must be verified BEFORE the entity-write below —
121
+ // an admin can restrict/delete the account or revoke its tenant
122
+ // membership in the window between preauth-enable-start and
123
+ // preauth-confirm (up to MFA_SETUP_TOKEN_TTL_MINUTES). Checking after
124
+ // the write left two irreversible side effects behind an
125
+ // already-signed, non-forgeable setupToken that then just returns
126
+ // invalidSetupToken(): the MFA row persisted and (formerly) all other
127
+ // sessions revoked — with mfaAlreadyEnabled() blocking any
128
+ // re-enrollment until an operator manually disables MFA. NOT fixed by
129
+ // this ordering: burnToken() above still runs before this check, so a
130
+ // mid-flight restricted/deleted user still burns their setup token
131
+ // (framework#1523/5) — moving the burn later would need care around
132
+ // the existing userId-unique-constraint backstop between the
133
+ // `existing` check above and executor.create() below.
135
134
  const systemUser = createSystemUser(tenantId, ["SystemAdmin"]);
136
135
  const userRow = (await ctx.queryAs(systemUser, UserQueries.findForAuth, {
137
136
  id: userId,
@@ -153,6 +152,27 @@ export function createEnableConfirmPreauthHandler(opts: EnableConfirmPreauthOpti
153
152
  })) as ReadonlyArray<{ tenantId: string; roles: readonly string[] }>; // @cast-boundary engine-payload
154
153
  const membership = memberships.find((m) => m.tenantId === tenantId);
155
154
  if (!membership) return invalidSetupToken();
155
+
156
+ const result = await executor.create(
157
+ {
158
+ userId,
159
+ totpSecret: verify.payload.totpSecretBase32,
160
+ recoveryCodes: encodeRecoveryCodes(verify.payload.recoveryCodeHashes),
161
+ enabledAt: Temporal.Now.instant(),
162
+ lastUsedAt: null,
163
+ },
164
+ scopedUser,
165
+ scopedDb,
166
+ );
167
+ if (!result.isSuccess) return result;
168
+
169
+ // ctx.redis is guaranteed by the fail-closed check above.
170
+ await clearMfaVerifyAttempts(ctx.redis, userId);
171
+
172
+ // Re-derive the full session the way verify.write.ts does — the
173
+ // setupToken only proves "password + new TOTP secret", not roles;
174
+ // roles come from the membership lookup above, same as every other
175
+ // login-completing handler.
156
176
  // buildSessionRoles calls stripForbiddenMembershipRoles to strip reserved
157
177
  // roles from the membership portion (globalRoles keeps SystemAdmin) —
158
178
  // read-time backstop against a rebuild-resurrected role, same as
@@ -164,6 +184,16 @@ export function createEnableConfirmPreauthHandler(opts: EnableConfirmPreauthOpti
164
184
  const session: SessionUser =
165
185
  Object.keys(claims).length > 0 ? { ...baseSession, claims } : baseSession;
166
186
 
187
+ // Last step before returning success: every gate above (status,
188
+ // membership, entity-write) has passed, so this is the point of no
189
+ // return anyway — revoking here instead of right after the write
190
+ // means a rejected confirm never touches the user's other sessions.
191
+ // No currentSid to exclude — this is the first session for this
192
+ // login, not a follow-up on an already-running one.
193
+ if (opts.revokeAllOtherSessions) {
194
+ await opts.revokeAllOtherSessions(userId, undefined);
195
+ }
196
+
167
197
  return { isSuccess: true, data: { kind: "mfa-preauth-confirm-success", session } };
168
198
  },
169
199
  });
@@ -1,5 +1,6 @@
1
1
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
4
  import { Temporal } from "temporal-polyfill";
4
5
  import { z } from "zod";
5
6
  import { burnToken } from "../../shared";
@@ -41,6 +42,21 @@ export function createEnableConfirmHandler(opts: EnableConfirmOptions) {
41
42
  // already rules that out).
42
43
  if (verify.payload.userId !== event.user.id) return invalidSetupToken();
43
44
 
45
+ // Fail closed, matching verify.write.ts/enable-confirm-preauth.write.ts
46
+ // (#1467): session-authed (lower risk — the caller already has a
47
+ // valid JWT), but silently skipping the single-use burn here would
48
+ // still let a double-confirm race complete twice instead of the
49
+ // second seeing invalidSetupToken(). Checked before the TOTP verify
50
+ // for the same reason the other two handlers check it early: fail
51
+ // closed before doing any of the guarded work, not partway through.
52
+ if (!ctx.redis) {
53
+ return writeFailure(
54
+ new InternalError({
55
+ message: "enable-confirm requires ctx.redis for the single-use setup-token burn",
56
+ }),
57
+ );
58
+ }
59
+
44
60
  const secret = base32Decode(verify.payload.totpSecretBase32);
45
61
  if (verifyTotp(secret, event.payload.code) === false) return invalidTotpCode();
46
62
 
@@ -51,18 +67,13 @@ export function createEnableConfirmHandler(opts: EnableConfirmOptions) {
51
67
  // ponytail: burned here, not unburned on a later executor.create
52
68
  // failure (unlike login.write.ts's unburnToken pattern) — self-service
53
69
  // enable-start always mints a fresh setup token on demand (no mailed
54
- // link to go stale), so the realistic failure path (double-confirm
55
- // race sans Redis) is meant to leave the burn standing. Add
56
- // unburnToken here if create-failure retries turn out to matter.
57
- if (ctx.redis) {
58
- const burnResult = await burnToken(
59
- ctx.redis,
60
- "mfa-setup",
61
- event.user.id,
62
- verify.expiresAtMs,
63
- );
64
- if (burnResult === "already-used") return invalidSetupToken();
65
- }
70
+ // link to go stale), so the realistic remaining failure path (a
71
+ // double-confirm race where executor.create itself fails, e.g. a
72
+ // concurrent mfa_already_enabled() write) is meant to leave the burn
73
+ // standing. Add unburnToken here if create-failure retries turn out
74
+ // to matter.
75
+ const burnResult = await burnToken(ctx.redis, "mfa-setup", event.user.id, verify.expiresAtMs);
76
+ if (burnResult === "already-used") return invalidSetupToken();
66
77
 
67
78
  const existing = await findUserMfaRow(ctx.db, event.user);
68
79
  if (existing) return mfaAlreadyEnabled();