@cosmicdrift/kumiko-bundled-features 0.149.2 → 0.150.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 (74) 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 +2 -0
  23. package/src/auth-email-password/web/login-screen.tsx +2 -0
  24. package/src/auth-mfa/__tests__/base32.test.ts +28 -0
  25. package/src/auth-mfa/__tests__/disable-and-regenerate.integration.test.ts +16 -0
  26. package/src/auth-mfa/__tests__/enable-flow.integration.test.ts +60 -0
  27. package/src/auth-mfa/__tests__/pii-subject-encryption.integration.test.ts +2 -0
  28. package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +2 -0
  29. package/src/auth-mfa/__tests__/session-auto-revoke.integration.test.ts +2 -1
  30. package/src/auth-mfa/__tests__/totp.test.ts +11 -2
  31. package/src/auth-mfa/__tests__/verify.integration.test.ts +67 -1
  32. package/src/auth-mfa/base32.ts +7 -1
  33. package/src/auth-mfa/constants.ts +14 -0
  34. package/src/auth-mfa/errors.ts +9 -16
  35. package/src/auth-mfa/feature.ts +4 -0
  36. package/src/auth-mfa/handlers/enable-confirm.write.ts +20 -1
  37. package/src/auth-mfa/handlers/verify.write.ts +26 -3
  38. package/src/auth-mfa/index.ts +0 -1
  39. package/src/auth-mfa/recovery-codes.ts +12 -2
  40. package/src/auth-mfa/schema/user-mfa.ts +3 -0
  41. package/src/auth-mfa/testing.ts +5 -0
  42. package/src/auth-mfa/web/__tests__/mfa-error-keys.test.ts +20 -0
  43. package/src/auth-mfa/web/i18n.ts +2 -0
  44. package/src/auth-mfa/web/mfa-enable-screen.tsx +37 -28
  45. package/src/auth-mfa/web/mfa-error-keys.ts +7 -5
  46. package/src/auth-mfa/web/mfa-verify-screen.tsx +11 -0
  47. package/src/auth-mfa/web/qrcode-browser.d.ts +7 -0
  48. package/src/auth-mfa-user-data/__tests__/hooks.integration.test.ts +2 -0
  49. package/src/config/handlers/cascade.query.ts +2 -1
  50. package/src/config/handlers/values.query.ts +2 -1
  51. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +15 -2
  52. package/src/legal-pages/feature.ts +2 -1
  53. package/src/managed-pages/feature.ts +1 -6
  54. package/src/seo/feature.ts +1 -6
  55. package/src/sessions/__tests__/sessions-screens.boot.test.ts +51 -0
  56. package/src/sessions/__tests__/sessions.integration.test.ts +44 -0
  57. package/src/sessions/__tests__/test-helpers.ts +1 -1
  58. package/src/sessions/constants.ts +5 -0
  59. package/src/sessions/feature.ts +67 -1
  60. package/src/sessions/handlers/detail.query.ts +36 -0
  61. package/src/sessions/i18n.ts +23 -0
  62. package/src/shared/index.ts +1 -0
  63. package/src/shared/system-query.ts +5 -0
  64. package/src/tenant/__tests__/tenant-security.integration.test.ts +1 -1
  65. package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +163 -0
  66. package/src/user-data-rights/__tests__/download.integration.test.ts +39 -0
  67. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +1 -1
  68. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +1 -1
  69. package/src/user-data-rights/handlers/download-by-job.query.ts +5 -2
  70. package/src/user-data-rights/handlers/download-by-token.query.ts +5 -4
  71. package/src/user-data-rights/lib/tenant-file-provider.ts +62 -0
  72. package/src/user-profile/__tests__/change-email.integration.test.ts +1 -1
  73. package/src/user-profile/handlers/change-email.write.ts +2 -2
  74. package/src/auth-email-password/password-hashing.ts +0 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.149.2",
3
+ "version": "0.150.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -91,6 +91,7 @@
91
91
  "./auth-mfa": "./src/auth-mfa/index.ts",
92
92
  "./auth-mfa-user-data": "./src/auth-mfa-user-data/index.ts",
93
93
  "./auth-mfa/web": "./src/auth-mfa/web/index.ts",
94
+ "./auth-mfa/testing": "./src/auth-mfa/testing.ts",
94
95
  "./sessions/testing": "./src/sessions/testing.ts",
95
96
  "./personal-access-tokens": "./src/personal-access-tokens/index.ts",
96
97
  "./personal-access-tokens/web": "./src/personal-access-tokens/web/index.ts",
@@ -114,11 +115,11 @@
114
115
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
115
116
  },
116
117
  "dependencies": {
117
- "@cosmicdrift/kumiko-dispatcher-live": "0.149.2",
118
- "@cosmicdrift/kumiko-framework": "0.149.2",
119
- "@cosmicdrift/kumiko-headless": "0.149.2",
120
- "@cosmicdrift/kumiko-renderer": "0.149.2",
121
- "@cosmicdrift/kumiko-renderer-web": "0.149.2",
118
+ "@cosmicdrift/kumiko-dispatcher-live": "0.150.0",
119
+ "@cosmicdrift/kumiko-framework": "0.150.0",
120
+ "@cosmicdrift/kumiko-headless": "0.150.0",
121
+ "@cosmicdrift/kumiko-renderer": "0.150.0",
122
+ "@cosmicdrift/kumiko-renderer-web": "0.150.0",
122
123
  "@mollie/api-client": "^4.5.0",
123
124
  "imapflow": "^1.3.3",
124
125
  "mailparser": "^3.9.8",
@@ -16,7 +16,6 @@ import {
16
16
  unsafePushTables,
17
17
  } from "@cosmicdrift/kumiko-framework/stack";
18
18
  import { createAuditFeature } from "../../audit/feature";
19
- import { hashPassword } from "../../auth-email-password/password-hashing";
20
19
  import { ConfigQueries } from "../../config/constants";
21
20
  import { createConfigFeature } from "../../config/feature";
22
21
  import { createConfigResolver } from "../../config/resolver";
@@ -26,6 +25,7 @@ import { globalFeatureStateTable } from "../../feature-toggles/global-feature-st
26
25
  import { JobQueries } from "../../jobs/constants";
27
26
  import { createJobsFeature } from "../../jobs/feature";
28
27
  import { jobRunLogsTable, jobRunsTable } from "../../jobs/job-run-table";
28
+ import { hashPassword } from "../../shared";
29
29
  import { TenantQueries } from "../../tenant/constants";
30
30
  import { createTenantFeature } from "../../tenant/feature";
31
31
  import { tenantInvitationEntity } from "../../tenant/invitation-table";
@@ -23,8 +23,8 @@ import {
23
23
  unsafePushTables,
24
24
  } from "@cosmicdrift/kumiko-framework/stack";
25
25
  import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
26
- import { hashPassword } from "../../auth-email-password/password-hashing";
27
26
  import { createConfigFeature } from "../../config/feature";
27
+ import { hashPassword } from "../../shared";
28
28
  import { createTenantFeature } from "../../tenant/feature";
29
29
  import { tenantMembershipsTable } from "../../tenant/membership-table";
30
30
  import { tenantEntity } from "../../tenant/schema/tenant";
@@ -25,6 +25,7 @@ import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing"
25
25
  import { createConfigFeature } from "../../config";
26
26
  import { createConfigResolver } from "../../config/resolver";
27
27
  import { configValuesTable } from "../../config/table";
28
+ import { hashPassword } from "../../shared";
28
29
  import { createTenantFeature } from "../../tenant";
29
30
  import { tenantMembershipsTable } from "../../tenant/membership-table";
30
31
  import { tenantEntity } from "../../tenant/schema/tenant";
@@ -34,7 +35,6 @@ import { createUserFeature } from "../../user/feature";
34
35
  import { userEntity, userTable } from "../../user/schema/user";
35
36
  import { AuthErrors, AuthHandlers } from "../constants";
36
37
  import { createAuthEmailPasswordFeature } from "../feature";
37
- import { hashPassword } from "../password-hashing";
38
38
 
39
39
  let stack: TestStack;
40
40
 
@@ -24,6 +24,7 @@ import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing"
24
24
  import { createConfigFeature } from "../../config";
25
25
  import { createConfigResolver } from "../../config/resolver";
26
26
  import { configValuesTable } from "../../config/table";
27
+ import { hashPassword } from "../../shared";
27
28
  import { createTenantFeature } from "../../tenant";
28
29
  import { tenantMembershipsTable } from "../../tenant/membership-table";
29
30
  import { tenantEntity } from "../../tenant/schema/tenant";
@@ -34,7 +35,6 @@ import { userEntity, userTable } from "../../user/schema/user";
34
35
  import { AuthErrors, AuthHandlers } from "../constants";
35
36
  import { createAuthEmailPasswordFeature } from "../feature";
36
37
  import { getLockoutState, type LockoutState } from "../lockout-store";
37
- import { hashPassword } from "../password-hashing";
38
38
 
39
39
  let stack: TestStack;
40
40
 
@@ -15,6 +15,7 @@ import { createTestEnvelopeCipher, seedRow } from "@cosmicdrift/kumiko-framework
15
15
  import { createConfigFeature } from "../../config";
16
16
  import { createConfigResolver } from "../../config/resolver";
17
17
  import { configValuesTable } from "../../config/table";
18
+ import { hashPassword } from "../../shared";
18
19
  import { createTenantFeature } from "../../tenant";
19
20
  import { tenantMembershipsTable } from "../../tenant/membership-table";
20
21
  import { tenantEntity } from "../../tenant/schema/tenant";
@@ -24,7 +25,6 @@ import { createUserFeature } from "../../user/feature";
24
25
  import { userEntity, userTable } from "../../user/schema/user";
25
26
  import { AuthErrors, AuthHandlers } from "../constants";
26
27
  import { createAuthEmailPasswordFeature } from "../feature";
27
- import { hashPassword } from "../password-hashing";
28
28
 
29
29
  // Sample-style extension feature that shows the real-world shape of a claims
30
30
  // hook: look something up in a tenant-scoped table and stuff it into the JWT.
@@ -20,6 +20,7 @@ import {
20
20
  import { createConfigFeature } from "../../config";
21
21
  import { createConfigResolver } from "../../config/resolver";
22
22
  import { configValuesTable } from "../../config/table";
23
+ import { hashPassword } from "../../shared";
23
24
  import { createTenantFeature } from "../../tenant";
24
25
  import { tenantMembershipsTable } from "../../tenant/membership-table";
25
26
  import { tenantEntity } from "../../tenant/schema/tenant";
@@ -29,7 +30,6 @@ import { createUserFeature } from "../../user/feature";
29
30
  import { userEntity, userTable } from "../../user/schema/user";
30
31
  import { AuthErrors, AuthHandlers } from "../constants";
31
32
  import { createAuthEmailPasswordFeature } from "../feature";
32
- import { hashPassword } from "../password-hashing";
33
33
 
34
34
  let stack: TestStack;
35
35
 
@@ -23,6 +23,7 @@ import { createDeliveryFeature, createDeliveryTestContext } from "../../delivery
23
23
  import { notificationPreferencesTable } from "../../delivery/tables";
24
24
  import { createRendererFoundationFeature } from "../../renderer-foundation/feature";
25
25
  import { createRendererSimpleFeature, simpleRenderer } from "../../renderer-simple";
26
+ import { hashPassword } from "../../shared";
26
27
  import { createTemplateResolverFeature } from "../../template-resolver/feature";
27
28
  import { createTenantFeature } from "../../tenant";
28
29
  import { tenantMembershipsTable } from "../../tenant/membership-table";
@@ -33,7 +34,6 @@ import { createUserFeature } from "../../user/feature";
33
34
  import { userEntity, userTable } from "../../user/schema/user";
34
35
  import { AuthErrors, AuthHandlers } from "../constants";
35
36
  import { createAuthEmailPasswordFeature } from "../feature";
36
- import { hashPassword } from "../password-hashing";
37
37
  import { signResetToken } from "../reset-token";
38
38
  import { signVerificationToken } from "../verification-token";
39
39
 
@@ -31,6 +31,7 @@ import { createDeliveryFeature, createDeliveryTestContext } from "../../delivery
31
31
  import { notificationPreferencesTable } from "../../delivery/tables";
32
32
  import { createRendererFoundationFeature } from "../../renderer-foundation/feature";
33
33
  import { createRendererSimpleFeature, simpleRenderer } from "../../renderer-simple";
34
+ import { hashPassword } from "../../shared";
34
35
  import { createTemplateResolverFeature } from "../../template-resolver/feature";
35
36
  import { createTenantFeature } from "../../tenant";
36
37
  import { tenantInvitationEntity, tenantInvitationsTable } from "../../tenant/invitation-table";
@@ -41,7 +42,6 @@ import { createUserFeature } from "../../user/feature";
41
42
  import { userEntity, userTable } from "../../user/schema/user";
42
43
  import { AuthHandlers } from "../constants";
43
44
  import { createAuthEmailPasswordFeature } from "../feature";
44
- import { hashPassword } from "../password-hashing";
45
45
  import { seedUser } from "../seeding";
46
46
 
47
47
  const APP_ACCEPT_URL = "https://app.example.com/invite/accept";
@@ -37,6 +37,7 @@ import { createDeliveryFeature, createDeliveryTestContext } from "../../delivery
37
37
  import { notificationPreferencesTable } from "../../delivery/tables";
38
38
  import { createRendererFoundationFeature } from "../../renderer-foundation/feature";
39
39
  import { createRendererSimpleFeature, simpleRenderer } from "../../renderer-simple";
40
+ import { hashPassword } from "../../shared";
40
41
  import { createTemplateResolverFeature } from "../../template-resolver/feature";
41
42
  import { createTenantFeature } from "../../tenant";
42
43
  import {
@@ -52,7 +53,6 @@ import { userEntity, userTable } from "../../user/schema/user";
52
53
  import { AuthErrors, AuthHandlers } from "../constants";
53
54
  import { createAuthEmailPasswordFeature } from "../feature";
54
55
  import { storeInviteToken } from "../invite-token-store";
55
- import { hashPassword } from "../password-hashing";
56
56
  import { seedUser } from "../seeding";
57
57
 
58
58
  const APP_ACCEPT_URL = "https://app.example.com/invite/accept";
@@ -23,6 +23,7 @@ import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
23
23
  import { createConfigFeature } from "../../config";
24
24
  import { createConfigResolver } from "../../config/resolver";
25
25
  import { configValuesTable } from "../../config/table";
26
+ import { hashPassword } from "../../shared";
26
27
  import { createTenantFeature } from "../../tenant";
27
28
  import { tenantMembershipsTable } from "../../tenant/membership-table";
28
29
  import { tenantEntity } from "../../tenant/schema/tenant";
@@ -31,7 +32,6 @@ import { createUserFeature } from "../../user/feature";
31
32
  import { userEntity, userTable } from "../../user/schema/user";
32
33
  import { AuthErrors, AuthHandlers } from "../constants";
33
34
  import { createAuthEmailPasswordFeature } from "../feature";
34
- import { hashPassword } from "../password-hashing";
35
35
 
36
36
  let stack: TestStack;
37
37
  const systemAdmin = TestUsers.systemAdmin;
@@ -20,6 +20,7 @@ import { notificationPreferencesTable } from "../../delivery/tables";
20
20
  import { createRendererFoundationFeature } from "../../renderer-foundation/feature";
21
21
  import { createRendererSimpleFeature, simpleRenderer } from "../../renderer-simple";
22
22
  import { createSessionsFeature, userSessionTable } from "../../sessions";
23
+ import { hashPassword, verifyPassword } from "../../shared";
23
24
  import { createTemplateResolverFeature } from "../../template-resolver/feature";
24
25
  import { createTenantFeature } from "../../tenant";
25
26
  import { tenantMembershipsTable } from "../../tenant/membership-table";
@@ -30,7 +31,6 @@ import { createUserFeature } from "../../user/feature";
30
31
  import { userEntity, userTable } from "../../user/schema/user";
31
32
  import { AuthErrors, AuthHandlers } from "../constants";
32
33
  import { createAuthEmailPasswordFeature } from "../feature";
33
- import { hashPassword, verifyPassword } from "../password-hashing";
34
34
  import { signResetToken } from "../reset-token";
35
35
 
36
36
  // Reset mails now go through delivery (ctx.notify → channel-email). The
@@ -23,12 +23,12 @@ import {
23
23
  import { createConfigFeature } from "../../config/feature";
24
24
  import { createConfigResolver } from "../../config/resolver";
25
25
  import { configValuesTable } from "../../config/table";
26
+ import { verifyPassword } from "../../shared";
26
27
  import { createTenantFeature } from "../../tenant/feature";
27
28
  import { tenantMembershipsTable } from "../../tenant/membership-table";
28
29
  import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
29
30
  import { createUserFeature } from "../../user/feature";
30
31
  import { userEntity, userTable } from "../../user/schema/user";
31
- import { verifyPassword } from "../password-hashing";
32
32
  import { seedAdmin } from "../testing";
33
33
 
34
34
  let stack: TestStack;
@@ -14,6 +14,7 @@ import * as jose from "jose";
14
14
  import { createConfigFeature } from "../../config";
15
15
  import { createConfigResolver } from "../../config/resolver";
16
16
  import { configValuesTable } from "../../config/table";
17
+ import { hashPassword } from "../../shared";
17
18
  import { createTenantFeature } from "../../tenant";
18
19
  import { tenantMembershipsTable } from "../../tenant/membership-table";
19
20
  import { tenantEntity } from "../../tenant/schema/tenant";
@@ -23,7 +24,6 @@ import { createUserFeature } from "../../user/feature";
23
24
  import { userEntity, userTable } from "../../user/schema/user";
24
25
  import { AuthErrors, AuthHandlers } from "../constants";
25
26
  import { createAuthEmailPasswordFeature } from "../feature";
26
- import { hashPassword } from "../password-hashing";
27
27
 
28
28
  // In-memory fake of a real sessions-store — just enough to observe that the
29
29
  // framework calls the callbacks at the right moments and threads the sid back
@@ -5,9 +5,9 @@ import {
5
5
  SYSTEM_TENANT_ID,
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
7
7
  import { z } from "zod";
8
+ import { hashPassword, verifyPassword } from "../../shared";
8
9
  import { UserHandlers, UserQueries } from "../../user";
9
10
  import { invalidCredentials } from "../errors";
10
- import { hashPassword, verifyPassword } from "../password-hashing";
11
11
 
12
12
  // Change-password — authenticated. The user supplies their current password
13
13
  // (re-auth) and the new one. The new hash is written via ctx.writeAs(system)
@@ -28,7 +28,7 @@ import {
28
28
  } from "@cosmicdrift/kumiko-framework/engine";
29
29
  import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
30
30
  import { z } from "zod";
31
- import { decryptStoredPii } from "../../shared";
31
+ import { decryptStoredPii, verifyPassword } from "../../shared";
32
32
  // kumiko-lint-ignore cross-feature-import invite-flow
33
33
  import {
34
34
  INVITATION_STATUS,
@@ -46,7 +46,6 @@ import {
46
46
  getInvitationIdForToken,
47
47
  unburnInviteToken,
48
48
  } from "../invite-token-store";
49
- import { verifyPassword } from "../password-hashing";
50
49
 
51
50
  const InviteAcceptWithLoginSchema = z.object({
52
51
  token: z.string().min(1),
@@ -9,6 +9,7 @@ import {
9
9
  } from "@cosmicdrift/kumiko-framework/engine";
10
10
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
11
11
  import { z } from "zod";
12
+ import { verifyDummyPassword, verifyPassword } from "../../shared";
12
13
  import { USER_STATUS, UserQueries } from "../../user";
13
14
  import { parseAuthUserRow } from "../auth-user-row";
14
15
  import {
@@ -23,7 +24,6 @@ import {
23
24
  noMembership,
24
25
  } from "../errors";
25
26
  import { clearLockoutState, getLockoutState, recordFailedAttempt } from "../lockout-store";
26
- import { verifyDummyPassword, verifyPassword } from "../password-hashing";
27
27
 
28
28
  export type LoginHandlerOptions = {
29
29
  // When true, a valid (email + password) login fails with email_not_verified
@@ -1,9 +1,9 @@
1
1
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
3
  import { z } from "zod";
4
+ import { hashPassword } from "../../shared";
4
5
  import { AuthErrors } from "../constants";
5
6
  import { invalidResetToken } from "../errors";
6
- import { hashPassword } from "../password-hashing";
7
7
  import { verifyResetToken } from "../reset-token";
8
8
  import { runConfirmTokenFlow } from "./confirm-token-flow";
9
9
 
@@ -32,6 +32,8 @@ export const defaultTranslations: TranslationsByLocale = {
32
32
  "auth.errors.rateLimited": "Zu viele Login-Versuche. Bitte kurz warten.",
33
33
  "auth.errors.invalidBody": "Ungültige Eingabe.",
34
34
  "auth.errors.loginFailed": "Login fehlgeschlagen.",
35
+ "auth.errors.mfaNotSupported":
36
+ "Diese App unterstützt keine Zwei-Faktor-Bestätigung. Bitte an den Support wenden.",
35
37
  "auth.errors.invalidResetToken":
36
38
  "Der Link ist ungültig oder abgelaufen. Bitte fordere einen neuen an.",
37
39
  "auth.errors.invalidVerificationToken": "Der Bestätigungs-Link ist ungültig oder abgelaufen.",
@@ -134,6 +136,8 @@ export const defaultTranslations: TranslationsByLocale = {
134
136
  "auth.errors.rateLimited": "Too many login attempts. Please wait briefly.",
135
137
  "auth.errors.invalidBody": "Invalid input.",
136
138
  "auth.errors.loginFailed": "Login failed.",
139
+ "auth.errors.mfaNotSupported":
140
+ "This app doesn't support two-factor verification. Please contact support.",
137
141
  "auth.errors.invalidResetToken": "Link is invalid or expired. Please request a new one.",
138
142
  "auth.errors.invalidVerificationToken": "Verification link is invalid or expired.",
139
143
  "auth.errors.invalidSignupToken":
@@ -1,5 +1,11 @@
1
1
  // Convention paths for the auth pages (reset/verify/signup/invite); apps build
2
2
  // their magic-link appUrls from these. All mail now goes through delivery.
3
+
4
+ // Kept as a public re-export (not just internal call sites) — this feature's
5
+ // barrel is where consumers of the published package have always found
6
+ // hashPassword/verifyPassword; removing it is a breaking change that needs
7
+ // its own changeset + deliberate deprecation window, not a silent drop.
8
+ export { hashPassword, verifyPassword } from "../shared/password-hashing";
3
9
  export { type AuthPaths, DEFAULT_AUTH_PATHS, makeAuthPaths } from "./auth-paths";
4
10
  export { AUTH_EMAIL_PASSWORD_FEATURE, AuthErrors, AuthHandlers } from "./constants";
5
11
  // Renderers for the auth mails. All four magic-link flows (reset, verify,
@@ -27,7 +33,6 @@ export type {
27
33
  SignupOptions,
28
34
  } from "./feature";
29
35
  export { authEmailPasswordEnvSchema, createAuthEmailPasswordFeature } from "./feature";
30
- export { hashPassword, verifyPassword } from "./password-hashing";
31
36
  // Generic HMAC-signed single-purpose token helpers. Re-exported damit
32
37
  // app-spezifische out-of-band-Flows (subscriber-confirm, magic-links,
33
38
  // invite-tokens) denselben battle-tested signer/verifier nutzen können
@@ -17,13 +17,13 @@ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
17
17
  import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
18
18
  import { ConflictError } from "@cosmicdrift/kumiko-framework/errors";
19
19
  import { TestUsers } from "@cosmicdrift/kumiko-framework/stack";
20
+ import { hashPassword } from "../shared";
20
21
  // kumiko-lint-ignore cross-feature-import auth-tests need user+tenant seed-helpers
21
22
  import { seedTenant, seedTenantMembership } from "../tenant/seeding";
22
23
  // kumiko-lint-ignore cross-feature-import signup create-only guard reads the user projection by email
23
24
  import { userTable } from "../user/schema/user";
24
25
  // kumiko-lint-ignore cross-feature-import auth-tests need user+tenant seed-helpers
25
26
  import { seedUser } from "../user/seeding";
26
- import { hashPassword } from "./password-hashing";
27
27
 
28
28
  // Re-export für ergonomische Single-Import-Site in tests/seed-scripts.
29
29
  // Das Auth-Feature ist der natürliche Aufrufer für "seed admin user mit
@@ -20,6 +20,7 @@ import { SessionProvider, useSession } from "./session";
20
20
  export type MfaVerifyComponentProps = {
21
21
  readonly challengeToken: string;
22
22
  readonly onSuccess?: () => void;
23
+ readonly onCancel?: () => void;
23
24
  };
24
25
 
25
26
  export function makeAuthGate(
@@ -45,6 +46,7 @@ export function makeAuthGate(
45
46
  <MfaVerifyComponent
46
47
  challengeToken={challengeToken}
47
48
  onSuccess={() => setChallengeToken(null)}
49
+ onCancel={() => setChallengeToken(null)}
48
50
  />
49
51
  );
50
52
  }
@@ -88,6 +88,8 @@ function reasonToKey(failure: LoginFailure): {
88
88
  return { key: "auth.errors.rateLimited" };
89
89
  case "invalid_body":
90
90
  return { key: "auth.errors.invalidBody" };
91
+ case "mfa_not_supported":
92
+ return { key: "auth.errors.mfaNotSupported" };
91
93
  default:
92
94
  return { key: "auth.errors.loginFailed" };
93
95
  }
@@ -0,0 +1,28 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { base32Decode, base32Encode } from "../base32";
3
+
4
+ describe("base32 encode/decode", () => {
5
+ test("round-trips arbitrary bytes", () => {
6
+ const bytes = Buffer.from("12345678901234567890", "ascii");
7
+ expect(base32Decode(base32Encode(bytes))).toEqual(bytes);
8
+ });
9
+
10
+ test("decodes lowercase and RFC 4648 '=' padding", () => {
11
+ const bytes = Buffer.from("12345678901234567890", "ascii");
12
+ const encoded = base32Encode(bytes);
13
+ const paddingLength = (8 - (encoded.length % 8)) % 8;
14
+ const withPadding = encoded + "=".repeat(paddingLength);
15
+ expect(base32Decode(withPadding.toLowerCase())).toEqual(bytes);
16
+ });
17
+
18
+ test("rejects a non-canonical length with non-zero residual bits", () => {
19
+ // "AA" decodes to 10 residual bits of all-zero value — canonical (padding
20
+ // zeros). A single stray char (5 bits, below the byte boundary but not a
21
+ // clean multiple) with non-zero value must be rejected, not truncated.
22
+ expect(() => base32Decode("B")).toThrow(/invalid length/);
23
+ });
24
+
25
+ test("throws on a genuinely invalid character", () => {
26
+ expect(() => base32Decode("01")).toThrow(/invalid character/);
27
+ });
28
+ });
@@ -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";
@@ -34,6 +35,7 @@ beforeAll(async () => {
34
35
  features: [
35
36
  createConfigFeature(),
36
37
  createUserFeature(),
38
+ createTenantFeature(),
37
39
  createAuthMfaFeature({
38
40
  setupTokenSecret: SETUP_TOKEN_SECRET,
39
41
  issuer: "Kumiko Test",
@@ -139,6 +141,20 @@ describe("disable", () => {
139
141
  expect(res.disabled).toBe(true);
140
142
  });
141
143
 
144
+ test("a recovery code typed lowercase and without the dash still matches", async () => {
145
+ const { user, recoveryCodes } = await enableMfaFor("disable-recovery-normalize-1");
146
+ const code = recoveryCodes[0];
147
+ if (code === undefined) throw new Error("test setup produced no recovery code");
148
+
149
+ const sloppy = code.toLowerCase().replace("-", "");
150
+ const res = await stack.http.writeOk<{ disabled: boolean }>(
151
+ AuthMfaHandlers.disable,
152
+ { code: sloppy },
153
+ user,
154
+ );
155
+ expect(res.disabled).toBe(true);
156
+ });
157
+
142
158
  test("a wrong code is rejected and MFA stays enabled", async () => {
143
159
  const { user, secret } = await enableMfaFor("disable-wrong-1");
144
160
 
@@ -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