@cosmicdrift/kumiko-bundled-features 0.161.0 → 0.162.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 (29) hide show
  1. package/package.json +6 -6
  2. package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +168 -0
  3. package/src/auth-email-password/constants.ts +6 -0
  4. package/src/auth-email-password/feature.ts +8 -1
  5. package/src/auth-email-password/handlers/self-registration-status.query.ts +20 -0
  6. package/src/auth-email-password/handlers/signup-request.write.ts +11 -0
  7. package/src/auth-email-password/index.ts +5 -1
  8. package/src/auth-email-password/self-registration-toggle.ts +25 -0
  9. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +79 -0
  10. package/src/auth-email-password/web/__tests__/login-screen.test.tsx +2 -2
  11. package/src/auth-email-password/web/auth-gate.tsx +44 -2
  12. package/src/auth-email-password/web/login-screen.tsx +8 -5
  13. package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +253 -0
  14. package/src/auth-mfa/__tests__/enable-start-preauth.integration.test.ts +214 -0
  15. package/src/auth-mfa/constants.ts +2 -0
  16. package/src/auth-mfa/feature.ts +15 -0
  17. package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +170 -0
  18. package/src/auth-mfa/handlers/enable-start-preauth.write.ts +87 -0
  19. package/src/auth-mfa/mfa-setup-token.ts +7 -1
  20. package/src/auth-mfa/web/__tests__/mfa-client.test.ts +144 -1
  21. package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +202 -0
  22. package/src/auth-mfa/web/i18n.ts +14 -0
  23. package/src/auth-mfa/web/index.ts +8 -2
  24. package/src/auth-mfa/web/mfa-client.ts +113 -0
  25. package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +244 -0
  26. package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +96 -0
  27. package/src/feature-toggles/compose-tier-resolver.ts +41 -0
  28. package/src/feature-toggles/index.ts +1 -0
  29. package/src/feature-toggles/toggle-runtime.ts +14 -0
@@ -0,0 +1,253 @@
1
+ // #1456: completes both the enrollment #1465 started AND the login that
2
+ // mfa-setup-required blocked. Real HTTP through setupTestStack, exercising
3
+ // the full chain: login → preauthSetupToken → preauth-enable-start →
4
+ // preauth-confirm → the minted JWT actually authenticates a follow-up
5
+ // request. That end-to-end seam is the thing a mocked test would fake.
6
+
7
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
8
+ import { randomBytes } from "node:crypto";
9
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
10
+ import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
11
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
12
+ import {
13
+ createTestUser,
14
+ setupTestStack,
15
+ type TestStack,
16
+ testTenantId,
17
+ unsafeCreateEntityTable,
18
+ unsafePushTables,
19
+ } from "@cosmicdrift/kumiko-framework/stack";
20
+ import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
21
+ import {
22
+ AuthErrors as AuthEmailPasswordErrors,
23
+ AuthHandlers as AuthEmailPasswordHandlers,
24
+ createAuthEmailPasswordFeature,
25
+ } from "../../auth-email-password";
26
+ import { ConfigHandlers, createConfigFeature } from "../../config";
27
+ import { createConfigResolver } from "../../config/resolver";
28
+ import { configValuesTable } from "../../config/table";
29
+ import { hashPassword } from "../../shared";
30
+ import { createTenantFeature } from "../../tenant";
31
+ import { tenantMembershipsTable } from "../../tenant/membership-table";
32
+ import { tenantEntity } from "../../tenant/schema/tenant";
33
+ import { seedTenantMembership } from "../../tenant/testing";
34
+ import { UserHandlers } from "../../user";
35
+ import { createUserFeature } from "../../user/feature";
36
+ import { userEntity, userTable } from "../../user/schema/user";
37
+ import { base32Decode } from "../base32";
38
+ import { mfaRequiredConfigHandle } from "../config";
39
+ import { AuthMfaHandlers } from "../constants";
40
+ import { createAuthMfaFeature, mfaStatusCheckerFromFeature } from "../feature";
41
+ import { userMfaEntity } from "../schema/user-mfa";
42
+ import { currentTotpCode } from "../totp";
43
+
44
+ let stack: TestStack;
45
+
46
+ const CHALLENGE_TOKEN_SECRET = "integration-test-challenge-token-secret-not-real-0001";
47
+ const SETUP_TOKEN_SECRET = "integration-test-setup-token-secret-not-real-0002";
48
+ const TENANT_ID: TenantId = testTenantId(420);
49
+
50
+ beforeAll(async () => {
51
+ const encryption = createTestEnvelopeCipher(randomBytes(32).toString("base64"));
52
+ configureEntityFieldEncryption(encryption);
53
+ const resolver = createConfigResolver({ cipher: encryption });
54
+ const authMfaFeature = createAuthMfaFeature({
55
+ setupTokenSecret: SETUP_TOKEN_SECRET,
56
+ issuer: "Kumiko Test",
57
+ challengeTokenSecret: CHALLENGE_TOKEN_SECRET,
58
+ });
59
+
60
+ stack = await setupTestStack({
61
+ features: [
62
+ createConfigFeature(),
63
+ createUserFeature(),
64
+ createTenantFeature(),
65
+ authMfaFeature,
66
+ createAuthEmailPasswordFeature({
67
+ mfaStatusChecker: mfaStatusCheckerFromFeature(authMfaFeature),
68
+ }),
69
+ ],
70
+ extraContext: { configResolver: resolver, configEncryption: encryption },
71
+ authConfig: {
72
+ membershipQuery: "tenant:query:memberships",
73
+ loginHandler: AuthEmailPasswordHandlers.login,
74
+ loginErrorStatusMap: {
75
+ [AuthEmailPasswordErrors.invalidCredentials]: 401,
76
+ [AuthEmailPasswordErrors.noMembership]: 403,
77
+ },
78
+ mfaPreauthEnableStartHandler: AuthMfaHandlers.enableStartPreauth,
79
+ mfaPreauthConfirmHandler: AuthMfaHandlers.enableConfirmPreauth,
80
+ },
81
+ });
82
+
83
+ await unsafeCreateEntityTable(stack.db, userEntity);
84
+ await unsafeCreateEntityTable(stack.db, tenantEntity);
85
+ await unsafeCreateEntityTable(stack.db, userMfaEntity);
86
+ await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
87
+ });
88
+
89
+ afterAll(async () => {
90
+ await stack.cleanup();
91
+ });
92
+
93
+ beforeEach(async () => {
94
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
95
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
96
+ });
97
+
98
+ async function loginBlockedByEnforcement(
99
+ email = "unenrolled@example.com",
100
+ ): Promise<{ preauthSetupToken: string; userId: string }> {
101
+ const password = "correct-horse-battery-2026";
102
+ const hash = await hashPassword(password);
103
+ const created = await stack.http.writeOk<{ id: string }>(
104
+ UserHandlers.create,
105
+ { email, passwordHash: hash, displayName: "Unenrolled" },
106
+ createTestUser({ id: 421, tenantId: TENANT_ID, roles: ["SystemAdmin"] }),
107
+ );
108
+ await seedTenantMembership(stack.db, {
109
+ userId: created.id,
110
+ tenantId: TENANT_ID,
111
+ roles: ["User"],
112
+ });
113
+ await stack.http.writeOk(
114
+ ConfigHandlers.set,
115
+ { key: mfaRequiredConfigHandle.name, value: "all" },
116
+ createTestUser({ id: 422, tenantId: TENANT_ID, roles: ["Admin"] }),
117
+ );
118
+
119
+ const loginRes = await stack.http.raw("POST", "/api/auth/login", { email, password });
120
+ const loginBody = await loginRes.json();
121
+ return { preauthSetupToken: loginBody.preauthSetupToken as string, userId: created.id };
122
+ }
123
+
124
+ async function startEnrollment(
125
+ preauthSetupToken: string,
126
+ accountLabel: string,
127
+ ): Promise<{ setupToken: string; secret: Buffer }> {
128
+ const startRes = await stack.http.raw("POST", "/api/auth/mfa/preauth-enable-start", {
129
+ preauthSetupToken,
130
+ accountLabel,
131
+ });
132
+ const startBody = await startRes.json();
133
+ const secretParam = new URLSearchParams(startBody.otpauthUri.split("?")[1]).get("secret") ?? "";
134
+ return { setupToken: startBody.setupToken as string, secret: base32Decode(secretParam) };
135
+ }
136
+
137
+ describe("POST /auth/mfa/preauth-confirm", () => {
138
+ test("completes enrollment + login: full chain, minted JWT authenticates a follow-up request", async () => {
139
+ const email = "chain@example.com";
140
+ const { preauthSetupToken } = await loginBlockedByEnforcement(email);
141
+ const { setupToken, secret } = await startEnrollment(preauthSetupToken, email);
142
+
143
+ const res = await stack.http.raw("POST", "/api/auth/mfa/preauth-confirm", {
144
+ setupToken,
145
+ code: currentTotpCode(secret),
146
+ });
147
+
148
+ expect(res.status).toBe(200);
149
+ const body = await res.json();
150
+ expect(body.isSuccess).toBe(true);
151
+ expect(typeof body.token).toBe("string");
152
+ expect(body.user.tenantId).toBe(TENANT_ID);
153
+
154
+ // The proof that matters: the minted JWT actually authenticates.
155
+ const meRes = await stack.http.raw(
156
+ "POST",
157
+ "/api/query",
158
+ { type: "user:query:user:me", payload: {} },
159
+ { Authorization: `Bearer ${body.token}` },
160
+ );
161
+ expect(meRes.status).toBe(200);
162
+ });
163
+
164
+ test("wrong TOTP code → invalid_totp_code, no enrollment persisted", async () => {
165
+ const email = "wrongcode@example.com";
166
+ const { preauthSetupToken } = await loginBlockedByEnforcement(email);
167
+ const { setupToken } = await startEnrollment(preauthSetupToken, email);
168
+
169
+ const res = await stack.http.raw("POST", "/api/auth/mfa/preauth-confirm", {
170
+ setupToken,
171
+ code: "000000",
172
+ });
173
+
174
+ expect(res.status).not.toBe(200);
175
+ const body = await res.json();
176
+ expect(body.isSuccess).toBe(false);
177
+ expect(body.error.details.reason).toBe("invalid_totp_code");
178
+ expect(body.token).toBeUndefined();
179
+ });
180
+
181
+ test("replaying the same setupToken after a successful confirm → invalid_setup_token (burned), not a second enrollment", async () => {
182
+ const email = "race@example.com";
183
+ const { preauthSetupToken } = await loginBlockedByEnforcement(email);
184
+ const { setupToken, secret } = await startEnrollment(preauthSetupToken, email);
185
+ const code = currentTotpCode(secret);
186
+
187
+ const first = await stack.http.raw("POST", "/api/auth/mfa/preauth-confirm", {
188
+ setupToken,
189
+ code,
190
+ });
191
+ expect(first.status).toBe(200);
192
+
193
+ // Same setupToken again — this integration stack wires real Redis, so
194
+ // the single-use burn from the first call rejects the replay before it
195
+ // ever reaches the already-enrolled DB check.
196
+ const second = await stack.http.raw("POST", "/api/auth/mfa/preauth-confirm", {
197
+ setupToken,
198
+ code,
199
+ });
200
+ expect(second.status).not.toBe(200);
201
+ const secondBody = await second.json();
202
+ expect(secondBody.error.details.reason).toBe("invalid_setup_token");
203
+ });
204
+
205
+ test("a session-issued setupToken (no tenantId) is rejected, not silently accepted", async () => {
206
+ const email = "sessionissued@example.com";
207
+ const password = "correct-horse-battery-2026";
208
+ const hash = await hashPassword(password);
209
+ const created = await stack.http.writeOk<{ id: string }>(
210
+ UserHandlers.create,
211
+ { email, passwordHash: hash, displayName: "Session Issued" },
212
+ createTestUser({ id: 423, tenantId: TENANT_ID, roles: ["SystemAdmin"] }),
213
+ );
214
+ await seedTenantMembership(stack.db, {
215
+ userId: created.id,
216
+ tenantId: TENANT_ID,
217
+ roles: ["User"],
218
+ });
219
+ const sessionUser = createTestUser({ id: created.id, tenantId: TENANT_ID, roles: ["User"] });
220
+
221
+ // The session-authed enable-start signs a setupToken with the SAME
222
+ // secret + format, but never carries tenantId (see mfa-setup-token.ts).
223
+ // A malicious or confused client resubmitting it to the pre-auth
224
+ // endpoint must not be able to piggyback on tenantId inference.
225
+ const start = await stack.http.writeOk<{ setupToken: string }>(
226
+ AuthMfaHandlers.enableStart,
227
+ { accountLabel: email },
228
+ sessionUser,
229
+ );
230
+
231
+ const res = await stack.http.raw("POST", "/api/auth/mfa/preauth-confirm", {
232
+ setupToken: start.setupToken,
233
+ code: "123456",
234
+ });
235
+
236
+ expect(res.status).not.toBe(200);
237
+ const body = await res.json();
238
+ expect(body.isSuccess).toBe(false);
239
+ expect(body.error.details.reason).toBe("invalid_setup_token");
240
+ });
241
+
242
+ test("expired/garbage setupToken → invalid_setup_token, no session minted", async () => {
243
+ const res = await stack.http.raw("POST", "/api/auth/mfa/preauth-confirm", {
244
+ setupToken: "not-a-real-token",
245
+ code: "123456",
246
+ });
247
+
248
+ expect(res.status).not.toBe(200);
249
+ const body = await res.json();
250
+ expect(body.isSuccess).toBe(false);
251
+ expect(body.token).toBeUndefined();
252
+ });
253
+ });
@@ -0,0 +1,214 @@
1
+ // #1465: a user blocked at login by MFA enforcement (mfa-setup-required,
2
+ // see #1455) can generate a TOTP secret + QR without a session, using the
3
+ // preauthSetupToken login.write.ts issued. Real HTTP through
4
+ // setupTestStack, exercising the full chain: login → preauthSetupToken →
5
+ // POST /auth/mfa/preauth-enable-start → a setupToken that #1456's
6
+ // (not-yet-built) confirm step will consume via verifyMfaSetupToken.
7
+
8
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
9
+ import { randomBytes } from "node:crypto";
10
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
11
+ import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
12
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
13
+ import {
14
+ createTestUser,
15
+ setupTestStack,
16
+ type TestStack,
17
+ testTenantId,
18
+ unsafeCreateEntityTable,
19
+ unsafePushTables,
20
+ } from "@cosmicdrift/kumiko-framework/stack";
21
+ import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
22
+ import {
23
+ AuthErrors as AuthEmailPasswordErrors,
24
+ AuthHandlers as AuthEmailPasswordHandlers,
25
+ createAuthEmailPasswordFeature,
26
+ } from "../../auth-email-password";
27
+ import { ConfigHandlers, createConfigFeature } from "../../config";
28
+ import { createConfigResolver } from "../../config/resolver";
29
+ import { configValuesTable } from "../../config/table";
30
+ import { hashPassword } from "../../shared";
31
+ import { createTenantFeature } from "../../tenant";
32
+ import { tenantMembershipsTable } from "../../tenant/membership-table";
33
+ import { tenantEntity } from "../../tenant/schema/tenant";
34
+ import { seedTenantMembership } from "../../tenant/testing";
35
+ import { UserHandlers } from "../../user";
36
+ import { createUserFeature } from "../../user/feature";
37
+ import { userEntity, userTable } from "../../user/schema/user";
38
+ import { base32Decode } from "../base32";
39
+ import { mfaRequiredConfigHandle } from "../config";
40
+ import { AuthMfaHandlers } from "../constants";
41
+ import { createAuthMfaFeature, mfaStatusCheckerFromFeature } from "../feature";
42
+ import { signMfaPreauthSetupToken } from "../mfa-preauth-setup-token";
43
+ import { verifyMfaSetupToken } from "../mfa-setup-token";
44
+ import { userMfaEntity } from "../schema/user-mfa";
45
+ import { currentTotpCode } from "../totp";
46
+
47
+ let stack: TestStack;
48
+
49
+ const CHALLENGE_TOKEN_SECRET = "test-mfa-challenge-secret-at-least-32-bytes!!";
50
+ const SETUP_TOKEN_SECRET = "test-mfa-setup-token-secret-at-least-32-bytes!!";
51
+ const TENANT_ID: TenantId = testTenantId(410);
52
+
53
+ beforeAll(async () => {
54
+ const encryption = createTestEnvelopeCipher(randomBytes(32).toString("base64"));
55
+ configureEntityFieldEncryption(encryption);
56
+ const resolver = createConfigResolver({ cipher: encryption });
57
+ const authMfaFeature = createAuthMfaFeature({
58
+ setupTokenSecret: SETUP_TOKEN_SECRET,
59
+ issuer: "Kumiko Test",
60
+ challengeTokenSecret: CHALLENGE_TOKEN_SECRET,
61
+ });
62
+
63
+ stack = await setupTestStack({
64
+ features: [
65
+ createConfigFeature(),
66
+ createUserFeature(),
67
+ createTenantFeature(),
68
+ authMfaFeature,
69
+ createAuthEmailPasswordFeature({
70
+ mfaStatusChecker: mfaStatusCheckerFromFeature(authMfaFeature),
71
+ }),
72
+ ],
73
+ extraContext: { configResolver: resolver, configEncryption: encryption },
74
+ authConfig: {
75
+ membershipQuery: "tenant:query:memberships",
76
+ loginHandler: AuthEmailPasswordHandlers.login,
77
+ loginErrorStatusMap: {
78
+ [AuthEmailPasswordErrors.invalidCredentials]: 401,
79
+ [AuthEmailPasswordErrors.noMembership]: 403,
80
+ },
81
+ mfaPreauthEnableStartHandler: AuthMfaHandlers.enableStartPreauth,
82
+ },
83
+ });
84
+
85
+ await unsafeCreateEntityTable(stack.db, userEntity);
86
+ await unsafeCreateEntityTable(stack.db, tenantEntity);
87
+ await unsafeCreateEntityTable(stack.db, userMfaEntity);
88
+ await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
89
+ });
90
+
91
+ afterAll(async () => {
92
+ await stack.cleanup();
93
+ });
94
+
95
+ beforeEach(async () => {
96
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
97
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
98
+ });
99
+
100
+ async function loginBlockedByEnforcement(): Promise<{
101
+ preauthSetupToken: string;
102
+ userId: string;
103
+ }> {
104
+ const password = "correct-horse-battery-2026";
105
+ const hash = await hashPassword(password);
106
+ const created = await stack.http.writeOk<{ id: string }>(
107
+ UserHandlers.create,
108
+ { email: "unenrolled@example.com", passwordHash: hash, displayName: "Unenrolled" },
109
+ createTestUser({ id: 411, tenantId: TENANT_ID, roles: ["SystemAdmin"] }),
110
+ );
111
+ await seedTenantMembership(stack.db, {
112
+ userId: created.id,
113
+ tenantId: TENANT_ID,
114
+ roles: ["User"],
115
+ });
116
+ await stack.http.writeOk(
117
+ ConfigHandlers.set,
118
+ { key: mfaRequiredConfigHandle.name, value: "all" },
119
+ createTestUser({ id: 412, tenantId: TENANT_ID, roles: ["Admin"] }),
120
+ );
121
+
122
+ const loginRes = await stack.http.raw("POST", "/api/auth/login", {
123
+ email: "unenrolled@example.com",
124
+ password,
125
+ });
126
+ const loginBody = await loginRes.json();
127
+ return { preauthSetupToken: loginBody.preauthSetupToken as string, userId: created.id };
128
+ }
129
+
130
+ describe("POST /auth/mfa/preauth-enable-start", () => {
131
+ test("generates a TOTP secret + QR from a valid preauthSetupToken, no session minted", async () => {
132
+ const { preauthSetupToken } = await loginBlockedByEnforcement();
133
+
134
+ const res = await stack.http.raw("POST", "/api/auth/mfa/preauth-enable-start", {
135
+ preauthSetupToken,
136
+ accountLabel: "unenrolled@example.com",
137
+ });
138
+
139
+ expect(res.status).toBe(200);
140
+ const body = await res.json();
141
+ expect(body.isSuccess).toBe(true);
142
+ expect(body.token).toBeUndefined();
143
+ expect(typeof body.setupToken).toBe("string");
144
+ expect(typeof body.otpauthUri).toBe("string");
145
+ expect(body.otpauthUri).toContain("otpauth://totp/");
146
+ expect(Array.isArray(body.recoveryCodes)).toBe(true);
147
+ expect(body.recoveryCodes).toHaveLength(8);
148
+
149
+ // The returned setupToken must compose with the EXISTING enable-confirm
150
+ // verification path (verifyMfaSetupToken) — proves #1465 and #1456
151
+ // actually chain together, not just that each returns *a* string.
152
+ const verified = verifyMfaSetupToken(body.setupToken, SETUP_TOKEN_SECRET);
153
+ expect(verified.ok).toBe(true);
154
+ if (verified.ok) {
155
+ expect(typeof verified.payload.totpSecretBase32).toBe("string");
156
+ expect(verified.payload.recoveryCodeHashes).toHaveLength(8);
157
+ }
158
+ });
159
+
160
+ test("expired/garbage preauthSetupToken → invalid_challenge_token, no secret leaked", async () => {
161
+ const res = await stack.http.raw("POST", "/api/auth/mfa/preauth-enable-start", {
162
+ preauthSetupToken: "not-a-real-token",
163
+ accountLabel: "unenrolled@example.com",
164
+ });
165
+
166
+ expect(res.status).not.toBe(200);
167
+ const body = await res.json();
168
+ expect(body.isSuccess).toBe(false);
169
+ expect(body.setupToken).toBeUndefined();
170
+ });
171
+
172
+ test("already-enrolled user gets mfa_already_enabled, not a fresh secret", async () => {
173
+ const { userId } = await loginBlockedByEnforcement();
174
+ const sessionUser = createTestUser({ id: userId, tenantId: TENANT_ID, roles: ["User"] });
175
+
176
+ // Really enrolls — via the existing session-authed enable-start/confirm
177
+ // pair, not a synthetic DB insert — so this test breaks if enrollment
178
+ // detection ever drifts from what a real enroll actually persists.
179
+ const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
180
+ AuthMfaHandlers.enableStart,
181
+ { accountLabel: "unenrolled@example.com" },
182
+ sessionUser,
183
+ );
184
+ const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
185
+ const secret = base32Decode(secretParam);
186
+ await stack.http.writeOk(
187
+ AuthMfaHandlers.enableConfirm,
188
+ { setupToken: start.setupToken, code: currentTotpCode(secret) },
189
+ sessionUser,
190
+ );
191
+
192
+ // A fresh preauthSetupToken for the SAME already-enrolled user — the
193
+ // login flow itself would no longer issue one (mfaStatusChecker sees
194
+ // the row and returns `required: true`, not `setupRequired`), so this
195
+ // mints one directly to isolate the handler's own already-enrolled
196
+ // check from that unrelated login-side branch.
197
+ const { token: freshPreauthToken } = signMfaPreauthSetupToken(
198
+ { userId, tenantId: TENANT_ID },
199
+ 10,
200
+ CHALLENGE_TOKEN_SECRET,
201
+ );
202
+
203
+ const res = await stack.http.raw("POST", "/api/auth/mfa/preauth-enable-start", {
204
+ preauthSetupToken: freshPreauthToken,
205
+ accountLabel: "unenrolled@example.com",
206
+ });
207
+
208
+ expect(res.status).not.toBe(200);
209
+ const body = await res.json();
210
+ expect(body.isSuccess).toBe(false);
211
+ expect(body.error.details.reason).toBe("mfa_already_enabled");
212
+ expect(body.setupToken).toBeUndefined();
213
+ });
214
+ });
@@ -9,7 +9,9 @@ export const AUTH_MFA_FEATURE = "auth-mfa" as const;
9
9
 
10
10
  export const AuthMfaHandlers = {
11
11
  enableStart: "auth-mfa:write:enable-start",
12
+ enableStartPreauth: "auth-mfa:write:enable-start-preauth",
12
13
  enableConfirm: "auth-mfa:write:enable-confirm",
14
+ enableConfirmPreauth: "auth-mfa:write:enable-confirm-preauth",
13
15
  disable: "auth-mfa:write:disable",
14
16
  regenerateRecovery: "auth-mfa:write:regenerate-recovery",
15
17
  verify: "auth-mfa:write:verify",
@@ -3,7 +3,9 @@ import { mfaRequiredConfigKey } from "./config";
3
3
  import { MFA_ENABLE_SCREEN_ID } from "./constants";
4
4
  import { createDisableHandler } from "./handlers/disable.write";
5
5
  import { createEnableConfirmHandler } from "./handlers/enable-confirm.write";
6
+ import { createEnableConfirmPreauthHandler } from "./handlers/enable-confirm-preauth.write";
6
7
  import { createEnableStartHandler } from "./handlers/enable-start.write";
8
+ import { createEnableStartPreauthHandler } from "./handlers/enable-start-preauth.write";
7
9
  import { mfaReencryptJob } from "./handlers/reencrypt.job";
8
10
  import { createRegenerateRecoveryHandler } from "./handlers/regenerate-recovery.write";
9
11
  import { mfaStatusQuery } from "./handlers/status.query";
@@ -119,12 +121,25 @@ export function createAuthMfaFeature(opts: AuthMfaFeatureOptions): FeatureDefini
119
121
  enableStart: r.writeHandler(
120
122
  createEnableStartHandler({ setupTokenSecret: opts.setupTokenSecret, issuer: opts.issuer }),
121
123
  ),
124
+ enableStartPreauth: r.writeHandler(
125
+ createEnableStartPreauthHandler({
126
+ challengeTokenSecret: opts.challengeTokenSecret,
127
+ setupTokenSecret: opts.setupTokenSecret,
128
+ issuer: opts.issuer,
129
+ }),
130
+ ),
122
131
  enableConfirm: r.writeHandler(
123
132
  createEnableConfirmHandler({
124
133
  setupTokenSecret: opts.setupTokenSecret,
125
134
  revokeAllOtherSessions: sharedRevoker,
126
135
  }),
127
136
  ),
137
+ enableConfirmPreauth: r.writeHandler(
138
+ createEnableConfirmPreauthHandler({
139
+ setupTokenSecret: opts.setupTokenSecret,
140
+ revokeAllOtherSessions: sharedRevoker,
141
+ }),
142
+ ),
128
143
  disable: r.writeHandler(createDisableHandler({ revokeAllOtherSessions: sharedRevoker })),
129
144
  regenerateRecovery: r.writeHandler(
130
145
  createRegenerateRecoveryHandler({ revokeAllOtherSessions: sharedRevoker }),
@@ -0,0 +1,170 @@
1
+ import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
2
+ import {
3
+ buildSessionRoles,
4
+ createSystemUser,
5
+ defineWriteHandler,
6
+ type SessionUser,
7
+ } from "@cosmicdrift/kumiko-framework/engine";
8
+ import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
9
+ import { Temporal } from "temporal-polyfill";
10
+ import { z } from "zod";
11
+ import { burnToken } from "../../shared";
12
+ import { USER_STATUS, UserQueries } from "../../user";
13
+ import { base32Decode } from "../base32";
14
+ import { MFA_VERIFY_LOCKOUT_MINUTES, MFA_VERIFY_MAX_ATTEMPTS } from "../constants";
15
+ import { findUserMfaRow } from "../db/queries";
16
+ import { invalidSetupToken, invalidTotpCode, mfaAlreadyEnabled, tooManyAttempts } from "../errors";
17
+ import { verifyMfaSetupToken } from "../mfa-setup-token";
18
+ import {
19
+ clearMfaVerifyAttempts,
20
+ getMfaVerifyLockoutState,
21
+ recordFailedMfaVerifyAttempt,
22
+ } from "../mfa-verify-attempts";
23
+ import { encodeRecoveryCodes, userMfaEntity, userMfaTable } from "../schema/user-mfa";
24
+ import { verifyTotp } from "../totp";
25
+
26
+ export type EnableConfirmPreauthOptions = {
27
+ // Must match the secret enable-start-preauth.write.ts signs setupTokens
28
+ // with — same secret + format as the session-authed enable-confirm.
29
+ readonly setupTokenSecret: string;
30
+ readonly maxAttempts?: number;
31
+ readonly lockoutMinutes?: number;
32
+ // Wired late by run-prod-app, same shared callback as enable-confirm and
33
+ // enable-start's session-authed siblings.
34
+ readonly revokeAllOtherSessions?: (
35
+ userId: string,
36
+ currentSid: string | undefined,
37
+ ) => Promise<number>;
38
+ };
39
+
40
+ const executor = createEventStoreExecutor(userMfaTable, userMfaEntity, {
41
+ entityName: "user-mfa",
42
+ });
43
+
44
+ // Pre-auth twin of enable-confirm.write.ts: identity comes from the
45
+ // verified setupToken (minted by enable-start-preauth.write.ts), not from
46
+ // event.user — there is no session yet. Combines enable-confirm's
47
+ // enrollment-completion logic with verify.write.ts's session-derivation
48
+ // (membership lookup, status re-check, buildSessionRoles) since this IS
49
+ // the completion of the blocked login, not just an enrollment step — a
50
+ // successful call here logs the user in.
51
+ export function createEnableConfirmPreauthHandler(opts: EnableConfirmPreauthOptions) {
52
+ const maxAttempts = opts.maxAttempts ?? MFA_VERIFY_MAX_ATTEMPTS;
53
+ const lockoutMinutes = opts.lockoutMinutes ?? MFA_VERIFY_LOCKOUT_MINUTES;
54
+
55
+ return defineWriteHandler({
56
+ name: "enable-confirm-preauth",
57
+ schema: z.object({
58
+ setupToken: z.string().min(1),
59
+ code: z.string().length(6),
60
+ }),
61
+ access: { roles: ["all"] },
62
+ handler: async (event, ctx) => {
63
+ const verify = verifyMfaSetupToken(event.payload.setupToken, opts.setupTokenSecret);
64
+ if (!verify.ok) return invalidSetupToken();
65
+ // A setupToken minted for the session-authed enable-start never
66
+ // carries tenantId (see mfa-setup-token.ts) — reject it here rather
67
+ // than falling back to a guest tenant, that token was never meant to
68
+ // reach this pre-auth endpoint.
69
+ const { userId, tenantId } = verify.payload;
70
+ if (tenantId === undefined) return invalidSetupToken();
71
+
72
+ // Same guessing surface as /auth/mfa/verify (a short TOTP code) and
73
+ // the same per-userId cap — deliberately shared counter, see
74
+ // mfa-verify-attempts.ts.
75
+ if (ctx.redis) {
76
+ const state = await getMfaVerifyLockoutState(ctx.redis, userId);
77
+ if (state?.lockedUntil !== null && state?.lockedUntil !== undefined) {
78
+ const now = Date.now();
79
+ if (state.lockedUntil > now) {
80
+ const retryAfterSeconds = Math.max(1, Math.ceil((state.lockedUntil - now) / 1000));
81
+ return tooManyAttempts(retryAfterSeconds);
82
+ }
83
+ }
84
+ }
85
+
86
+ const secret = base32Decode(verify.payload.totpSecretBase32);
87
+ if (verifyTotp(secret, event.payload.code) === false) {
88
+ if (ctx.redis) {
89
+ await recordFailedMfaVerifyAttempt(ctx.redis, userId, maxAttempts, lockoutMinutes);
90
+ }
91
+ return invalidTotpCode();
92
+ }
93
+
94
+ // Burn the setup token on the first successful confirm — same
95
+ // single-use guarantee as enable-confirm.write.ts.
96
+ if (ctx.redis) {
97
+ const burnResult = await burnToken(ctx.redis, "mfa-setup", userId, verify.expiresAtMs);
98
+ if (burnResult === "already-used") return invalidSetupToken();
99
+ }
100
+
101
+ // "system" mode: no session exists yet, tenantId comes from the
102
+ // verified token, mirroring enable-start-preauth.write.ts.
103
+ const scopedDb = createTenantDb(ctx.db.raw, tenantId, "system");
104
+ const scopedUser: SessionUser = { id: userId, tenantId, roles: ["User"] };
105
+ const existing = await findUserMfaRow(scopedDb, scopedUser);
106
+ if (existing) return mfaAlreadyEnabled();
107
+
108
+ const result = await executor.create(
109
+ {
110
+ userId,
111
+ totpSecret: verify.payload.totpSecretBase32,
112
+ recoveryCodes: encodeRecoveryCodes(verify.payload.recoveryCodeHashes),
113
+ enabledAt: Temporal.Now.instant(),
114
+ lastUsedAt: null,
115
+ },
116
+ scopedUser,
117
+ scopedDb,
118
+ );
119
+ if (!result.isSuccess) return result;
120
+
121
+ if (ctx.redis) {
122
+ await clearMfaVerifyAttempts(ctx.redis, userId);
123
+ }
124
+
125
+ // No currentSid to exclude — this is the first session for this
126
+ // login, not a follow-up on an already-running one.
127
+ if (opts.revokeAllOtherSessions) {
128
+ await opts.revokeAllOtherSessions(userId, undefined);
129
+ }
130
+
131
+ // Re-derive the full session the way verify.write.ts does — the
132
+ // setupToken only proves "password + new TOTP secret", not roles;
133
+ // roles come from a fresh membership lookup, same as every other
134
+ // login-completing handler.
135
+ const systemUser = createSystemUser(tenantId, ["SystemAdmin"]);
136
+ const userRow = (await ctx.queryAs(systemUser, UserQueries.findForAuth, {
137
+ id: userId,
138
+ })) as { roles?: string | null; status?: string } | null; // @cast-boundary engine-payload
139
+
140
+ if (!userRow) return invalidSetupToken();
141
+ if (
142
+ userRow?.status === USER_STATUS.Restricted ||
143
+ userRow?.status === USER_STATUS.DeletionRequested ||
144
+ userRow?.status === USER_STATUS.Deleted
145
+ ) {
146
+ return invalidSetupToken();
147
+ }
148
+
149
+ const globalRoles = parseRoles(userRow?.roles ?? null);
150
+
151
+ const memberships = (await ctx.queryAs(systemUser, "tenant:query:memberships", {
152
+ userId,
153
+ })) as ReadonlyArray<{ tenantId: string; roles: readonly string[] }>; // @cast-boundary engine-payload
154
+ const membership = memberships.find((m) => m.tenantId === tenantId);
155
+ if (!membership) return invalidSetupToken();
156
+ // buildSessionRoles calls stripForbiddenMembershipRoles to strip reserved
157
+ // roles from the membership portion (globalRoles keeps SystemAdmin) —
158
+ // read-time backstop against a rebuild-resurrected role, same as
159
+ // verify.write.ts.
160
+ const mergedRoles = buildSessionRoles(globalRoles, membership.roles);
161
+
162
+ const baseSession: SessionUser = { id: userId, tenantId, roles: mergedRoles };
163
+ const claims = await ctx.resolveAuthClaims(baseSession);
164
+ const session: SessionUser =
165
+ Object.keys(claims).length > 0 ? { ...baseSession, claims } : baseSession;
166
+
167
+ return { isSuccess: true, data: { kind: "mfa-preauth-confirm-success", session } };
168
+ },
169
+ });
170
+ }