@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
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.151.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.151.0",
119
+ "@cosmicdrift/kumiko-framework": "0.151.0",
120
+ "@cosmicdrift/kumiko-headless": "0.151.0",
121
+ "@cosmicdrift/kumiko-renderer": "0.151.0",
122
+ "@cosmicdrift/kumiko-renderer-web": "0.151.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
@@ -9,7 +9,7 @@
9
9
  // (nur `{ children }`-Prop). Der Sample kann so einen eigenen Login-
10
10
  // Screen rein konfigurieren, ohne den Gate selbst ersetzen zu müssen.
11
11
 
12
- import { type ComponentType, type ReactNode, useState } from "react";
12
+ import { type ComponentType, type ReactNode, useEffect, useState } from "react";
13
13
  import { LoginScreen, type LoginScreenProps } from "./login-screen";
14
14
  import { SessionProvider, useSession } from "./session";
15
15
 
@@ -20,42 +20,87 @@ 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
- export function makeAuthGate(
26
- LoginComponent: ComponentType<LoginScreenProps> = LoginScreen,
27
- loginProps?: LoginScreenProps,
28
- MfaVerifyComponent?: ComponentType<MfaVerifyComponentProps>,
29
- ): ComponentType<{ children: ReactNode }> {
30
- function AuthGate({ children }: { readonly children: ReactNode }): ReactNode {
26
+ export type LoginRouteOptions = {
27
+ readonly loginScreen?: ComponentType<LoginScreenProps>;
28
+ readonly loginScreenProps?: LoginScreenProps;
29
+ readonly mfaVerifyScreen?: ComponentType<MfaVerifyComponentProps>;
30
+ /** Called once the session becomes authenticated. makeAuthGate ignores
31
+ * this (it renders `children` on its own authenticated branch instead);
32
+ * standalone routes — no parent gate, e.g. an anonymous apex/marketing
33
+ * surface — use it to redirect. */
34
+ readonly onAuthenticated?: () => void;
35
+ };
36
+
37
+ // The one sanctioned way to render a login flow that correctly completes
38
+ // an MFA challenge — makeAuthGate below is just this wrapped for the
39
+ // `{ children }` gate shape. Also exported directly for apps that render
40
+ // their own standalone login route outside any gate (createPublicSurface
41
+ // apex pages stack providers, not gates — see auth-mount.tsx recipes).
42
+ // Handing an app raw LoginScreen + telling it to hand-roll the challenge-
43
+ // token swap itself is exactly how kumiko-framework#266's login-time MFA
44
+ // step went missing in a real apex surface; this makes that mistake
45
+ // structurally unavailable — there is no lower-level piece left to misuse.
46
+ export function createLoginRoute(
47
+ opts: LoginRouteOptions = {},
48
+ ): ComponentType<Record<string, never>> {
49
+ const LoginComponent = opts.loginScreen ?? LoginScreen;
50
+ const MfaVerifyComponent = opts.mfaVerifyScreen;
51
+
52
+ function LoginRoute(): ReactNode {
31
53
  const { status } = useSession();
32
54
  // Pending challenge-token from LoginScreen's onMfaChallenge. Lives here
33
55
  // (not in SessionState) because it's a UI-only transition — the server
34
56
  // never considers this session authenticated until verify succeeds.
35
57
  const [challengeToken, setChallengeToken] = useState<string | null>(null);
36
58
 
59
+ useEffect(() => {
60
+ if (status === "authenticated") opts.onAuthenticated?.();
61
+ }, [status]);
62
+
37
63
  if (status === "loading") {
38
64
  return (
39
65
  <div className="min-h-screen flex items-center justify-center text-muted-foreground text-sm" />
40
66
  );
41
67
  }
42
- if (status === "unauthenticated") {
43
- if (challengeToken !== null && MfaVerifyComponent) {
44
- return (
45
- <MfaVerifyComponent
46
- challengeToken={challengeToken}
47
- onSuccess={() => setChallengeToken(null)}
48
- />
49
- );
50
- }
68
+ if (status === "authenticated") return null;
69
+ if (challengeToken !== null && MfaVerifyComponent) {
51
70
  return (
52
- <LoginComponent
53
- {...loginProps}
54
- onMfaChallenge={MfaVerifyComponent ? setChallengeToken : loginProps?.onMfaChallenge}
71
+ <MfaVerifyComponent
72
+ challengeToken={challengeToken}
73
+ onSuccess={() => setChallengeToken(null)}
74
+ onCancel={() => setChallengeToken(null)}
55
75
  />
56
76
  );
57
77
  }
58
- return <>{children}</>;
78
+ return (
79
+ <LoginComponent
80
+ {...opts.loginScreenProps}
81
+ onMfaChallenge={
82
+ MfaVerifyComponent ? setChallengeToken : opts.loginScreenProps?.onMfaChallenge
83
+ }
84
+ />
85
+ );
86
+ }
87
+ return LoginRoute;
88
+ }
89
+
90
+ export function makeAuthGate(
91
+ LoginComponent: ComponentType<LoginScreenProps> = LoginScreen,
92
+ loginProps?: LoginScreenProps,
93
+ MfaVerifyComponent?: ComponentType<MfaVerifyComponentProps>,
94
+ ): ComponentType<{ children: ReactNode }> {
95
+ const LoginRoute = createLoginRoute({
96
+ loginScreen: LoginComponent,
97
+ loginScreenProps: loginProps,
98
+ mfaVerifyScreen: MfaVerifyComponent,
99
+ });
100
+ function AuthGate({ children }: { readonly children: ReactNode }): ReactNode {
101
+ const { status } = useSession();
102
+ if (status === "authenticated") return <>{children}</>;
103
+ return <LoginRoute />;
59
104
  }
60
105
  return AuthGate;
61
106
  }
@@ -27,8 +27,8 @@ export {
27
27
  } from "./auth-client";
28
28
  export type { AuthCardProps, AuthShellRenderer } from "./auth-form-primitives";
29
29
  export { AuthCard, AuthShellProvider, useAuthShell } from "./auth-form-primitives";
30
- export type { MfaVerifyComponentProps } from "./auth-gate";
31
- export { makeAuthGate, makeSessionAuthGate } from "./auth-gate";
30
+ export type { LoginRouteOptions, MfaVerifyComponentProps } from "./auth-gate";
31
+ export { createLoginRoute, makeAuthGate, makeSessionAuthGate } from "./auth-gate";
32
32
  export type {
33
33
  EmailPasswordClientFeature,
34
34
  EmailPasswordClientOptions,
@@ -40,8 +40,6 @@ export type { ForgotPasswordScreenProps } from "./forgot-password-screen";
40
40
  export { ForgotPasswordScreen } from "./forgot-password-screen";
41
41
  export type { InviteAcceptScreenProps } from "./invite-accept-screen";
42
42
  export { InviteAcceptScreen } from "./invite-accept-screen";
43
- export type { LoginScreenProps } from "./login-screen";
44
- export { LoginScreen } from "./login-screen";
45
43
  export type { ResetPasswordScreenProps } from "./reset-password-screen";
46
44
  export { ResetPasswordScreen } from "./reset-password-screen";
47
45
  export type { SessionApi, SessionState, SessionStatus } from "./session";
@@ -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