@cosmicdrift/kumiko-bundled-features 0.165.0 → 0.165.2

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 (159) 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 +205 -0
  4. package/src/audit/__tests__/audit.integration.test.ts +52 -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__/login-gates.test.ts +39 -0
  9. package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +9 -14
  10. package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +16 -5
  11. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +54 -0
  12. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -1
  13. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -1
  14. package/src/auth-email-password/handlers/login.write.ts +196 -155
  15. package/src/auth-email-password/handlers/self-registration-status.query.ts +6 -3
  16. package/src/auth-email-password/handlers/signup-request.write.ts +12 -1
  17. package/src/auth-email-password/i18n.ts +2 -0
  18. package/src/auth-email-password/seeding.ts +41 -23
  19. package/src/auth-email-password/web/__tests__/auth-client.test.ts +13 -0
  20. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +55 -8
  21. package/src/auth-email-password/web/__tests__/client-plugin.test.tsx +34 -0
  22. package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +8 -1
  23. package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +5 -1
  24. package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +3 -0
  25. package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +8 -2
  26. package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +5 -1
  27. package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +5 -1
  28. package/src/auth-email-password/web/auth-client.ts +32 -10
  29. package/src/auth-email-password/web/auth-gate.tsx +17 -21
  30. package/src/auth-email-password/web/client-plugin.ts +15 -2
  31. package/src/auth-email-password/web/index.ts +5 -1
  32. package/src/auth-email-password/web/login-screen.tsx +7 -3
  33. package/src/auth-email-password/web/request-account-unlock-screen.tsx +1 -1
  34. package/src/auth-foundation/__tests__/session-store.test.ts +2 -4
  35. package/src/auth-foundation/__tests__/tenant-providers.test.ts +24 -0
  36. package/src/auth-foundation/__tests__/token-verifier.test.ts +36 -3
  37. package/src/auth-foundation/boot-checks.ts +74 -69
  38. package/src/auth-foundation/resolve-anonymous-access.ts +13 -5
  39. package/src/auth-foundation/types.ts +3 -3
  40. package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +61 -1
  41. package/src/auth-mfa/__tests__/no-redis.integration.test.ts +209 -0
  42. package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +157 -4
  43. package/src/auth-mfa/handlers/disable.write.ts +16 -1
  44. package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +72 -42
  45. package/src/auth-mfa/handlers/enable-confirm.write.ts +23 -12
  46. package/src/auth-mfa/handlers/reencrypt.job.ts +26 -17
  47. package/src/auth-mfa/handlers/regenerate-recovery.write.ts +15 -1
  48. package/src/auth-mfa/handlers/verify.write.ts +28 -24
  49. package/src/auth-mfa/web/__tests__/mfa-client.test.ts +5 -1
  50. package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +10 -1
  51. package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +104 -1
  52. package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +5 -1
  53. package/src/auth-mfa/web/i18n.ts +4 -0
  54. package/src/auth-mfa/web/mfa-client.ts +13 -2
  55. package/src/auth-mfa/web/mfa-enable-screen.tsx +1 -1
  56. package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +25 -2
  57. package/src/billing-foundation/__tests__/subscription-provider-contract.ts +36 -12
  58. package/src/config/__tests__/backing-secrets.integration.test.ts +4 -0
  59. package/src/config/__tests__/cascade.integration.test.ts +13 -2
  60. package/src/config/__tests__/pii-encrypted.integration.test.ts +6 -6
  61. package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +78 -5
  62. package/src/config/db/queries/resolver.ts +3 -3
  63. package/src/config/handlers/reencrypt.job.ts +36 -24
  64. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +14 -0
  65. package/src/document-ingest-foundation/__tests__/feature.test.ts +38 -0
  66. package/src/document-ingest-foundation/__tests__/pages.test.ts +37 -0
  67. package/src/document-ingest-foundation/entity.ts +10 -3
  68. package/src/document-ingest-foundation/feature.ts +26 -8
  69. package/src/document-ingest-foundation/index.ts +1 -0
  70. package/src/document-ingest-foundation/pages.ts +34 -0
  71. package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +114 -11
  72. package/src/feature-toggles/compose-tier-resolver.ts +77 -8
  73. package/src/feature-toggles/global-feature-state-table.ts +15 -12
  74. package/src/files-provider-s3/s3-provider.ts +12 -1
  75. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +78 -1
  76. package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +58 -40
  77. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +9 -1
  78. package/src/inbound-mail-foundation/entities.ts +3 -3
  79. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +22 -12
  80. package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +1 -1
  81. package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +35 -8
  82. package/src/inbound-provider-inmemory/__tests__/feature.test.ts +37 -33
  83. package/src/jobs/__tests__/job-instant.test.ts +15 -0
  84. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +19 -9
  85. package/src/jobs/feature.ts +6 -5
  86. package/src/jobs/job-instant.ts +9 -0
  87. package/src/mail-foundation/__tests__/mail-transport-contract.ts +27 -11
  88. package/src/mail-transport-inmemory/__tests__/feature.test.ts +10 -6
  89. package/src/mail-transport-smtp/__tests__/feature.test.ts +10 -5
  90. package/src/page-render/__tests__/markdown.test.ts +29 -0
  91. package/src/page-render/markdown.ts +1 -9
  92. package/src/personal-access-tokens/feature.ts +3 -3
  93. package/src/personal-access-tokens/schema/api-token.ts +3 -3
  94. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +3 -1
  95. package/src/sessions/__tests__/sessions-screens.boot.test.ts +5 -10
  96. package/src/sessions/__tests__/sessions.integration.test.ts +88 -1
  97. package/src/sessions/feature.ts +21 -6
  98. package/src/sessions/handlers/revoke-all-for-user.write.ts +51 -12
  99. package/src/sessions/handlers/revoke-all-others.write.ts +19 -1
  100. package/src/sessions/handlers/revoke.write.ts +13 -0
  101. package/src/sessions/index.ts +7 -0
  102. package/src/sessions/schema/user-session.ts +6 -7
  103. package/src/sessions/session-callbacks.ts +58 -5
  104. package/src/sessions/session-revoked-event.ts +29 -0
  105. package/src/shared/__tests__/chunked-entity-migration.test.ts +4 -4
  106. package/src/shared/chunked-entity-migration.ts +2 -2
  107. package/src/shared/grace-period.test.ts +26 -0
  108. package/src/shared/grace-period.ts +20 -0
  109. package/src/shared/index.ts +2 -0
  110. package/src/shared/map-with-concurrency.test.ts +104 -0
  111. package/src/shared/map-with-concurrency.ts +38 -0
  112. package/src/subscription-mollie/__tests__/feature.test.ts +16 -6
  113. package/src/subscription-mollie/__tests__/verify-webhook.test.ts +21 -0
  114. package/src/subscription-mollie/verify-webhook.ts +1 -0
  115. package/src/subscription-stripe/__tests__/feature.test.ts +14 -7
  116. package/src/subscription-stripe/__tests__/verify-webhook.test.ts +20 -0
  117. package/src/subscription-stripe/verify-webhook.ts +3 -2
  118. package/src/tenant/__tests__/members-query-kms.integration.test.ts +2 -2
  119. package/src/tenant/__tests__/multi-tenant.integration.test.ts +1 -1
  120. package/src/tenant/handlers/active-tenant-ids.query.ts +8 -2
  121. package/src/tenant/handlers/invitations.query.ts +13 -15
  122. package/src/tenant/handlers/members.query.ts +12 -12
  123. package/src/tenant/seeding.ts +110 -16
  124. package/src/tenant-lifecycle/boot-checks.ts +11 -4
  125. package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +2 -6
  126. package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +94 -0
  127. package/src/tenant-settings/config.ts +44 -0
  128. package/src/tenant-settings/constants.ts +16 -0
  129. package/src/tenant-settings/feature.ts +49 -0
  130. package/src/tenant-settings/index.ts +8 -0
  131. package/src/tenant-settings/tenant-defaults.ts +72 -0
  132. package/src/text-content/__tests__/seed-legal-content.integration.test.ts +33 -6
  133. package/src/text-content/handlers/set.write.ts +2 -2
  134. package/src/text-content/seeding.ts +31 -29
  135. package/src/user/handlers/find-for-auth.query.ts +1 -1
  136. package/src/user/seeding.ts +8 -0
  137. package/src/user-data-rights/COMPLIANCE.md +1 -1
  138. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +65 -0
  139. package/src/user-data-rights/__tests__/boot-checks.test.ts +15 -17
  140. package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +9 -5
  141. package/src/user-data-rights/__tests__/download.integration.test.ts +25 -41
  142. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +7 -10
  143. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +15 -1
  144. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +140 -0
  145. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +9 -4
  146. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +74 -8
  147. package/src/user-data-rights/db/queries/__tests__/forget-cleanup.integration.test.ts +69 -0
  148. package/src/user-data-rights/db/queries/forget-cleanup.ts +9 -2
  149. package/src/user-data-rights/feature.ts +45 -45
  150. package/src/user-data-rights/handlers/cancel-deletion.write.ts +6 -11
  151. package/src/user-data-rights/handlers/download-by-job.query.ts +6 -6
  152. package/src/user-data-rights/handlers/download-by-token.query.ts +6 -6
  153. package/src/user-data-rights/handlers/lift-restriction.write.ts +33 -2
  154. package/src/user-data-rights/handlers/list-download-attempts.query.ts +1 -0
  155. package/src/user-data-rights/handlers/my-audit-log.query.ts +1 -0
  156. package/src/user-data-rights/handlers/restrict-account.write.ts +33 -4
  157. package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +2 -0
  158. package/src/user-data-rights/lib/is-admin-actor.ts +19 -0
  159. package/src/user-data-rights/run-forget-cleanup.ts +27 -7
@@ -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";
@@ -84,10 +84,16 @@ afterAll(async () => {
84
84
  await stack.cleanup();
85
85
  });
86
86
 
87
- type RawUserMfaRow = { id: string; totpSecret: string; version: number };
87
+ type RawUserMfaRow = {
88
+ id: string;
89
+ userId: string;
90
+ totpSecret: string;
91
+ recoveryCodes: string;
92
+ version: number;
93
+ };
88
94
 
89
- async function readRawRow(): Promise<RawUserMfaRow> {
90
- const rows = await selectMany<RawUserMfaRow>(stack.db, userMfaTable, {});
95
+ async function readRawRow(userId?: string): Promise<RawUserMfaRow> {
96
+ const rows = await selectMany<RawUserMfaRow>(stack.db, userMfaTable, userId ? { userId } : {});
91
97
  const row = rows[0];
92
98
  if (!row) throw new Error("no user-mfa row");
93
99
  return row;
@@ -167,3 +173,150 @@ describe("auth-mfa KEK-rotation job — kumiko-framework#266 Step 8", () => {
167
173
  await stack.http.writeOk(AuthMfaHandlers.disable, { code: currentTotpCode(secret) }, user);
168
174
  });
169
175
  });
176
+
177
+ describe("auth-mfa KEK-rotation job — unrecognized values are not silently skipped (kumiko-framework#1541)", () => {
178
+ test("a non-envelope totpSecret is counted failed, never alreadyCurrent, and is left untouched", async () => {
179
+ // Reset provider to V1 so a freshly-enabled row is "current" (nothing to
180
+ // rotate) — the garbage field is the only failure we expect.
181
+ mutableProvider.replace(
182
+ createEnvMasterKeyProvider({
183
+ env: {
184
+ KUMIKO_SECRETS_MASTER_KEY_V1: v1Key,
185
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
186
+ },
187
+ }),
188
+ );
189
+
190
+ const user = createTestUser({ id: 2, roles: ["User"] });
191
+ const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
192
+ AuthMfaHandlers.enableStart,
193
+ { accountLabel: "user-2@example.com" },
194
+ user,
195
+ );
196
+ const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
197
+ const secret = base32Decode(secretParam);
198
+ await stack.http.writeOk(
199
+ AuthMfaHandlers.enableConfirm,
200
+ { setupToken: start.setupToken, code: currentTotpCode(secret) },
201
+ user,
202
+ );
203
+
204
+ const beforeRow = await readRawRow(user.id);
205
+ const garbage = "not-a-stored-envelope";
206
+ await asRawClient(stack.db).unsafe(`UPDATE read_user_mfa SET totp_secret = $1 WHERE id = $2`, [
207
+ garbage,
208
+ beforeRow.id,
209
+ ]);
210
+
211
+ type CapturedLog = { info: string[]; warn: string[] };
212
+ const captured: CapturedLog = { info: [], warn: [] };
213
+ const capturingLog = {
214
+ info: (msg: string) => {
215
+ captured.info.push(msg);
216
+ },
217
+ warn: (msg: string) => {
218
+ captured.warn.push(msg);
219
+ },
220
+ error: () => {},
221
+ debug: () => {},
222
+ child: () => capturingLog,
223
+ };
224
+
225
+ await mfaReencryptJob({}, {
226
+ db: stack.db,
227
+ registry: stack.registry,
228
+ masterKeyProvider: mutableProvider,
229
+ log: capturingLog,
230
+ } as AppContext);
231
+
232
+ const completeLine = captured.info.find((line) =>
233
+ line.includes("[auth-mfa:reencrypt] complete:"),
234
+ );
235
+ if (!completeLine) throw new Error("job did not log a completion summary");
236
+ const result = JSON.parse(completeLine.slice(completeLine.indexOf("{"))) as {
237
+ migrated: number;
238
+ failed: number;
239
+ alreadyCurrent: number;
240
+ };
241
+ expect(result.migrated).toBe(0);
242
+ expect(result.failed).toBe(1);
243
+ expect(result.alreadyCurrent).toBe(0);
244
+
245
+ const rejectedWarning = captured.warn.find((line) =>
246
+ line.includes("not a current-cipher envelope"),
247
+ );
248
+ expect(rejectedWarning).toBeDefined();
249
+
250
+ const afterRow = await readRawRow(user.id);
251
+ expect(afterRow.totpSecret).toBe(garbage);
252
+ });
253
+
254
+ test("a non-envelope recoveryCodes is counted failed, never alreadyCurrent, and is left untouched", async () => {
255
+ mutableProvider.replace(
256
+ createEnvMasterKeyProvider({
257
+ env: {
258
+ KUMIKO_SECRETS_MASTER_KEY_V1: v1Key,
259
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
260
+ },
261
+ }),
262
+ );
263
+
264
+ const user = createTestUser({ id: 3, roles: ["User"] });
265
+ const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
266
+ AuthMfaHandlers.enableStart,
267
+ { accountLabel: "user-3@example.com" },
268
+ user,
269
+ );
270
+ const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
271
+ const secret = base32Decode(secretParam);
272
+ await stack.http.writeOk(
273
+ AuthMfaHandlers.enableConfirm,
274
+ { setupToken: start.setupToken, code: currentTotpCode(secret) },
275
+ user,
276
+ );
277
+
278
+ const beforeRow = await readRawRow(user.id);
279
+ const garbage = "not-a-stored-envelope";
280
+ await asRawClient(stack.db).unsafe(
281
+ `UPDATE read_user_mfa SET recovery_codes = $1 WHERE id = $2`,
282
+ [garbage, beforeRow.id],
283
+ );
284
+
285
+ type CapturedLog = { info: string[]; warn: string[] };
286
+ const captured: CapturedLog = { info: [], warn: [] };
287
+ const capturingLog = {
288
+ info: (msg: string) => {
289
+ captured.info.push(msg);
290
+ },
291
+ warn: (msg: string) => {
292
+ captured.warn.push(msg);
293
+ },
294
+ error: () => {},
295
+ debug: () => {},
296
+ child: () => capturingLog,
297
+ };
298
+
299
+ await mfaReencryptJob({}, {
300
+ db: stack.db,
301
+ registry: stack.registry,
302
+ masterKeyProvider: mutableProvider,
303
+ log: capturingLog,
304
+ } as AppContext);
305
+
306
+ const completeLine = captured.info.find((line) =>
307
+ line.includes("[auth-mfa:reencrypt] complete:"),
308
+ );
309
+ if (!completeLine) throw new Error("job did not log a completion summary");
310
+ const result = JSON.parse(completeLine.slice(completeLine.indexOf("{"))) as {
311
+ migrated: number;
312
+ failed: number;
313
+ alreadyCurrent: number;
314
+ };
315
+ expect(result.migrated).toBe(0);
316
+ expect(result.failed).toBeGreaterThanOrEqual(1);
317
+ expect(result.alreadyCurrent).toBe(0);
318
+
319
+ const afterRow = await readRawRow(user.id);
320
+ expect(afterRow.recoveryCodes).toBe(garbage);
321
+ });
322
+ });
@@ -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();