@cosmicdrift/kumiko-bundled-features 0.149.2 → 0.151.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 (81) hide show
  1. package/package.json +7 -6
  2. package/src/admin-shell/__tests__/admin-shell-security.integration.test.ts +1 -1
  3. package/src/audit/__tests__/audit-security.integration.test.ts +1 -1
  4. package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.test.ts +1 -1
  5. package/src/auth-email-password/__tests__/account-lockout.integration.test.ts +1 -1
  6. package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +1 -1
  7. package/src/auth-email-password/__tests__/auth.integration.test.ts +1 -1
  8. package/src/auth-email-password/__tests__/email-verification.integration.test.ts +1 -1
  9. package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +1 -1
  10. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +1 -1
  11. package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +1 -1
  12. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +1 -1
  13. package/src/auth-email-password/__tests__/seed-admin.integration.test.ts +1 -1
  14. package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +1 -1
  15. package/src/auth-email-password/handlers/change-password.write.ts +1 -1
  16. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +1 -2
  17. package/src/auth-email-password/handlers/login.write.ts +1 -1
  18. package/src/auth-email-password/handlers/reset-password.write.ts +1 -1
  19. package/src/auth-email-password/i18n.ts +4 -0
  20. package/src/auth-email-password/index.ts +6 -1
  21. package/src/auth-email-password/seeding.ts +1 -1
  22. package/src/auth-email-password/web/auth-gate.tsx +65 -20
  23. package/src/auth-email-password/web/index.ts +2 -4
  24. package/src/auth-email-password/web/login-screen.tsx +2 -0
  25. package/src/auth-mfa/__tests__/base32.test.ts +28 -0
  26. package/src/auth-mfa/__tests__/disable-and-regenerate.integration.test.ts +16 -0
  27. package/src/auth-mfa/__tests__/enable-flow.integration.test.ts +60 -0
  28. package/src/auth-mfa/__tests__/pii-subject-encryption.integration.test.ts +2 -0
  29. package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +2 -0
  30. package/src/auth-mfa/__tests__/session-auto-revoke.integration.test.ts +2 -1
  31. package/src/auth-mfa/__tests__/totp.test.ts +11 -2
  32. package/src/auth-mfa/__tests__/verify.integration.test.ts +67 -1
  33. package/src/auth-mfa/base32.ts +7 -1
  34. package/src/auth-mfa/constants.ts +14 -0
  35. package/src/auth-mfa/errors.ts +9 -16
  36. package/src/auth-mfa/feature.ts +5 -1
  37. package/src/auth-mfa/handlers/enable-confirm.write.ts +20 -1
  38. package/src/auth-mfa/handlers/verify.write.ts +26 -3
  39. package/src/auth-mfa/index.ts +0 -1
  40. package/src/auth-mfa/recovery-codes.ts +12 -2
  41. package/src/auth-mfa/schema/user-mfa.ts +3 -0
  42. package/src/auth-mfa/testing.ts +5 -0
  43. package/src/auth-mfa/web/__tests__/mfa-error-keys.test.ts +20 -0
  44. package/src/auth-mfa/web/i18n.ts +2 -0
  45. package/src/auth-mfa/web/mfa-enable-screen.tsx +44 -28
  46. package/src/auth-mfa/web/mfa-error-keys.ts +7 -5
  47. package/src/auth-mfa/web/mfa-verify-screen.tsx +11 -0
  48. package/src/auth-mfa/web/qrcode-browser.d.ts +7 -0
  49. package/src/auth-mfa-user-data/__tests__/hooks.integration.test.ts +2 -0
  50. package/src/config/__tests__/config.integration.test.ts +50 -2
  51. package/src/config/handlers/cascade.query.ts +2 -1
  52. package/src/config/handlers/values.query.ts +2 -1
  53. package/src/file-foundation/__tests__/feature.test.ts +4 -4
  54. package/src/file-foundation/feature.ts +10 -11
  55. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +15 -2
  56. package/src/legal-pages/feature.ts +2 -1
  57. package/src/mail-foundation/__tests__/feature.test.ts +4 -5
  58. package/src/mail-foundation/feature.ts +21 -22
  59. package/src/managed-pages/feature.ts +1 -6
  60. package/src/seo/feature.ts +1 -6
  61. package/src/sessions/__tests__/sessions-screens.boot.test.ts +51 -0
  62. package/src/sessions/__tests__/sessions.integration.test.ts +44 -0
  63. package/src/sessions/__tests__/test-helpers.ts +1 -1
  64. package/src/sessions/constants.ts +5 -0
  65. package/src/sessions/feature.ts +67 -1
  66. package/src/sessions/handlers/detail.query.ts +36 -0
  67. package/src/sessions/i18n.ts +23 -0
  68. package/src/shared/index.ts +1 -0
  69. package/src/shared/system-query.ts +5 -0
  70. package/src/tenant/__tests__/tenant-security.integration.test.ts +1 -1
  71. package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +163 -0
  72. package/src/user-data-rights/__tests__/download.integration.test.ts +39 -0
  73. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +1 -1
  74. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +1 -1
  75. package/src/user-data-rights/feature.ts +7 -8
  76. package/src/user-data-rights/handlers/download-by-job.query.ts +5 -2
  77. package/src/user-data-rights/handlers/download-by-token.query.ts +5 -4
  78. package/src/user-data-rights/lib/tenant-file-provider.ts +62 -0
  79. package/src/user-profile/__tests__/change-email.integration.test.ts +1 -1
  80. package/src/user-profile/handlers/change-email.write.ts +2 -2
  81. package/src/auth-email-password/password-hashing.ts +0 -4
@@ -14,6 +14,7 @@ import {
14
14
  import { createConfigFeature } from "../../config";
15
15
  import { createConfigResolver } from "../../config/resolver";
16
16
  import { configValuesTable } from "../../config/table";
17
+ import { createTenantFeature } from "../../tenant";
17
18
  import { createUserFeature } from "../../user/feature";
18
19
  import { userEntity } from "../../user/schema/user";
19
20
  import { base32Decode } from "../base32";
@@ -36,6 +37,7 @@ beforeAll(async () => {
36
37
  features: [
37
38
  createConfigFeature(),
38
39
  createUserFeature(),
40
+ createTenantFeature(),
39
41
  createAuthMfaFeature({
40
42
  setupTokenSecret: SETUP_TOKEN_SECRET,
41
43
  issuer: "Kumiko Test",
@@ -171,3 +173,61 @@ describe("enable-start + enable-confirm round trip", () => {
171
173
  expect(after.enabled).toBe(true);
172
174
  });
173
175
  });
176
+
177
+ describe("enable-confirm burns the setup token on first success", () => {
178
+ test("replaying the same setup token after a disable does not silently re-enable MFA", async () => {
179
+ const user = createTestUser({ id: "setup-token-replay-1", roles: ["User"] });
180
+
181
+ const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
182
+ AuthMfaHandlers.enableStart,
183
+ { accountLabel: "replay@example.com" },
184
+ user,
185
+ );
186
+ const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
187
+ const code = currentTotpCode(base32Decode(secretParam));
188
+
189
+ await stack.http.writeOk(
190
+ AuthMfaHandlers.enableConfirm,
191
+ { setupToken: start.setupToken, code },
192
+ user,
193
+ );
194
+ await stack.http.writeOk(AuthMfaHandlers.disable, { code }, user);
195
+
196
+ // Same setupToken + a still-computable code for the disabled secret —
197
+ // without burning the token on first confirm this would re-create the
198
+ // row with the secret the user just discarded.
199
+ const replay = await stack.http.writeErr(
200
+ AuthMfaHandlers.enableConfirm,
201
+ { setupToken: start.setupToken, code: currentTotpCode(base32Decode(secretParam)) },
202
+ user,
203
+ );
204
+ expectErrorIncludes(replay, "invalid_setup_token");
205
+
206
+ const after = await stack.http.queryOk<{ enabled: boolean }>(AuthMfaQueries.status, {}, user);
207
+ expect(after.enabled).toBe(false);
208
+ });
209
+
210
+ test("a second confirm of the same setup token (already enabled) is rejected, not a raw db error", async () => {
211
+ const user = createTestUser({ id: "setup-token-double-confirm-1", roles: ["User"] });
212
+
213
+ const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
214
+ AuthMfaHandlers.enableStart,
215
+ { accountLabel: "double@example.com" },
216
+ user,
217
+ );
218
+ const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
219
+ const code = currentTotpCode(base32Decode(secretParam));
220
+
221
+ await stack.http.writeOk(
222
+ AuthMfaHandlers.enableConfirm,
223
+ { setupToken: start.setupToken, code },
224
+ user,
225
+ );
226
+ const second = await stack.http.writeErr(
227
+ AuthMfaHandlers.enableConfirm,
228
+ { setupToken: start.setupToken, code: currentTotpCode(base32Decode(secretParam)) },
229
+ user,
230
+ );
231
+ expectErrorIncludes(second, "invalid_setup_token");
232
+ });
233
+ });
@@ -27,6 +27,7 @@ import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing"
27
27
  import { createConfigFeature } from "../../config";
28
28
  import { createConfigResolver } from "../../config/resolver";
29
29
  import { configValuesTable } from "../../config/table";
30
+ import { createTenantFeature } from "../../tenant";
30
31
  import { createUserFeature } from "../../user/feature";
31
32
  import { userEntity } from "../../user/schema/user";
32
33
  import { base32Decode } from "../base32";
@@ -47,6 +48,7 @@ beforeAll(async () => {
47
48
  features: [
48
49
  createConfigFeature(),
49
50
  createUserFeature(),
51
+ createTenantFeature(),
50
52
  createAuthMfaFeature({
51
53
  setupTokenSecret: SETUP_TOKEN_SECRET,
52
54
  issuer: "Kumiko Test",
@@ -32,6 +32,7 @@ import { createMutableMasterKeyProvider } from "@cosmicdrift/kumiko-framework/te
32
32
  import { createConfigFeature } from "../../config";
33
33
  import { createConfigResolver } from "../../config/resolver";
34
34
  import { configValuesTable } from "../../config/table";
35
+ import { createTenantFeature } from "../../tenant";
35
36
  import { createUserFeature } from "../../user/feature";
36
37
  import { userEntity } from "../../user/schema/user";
37
38
  import { base32Decode } from "../base32";
@@ -64,6 +65,7 @@ beforeAll(async () => {
64
65
  features: [
65
66
  createConfigFeature(),
66
67
  createUserFeature(),
68
+ createTenantFeature(),
67
69
  createAuthMfaFeature({
68
70
  setupTokenSecret: SETUP_TOKEN_SECRET,
69
71
  issuer: "Kumiko Test",
@@ -17,6 +17,7 @@ import { createConfigResolver } from "../../config/resolver";
17
17
  import { configValuesTable } from "../../config/table";
18
18
  import { userSessionEntity, userSessionTable } from "../../sessions/schema/user-session";
19
19
  import { createSessionCallbacks } from "../../sessions/session-callbacks";
20
+ import { createTenantFeature } from "../../tenant";
20
21
  import { createUserFeature } from "../../user/feature";
21
22
  import { userEntity } from "../../user/schema/user";
22
23
  import { base32Decode } from "../base32";
@@ -44,7 +45,7 @@ beforeAll(async () => {
44
45
  challengeTokenSecret: "test-mfa-challenge-secret-at-least-32-bytes!!",
45
46
  });
46
47
  stack = await setupTestStack({
47
- features: [createConfigFeature(), createUserFeature(), mfaFeature],
48
+ features: [createConfigFeature(), createUserFeature(), createTenantFeature(), mfaFeature],
48
49
  extraContext: { configResolver: resolver, configEncryption: encryption },
49
50
  });
50
51
 
@@ -37,12 +37,20 @@ describe("TOTP — RFC 6238 vectors", () => {
37
37
  test("accepts a code one step in the past or future (clock drift)", () => {
38
38
  const secret = generateTotpSecret();
39
39
  const now = 1_700_000_000_000;
40
- // Derive the code for now, then verify it's still accepted 25s later —
41
- // same 30s step, no window needed, sanity check for the step math.
40
+ // now + 25s crosses the 30s step boundary (deliberately) this exercises
41
+ // the ±1 window, not same-step reuse. See the ±2-steps negative test below
42
+ // for the window's upper bound.
42
43
  const codeAtNow = deriveCodeForTest(secret, now);
43
44
  expect(verifyTotp(secret, codeAtNow, now + 25_000)).toBe(true);
44
45
  });
45
46
 
47
+ test("rejects a code two steps in the future (outside the ±1 window)", () => {
48
+ const secret = generateTotpSecret();
49
+ const now = 1_700_000_000_000;
50
+ const codeAtNow = deriveCodeForTest(secret, now);
51
+ expect(verifyTotp(secret, codeAtNow, now + 2 * 30_000 + 5_000)).toBe(false);
52
+ });
53
+
46
54
  test("rejects wrong-length or non-numeric input without throwing", () => {
47
55
  const secret = generateTotpSecret();
48
56
  expect(verifyTotp(secret, "12345")).toBe(false);
@@ -56,6 +64,7 @@ describe("TOTP — RFC 6238 vectors", () => {
56
64
  const b = generateTotpSecret();
57
65
  expect(a.length).toBe(20);
58
66
  expect(a.equals(b)).toBe(false);
67
+ expect(a.some((byte) => byte !== 0)).toBe(true);
59
68
  });
60
69
  });
61
70
 
@@ -1,4 +1,5 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
2
3
  import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
3
4
  import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
5
  import {
@@ -11,6 +12,7 @@ import {
11
12
  import {
12
13
  createTestEnvelopeCipher,
13
14
  expectErrorIncludes,
15
+ seedRow,
14
16
  } from "@cosmicdrift/kumiko-framework/testing";
15
17
  import { createConfigFeature } from "../../config";
16
18
  import { createConfigResolver } from "../../config/resolver";
@@ -18,8 +20,9 @@ import { configValuesTable } from "../../config/table";
18
20
  import { createTenantFeature } from "../../tenant";
19
21
  import { tenantMembershipsTable } from "../../tenant/membership-table";
20
22
  import { tenantEntity } from "../../tenant/schema/tenant";
23
+ import { USER_STATUS } from "../../user";
21
24
  import { createUserFeature } from "../../user/feature";
22
- import { userEntity } from "../../user/schema/user";
25
+ import { userEntity, userTable } from "../../user/schema/user";
23
26
  import { base32Decode } from "../base32";
24
27
  import { AuthMfaHandlers } from "../constants";
25
28
  import { createAuthMfaFeature } from "../feature";
@@ -87,6 +90,15 @@ async function enableMfaFor(idSeed: number): Promise<{
87
90
  { setupToken: start.setupToken, code: currentTotpCode(secret) },
88
91
  user,
89
92
  );
93
+ // verify.write.ts re-checks tenant membership the same way login.write.ts
94
+ // does — without a seeded row here every verify would now hit the
95
+ // membership-revoked gate below instead of the scenario each test means
96
+ // to exercise.
97
+ await seedRow(stack.db, tenantMembershipsTable, {
98
+ userId: user.id,
99
+ tenantId: user.tenantId,
100
+ roles: JSON.stringify(["User"]),
101
+ });
90
102
  return { user, secret, recoveryCodes: start.recoveryCodes };
91
103
  }
92
104
 
@@ -184,3 +196,57 @@ describe("mfa verify — completes a two-step login", () => {
184
196
  expectErrorIncludes(err, "invalid_challenge_token");
185
197
  });
186
198
  });
199
+
200
+ describe("mfa verify — re-checks account state the way login.write.ts does", () => {
201
+ test("membership revoked between login and verify → challenge rejected, no fallback to global roles", async () => {
202
+ const { user, secret } = await enableMfaFor(5);
203
+ const challengeToken = challengeFor(user.id, user.tenantId);
204
+
205
+ // Simulate the membership being pulled after the challenge token was
206
+ // issued but before verify runs — the exact gap login.write.ts's
207
+ // noMembership() guards against.
208
+ await asRawClient(stack.db).unsafe(
209
+ `DELETE FROM "${tenantMembershipsTable.tableName}" WHERE user_id = $1 AND tenant_id = $2`,
210
+ [user.id, user.tenantId],
211
+ );
212
+
213
+ const err = await stack.http.writeErr(
214
+ AuthMfaHandlers.verify,
215
+ { challengeToken, code: currentTotpCode(secret) },
216
+ GUEST,
217
+ );
218
+ expectErrorIncludes(err, "invalid_challenge_token");
219
+ });
220
+
221
+ test("account restricted between login and verify → challenge rejected", async () => {
222
+ const { user, secret } = await enableMfaFor(6);
223
+ const challengeToken = challengeFor(user.id, user.tenantId);
224
+
225
+ // auth-mfa never writes a read_users row itself (enableMfaFor only
226
+ // drives enable-start/enable-confirm) — seed one so the status gate has
227
+ // a row to actually flip, then flip it to simulate a restriction landing
228
+ // between login and verify.
229
+ await seedRow(stack.db, userTable, {
230
+ id: user.id,
231
+ tenantId: user.tenantId,
232
+ email: `user-${user.id}@example.com`,
233
+ passwordHash: "h",
234
+ displayName: "Restricted User",
235
+ locale: "de",
236
+ emailVerified: true,
237
+ roles: "[]",
238
+ status: USER_STATUS.Active,
239
+ });
240
+ await asRawClient(stack.db).unsafe(
241
+ `UPDATE "${userTable.tableName}" SET status = $1 WHERE id = $2`,
242
+ [USER_STATUS.Restricted, user.id],
243
+ );
244
+
245
+ const err = await stack.http.writeErr(
246
+ AuthMfaHandlers.verify,
247
+ { challengeToken, code: currentTotpCode(secret) },
248
+ GUEST,
249
+ );
250
+ expectErrorIncludes(err, "invalid_challenge_token");
251
+ });
252
+ });
@@ -27,7 +27,7 @@ export function base32Encode(bytes: Buffer): string {
27
27
  // character so a corrupted stored secret fails loud at read time instead of
28
28
  // silently producing wrong codes.
29
29
  export function base32Decode(encoded: string): Buffer {
30
- const clean = encoded.trim().toUpperCase().replace(/\s+/g, "");
30
+ const clean = encoded.trim().toUpperCase().replace(/\s+/g, "").replace(/=+$/, "");
31
31
  let bits = 0;
32
32
  let value = 0;
33
33
  const bytes: number[] = [];
@@ -41,5 +41,11 @@ export function base32Decode(encoded: string): Buffer {
41
41
  bits -= 8;
42
42
  }
43
43
  }
44
+ // Leftover bits must be padding zeros (a canonical-length encoding never
45
+ // leaves a full byte's worth, i.e. >=5 residual bits) — anything else means
46
+ // truncated/non-canonical input decoded to the wrong byte count.
47
+ if (bits >= 5 || (value & ((1 << bits) - 1)) !== 0) {
48
+ throw new Error("base32Decode: invalid length or non-zero padding bits");
49
+ }
44
50
  return Buffer.from(bytes);
45
51
  }
@@ -19,6 +19,20 @@ export const AuthMfaQueries = {
19
19
  status: "auth-mfa:query:user-mfa:status",
20
20
  } as const;
21
21
 
22
+ // Write-failure codes shared between errors.ts (server, mints them into
23
+ // UnprocessableError) and web/mfa-error-keys.ts (client, maps them to i18n
24
+ // keys) — one source so a renamed code can't drift out of sync between the
25
+ // two switches.
26
+ export const AuthMfaErrorCodes = {
27
+ mfaAlreadyEnabled: "mfa_already_enabled",
28
+ mfaNotEnabled: "mfa_not_enabled",
29
+ invalidSetupToken: "invalid_setup_token",
30
+ invalidTotpCode: "invalid_totp_code",
31
+ invalidRecoveryCode: "invalid_recovery_code",
32
+ invalidChallengeToken: "invalid_challenge_token",
33
+ tooManyAttempts: "too_many_attempts",
34
+ } as const;
35
+
22
36
  export const MFA_SETUP_TOKEN_TTL_MINUTES = 10;
23
37
  export const MFA_CHALLENGE_TOKEN_TTL_MINUTES = 10;
24
38
  export const MFA_VERIFY_MAX_ATTEMPTS = 5;
@@ -3,20 +3,13 @@ import {
3
3
  type WriteFailure,
4
4
  writeFailure,
5
5
  } from "@cosmicdrift/kumiko-framework/errors";
6
+ import { AuthMfaErrorCodes } from "./constants";
6
7
 
7
- export const AuthMfaErrors = {
8
- mfaAlreadyEnabled: "mfa_already_enabled",
9
- mfaNotEnabled: "mfa_not_enabled",
10
- invalidSetupToken: "invalid_setup_token",
11
- invalidTotpCode: "invalid_totp_code",
12
- invalidRecoveryCode: "invalid_recovery_code",
13
- invalidChallengeToken: "invalid_challenge_token",
14
- tooManyAttempts: "too_many_attempts",
15
- } as const;
8
+ export { AuthMfaErrorCodes as AuthMfaErrors } from "./constants";
16
9
 
17
10
  export function mfaAlreadyEnabled(): WriteFailure {
18
11
  return writeFailure(
19
- new UnprocessableError(AuthMfaErrors.mfaAlreadyEnabled, {
12
+ new UnprocessableError(AuthMfaErrorCodes.mfaAlreadyEnabled, {
20
13
  i18nKey: "authMfa.errors.mfaAlreadyEnabled",
21
14
  }),
22
15
  );
@@ -24,7 +17,7 @@ export function mfaAlreadyEnabled(): WriteFailure {
24
17
 
25
18
  export function mfaNotEnabled(): WriteFailure {
26
19
  return writeFailure(
27
- new UnprocessableError(AuthMfaErrors.mfaNotEnabled, {
20
+ new UnprocessableError(AuthMfaErrorCodes.mfaNotEnabled, {
28
21
  i18nKey: "authMfa.errors.mfaNotEnabled",
29
22
  }),
30
23
  );
@@ -32,7 +25,7 @@ export function mfaNotEnabled(): WriteFailure {
32
25
 
33
26
  export function invalidSetupToken(): WriteFailure {
34
27
  return writeFailure(
35
- new UnprocessableError(AuthMfaErrors.invalidSetupToken, {
28
+ new UnprocessableError(AuthMfaErrorCodes.invalidSetupToken, {
36
29
  i18nKey: "authMfa.errors.invalidSetupToken",
37
30
  }),
38
31
  );
@@ -40,7 +33,7 @@ export function invalidSetupToken(): WriteFailure {
40
33
 
41
34
  export function invalidTotpCode(): WriteFailure {
42
35
  return writeFailure(
43
- new UnprocessableError(AuthMfaErrors.invalidTotpCode, {
36
+ new UnprocessableError(AuthMfaErrorCodes.invalidTotpCode, {
44
37
  i18nKey: "authMfa.errors.invalidTotpCode",
45
38
  }),
46
39
  );
@@ -48,7 +41,7 @@ export function invalidTotpCode(): WriteFailure {
48
41
 
49
42
  export function invalidRecoveryCode(): WriteFailure {
50
43
  return writeFailure(
51
- new UnprocessableError(AuthMfaErrors.invalidRecoveryCode, {
44
+ new UnprocessableError(AuthMfaErrorCodes.invalidRecoveryCode, {
52
45
  i18nKey: "authMfa.errors.invalidRecoveryCode",
53
46
  }),
54
47
  );
@@ -56,7 +49,7 @@ export function invalidRecoveryCode(): WriteFailure {
56
49
 
57
50
  export function invalidChallengeToken(): WriteFailure {
58
51
  return writeFailure(
59
- new UnprocessableError(AuthMfaErrors.invalidChallengeToken, {
52
+ new UnprocessableError(AuthMfaErrorCodes.invalidChallengeToken, {
60
53
  i18nKey: "authMfa.errors.invalidChallengeToken",
61
54
  }),
62
55
  );
@@ -65,7 +58,7 @@ export function invalidChallengeToken(): WriteFailure {
65
58
  // retryAfterSeconds drives the login UI countdown — must stay > 0.
66
59
  export function tooManyAttempts(retryAfterSeconds: number): WriteFailure {
67
60
  return writeFailure(
68
- new UnprocessableError(AuthMfaErrors.tooManyAttempts, {
61
+ new UnprocessableError(AuthMfaErrorCodes.tooManyAttempts, {
69
62
  i18nKey: "authMfa.errors.tooManyAttempts",
70
63
  details: { retryAfterSeconds },
71
64
  }),
@@ -80,7 +80,11 @@ export function createAuthMfaFeature(opts: AuthMfaFeatureOptions): FeatureDefini
80
80
  });
81
81
  r.requires("user");
82
82
  r.requires("config");
83
- r.config({ keys: { required: mfaRequiredConfigKey() } });
83
+ // verify.write.ts dispatches "tenant:query:memberships" without this
84
+ // the app boots clean and the first MFA login dies at runtime on an
85
+ // unknown handler instead of failing at mount time.
86
+ r.requires("tenant");
87
+ r.configKey("required", mfaRequiredConfigKey());
84
88
 
85
89
  // Dormant custom-screen — the client maps MFA_ENABLE_SCREEN_ID to
86
90
  // MfaEnableScreen (see personal-access-tokens/feature.ts for the same
@@ -2,8 +2,10 @@ import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { Temporal } from "temporal-polyfill";
4
4
  import { z } from "zod";
5
+ import { burnToken } from "../../shared";
5
6
  import { base32Decode } from "../base32";
6
- import { invalidSetupToken, invalidTotpCode } from "../errors";
7
+ import { findUserMfaRow } from "../db/queries";
8
+ import { invalidSetupToken, invalidTotpCode, mfaAlreadyEnabled } from "../errors";
7
9
  import { verifyMfaSetupToken } from "../mfa-setup-token";
8
10
  import { userMfaEntity, userMfaTable } from "../schema/user-mfa";
9
11
  import { verifyTotp } from "../totp";
@@ -42,6 +44,23 @@ export function createEnableConfirmHandler(opts: EnableConfirmOptions) {
42
44
  const secret = base32Decode(verify.payload.totpSecretBase32);
43
45
  if (!verifyTotp(secret, event.payload.code)) return invalidTotpCode();
44
46
 
47
+ // Burn the setup token on the first successful confirm — without this
48
+ // a `disable` doesn't retire it (re-enabling with the secret the user
49
+ // thinks they discarded), and two parallel confirms both proceed to
50
+ // executor.create instead of the second seeing mfa_already_enabled().
51
+ if (ctx.redis) {
52
+ const burnResult = await burnToken(
53
+ ctx.redis,
54
+ "mfa-setup",
55
+ event.user.id,
56
+ verify.expiresAtMs,
57
+ );
58
+ if (burnResult === "already-used") return invalidSetupToken();
59
+ }
60
+
61
+ const existing = await findUserMfaRow(ctx.db, event.user);
62
+ if (existing) return mfaAlreadyEnabled();
63
+
45
64
  const result = await executor.create(
46
65
  {
47
66
  userId: event.user.id,
@@ -8,7 +8,7 @@ import {
8
8
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
9
9
  import { z } from "zod";
10
10
  import { burnToken } from "../../shared";
11
- import { UserQueries } from "../../user";
11
+ import { USER_STATUS, UserQueries } from "../../user";
12
12
  import { MFA_VERIFY_LOCKOUT_MINUTES, MFA_VERIFY_MAX_ATTEMPTS } from "../constants";
13
13
  import { findUserMfaRow } from "../db/queries";
14
14
  import { invalidChallengeToken, invalidTotpCode, tooManyAttempts } from "../errors";
@@ -121,14 +121,37 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
121
121
  const systemUser = createSystemUser(tenantId, ["SystemAdmin"]);
122
122
  const userRow = (await ctx.queryAs(systemUser, UserQueries.detail, {
123
123
  id: userId,
124
- })) as { roles?: string | null } | null; // @cast-boundary engine-payload
124
+ })) as { roles?: string | null; status?: string } | null; // @cast-boundary engine-payload
125
+
126
+ // Re-check status + membership the way login.write.ts does after its
127
+ // password check — the challenge token only proves "password was
128
+ // correct 10 minutes ago", not that the account is still loginable
129
+ // now. Deliberately NOT re-checking strictEmailVerification here:
130
+ // MfaVerifyOptions has no such flag, and email verification can't
131
+ // regress between login and verify within the 10-minute window.
132
+ if (
133
+ userRow?.status === USER_STATUS.Restricted ||
134
+ userRow?.status === USER_STATUS.DeletionRequested ||
135
+ userRow?.status === USER_STATUS.Deleted
136
+ ) {
137
+ return invalidChallengeToken();
138
+ }
139
+
125
140
  const globalRoles = parseRoles(userRow?.roles ?? null);
126
141
 
127
142
  const memberships = (await ctx.queryAs(systemUser, "tenant:query:memberships", {
128
143
  userId,
129
144
  })) as ReadonlyArray<{ tenantId: string; roles: readonly string[] }>; // @cast-boundary engine-payload
130
145
  const membership = memberships.find((m) => m.tenantId === tenantId);
131
- const mergedRoles = buildSessionRoles(globalRoles, membership?.roles ?? []);
146
+ // Membership revoked between login and verify (race, or a stale
147
+ // challenge token from before removal) — login.write.ts would have
148
+ // refused with noMembership() at the same juncture; mirror it here
149
+ // instead of silently falling back to globalRoles only.
150
+ if (!membership) return invalidChallengeToken();
151
+ // buildSessionRoles calls stripForbiddenMembershipRoles to strip reserved
152
+ // roles from the membership portion (globalRoles keeps SystemAdmin) —
153
+ // read-time backstop against a rebuild-resurrected role.
154
+ const mergedRoles = buildSessionRoles(globalRoles, membership.roles);
132
155
 
133
156
  const baseSession: SessionUser = { id: userId, tenantId, roles: mergedRoles };
134
157
  const claims = await ctx.resolveAuthClaims(baseSession);
@@ -17,4 +17,3 @@ export {
17
17
  } from "./feature";
18
18
  export type { MfaStatusChecker, MfaStatusCheckResult } from "./mfa-status-checker";
19
19
  export { userMfaEntity, userMfaTable } from "./schema/user-mfa";
20
- export { currentTotpCode } from "./totp";
@@ -7,6 +7,15 @@ const RECOVERY_CODE_COUNT = 8;
7
7
  // ticket, not just a retry.
8
8
  const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
9
9
 
10
+ // Recovery codes get read aloud or copy-typed from a screen — a user retyping
11
+ // "abcd1234" lowercase or without the dash must still match. Applied on both
12
+ // sides (hash-time here is a no-op on freshly generated codes, but keeps
13
+ // hash and verify deckungsgleich if generation ever changes) so a formatting
14
+ // slip never surfaces as "wrong code" for an otherwise-correct code.
15
+ function normalizeRecoveryCode(code: string): string {
16
+ return code.toUpperCase().replace(/[^A-Z2-9]/g, "");
17
+ }
18
+
10
19
  function randomCodeGroup(): string {
11
20
  let out = "";
12
21
  for (let i = 0; i < 4; i++) {
@@ -26,7 +35,7 @@ export function generateRecoveryCodes(count: number = RECOVERY_CODE_COUNT): stri
26
35
  // Reuses the same argon2id hashing as passwords — recovery codes are
27
36
  // bearer-secrets of comparable sensitivity (whoever has one can log in).
28
37
  export async function hashRecoveryCodes(codes: readonly string[]): Promise<string[]> {
29
- return Promise.all(codes.map((code) => hashPassword(code)));
38
+ return Promise.all(codes.map((code) => hashPassword(normalizeRecoveryCode(code))));
30
39
  }
31
40
 
32
41
  // Sequential (not Promise.all) — recovery-code lists are ≤8 entries, and a
@@ -36,9 +45,10 @@ export async function findMatchingRecoveryCodeIndex(
36
45
  code: string,
37
46
  hashes: readonly string[],
38
47
  ): Promise<number> {
48
+ const normalized = normalizeRecoveryCode(code);
39
49
  for (let i = 0; i < hashes.length; i++) {
40
50
  const hash = hashes[i];
41
- if (hash !== undefined && (await verifyPassword(hash, code))) return i;
51
+ if (hash !== undefined && (await verifyPassword(hash, normalized))) return i;
42
52
  }
43
53
  return -1;
44
54
  }
@@ -33,6 +33,9 @@ export const userMfaEntity = createEntity({
33
33
  // (see db/queries.ts's findUserMfaRow) rather than storing jsonb here.
34
34
  // A redeemed code's hash is removed from the array (see disable/regen
35
35
  // handlers), so array length also IS the remaining-codes count.
36
+ // No jsonb->text migration: zero-legacy/single-user framework repo, no
37
+ // production user_mfa rows exist to backfill (delete-and-re-enroll is
38
+ // the upgrade path for anyone who hit the old jsonb shape locally).
36
39
  recoveryCodes: createTextField({
37
40
  required: true,
38
41
  encrypted: true,
@@ -0,0 +1,5 @@
1
+ // Test-only helper — not part of the runtime surface (a real client never
2
+ // derives a code from the raw secret; the authenticator app does that).
3
+ // Kept out of the main "./auth-mfa" barrel so consumers don't see a
4
+ // server-side "valid code now" generator in autocomplete.
5
+ export { currentTotpCode } from "./totp";
@@ -0,0 +1,20 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { AuthMfaErrorCodes } from "../../constants";
3
+ import { defaultTranslations } from "../i18n";
4
+ import { mfaManageErrorKey } from "../mfa-error-keys";
5
+
6
+ describe("mfaManageErrorKey", () => {
7
+ for (const code of Object.values(AuthMfaErrorCodes)) {
8
+ test(`${code} maps to a key registered in en/de translations`, () => {
9
+ const key = mfaManageErrorKey(code);
10
+ expect(defaultTranslations["en"]?.[key]).toBeDefined();
11
+ expect(defaultTranslations["de"]?.[key]).toBeDefined();
12
+ });
13
+ }
14
+
15
+ test("unknown code falls back to the generic verifyFailed key", () => {
16
+ const key = mfaManageErrorKey("some_future_server_code");
17
+ expect(key).toBe("auth.mfa.errors.verifyFailed");
18
+ expect(defaultTranslations["en"]?.[key]).toBeDefined();
19
+ });
20
+ });
@@ -13,6 +13,7 @@ export const defaultTranslations: TranslationsByLocale = {
13
13
  "auth.mfa.verify.code": "Code",
14
14
  "auth.mfa.verify.submit": "Bestätigen",
15
15
  "auth.mfa.verify.submitting": "…",
16
+ "auth.mfa.verify.backToLogin": "Zurück zum Login",
16
17
  "auth.mfa.errors.invalidCode": "Ungültiger Code. Bitte erneut versuchen.",
17
18
  "auth.mfa.errors.challengeExpired": "Die Anmeldung ist abgelaufen. Bitte erneut einloggen.",
18
19
  "auth.mfa.errors.tooManyAttempts": "Zu viele Fehlversuche. Bitte erneut einloggen.",
@@ -62,6 +63,7 @@ export const defaultTranslations: TranslationsByLocale = {
62
63
  "auth.mfa.verify.code": "Code",
63
64
  "auth.mfa.verify.submit": "Verify",
64
65
  "auth.mfa.verify.submitting": "…",
66
+ "auth.mfa.verify.backToLogin": "Back to login",
65
67
  "auth.mfa.errors.invalidCode": "Invalid code. Please try again.",
66
68
  "auth.mfa.errors.challengeExpired": "Your sign-in has expired. Please sign in again.",
67
69
  "auth.mfa.errors.tooManyAttempts": "Too many failed attempts. Please sign in again.",