@cosmicdrift/kumiko-bundled-features 0.165.0 → 2.0.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 +10 -6
- package/src/__tests__/access-invalidation-consumer.integration.test.ts +279 -0
- package/src/__tests__/access-invalidation-stream.integration.test.ts +217 -0
- package/src/audit/__tests__/audit.integration.test.ts +50 -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__/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 +8 -0
- 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 +79 -1
- 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/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 +1 -1
- 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 +72 -1
- package/src/sessions/feature.ts +12 -0
- 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/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 +4 -6
- package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +115 -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 +67 -0
- 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 +12 -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 +120 -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 +8 -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
|
@@ -14,11 +14,14 @@
|
|
|
14
14
|
import type {
|
|
15
15
|
SessionChecker,
|
|
16
16
|
SessionCreator,
|
|
17
|
+
SessionMassRevoker,
|
|
17
18
|
SessionRevoker,
|
|
18
19
|
} from "@cosmicdrift/kumiko-framework/api";
|
|
19
20
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
20
21
|
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
21
22
|
|
|
23
|
+
export type { SessionMassRevoker };
|
|
24
|
+
|
|
22
25
|
export const EXT_TOKEN_VERIFIER = "tokenVerifier";
|
|
23
26
|
|
|
24
27
|
/**
|
|
@@ -83,9 +86,6 @@ export function tokenShapeKey(shape: TokenShape): string {
|
|
|
83
86
|
|
|
84
87
|
export const EXT_SESSION_STORE = "sessionStore";
|
|
85
88
|
|
|
86
|
-
/** Mass-revoke every live session for a user. Used by password-change and "sign out everywhere". */
|
|
87
|
-
export type SessionMassRevoker = (userId: string) => Promise<number>;
|
|
88
|
-
|
|
89
89
|
/** Keep current sid (if any); revoke every other live session. Used by auth-mfa. */
|
|
90
90
|
export type SessionRevokeAllOthers = (
|
|
91
91
|
userId: string,
|
|
@@ -37,11 +37,16 @@ import { userEntity, userTable } from "../../user/schema/user";
|
|
|
37
37
|
import { base32Decode } from "../base32";
|
|
38
38
|
import { mfaRequiredConfigHandle } from "../config";
|
|
39
39
|
import { AuthMfaHandlers } from "../constants";
|
|
40
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
bindMfaRevokeAllOtherSessionsFromFeature,
|
|
42
|
+
createAuthMfaFeature,
|
|
43
|
+
mfaStatusCheckerFromFeature,
|
|
44
|
+
} from "../feature";
|
|
41
45
|
import { userMfaEntity, userMfaTable } from "../schema/user-mfa";
|
|
42
46
|
import { currentTotpCode } from "../totp";
|
|
43
47
|
|
|
44
48
|
let stack: TestStack;
|
|
49
|
+
const revokedForUserIds: string[] = [];
|
|
45
50
|
|
|
46
51
|
const CHALLENGE_TOKEN_SECRET = "integration-test-challenge-token-secret-not-real-0001";
|
|
47
52
|
const SETUP_TOKEN_SECRET = "integration-test-setup-token-secret-not-real-0002";
|
|
@@ -84,6 +89,20 @@ beforeAll(async () => {
|
|
|
84
89
|
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
85
90
|
await unsafeCreateEntityTable(stack.db, userMfaEntity);
|
|
86
91
|
await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
92
|
+
|
|
93
|
+
// Wire a spy in place of the real session-revoke callback (run-prod-app
|
|
94
|
+
// only binds this once the sessions feature is mounted — not the case in
|
|
95
|
+
// this test stack) so #1467's regression can observe whether
|
|
96
|
+
// revokeAllOtherSessions actually fires, instead of silently no-op'ing.
|
|
97
|
+
const bind = bindMfaRevokeAllOtherSessionsFromFeature(authMfaFeature);
|
|
98
|
+
// If the binder ever returns undefined, the spy below never wires up and
|
|
99
|
+
// the #1467 regression check further down would pass vacuously (nothing
|
|
100
|
+
// to revoke because nothing observed) instead of catching a real re-break.
|
|
101
|
+
expect(bind).toBeDefined();
|
|
102
|
+
bind?.((userId) => {
|
|
103
|
+
revokedForUserIds.push(userId);
|
|
104
|
+
return Promise.resolve(0);
|
|
105
|
+
});
|
|
87
106
|
});
|
|
88
107
|
|
|
89
108
|
afterAll(async () => {
|
|
@@ -93,6 +112,7 @@ afterAll(async () => {
|
|
|
93
112
|
beforeEach(async () => {
|
|
94
113
|
await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
|
|
95
114
|
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
|
|
115
|
+
revokedForUserIds.length = 0;
|
|
96
116
|
});
|
|
97
117
|
|
|
98
118
|
async function loginBlockedByEnforcement(
|
|
@@ -159,6 +179,10 @@ describe("POST /auth/mfa/preauth-confirm", () => {
|
|
|
159
179
|
{ Authorization: `Bearer ${body.token}` },
|
|
160
180
|
);
|
|
161
181
|
expect(meRes.status).toBe(200);
|
|
182
|
+
|
|
183
|
+
// Positive control for the #1467 regression below — proves the spy
|
|
184
|
+
// wiring actually observes a real revoke call, not just silent no-ops.
|
|
185
|
+
expect(revokedForUserIds).toContain(body.user.id);
|
|
162
186
|
});
|
|
163
187
|
|
|
164
188
|
test("wrong TOTP code → invalid_totp_code, no enrollment persisted", async () => {
|
|
@@ -247,6 +271,42 @@ describe("POST /auth/mfa/preauth-confirm", () => {
|
|
|
247
271
|
expect(body.error.details.reason).toBe("invalid_setup_token");
|
|
248
272
|
});
|
|
249
273
|
|
|
274
|
+
// #1467: findForAuth's status check + membership lookup used to run AFTER
|
|
275
|
+
// the entity-write and the revokeAllOtherSessions call — an admin
|
|
276
|
+
// restricting/deleting the account between preauth-enable-start and
|
|
277
|
+
// preauth-confirm still got invalid_setup_token back correctly (the
|
|
278
|
+
// handler's whole DB transaction rolls back on a non-success return, so
|
|
279
|
+
// the MFA row itself never actually persisted), but revokeAllOtherSessions
|
|
280
|
+
// is an external side effect outside that transaction — it fired
|
|
281
|
+
// regardless, logging the user's other devices out over an enrollment
|
|
282
|
+
// attempt that was rejected. Regression: with status/membership checked
|
|
283
|
+
// BEFORE the write, the revoke call must not fire at all for this case.
|
|
284
|
+
test("user restricted between start and confirm → invalid_setup_token, no session revoked (#1467)", async () => {
|
|
285
|
+
const email = "restricted-midflight@example.com";
|
|
286
|
+
const { preauthSetupToken, userId } = await loginBlockedByEnforcement(email);
|
|
287
|
+
const { setupToken, secret } = await startEnrollment(preauthSetupToken, email);
|
|
288
|
+
|
|
289
|
+
await asRawClient(stack.db).unsafe(
|
|
290
|
+
`UPDATE "${userTable.tableName}" SET "status" = 'restricted' WHERE "id" = $1`,
|
|
291
|
+
[userId],
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
const res = await stack.http.raw("POST", "/api/auth/mfa/preauth-confirm", {
|
|
295
|
+
setupToken,
|
|
296
|
+
code: currentTotpCode(secret),
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
expect(res.status).not.toBe(200);
|
|
300
|
+
const body = await res.json();
|
|
301
|
+
expect(body.error.details.reason).toBe("invalid_setup_token");
|
|
302
|
+
expect(revokedForUserIds).not.toContain(userId);
|
|
303
|
+
// Enrollment write itself rolls back with the handler's failure return —
|
|
304
|
+
// pinned here too so a future change to that transactional behavior
|
|
305
|
+
// doesn't silently reintroduce the original "row persists" half of the bug.
|
|
306
|
+
const rows = await selectMany(stack.db, userMfaTable, { userId });
|
|
307
|
+
expect(rows).toHaveLength(0);
|
|
308
|
+
});
|
|
309
|
+
|
|
250
310
|
test("expired/garbage setupToken → invalid_setup_token, no session minted", async () => {
|
|
251
311
|
const res = await stack.http.raw("POST", "/api/auth/mfa/preauth-confirm", {
|
|
252
312
|
setupToken: "not-a-real-token",
|
|
@@ -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";
|
|
@@ -167,3 +167,81 @@ describe("auth-mfa KEK-rotation job — kumiko-framework#266 Step 8", () => {
|
|
|
167
167
|
await stack.http.writeOk(AuthMfaHandlers.disable, { code: currentTotpCode(secret) }, user);
|
|
168
168
|
});
|
|
169
169
|
});
|
|
170
|
+
|
|
171
|
+
describe("auth-mfa KEK-rotation job — unrecognized values are not silently skipped (kumiko-framework#1541)", () => {
|
|
172
|
+
test("a non-envelope totpSecret is counted failed, never alreadyCurrent, and is left untouched", async () => {
|
|
173
|
+
// Reset provider to V1 so a freshly-enabled row is "current" (nothing to
|
|
174
|
+
// rotate) — the garbage field is the only failure we expect.
|
|
175
|
+
mutableProvider.replace(
|
|
176
|
+
createEnvMasterKeyProvider({
|
|
177
|
+
env: {
|
|
178
|
+
KUMIKO_SECRETS_MASTER_KEY_V1: v1Key,
|
|
179
|
+
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
|
|
180
|
+
},
|
|
181
|
+
}),
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const user = createTestUser({ id: 2, roles: ["User"] });
|
|
185
|
+
const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
|
|
186
|
+
AuthMfaHandlers.enableStart,
|
|
187
|
+
{ accountLabel: "user-2@example.com" },
|
|
188
|
+
user,
|
|
189
|
+
);
|
|
190
|
+
const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
|
|
191
|
+
const secret = base32Decode(secretParam);
|
|
192
|
+
await stack.http.writeOk(
|
|
193
|
+
AuthMfaHandlers.enableConfirm,
|
|
194
|
+
{ setupToken: start.setupToken, code: currentTotpCode(secret) },
|
|
195
|
+
user,
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const beforeRow = await readRawRow();
|
|
199
|
+
const garbage = "not-a-stored-envelope";
|
|
200
|
+
await asRawClient(stack.db).unsafe(`UPDATE read_user_mfa SET totp_secret = $1 WHERE id = $2`, [
|
|
201
|
+
garbage,
|
|
202
|
+
beforeRow.id,
|
|
203
|
+
]);
|
|
204
|
+
|
|
205
|
+
type CapturedLog = { info: string[]; warn: string[] };
|
|
206
|
+
const captured: CapturedLog = { info: [], warn: [] };
|
|
207
|
+
const capturingLog = {
|
|
208
|
+
info: (msg: string) => {
|
|
209
|
+
captured.info.push(msg);
|
|
210
|
+
},
|
|
211
|
+
warn: (msg: string) => {
|
|
212
|
+
captured.warn.push(msg);
|
|
213
|
+
},
|
|
214
|
+
error: () => {},
|
|
215
|
+
debug: () => {},
|
|
216
|
+
child: () => capturingLog,
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
await mfaReencryptJob({}, {
|
|
220
|
+
db: stack.db,
|
|
221
|
+
registry: stack.registry,
|
|
222
|
+
masterKeyProvider: mutableProvider,
|
|
223
|
+
log: capturingLog,
|
|
224
|
+
} as AppContext);
|
|
225
|
+
|
|
226
|
+
const completeLine = captured.info.find((line) =>
|
|
227
|
+
line.includes("[auth-mfa:reencrypt] complete:"),
|
|
228
|
+
);
|
|
229
|
+
if (!completeLine) throw new Error("job did not log a completion summary");
|
|
230
|
+
const result = JSON.parse(completeLine.slice(completeLine.indexOf("{"))) as {
|
|
231
|
+
migrated: number;
|
|
232
|
+
failed: number;
|
|
233
|
+
alreadyCurrent: number;
|
|
234
|
+
};
|
|
235
|
+
expect(result.migrated).toBe(0);
|
|
236
|
+
expect(result.failed).toBe(1);
|
|
237
|
+
expect(result.alreadyCurrent).toBe(0);
|
|
238
|
+
|
|
239
|
+
const rejectedWarning = captured.warn.find((line) =>
|
|
240
|
+
line.includes("not a current-cipher envelope"),
|
|
241
|
+
);
|
|
242
|
+
expect(rejectedWarning).toBeDefined();
|
|
243
|
+
|
|
244
|
+
const afterRow = await readRawRow();
|
|
245
|
+
expect(afterRow.totpSecret).toBe(garbage);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
@@ -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();
|