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