@cosmicdrift/kumiko-bundled-features 0.165.0 → 0.165.2
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 +10 -6
- package/src/__tests__/access-invalidation-consumer.integration.test.ts +279 -0
- package/src/__tests__/access-invalidation-stream.integration.test.ts +205 -0
- package/src/audit/__tests__/audit.integration.test.ts +52 -21
- package/src/audit/handlers/list.query.ts +1 -0
- package/src/auth-email-password/__tests__/auth.integration.test.ts +6 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +34 -0
- package/src/auth-email-password/__tests__/login-gates.test.ts +39 -0
- package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +9 -14
- package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +16 -5
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +54 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -1
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -1
- package/src/auth-email-password/handlers/login.write.ts +196 -155
- package/src/auth-email-password/handlers/self-registration-status.query.ts +6 -3
- package/src/auth-email-password/handlers/signup-request.write.ts +12 -1
- package/src/auth-email-password/i18n.ts +2 -0
- package/src/auth-email-password/seeding.ts +41 -23
- package/src/auth-email-password/web/__tests__/auth-client.test.ts +13 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +55 -8
- package/src/auth-email-password/web/__tests__/client-plugin.test.tsx +34 -0
- package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +8 -1
- package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +5 -1
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +3 -0
- package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +8 -2
- package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +5 -1
- package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +5 -1
- package/src/auth-email-password/web/auth-client.ts +32 -10
- package/src/auth-email-password/web/auth-gate.tsx +17 -21
- package/src/auth-email-password/web/client-plugin.ts +15 -2
- package/src/auth-email-password/web/index.ts +5 -1
- package/src/auth-email-password/web/login-screen.tsx +7 -3
- package/src/auth-email-password/web/request-account-unlock-screen.tsx +1 -1
- package/src/auth-foundation/__tests__/session-store.test.ts +2 -4
- package/src/auth-foundation/__tests__/tenant-providers.test.ts +24 -0
- package/src/auth-foundation/__tests__/token-verifier.test.ts +36 -3
- package/src/auth-foundation/boot-checks.ts +74 -69
- package/src/auth-foundation/resolve-anonymous-access.ts +13 -5
- package/src/auth-foundation/types.ts +3 -3
- package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +61 -1
- package/src/auth-mfa/__tests__/no-redis.integration.test.ts +209 -0
- package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +157 -4
- package/src/auth-mfa/handlers/disable.write.ts +16 -1
- package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +72 -42
- package/src/auth-mfa/handlers/enable-confirm.write.ts +23 -12
- package/src/auth-mfa/handlers/reencrypt.job.ts +26 -17
- package/src/auth-mfa/handlers/regenerate-recovery.write.ts +15 -1
- package/src/auth-mfa/handlers/verify.write.ts +28 -24
- package/src/auth-mfa/web/__tests__/mfa-client.test.ts +5 -1
- package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +10 -1
- package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +104 -1
- package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +5 -1
- package/src/auth-mfa/web/i18n.ts +4 -0
- package/src/auth-mfa/web/mfa-client.ts +13 -2
- package/src/auth-mfa/web/mfa-enable-screen.tsx +1 -1
- package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +25 -2
- package/src/billing-foundation/__tests__/subscription-provider-contract.ts +36 -12
- package/src/config/__tests__/backing-secrets.integration.test.ts +4 -0
- package/src/config/__tests__/cascade.integration.test.ts +13 -2
- package/src/config/__tests__/pii-encrypted.integration.test.ts +6 -6
- package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +78 -5
- package/src/config/db/queries/resolver.ts +3 -3
- package/src/config/handlers/reencrypt.job.ts +36 -24
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +14 -0
- package/src/document-ingest-foundation/__tests__/feature.test.ts +38 -0
- package/src/document-ingest-foundation/__tests__/pages.test.ts +37 -0
- package/src/document-ingest-foundation/entity.ts +10 -3
- package/src/document-ingest-foundation/feature.ts +26 -8
- package/src/document-ingest-foundation/index.ts +1 -0
- package/src/document-ingest-foundation/pages.ts +34 -0
- package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +114 -11
- package/src/feature-toggles/compose-tier-resolver.ts +77 -8
- package/src/feature-toggles/global-feature-state-table.ts +15 -12
- package/src/files-provider-s3/s3-provider.ts +12 -1
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +78 -1
- package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +58 -40
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +9 -1
- package/src/inbound-mail-foundation/entities.ts +3 -3
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +22 -12
- package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +1 -1
- package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +35 -8
- package/src/inbound-provider-inmemory/__tests__/feature.test.ts +37 -33
- package/src/jobs/__tests__/job-instant.test.ts +15 -0
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +19 -9
- package/src/jobs/feature.ts +6 -5
- package/src/jobs/job-instant.ts +9 -0
- package/src/mail-foundation/__tests__/mail-transport-contract.ts +27 -11
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +10 -6
- package/src/mail-transport-smtp/__tests__/feature.test.ts +10 -5
- package/src/page-render/__tests__/markdown.test.ts +29 -0
- package/src/page-render/markdown.ts +1 -9
- package/src/personal-access-tokens/feature.ts +3 -3
- package/src/personal-access-tokens/schema/api-token.ts +3 -3
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +3 -1
- package/src/sessions/__tests__/sessions-screens.boot.test.ts +5 -10
- package/src/sessions/__tests__/sessions.integration.test.ts +88 -1
- package/src/sessions/feature.ts +21 -6
- package/src/sessions/handlers/revoke-all-for-user.write.ts +51 -12
- package/src/sessions/handlers/revoke-all-others.write.ts +19 -1
- package/src/sessions/handlers/revoke.write.ts +13 -0
- package/src/sessions/index.ts +7 -0
- package/src/sessions/schema/user-session.ts +6 -7
- package/src/sessions/session-callbacks.ts +58 -5
- package/src/sessions/session-revoked-event.ts +29 -0
- package/src/shared/__tests__/chunked-entity-migration.test.ts +4 -4
- package/src/shared/chunked-entity-migration.ts +2 -2
- package/src/shared/grace-period.test.ts +26 -0
- package/src/shared/grace-period.ts +20 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/map-with-concurrency.test.ts +104 -0
- package/src/shared/map-with-concurrency.ts +38 -0
- package/src/subscription-mollie/__tests__/feature.test.ts +16 -6
- package/src/subscription-mollie/__tests__/verify-webhook.test.ts +21 -0
- package/src/subscription-mollie/verify-webhook.ts +1 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +14 -7
- package/src/subscription-stripe/__tests__/verify-webhook.test.ts +20 -0
- package/src/subscription-stripe/verify-webhook.ts +3 -2
- package/src/tenant/__tests__/members-query-kms.integration.test.ts +2 -2
- package/src/tenant/__tests__/multi-tenant.integration.test.ts +1 -1
- package/src/tenant/handlers/active-tenant-ids.query.ts +8 -2
- package/src/tenant/handlers/invitations.query.ts +13 -15
- package/src/tenant/handlers/members.query.ts +12 -12
- package/src/tenant/seeding.ts +110 -16
- package/src/tenant-lifecycle/boot-checks.ts +11 -4
- package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +2 -6
- package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +94 -0
- package/src/tenant-settings/config.ts +44 -0
- package/src/tenant-settings/constants.ts +16 -0
- package/src/tenant-settings/feature.ts +49 -0
- package/src/tenant-settings/index.ts +8 -0
- package/src/tenant-settings/tenant-defaults.ts +72 -0
- package/src/text-content/__tests__/seed-legal-content.integration.test.ts +33 -6
- package/src/text-content/handlers/set.write.ts +2 -2
- package/src/text-content/seeding.ts +31 -29
- package/src/user/handlers/find-for-auth.query.ts +1 -1
- package/src/user/seeding.ts +8 -0
- package/src/user-data-rights/COMPLIANCE.md +1 -1
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +65 -0
- package/src/user-data-rights/__tests__/boot-checks.test.ts +15 -17
- package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +9 -5
- package/src/user-data-rights/__tests__/download.integration.test.ts +25 -41
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +7 -10
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +15 -1
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +140 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +9 -4
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +74 -8
- package/src/user-data-rights/db/queries/__tests__/forget-cleanup.integration.test.ts +69 -0
- package/src/user-data-rights/db/queries/forget-cleanup.ts +9 -2
- package/src/user-data-rights/feature.ts +45 -45
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +6 -11
- package/src/user-data-rights/handlers/download-by-job.query.ts +6 -6
- package/src/user-data-rights/handlers/download-by-token.query.ts +6 -6
- package/src/user-data-rights/handlers/lift-restriction.write.ts +33 -2
- package/src/user-data-rights/handlers/list-download-attempts.query.ts +1 -0
- package/src/user-data-rights/handlers/my-audit-log.query.ts +1 -0
- package/src/user-data-rights/handlers/restrict-account.write.ts +33 -4
- package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +2 -0
- package/src/user-data-rights/lib/is-admin-actor.ts +19 -0
- package/src/user-data-rights/run-forget-cleanup.ts +27 -7
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// #1467: verify / enable-confirm / enable-confirm-preauth used to silently
|
|
2
|
+
// skip their brute-force cap and single-use burn when ctx.redis was
|
|
3
|
+
// unavailable, instead of failing closed. Real HTTP through setupTestStack
|
|
4
|
+
// with redis explicitly unwired (same pattern as
|
|
5
|
+
// auth-email-password/__tests__/account-lockout-no-redis.integration.test.ts),
|
|
6
|
+
// asserting each handler now returns an internal_error instead of silently
|
|
7
|
+
// degrading and continuing to the TOTP check.
|
|
8
|
+
|
|
9
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
10
|
+
import { randomBytes } from "node:crypto";
|
|
11
|
+
import {
|
|
12
|
+
configureEntityFieldEncryption,
|
|
13
|
+
createEventStoreExecutor,
|
|
14
|
+
createTenantDb,
|
|
15
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
16
|
+
import {
|
|
17
|
+
type SessionUser,
|
|
18
|
+
SYSTEM_TENANT_ID,
|
|
19
|
+
type TenantId,
|
|
20
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
21
|
+
import {
|
|
22
|
+
createTestUser,
|
|
23
|
+
setupTestStack,
|
|
24
|
+
type TestStack,
|
|
25
|
+
testTenantId,
|
|
26
|
+
unsafeCreateEntityTable,
|
|
27
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
28
|
+
import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
|
|
29
|
+
import { Temporal } from "temporal-polyfill";
|
|
30
|
+
import { createConfigFeature } from "../../config";
|
|
31
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
32
|
+
import { createTenantFeature } from "../../tenant";
|
|
33
|
+
import { createUserFeature } from "../../user/feature";
|
|
34
|
+
import { base32Encode } from "../base32";
|
|
35
|
+
import { AuthMfaHandlers } from "../constants";
|
|
36
|
+
import { createAuthMfaFeature } from "../feature";
|
|
37
|
+
import { signMfaChallengeToken } from "../mfa-challenge-token";
|
|
38
|
+
import { signMfaSetupToken } from "../mfa-setup-token";
|
|
39
|
+
import { encodeRecoveryCodes, userMfaEntity, userMfaTable } from "../schema/user-mfa";
|
|
40
|
+
import { generateTotpSecret } from "../totp";
|
|
41
|
+
|
|
42
|
+
let stack: TestStack;
|
|
43
|
+
|
|
44
|
+
const CHALLENGE_TOKEN_SECRET = "no-redis-test-challenge-token-secret-not-real-0001";
|
|
45
|
+
const SETUP_TOKEN_SECRET = "no-redis-test-setup-token-secret-not-real-0002";
|
|
46
|
+
const TENANT_ID: TenantId = testTenantId(421);
|
|
47
|
+
|
|
48
|
+
// Mirrors framework's auth-routes.ts GUEST_USER — verify/enable-confirm-preauth
|
|
49
|
+
// are guest-callable (access: { roles: ["all"] }), the real identity comes
|
|
50
|
+
// entirely from the signed token, not from this dispatch-level user.
|
|
51
|
+
const GUEST_USER: SessionUser = {
|
|
52
|
+
id: "00000000-0000-0000-0000-000000000000",
|
|
53
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
54
|
+
roles: ["all"],
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
beforeAll(async () => {
|
|
58
|
+
const encryption = createTestEnvelopeCipher(randomBytes(32).toString("base64"));
|
|
59
|
+
configureEntityFieldEncryption(encryption);
|
|
60
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
61
|
+
|
|
62
|
+
stack = await setupTestStack({
|
|
63
|
+
features: [
|
|
64
|
+
createConfigFeature(),
|
|
65
|
+
createUserFeature(),
|
|
66
|
+
createTenantFeature(),
|
|
67
|
+
createAuthMfaFeature({
|
|
68
|
+
setupTokenSecret: SETUP_TOKEN_SECRET,
|
|
69
|
+
issuer: "Kumiko Test",
|
|
70
|
+
challengeTokenSecret: CHALLENGE_TOKEN_SECRET,
|
|
71
|
+
}),
|
|
72
|
+
],
|
|
73
|
+
// extraContext runs after the default `redis: testRedis.redis` spread —
|
|
74
|
+
// setting redis:undefined here overrides it on the handler-facing
|
|
75
|
+
// AppContext (see account-lockout-no-redis.integration.test.ts).
|
|
76
|
+
extraContext: () => ({
|
|
77
|
+
configResolver: resolver,
|
|
78
|
+
configEncryption: encryption,
|
|
79
|
+
redis: undefined,
|
|
80
|
+
}),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
await unsafeCreateEntityTable(stack.db, userMfaEntity);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
afterAll(async () => {
|
|
87
|
+
await stack.cleanup();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// disable/regenerate-recovery are session-authed and only reachable once
|
|
91
|
+
// MFA is already enabled — but enable-confirm itself now requires
|
|
92
|
+
// ctx.redis (#1467), so it can't run against this no-redis stack to seed
|
|
93
|
+
// the row. Insert the row directly via the same executor.create the
|
|
94
|
+
// handler uses, bypassing the HTTP layer entirely (irrelevant to what
|
|
95
|
+
// this test is pinning: the handler's own ctx.redis gate).
|
|
96
|
+
const userMfaExecutor = createEventStoreExecutor(userMfaTable, userMfaEntity, {
|
|
97
|
+
entityName: "user-mfa",
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
async function seedEnabledMfa(user: SessionUser): Promise<void> {
|
|
101
|
+
const tdb = createTenantDb(stack.db, user.tenantId, "system");
|
|
102
|
+
const result = await userMfaExecutor.create(
|
|
103
|
+
{
|
|
104
|
+
userId: user.id,
|
|
105
|
+
totpSecret: base32Encode(generateTotpSecret()),
|
|
106
|
+
recoveryCodes: encodeRecoveryCodes([]),
|
|
107
|
+
enabledAt: Temporal.Now.instant(),
|
|
108
|
+
lastUsedAt: null,
|
|
109
|
+
},
|
|
110
|
+
user,
|
|
111
|
+
tdb,
|
|
112
|
+
);
|
|
113
|
+
if (!result.isSuccess) throw new Error(`seedEnabledMfa failed: ${result.error.code}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
describe("auth-mfa handlers — ctx.redis unset (#1467)", () => {
|
|
117
|
+
test("verify: fails closed with internal_error instead of skipping the brute-force cap/burn", async () => {
|
|
118
|
+
const user = createTestUser({ tenantId: TENANT_ID });
|
|
119
|
+
const { token } = signMfaChallengeToken(
|
|
120
|
+
{ userId: user.id, tenantId: TENANT_ID },
|
|
121
|
+
5,
|
|
122
|
+
CHALLENGE_TOKEN_SECRET,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const err = await stack.http.writeErr(
|
|
126
|
+
AuthMfaHandlers.verify,
|
|
127
|
+
{ challengeToken: token, code: "123456" },
|
|
128
|
+
GUEST_USER,
|
|
129
|
+
);
|
|
130
|
+
expect(err.httpStatus).toBeGreaterThanOrEqual(500);
|
|
131
|
+
expect(err.code).toBe("internal_error");
|
|
132
|
+
expect(err.message).toContain("requires ctx.redis");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("enable-confirm-preauth: fails closed with internal_error instead of skipping the brute-force cap/burn", async () => {
|
|
136
|
+
const user = createTestUser({ tenantId: TENANT_ID });
|
|
137
|
+
const { token } = signMfaSetupToken(
|
|
138
|
+
{
|
|
139
|
+
userId: user.id,
|
|
140
|
+
tenantId: TENANT_ID,
|
|
141
|
+
totpSecretBase32: base32Encode(generateTotpSecret()),
|
|
142
|
+
recoveryCodeHashes: [],
|
|
143
|
+
},
|
|
144
|
+
5,
|
|
145
|
+
SETUP_TOKEN_SECRET,
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const err = await stack.http.writeErr(
|
|
149
|
+
AuthMfaHandlers.enableConfirmPreauth,
|
|
150
|
+
{ setupToken: token, code: "123456" },
|
|
151
|
+
GUEST_USER,
|
|
152
|
+
);
|
|
153
|
+
expect(err.httpStatus).toBeGreaterThanOrEqual(500);
|
|
154
|
+
expect(err.code).toBe("internal_error");
|
|
155
|
+
expect(err.message).toContain("requires ctx.redis");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("enable-confirm: fails closed with internal_error instead of skipping the single-use burn", async () => {
|
|
159
|
+
const user = createTestUser({ tenantId: TENANT_ID });
|
|
160
|
+
const { token } = signMfaSetupToken(
|
|
161
|
+
{
|
|
162
|
+
userId: user.id,
|
|
163
|
+
totpSecretBase32: base32Encode(generateTotpSecret()),
|
|
164
|
+
recoveryCodeHashes: [],
|
|
165
|
+
},
|
|
166
|
+
5,
|
|
167
|
+
SETUP_TOKEN_SECRET,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const err = await stack.http.writeErr(
|
|
171
|
+
AuthMfaHandlers.enableConfirm,
|
|
172
|
+
{ setupToken: token, code: "123456" },
|
|
173
|
+
user,
|
|
174
|
+
);
|
|
175
|
+
expect(err.httpStatus).toBeGreaterThanOrEqual(500);
|
|
176
|
+
expect(err.code).toBe("internal_error");
|
|
177
|
+
expect(err.message).toContain("requires ctx.redis");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// kumiko-framework#1534: disable/regenerate-recovery used to keep the
|
|
181
|
+
// old two-factor-authenticate-then-verify flow working even without
|
|
182
|
+
// ctx.redis (verifyMfaFactor's replay-burn silently no-ops), so an
|
|
183
|
+
// observed TOTP code could be replayed within the ±1-step window against
|
|
184
|
+
// a session-authed route — unlike verify/enable-confirm-preauth, no
|
|
185
|
+
// guest-token expiry limits the attack window.
|
|
186
|
+
test("disable: fails closed with internal_error instead of skipping TOTP replay protection", async () => {
|
|
187
|
+
const user = createTestUser({ id: 501, tenantId: TENANT_ID });
|
|
188
|
+
await seedEnabledMfa(user);
|
|
189
|
+
|
|
190
|
+
const err = await stack.http.writeErr(AuthMfaHandlers.disable, { code: "123456" }, user);
|
|
191
|
+
expect(err.httpStatus).toBeGreaterThanOrEqual(500);
|
|
192
|
+
expect(err.code).toBe("internal_error");
|
|
193
|
+
expect(err.message).toContain("requires ctx.redis");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("regenerate-recovery: fails closed with internal_error instead of skipping TOTP replay protection", async () => {
|
|
197
|
+
const user = createTestUser({ id: 502, tenantId: TENANT_ID });
|
|
198
|
+
await seedEnabledMfa(user);
|
|
199
|
+
|
|
200
|
+
const err = await stack.http.writeErr(
|
|
201
|
+
AuthMfaHandlers.regenerateRecovery,
|
|
202
|
+
{ code: "123456" },
|
|
203
|
+
user,
|
|
204
|
+
);
|
|
205
|
+
expect(err.httpStatus).toBeGreaterThanOrEqual(500);
|
|
206
|
+
expect(err.code).toBe("internal_error");
|
|
207
|
+
expect(err.message).toContain("requires ctx.redis");
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
15
15
|
import { randomBytes } from "node:crypto";
|
|
16
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
16
|
+
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
17
17
|
import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
|
|
18
18
|
import type { AppContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
19
19
|
import { rebuildProjection } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
@@ -84,10 +84,16 @@ afterAll(async () => {
|
|
|
84
84
|
await stack.cleanup();
|
|
85
85
|
});
|
|
86
86
|
|
|
87
|
-
type RawUserMfaRow = {
|
|
87
|
+
type RawUserMfaRow = {
|
|
88
|
+
id: string;
|
|
89
|
+
userId: string;
|
|
90
|
+
totpSecret: string;
|
|
91
|
+
recoveryCodes: string;
|
|
92
|
+
version: number;
|
|
93
|
+
};
|
|
88
94
|
|
|
89
|
-
async function readRawRow(): Promise<RawUserMfaRow> {
|
|
90
|
-
const rows = await selectMany<RawUserMfaRow>(stack.db, userMfaTable, {});
|
|
95
|
+
async function readRawRow(userId?: string): Promise<RawUserMfaRow> {
|
|
96
|
+
const rows = await selectMany<RawUserMfaRow>(stack.db, userMfaTable, userId ? { userId } : {});
|
|
91
97
|
const row = rows[0];
|
|
92
98
|
if (!row) throw new Error("no user-mfa row");
|
|
93
99
|
return row;
|
|
@@ -167,3 +173,150 @@ describe("auth-mfa KEK-rotation job — kumiko-framework#266 Step 8", () => {
|
|
|
167
173
|
await stack.http.writeOk(AuthMfaHandlers.disable, { code: currentTotpCode(secret) }, user);
|
|
168
174
|
});
|
|
169
175
|
});
|
|
176
|
+
|
|
177
|
+
describe("auth-mfa KEK-rotation job — unrecognized values are not silently skipped (kumiko-framework#1541)", () => {
|
|
178
|
+
test("a non-envelope totpSecret is counted failed, never alreadyCurrent, and is left untouched", async () => {
|
|
179
|
+
// Reset provider to V1 so a freshly-enabled row is "current" (nothing to
|
|
180
|
+
// rotate) — the garbage field is the only failure we expect.
|
|
181
|
+
mutableProvider.replace(
|
|
182
|
+
createEnvMasterKeyProvider({
|
|
183
|
+
env: {
|
|
184
|
+
KUMIKO_SECRETS_MASTER_KEY_V1: v1Key,
|
|
185
|
+
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
|
|
186
|
+
},
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const user = createTestUser({ id: 2, roles: ["User"] });
|
|
191
|
+
const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
|
|
192
|
+
AuthMfaHandlers.enableStart,
|
|
193
|
+
{ accountLabel: "user-2@example.com" },
|
|
194
|
+
user,
|
|
195
|
+
);
|
|
196
|
+
const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
|
|
197
|
+
const secret = base32Decode(secretParam);
|
|
198
|
+
await stack.http.writeOk(
|
|
199
|
+
AuthMfaHandlers.enableConfirm,
|
|
200
|
+
{ setupToken: start.setupToken, code: currentTotpCode(secret) },
|
|
201
|
+
user,
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const beforeRow = await readRawRow(user.id);
|
|
205
|
+
const garbage = "not-a-stored-envelope";
|
|
206
|
+
await asRawClient(stack.db).unsafe(`UPDATE read_user_mfa SET totp_secret = $1 WHERE id = $2`, [
|
|
207
|
+
garbage,
|
|
208
|
+
beforeRow.id,
|
|
209
|
+
]);
|
|
210
|
+
|
|
211
|
+
type CapturedLog = { info: string[]; warn: string[] };
|
|
212
|
+
const captured: CapturedLog = { info: [], warn: [] };
|
|
213
|
+
const capturingLog = {
|
|
214
|
+
info: (msg: string) => {
|
|
215
|
+
captured.info.push(msg);
|
|
216
|
+
},
|
|
217
|
+
warn: (msg: string) => {
|
|
218
|
+
captured.warn.push(msg);
|
|
219
|
+
},
|
|
220
|
+
error: () => {},
|
|
221
|
+
debug: () => {},
|
|
222
|
+
child: () => capturingLog,
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
await mfaReencryptJob({}, {
|
|
226
|
+
db: stack.db,
|
|
227
|
+
registry: stack.registry,
|
|
228
|
+
masterKeyProvider: mutableProvider,
|
|
229
|
+
log: capturingLog,
|
|
230
|
+
} as AppContext);
|
|
231
|
+
|
|
232
|
+
const completeLine = captured.info.find((line) =>
|
|
233
|
+
line.includes("[auth-mfa:reencrypt] complete:"),
|
|
234
|
+
);
|
|
235
|
+
if (!completeLine) throw new Error("job did not log a completion summary");
|
|
236
|
+
const result = JSON.parse(completeLine.slice(completeLine.indexOf("{"))) as {
|
|
237
|
+
migrated: number;
|
|
238
|
+
failed: number;
|
|
239
|
+
alreadyCurrent: number;
|
|
240
|
+
};
|
|
241
|
+
expect(result.migrated).toBe(0);
|
|
242
|
+
expect(result.failed).toBe(1);
|
|
243
|
+
expect(result.alreadyCurrent).toBe(0);
|
|
244
|
+
|
|
245
|
+
const rejectedWarning = captured.warn.find((line) =>
|
|
246
|
+
line.includes("not a current-cipher envelope"),
|
|
247
|
+
);
|
|
248
|
+
expect(rejectedWarning).toBeDefined();
|
|
249
|
+
|
|
250
|
+
const afterRow = await readRawRow(user.id);
|
|
251
|
+
expect(afterRow.totpSecret).toBe(garbage);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("a non-envelope recoveryCodes is counted failed, never alreadyCurrent, and is left untouched", async () => {
|
|
255
|
+
mutableProvider.replace(
|
|
256
|
+
createEnvMasterKeyProvider({
|
|
257
|
+
env: {
|
|
258
|
+
KUMIKO_SECRETS_MASTER_KEY_V1: v1Key,
|
|
259
|
+
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
|
|
260
|
+
},
|
|
261
|
+
}),
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
const user = createTestUser({ id: 3, roles: ["User"] });
|
|
265
|
+
const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
|
|
266
|
+
AuthMfaHandlers.enableStart,
|
|
267
|
+
{ accountLabel: "user-3@example.com" },
|
|
268
|
+
user,
|
|
269
|
+
);
|
|
270
|
+
const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
|
|
271
|
+
const secret = base32Decode(secretParam);
|
|
272
|
+
await stack.http.writeOk(
|
|
273
|
+
AuthMfaHandlers.enableConfirm,
|
|
274
|
+
{ setupToken: start.setupToken, code: currentTotpCode(secret) },
|
|
275
|
+
user,
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
const beforeRow = await readRawRow(user.id);
|
|
279
|
+
const garbage = "not-a-stored-envelope";
|
|
280
|
+
await asRawClient(stack.db).unsafe(
|
|
281
|
+
`UPDATE read_user_mfa SET recovery_codes = $1 WHERE id = $2`,
|
|
282
|
+
[garbage, beforeRow.id],
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
type CapturedLog = { info: string[]; warn: string[] };
|
|
286
|
+
const captured: CapturedLog = { info: [], warn: [] };
|
|
287
|
+
const capturingLog = {
|
|
288
|
+
info: (msg: string) => {
|
|
289
|
+
captured.info.push(msg);
|
|
290
|
+
},
|
|
291
|
+
warn: (msg: string) => {
|
|
292
|
+
captured.warn.push(msg);
|
|
293
|
+
},
|
|
294
|
+
error: () => {},
|
|
295
|
+
debug: () => {},
|
|
296
|
+
child: () => capturingLog,
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
await mfaReencryptJob({}, {
|
|
300
|
+
db: stack.db,
|
|
301
|
+
registry: stack.registry,
|
|
302
|
+
masterKeyProvider: mutableProvider,
|
|
303
|
+
log: capturingLog,
|
|
304
|
+
} as AppContext);
|
|
305
|
+
|
|
306
|
+
const completeLine = captured.info.find((line) =>
|
|
307
|
+
line.includes("[auth-mfa:reencrypt] complete:"),
|
|
308
|
+
);
|
|
309
|
+
if (!completeLine) throw new Error("job did not log a completion summary");
|
|
310
|
+
const result = JSON.parse(completeLine.slice(completeLine.indexOf("{"))) as {
|
|
311
|
+
migrated: number;
|
|
312
|
+
failed: number;
|
|
313
|
+
alreadyCurrent: number;
|
|
314
|
+
};
|
|
315
|
+
expect(result.migrated).toBe(0);
|
|
316
|
+
expect(result.failed).toBeGreaterThanOrEqual(1);
|
|
317
|
+
expect(result.alreadyCurrent).toBe(0);
|
|
318
|
+
|
|
319
|
+
const afterRow = await readRawRow(user.id);
|
|
320
|
+
expect(afterRow.recoveryCodes).toBe(garbage);
|
|
321
|
+
});
|
|
322
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import { findUserMfaRow } from "../db/queries";
|
|
5
6
|
import { invalidTotpCode, mfaNotEnabled } from "../errors";
|
|
@@ -30,7 +31,21 @@ export function createDisableHandler(opts: DisableOptions) {
|
|
|
30
31
|
const row = await findUserMfaRow(ctx.db, event.user);
|
|
31
32
|
if (!row) return mfaNotEnabled();
|
|
32
33
|
|
|
33
|
-
|
|
34
|
+
// Fail closed, matching verify.write.ts/enable-confirm-preauth.write.ts
|
|
35
|
+
// (#1467): without ctx.redis, verifyMfaFactor's TOTP-replay burn is
|
|
36
|
+
// skipped, and a code observed once (phishing proxy/shoulder-surfing)
|
|
37
|
+
// could be replayed within the ±1-step window to disable MFA outright.
|
|
38
|
+
// "no ctx.redis means no one gets through MFA login" doesn't cover
|
|
39
|
+
// this route — it's session-authed, and JWTs issued before Redis was
|
|
40
|
+
// unwired stay valid until they expire.
|
|
41
|
+
if (!ctx.redis) {
|
|
42
|
+
return writeFailure(
|
|
43
|
+
new InternalError({
|
|
44
|
+
message: "disable (mfa) requires ctx.redis for TOTP replay protection",
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
const replay = { redis: ctx.redis, userId: event.user.id };
|
|
34
49
|
const verify = await verifyMfaFactor(row, event.payload.code, replay);
|
|
35
50
|
if (!verify.ok) return invalidTotpCode();
|
|
36
51
|
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
defineWriteHandler,
|
|
6
6
|
type SessionUser,
|
|
7
7
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
8
9
|
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
9
10
|
import { Temporal } from "temporal-polyfill";
|
|
10
11
|
import { z } from "zod";
|
|
@@ -69,34 +70,45 @@ export function createEnableConfirmPreauthHandler(opts: EnableConfirmPreauthOpti
|
|
|
69
70
|
const { userId, tenantId } = verify.payload;
|
|
70
71
|
if (tenantId === undefined) return invalidSetupToken();
|
|
71
72
|
|
|
73
|
+
// Fail closed: unlike verify.write.ts/enable-confirm.write.ts, this
|
|
74
|
+
// endpoint has NO gate before it (access: { roles: ["all"] }, no JWT,
|
|
75
|
+
// no prior challenge-token from a successful login) — its only secret
|
|
76
|
+
// is a 6-digit TOTP code. Silently skipping the brute-force cap and
|
|
77
|
+
// single-use burn when ctx.redis is absent would let the setup token
|
|
78
|
+
// be guessed against unboundedly until its TTL expires. The route's
|
|
79
|
+
// IP rate limiter is not a substitute: it's IP-scoped and resets on
|
|
80
|
+
// every success.
|
|
81
|
+
if (!ctx.redis) {
|
|
82
|
+
return writeFailure(
|
|
83
|
+
new InternalError({
|
|
84
|
+
message:
|
|
85
|
+
"enable-confirm-preauth requires ctx.redis for the brute-force cap and single-use burn",
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
72
90
|
// Same guessing surface as /auth/mfa/verify (a short TOTP code) and
|
|
73
91
|
// the same per-userId cap — deliberately shared counter, see
|
|
74
92
|
// mfa-verify-attempts.ts.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
return tooManyAttempts(retryAfterSeconds);
|
|
82
|
-
}
|
|
93
|
+
const state = await getMfaVerifyLockoutState(ctx.redis, userId);
|
|
94
|
+
if (state?.lockedUntil !== null && state?.lockedUntil !== undefined) {
|
|
95
|
+
const now = Date.now();
|
|
96
|
+
if (state.lockedUntil > now) {
|
|
97
|
+
const retryAfterSeconds = Math.max(1, Math.ceil((state.lockedUntil - now) / 1000));
|
|
98
|
+
return tooManyAttempts(retryAfterSeconds);
|
|
83
99
|
}
|
|
84
100
|
}
|
|
85
101
|
|
|
86
102
|
const secret = base32Decode(verify.payload.totpSecretBase32);
|
|
87
103
|
if (verifyTotp(secret, event.payload.code) === false) {
|
|
88
|
-
|
|
89
|
-
await recordFailedMfaVerifyAttempt(ctx.redis, userId, maxAttempts, lockoutMinutes);
|
|
90
|
-
}
|
|
104
|
+
await recordFailedMfaVerifyAttempt(ctx.redis, userId, maxAttempts, lockoutMinutes);
|
|
91
105
|
return invalidTotpCode();
|
|
92
106
|
}
|
|
93
107
|
|
|
94
108
|
// Burn the setup token on the first successful confirm — same
|
|
95
109
|
// single-use guarantee as enable-confirm.write.ts.
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
if (burnResult === "already-used") return invalidSetupToken();
|
|
99
|
-
}
|
|
110
|
+
const burnResult = await burnToken(ctx.redis, "mfa-setup", userId, verify.expiresAtMs);
|
|
111
|
+
if (burnResult === "already-used") return invalidSetupToken();
|
|
100
112
|
|
|
101
113
|
// "system" mode: no session exists yet, tenantId comes from the
|
|
102
114
|
// verified token, mirroring enable-start-preauth.write.ts.
|
|
@@ -105,33 +117,20 @@ export function createEnableConfirmPreauthHandler(opts: EnableConfirmPreauthOpti
|
|
|
105
117
|
const existing = await findUserMfaRow(scopedDb, scopedUser);
|
|
106
118
|
if (existing) return mfaAlreadyEnabled();
|
|
107
119
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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.
|
|
120
|
+
// Status + membership must be verified BEFORE the entity-write below —
|
|
121
|
+
// an admin can restrict/delete the account or revoke its tenant
|
|
122
|
+
// membership in the window between preauth-enable-start and
|
|
123
|
+
// preauth-confirm (up to MFA_SETUP_TOKEN_TTL_MINUTES). Checking after
|
|
124
|
+
// the write left two irreversible side effects behind an
|
|
125
|
+
// already-signed, non-forgeable setupToken that then just returns
|
|
126
|
+
// invalidSetupToken(): the MFA row persisted and (formerly) all other
|
|
127
|
+
// sessions revoked — with mfaAlreadyEnabled() blocking any
|
|
128
|
+
// re-enrollment until an operator manually disables MFA. NOT fixed by
|
|
129
|
+
// this ordering: burnToken() above still runs before this check, so a
|
|
130
|
+
// mid-flight restricted/deleted user still burns their setup token
|
|
131
|
+
// (framework#1523/5) — moving the burn later would need care around
|
|
132
|
+
// the existing userId-unique-constraint backstop between the
|
|
133
|
+
// `existing` check above and executor.create() below.
|
|
135
134
|
const systemUser = createSystemUser(tenantId, ["SystemAdmin"]);
|
|
136
135
|
const userRow = (await ctx.queryAs(systemUser, UserQueries.findForAuth, {
|
|
137
136
|
id: userId,
|
|
@@ -153,6 +152,27 @@ export function createEnableConfirmPreauthHandler(opts: EnableConfirmPreauthOpti
|
|
|
153
152
|
})) as ReadonlyArray<{ tenantId: string; roles: readonly string[] }>; // @cast-boundary engine-payload
|
|
154
153
|
const membership = memberships.find((m) => m.tenantId === tenantId);
|
|
155
154
|
if (!membership) return invalidSetupToken();
|
|
155
|
+
|
|
156
|
+
const result = await executor.create(
|
|
157
|
+
{
|
|
158
|
+
userId,
|
|
159
|
+
totpSecret: verify.payload.totpSecretBase32,
|
|
160
|
+
recoveryCodes: encodeRecoveryCodes(verify.payload.recoveryCodeHashes),
|
|
161
|
+
enabledAt: Temporal.Now.instant(),
|
|
162
|
+
lastUsedAt: null,
|
|
163
|
+
},
|
|
164
|
+
scopedUser,
|
|
165
|
+
scopedDb,
|
|
166
|
+
);
|
|
167
|
+
if (!result.isSuccess) return result;
|
|
168
|
+
|
|
169
|
+
// ctx.redis is guaranteed by the fail-closed check above.
|
|
170
|
+
await clearMfaVerifyAttempts(ctx.redis, userId);
|
|
171
|
+
|
|
172
|
+
// Re-derive the full session the way verify.write.ts does — the
|
|
173
|
+
// setupToken only proves "password + new TOTP secret", not roles;
|
|
174
|
+
// roles come from the membership lookup above, same as every other
|
|
175
|
+
// login-completing handler.
|
|
156
176
|
// buildSessionRoles calls stripForbiddenMembershipRoles to strip reserved
|
|
157
177
|
// roles from the membership portion (globalRoles keeps SystemAdmin) —
|
|
158
178
|
// read-time backstop against a rebuild-resurrected role, same as
|
|
@@ -164,6 +184,16 @@ export function createEnableConfirmPreauthHandler(opts: EnableConfirmPreauthOpti
|
|
|
164
184
|
const session: SessionUser =
|
|
165
185
|
Object.keys(claims).length > 0 ? { ...baseSession, claims } : baseSession;
|
|
166
186
|
|
|
187
|
+
// Last step before returning success: every gate above (status,
|
|
188
|
+
// membership, entity-write) has passed, so this is the point of no
|
|
189
|
+
// return anyway — revoking here instead of right after the write
|
|
190
|
+
// means a rejected confirm never touches the user's other sessions.
|
|
191
|
+
// No currentSid to exclude — this is the first session for this
|
|
192
|
+
// login, not a follow-up on an already-running one.
|
|
193
|
+
if (opts.revokeAllOtherSessions) {
|
|
194
|
+
await opts.revokeAllOtherSessions(userId, undefined);
|
|
195
|
+
}
|
|
196
|
+
|
|
167
197
|
return { isSuccess: true, data: { kind: "mfa-preauth-confirm-success", session } };
|
|
168
198
|
},
|
|
169
199
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
4
|
import { Temporal } from "temporal-polyfill";
|
|
4
5
|
import { z } from "zod";
|
|
5
6
|
import { burnToken } from "../../shared";
|
|
@@ -41,6 +42,21 @@ export function createEnableConfirmHandler(opts: EnableConfirmOptions) {
|
|
|
41
42
|
// already rules that out).
|
|
42
43
|
if (verify.payload.userId !== event.user.id) return invalidSetupToken();
|
|
43
44
|
|
|
45
|
+
// Fail closed, matching verify.write.ts/enable-confirm-preauth.write.ts
|
|
46
|
+
// (#1467): session-authed (lower risk — the caller already has a
|
|
47
|
+
// valid JWT), but silently skipping the single-use burn here would
|
|
48
|
+
// still let a double-confirm race complete twice instead of the
|
|
49
|
+
// second seeing invalidSetupToken(). Checked before the TOTP verify
|
|
50
|
+
// for the same reason the other two handlers check it early: fail
|
|
51
|
+
// closed before doing any of the guarded work, not partway through.
|
|
52
|
+
if (!ctx.redis) {
|
|
53
|
+
return writeFailure(
|
|
54
|
+
new InternalError({
|
|
55
|
+
message: "enable-confirm requires ctx.redis for the single-use setup-token burn",
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
44
60
|
const secret = base32Decode(verify.payload.totpSecretBase32);
|
|
45
61
|
if (verifyTotp(secret, event.payload.code) === false) return invalidTotpCode();
|
|
46
62
|
|
|
@@ -51,18 +67,13 @@ export function createEnableConfirmHandler(opts: EnableConfirmOptions) {
|
|
|
51
67
|
// ponytail: burned here, not unburned on a later executor.create
|
|
52
68
|
// failure (unlike login.write.ts's unburnToken pattern) — self-service
|
|
53
69
|
// enable-start always mints a fresh setup token on demand (no mailed
|
|
54
|
-
// link to go stale), so the realistic failure path (
|
|
55
|
-
// race
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
event.user.id,
|
|
62
|
-
verify.expiresAtMs,
|
|
63
|
-
);
|
|
64
|
-
if (burnResult === "already-used") return invalidSetupToken();
|
|
65
|
-
}
|
|
70
|
+
// link to go stale), so the realistic remaining failure path (a
|
|
71
|
+
// double-confirm race where executor.create itself fails, e.g. a
|
|
72
|
+
// concurrent mfa_already_enabled() write) is meant to leave the burn
|
|
73
|
+
// standing. Add unburnToken here if create-failure retries turn out
|
|
74
|
+
// to matter.
|
|
75
|
+
const burnResult = await burnToken(ctx.redis, "mfa-setup", event.user.id, verify.expiresAtMs);
|
|
76
|
+
if (burnResult === "already-used") return invalidSetupToken();
|
|
66
77
|
|
|
67
78
|
const existing = await findUserMfaRow(ctx.db, event.user);
|
|
68
79
|
if (existing) return mfaAlreadyEnabled();
|