@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.
- package/package.json +6 -6
- package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +168 -0
- package/src/auth-email-password/constants.ts +6 -0
- package/src/auth-email-password/feature.ts +8 -1
- package/src/auth-email-password/handlers/self-registration-status.query.ts +20 -0
- package/src/auth-email-password/handlers/signup-request.write.ts +11 -0
- package/src/auth-email-password/index.ts +5 -1
- package/src/auth-email-password/self-registration-toggle.ts +25 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +79 -0
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +2 -2
- package/src/auth-email-password/web/auth-gate.tsx +44 -2
- package/src/auth-email-password/web/login-screen.tsx +8 -5
- package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +253 -0
- package/src/auth-mfa/__tests__/enable-start-preauth.integration.test.ts +214 -0
- package/src/auth-mfa/constants.ts +2 -0
- package/src/auth-mfa/feature.ts +15 -0
- package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +170 -0
- package/src/auth-mfa/handlers/enable-start-preauth.write.ts +87 -0
- package/src/auth-mfa/mfa-setup-token.ts +7 -1
- package/src/auth-mfa/web/__tests__/mfa-client.test.ts +144 -1
- package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +202 -0
- package/src/auth-mfa/web/i18n.ts +14 -0
- package/src/auth-mfa/web/index.ts +8 -2
- package/src/auth-mfa/web/mfa-client.ts +113 -0
- package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +244 -0
- package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +96 -0
- package/src/feature-toggles/compose-tier-resolver.ts +41 -0
- package/src/feature-toggles/index.ts +1 -0
- 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",
|
package/src/auth-mfa/feature.ts
CHANGED
|
@@ -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
|
+
}
|