@cosmicdrift/kumiko-bundled-features 0.147.0 → 0.147.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.
Files changed (54) hide show
  1. package/package.json +12 -7
  2. package/src/auth-email-password/feature.ts +6 -1
  3. package/src/auth-email-password/handlers/confirm-token-flow.ts +1 -1
  4. package/src/auth-email-password/handlers/login.write.ts +66 -9
  5. package/src/auth-email-password/web/__tests__/login-screen.test.tsx +15 -5
  6. package/src/auth-email-password/web/__tests__/test-utils.tsx +12 -2
  7. package/src/auth-email-password/web/auth-client.ts +34 -12
  8. package/src/auth-email-password/web/auth-gate.tsx +32 -3
  9. package/src/auth-email-password/web/client-plugin.ts +11 -2
  10. package/src/auth-email-password/web/index.ts +4 -2
  11. package/src/auth-email-password/web/login-screen.tsx +31 -2
  12. package/src/auth-email-password/web/session.tsx +11 -6
  13. package/src/auth-mfa/__tests__/disable-and-regenerate.integration.test.ts +202 -0
  14. package/src/auth-mfa/__tests__/enable-flow.integration.test.ts +150 -0
  15. package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +167 -0
  16. package/src/auth-mfa/__tests__/session-auto-revoke.integration.test.ts +168 -0
  17. package/src/auth-mfa/__tests__/verify.integration.test.ts +186 -0
  18. package/src/auth-mfa/config.ts +26 -0
  19. package/src/auth-mfa/constants.ts +25 -0
  20. package/src/auth-mfa/db/queries.ts +34 -0
  21. package/src/auth-mfa/errors.ts +73 -0
  22. package/src/auth-mfa/feature.ts +146 -0
  23. package/src/auth-mfa/handlers/disable.write.ts +51 -0
  24. package/src/auth-mfa/handlers/enable-confirm.write.ts +68 -0
  25. package/src/auth-mfa/handlers/enable-start.write.ts +66 -0
  26. package/src/auth-mfa/handlers/reencrypt.job.ts +185 -0
  27. package/src/auth-mfa/handlers/regenerate-recovery.write.ts +62 -0
  28. package/src/auth-mfa/handlers/verify.write.ts +141 -0
  29. package/src/auth-mfa/i18n.ts +12 -0
  30. package/src/auth-mfa/index.ts +15 -0
  31. package/src/auth-mfa/mfa-challenge-token.ts +90 -0
  32. package/src/auth-mfa/mfa-setup-token.ts +90 -0
  33. package/src/auth-mfa/mfa-status-checker.ts +75 -0
  34. package/src/auth-mfa/mfa-verify-attempts.ts +90 -0
  35. package/src/auth-mfa/recovery-codes.ts +44 -0
  36. package/src/auth-mfa/schema/user-mfa.ts +40 -0
  37. package/src/auth-mfa/totp.ts +8 -0
  38. package/src/auth-mfa/verify-factor.ts +30 -0
  39. package/src/auth-mfa/web/client-plugin.ts +37 -0
  40. package/src/auth-mfa/web/i18n.ts +69 -0
  41. package/src/auth-mfa/web/index.ts +15 -0
  42. package/src/auth-mfa/web/mfa-client.ts +60 -0
  43. package/src/auth-mfa/web/mfa-enable-screen.tsx +187 -0
  44. package/src/auth-mfa/web/mfa-verify-screen.tsx +106 -0
  45. package/src/auth-mfa-user-data/__tests__/hooks.integration.test.ts +132 -0
  46. package/src/auth-mfa-user-data/hooks.ts +53 -0
  47. package/src/auth-mfa-user-data/index.ts +21 -0
  48. package/src/legal-pages/__tests__/legal-pages.integration.test.ts +4 -0
  49. package/src/legal-pages/feature.ts +19 -28
  50. package/src/managed-pages/feature.ts +1 -1
  51. package/src/sessions/feature.ts +2 -1
  52. package/src/sessions/session-callbacks.ts +23 -0
  53. package/src/shared/index.ts +1 -0
  54. package/src/{auth-email-password → shared}/token-burn-store.ts +1 -1
@@ -0,0 +1,186 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
3
+ import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
+ import {
5
+ createTestUser,
6
+ setupTestStack,
7
+ type TestStack,
8
+ unsafeCreateEntityTable,
9
+ unsafePushTables,
10
+ } from "@cosmicdrift/kumiko-framework/stack";
11
+ import {
12
+ createTestEnvelopeCipher,
13
+ expectErrorIncludes,
14
+ } from "@cosmicdrift/kumiko-framework/testing";
15
+ import { createConfigFeature } from "../../config";
16
+ import { createConfigResolver } from "../../config/resolver";
17
+ import { configValuesTable } from "../../config/table";
18
+ import { createTenantFeature } from "../../tenant";
19
+ import { tenantMembershipsTable } from "../../tenant/membership-table";
20
+ import { tenantEntity } from "../../tenant/schema/tenant";
21
+ import { createUserFeature } from "../../user/feature";
22
+ import { userEntity } from "../../user/schema/user";
23
+ import { base32Decode } from "../base32";
24
+ import { AuthMfaHandlers } from "../constants";
25
+ import { createAuthMfaFeature } from "../feature";
26
+ import { signMfaChallengeToken } from "../mfa-challenge-token";
27
+ import { userMfaEntity } from "../schema/user-mfa";
28
+ import { currentTotpCode } from "../totp";
29
+
30
+ let stack: TestStack;
31
+
32
+ const SETUP_TOKEN_SECRET = "test-mfa-setup-secret-at-least-32-bytes-long!!";
33
+ const CHALLENGE_TOKEN_SECRET = "test-mfa-challenge-secret-at-least-32-bytes!!";
34
+
35
+ // Handler access is `{ roles: ["all"] }`, matching how the framework route
36
+ // dispatches with a guest identity — a literal here is enough since the
37
+ // handler derives everything from the challenge-token payload, not from
38
+ // event.user.
39
+ const GUEST: SessionUser = {
40
+ id: "00000000-0000-0000-0000-000000000000",
41
+ tenantId: "00000000-0000-4000-8000-000000000001" as TenantId,
42
+ roles: ["all"],
43
+ };
44
+
45
+ beforeAll(async () => {
46
+ const encryption = createTestEnvelopeCipher();
47
+ configureEntityFieldEncryption(encryption);
48
+ const resolver = createConfigResolver({ cipher: encryption });
49
+ stack = await setupTestStack({
50
+ features: [
51
+ createConfigFeature(),
52
+ createUserFeature(),
53
+ createTenantFeature(),
54
+ createAuthMfaFeature({
55
+ setupTokenSecret: SETUP_TOKEN_SECRET,
56
+ issuer: "Kumiko Test",
57
+ challengeTokenSecret: CHALLENGE_TOKEN_SECRET,
58
+ }),
59
+ ],
60
+ extraContext: { configResolver: resolver, configEncryption: encryption },
61
+ });
62
+ await unsafeCreateEntityTable(stack.db, userEntity);
63
+ await unsafeCreateEntityTable(stack.db, tenantEntity);
64
+ await unsafeCreateEntityTable(stack.db, userMfaEntity);
65
+ await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
66
+ });
67
+
68
+ afterAll(async () => {
69
+ await stack.cleanup();
70
+ });
71
+
72
+ async function enableMfaFor(idSeed: number): Promise<{
73
+ user: ReturnType<typeof createTestUser>;
74
+ secret: Buffer;
75
+ recoveryCodes: string[];
76
+ }> {
77
+ const user = createTestUser({ id: idSeed, roles: ["User"] });
78
+ const start = await stack.http.writeOk<{
79
+ setupToken: string;
80
+ otpauthUri: string;
81
+ recoveryCodes: string[];
82
+ }>(AuthMfaHandlers.enableStart, { accountLabel: `user-${idSeed}@example.com` }, user);
83
+ const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
84
+ const secret = base32Decode(secretParam);
85
+ await stack.http.writeOk(
86
+ AuthMfaHandlers.enableConfirm,
87
+ { setupToken: start.setupToken, code: currentTotpCode(secret) },
88
+ user,
89
+ );
90
+ return { user, secret, recoveryCodes: start.recoveryCodes };
91
+ }
92
+
93
+ function challengeFor(userId: string, tenantId: TenantId): string {
94
+ return signMfaChallengeToken({ userId, tenantId }, 10, CHALLENGE_TOKEN_SECRET).token;
95
+ }
96
+
97
+ describe("mfa verify — completes a two-step login", () => {
98
+ test("a valid TOTP code succeeds and returns the full session", async () => {
99
+ const { user, secret } = await enableMfaFor(1);
100
+ const challengeToken = challengeFor(user.id, user.tenantId);
101
+
102
+ const res = await stack.http.writeOk<{ session: SessionUser }>(
103
+ AuthMfaHandlers.verify,
104
+ { challengeToken, code: currentTotpCode(secret) },
105
+ GUEST,
106
+ );
107
+ expect(res.session.id).toBe(user.id);
108
+ expect(res.session.tenantId).toBe(user.tenantId);
109
+ });
110
+
111
+ test("a wrong TOTP code is rejected", async () => {
112
+ const { user } = await enableMfaFor(2);
113
+ const challengeToken = challengeFor(user.id, user.tenantId);
114
+ const err = await stack.http.writeErr(
115
+ AuthMfaHandlers.verify,
116
+ { challengeToken, code: "000000" },
117
+ GUEST,
118
+ );
119
+ expectErrorIncludes(err, "invalid_totp_code");
120
+ });
121
+
122
+ test("a valid recovery code succeeds, and the SAME code is rejected on replay", async () => {
123
+ const { user, recoveryCodes } = await enableMfaFor(3);
124
+ const code = recoveryCodes[0];
125
+ if (code === undefined) throw new Error("no recovery code minted");
126
+
127
+ const challengeA = challengeFor(user.id, user.tenantId);
128
+ const res = await stack.http.writeOk<{ session: SessionUser }>(
129
+ AuthMfaHandlers.verify,
130
+ { challengeToken: challengeA, code },
131
+ GUEST,
132
+ );
133
+ expect(res.session.id).toBe(user.id);
134
+
135
+ // Advisor-flagged trap: a recovery code must be single-use. Re-verify
136
+ // with a FRESH challenge token (a real attacker would just re-login)
137
+ // but the SAME recovery code — it must now be rejected because
138
+ // verify.write.ts persists remainingHashes on the row.
139
+ const challengeB = challengeFor(user.id, user.tenantId);
140
+ const err = await stack.http.writeErr(
141
+ AuthMfaHandlers.verify,
142
+ { challengeToken: challengeB, code },
143
+ GUEST,
144
+ );
145
+ expectErrorIncludes(err, "invalid_totp_code");
146
+ });
147
+
148
+ test("a challenge token cannot be replayed even with a still-valid TOTP code", async () => {
149
+ const { user, secret } = await enableMfaFor(4);
150
+ const challengeToken = challengeFor(user.id, user.tenantId);
151
+
152
+ await stack.http.writeOk<{ session: SessionUser }>(
153
+ AuthMfaHandlers.verify,
154
+ { challengeToken, code: currentTotpCode(secret) },
155
+ GUEST,
156
+ );
157
+
158
+ // Same challenge token, same (still time-window-valid) code — must be
159
+ // rejected because burnToken() marks it used on the first success.
160
+ const err = await stack.http.writeErr(
161
+ AuthMfaHandlers.verify,
162
+ { challengeToken, code: currentTotpCode(secret) },
163
+ GUEST,
164
+ );
165
+ expectErrorIncludes(err, "invalid_challenge_token");
166
+ });
167
+
168
+ test("a malformed challenge token is rejected without leaking account state", async () => {
169
+ const err = await stack.http.writeErr(
170
+ AuthMfaHandlers.verify,
171
+ { challengeToken: "not-a-real-token", code: "123456" },
172
+ GUEST,
173
+ );
174
+ expectErrorIncludes(err, "invalid_challenge_token");
175
+ });
176
+
177
+ test("a challenge token for a user without MFA enabled is rejected", async () => {
178
+ const challengeToken = challengeFor("verify-never-enabled-1", GUEST.tenantId);
179
+ const err = await stack.http.writeErr(
180
+ AuthMfaHandlers.verify,
181
+ { challengeToken, code: "123456" },
182
+ GUEST,
183
+ );
184
+ expectErrorIncludes(err, "invalid_challenge_token");
185
+ });
186
+ });
@@ -0,0 +1,26 @@
1
+ // Tenant-scoped enforcement policy: does a user need MFA to log in even if
2
+ // they haven't opted in themselves? Default "optional" means the feature
3
+ // is fully opt-in (existing tenants/users unaffected — no migration step).
4
+ //
5
+ // ponytail: "admins"/"all" HARD-BLOCK login for an unenrolled user (see
6
+ // mfa-status-checker.ts's setupRequired branch) — there is no in-band way
7
+ // for a blocked user to complete enrollment yet (that flow ships in PR3's
8
+ // UI). Flipping this away from "optional" before PR3 locks out every
9
+ // unenrolled matching user with no recovery path. Fine for a tenant that
10
+ // enrolls its admins out-of-band first; a footgun otherwise.
11
+ import { type ConfigKeyHandle, createTenantConfig } from "@cosmicdrift/kumiko-framework/engine";
12
+
13
+ export const MFA_REQUIRED_POLICIES = ["optional", "admins", "all"] as const;
14
+ export type MfaRequiredPolicy = (typeof MFA_REQUIRED_POLICIES)[number];
15
+
16
+ export const mfaRequiredConfigHandle: ConfigKeyHandle<"select"> = {
17
+ name: "auth-mfa:config:required",
18
+ type: "select",
19
+ };
20
+
21
+ export function mfaRequiredConfigKey() {
22
+ return createTenantConfig("select", {
23
+ default: "optional" satisfies MfaRequiredPolicy,
24
+ options: [...MFA_REQUIRED_POLICIES],
25
+ });
26
+ }
@@ -0,0 +1,25 @@
1
+ // @runtime client
2
+ // Pure constants — client-marked so auth-mfa/web/ may import handler QNs
3
+ // and the screen id without pulling the feature's server runtime barrel.
4
+ // Qualified write-handler names — feature is registered as "auth-mfa", each
5
+ // short `name` below gets auto-prefixed to "auth-mfa:write:<name>" by
6
+ // r.writeHandler. Exported so cross-feature wiring (login.write.ts,
7
+ // run-prod-app.ts) can reference them without hardcoding the string.
8
+ export const AUTH_MFA_FEATURE = "auth-mfa" as const;
9
+
10
+ export const AuthMfaHandlers = {
11
+ enableStart: "auth-mfa:write:enable-start",
12
+ enableConfirm: "auth-mfa:write:enable-confirm",
13
+ disable: "auth-mfa:write:disable",
14
+ regenerateRecovery: "auth-mfa:write:regenerate-recovery",
15
+ verify: "auth-mfa:write:verify",
16
+ } as const;
17
+
18
+ export const MFA_SETUP_TOKEN_TTL_MINUTES = 10;
19
+ export const MFA_CHALLENGE_TOKEN_TTL_MINUTES = 10;
20
+ export const MFA_VERIFY_MAX_ATTEMPTS = 5;
21
+ export const MFA_VERIFY_LOCKOUT_MINUTES = 5;
22
+
23
+ // Dormant custom-screen id — see personal-access-tokens/feature.ts for the
24
+ // same convention. App places it via r.nav in its logged-in settings area.
25
+ export const MFA_ENABLE_SCREEN_ID = "auth-mfa-enable";
@@ -0,0 +1,34 @@
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { createEventStoreExecutor, type TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
+ import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { userMfaEntity, userMfaTable } from "../schema/user-mfa";
5
+
6
+ export type UserMfaRow = {
7
+ readonly id: string;
8
+ readonly version: number;
9
+ readonly userId: string;
10
+ readonly totpSecret: string;
11
+ readonly recoveryCodes: { readonly hashes: readonly string[] };
12
+ readonly enabledAt: string;
13
+ readonly lastUsedAt: string | null;
14
+ };
15
+
16
+ const executor = createEventStoreExecutor(userMfaTable, userMfaEntity, {
17
+ entityName: "user-mfa",
18
+ });
19
+
20
+ // `totpSecret` is `encrypted: true` — a raw fetchOne would return ciphertext,
21
+ // not the plaintext base32 secret. fetchOne only locates the row's id (no
22
+ // decryption needed for that); executor.detail() does the real, decrypting
23
+ // read the same way every other entity handler does.
24
+ export async function findUserMfaRow(db: TenantDb, user: SessionUser): Promise<UserMfaRow | null> {
25
+ const idLookup = await fetchOne<{ id: string }>(db, userMfaTable, {
26
+ userId: user.id,
27
+ tenantId: user.tenantId,
28
+ });
29
+ if (!idLookup) return null;
30
+ const detail = await executor.detail({ id: idLookup.id }, user, db);
31
+ // @cast-boundary engine-payload — executor.detail returns a decrypted,
32
+ // untyped Record; the userMfa entity's own field shape narrows it.
33
+ return detail as UserMfaRow | null;
34
+ }
@@ -0,0 +1,73 @@
1
+ import {
2
+ UnprocessableError,
3
+ type WriteFailure,
4
+ writeFailure,
5
+ } from "@cosmicdrift/kumiko-framework/errors";
6
+
7
+ export const AuthMfaErrors = {
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;
16
+
17
+ export function mfaAlreadyEnabled(): WriteFailure {
18
+ return writeFailure(
19
+ new UnprocessableError(AuthMfaErrors.mfaAlreadyEnabled, {
20
+ i18nKey: "authMfa.errors.mfaAlreadyEnabled",
21
+ }),
22
+ );
23
+ }
24
+
25
+ export function mfaNotEnabled(): WriteFailure {
26
+ return writeFailure(
27
+ new UnprocessableError(AuthMfaErrors.mfaNotEnabled, {
28
+ i18nKey: "authMfa.errors.mfaNotEnabled",
29
+ }),
30
+ );
31
+ }
32
+
33
+ export function invalidSetupToken(): WriteFailure {
34
+ return writeFailure(
35
+ new UnprocessableError(AuthMfaErrors.invalidSetupToken, {
36
+ i18nKey: "authMfa.errors.invalidSetupToken",
37
+ }),
38
+ );
39
+ }
40
+
41
+ export function invalidTotpCode(): WriteFailure {
42
+ return writeFailure(
43
+ new UnprocessableError(AuthMfaErrors.invalidTotpCode, {
44
+ i18nKey: "authMfa.errors.invalidTotpCode",
45
+ }),
46
+ );
47
+ }
48
+
49
+ export function invalidRecoveryCode(): WriteFailure {
50
+ return writeFailure(
51
+ new UnprocessableError(AuthMfaErrors.invalidRecoveryCode, {
52
+ i18nKey: "authMfa.errors.invalidRecoveryCode",
53
+ }),
54
+ );
55
+ }
56
+
57
+ export function invalidChallengeToken(): WriteFailure {
58
+ return writeFailure(
59
+ new UnprocessableError(AuthMfaErrors.invalidChallengeToken, {
60
+ i18nKey: "authMfa.errors.invalidChallengeToken",
61
+ }),
62
+ );
63
+ }
64
+
65
+ // retryAfterSeconds drives the login UI countdown — must stay > 0.
66
+ export function tooManyAttempts(retryAfterSeconds: number): WriteFailure {
67
+ return writeFailure(
68
+ new UnprocessableError(AuthMfaErrors.tooManyAttempts, {
69
+ i18nKey: "authMfa.errors.tooManyAttempts",
70
+ details: { retryAfterSeconds },
71
+ }),
72
+ );
73
+ }
@@ -0,0 +1,146 @@
1
+ import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { mfaRequiredConfigKey } from "./config";
3
+ import { MFA_ENABLE_SCREEN_ID } from "./constants";
4
+ import { createDisableHandler } from "./handlers/disable.write";
5
+ import { createEnableConfirmHandler } from "./handlers/enable-confirm.write";
6
+ import { createEnableStartHandler } from "./handlers/enable-start.write";
7
+ import { mfaReencryptJob } from "./handlers/reencrypt.job";
8
+ import { createRegenerateRecoveryHandler } from "./handlers/regenerate-recovery.write";
9
+ import { createMfaVerifyHandler } from "./handlers/verify.write";
10
+ import { AUTH_MFA_FEATURE_I18N } from "./i18n";
11
+ import { createMfaStatusChecker, type MfaStatusChecker } from "./mfa-status-checker";
12
+ import { userMfaEntity } from "./schema/user-mfa";
13
+
14
+ export type AuthMfaFeatureOptions = {
15
+ // HMAC secret for the stateless enable-flow token (carries the generated
16
+ // TOTP secret + recovery-code hashes between enable.start and
17
+ // enable.confirm). Distinct secret from any other token flow in the app —
18
+ // do not reuse passwordReset.hmacSecret.
19
+ readonly setupTokenSecret: string;
20
+ // otpauth:// URI issuer — shown in the user's authenticator app next to
21
+ // the account label ("Kumiko: jane@example.com").
22
+ readonly issuer: string;
23
+ // HMAC secret for the login-flow challenge token (carries {userId,
24
+ // tenantId} between the password-check and /auth/mfa/verify). Distinct
25
+ // secret from setupTokenSecret — a compromised setup-token secret must
26
+ // not also forge login challenges.
27
+ readonly challengeTokenSecret: string;
28
+ };
29
+
30
+ export type BindMfaRevokeAllOtherSessions = (
31
+ revoker: (userId: string, currentSid: string | undefined) => Promise<number>,
32
+ ) => void;
33
+
34
+ // Reads the late-bind setter off a mounted auth-mfa feature's exports —
35
+ // mirrors sessions' own bindAutoRevokeFromFeature. run{Prod,Dev}App call
36
+ // this once the sessions feature (if mounted) has produced a concrete
37
+ // sessionRevokeAllOthers callback.
38
+ export function bindMfaRevokeAllOtherSessionsFromFeature(
39
+ feature: FeatureDefinition,
40
+ ): BindMfaRevokeAllOtherSessions | undefined {
41
+ const exports = feature.exports;
42
+ if (exports && typeof exports === "object" && "bindRevokeAllOtherSessions" in exports) {
43
+ const { bindRevokeAllOtherSessions } = exports as {
44
+ bindRevokeAllOtherSessions: unknown;
45
+ };
46
+ if (typeof bindRevokeAllOtherSessions === "function") {
47
+ // @cast-boundary exports-walk — feature.exports is untyped by design
48
+ return bindRevokeAllOtherSessions as BindMfaRevokeAllOtherSessions;
49
+ }
50
+ }
51
+ return undefined;
52
+ }
53
+
54
+ // Reads the eagerly-built `checkMfaStatus` off a mounted auth-mfa feature's
55
+ // exports — no bind-setter needed (see the comment where it's built).
56
+ export function mfaStatusCheckerFromFeature(
57
+ feature: FeatureDefinition,
58
+ ): MfaStatusChecker | undefined {
59
+ const exports = feature.exports;
60
+ if (exports && typeof exports === "object" && "checkMfaStatus" in exports) {
61
+ const { checkMfaStatus } = exports as { checkMfaStatus: unknown };
62
+ if (typeof checkMfaStatus === "function") {
63
+ // @cast-boundary exports-walk — feature.exports is untyped by design
64
+ return checkMfaStatus as MfaStatusChecker;
65
+ }
66
+ }
67
+ return undefined;
68
+ }
69
+
70
+ export function createAuthMfaFeature(opts: AuthMfaFeatureOptions): FeatureDefinition {
71
+ return defineFeature("auth-mfa", (r) => {
72
+ r.describe(
73
+ "TOTP-based two-factor authentication: enable/disable flow with QR-code setup, 8 single-use recovery codes, and (once wired into a login flow) a second login step after password verification. Secrets are envelope-encrypted at rest via the same MasterKeyProvider as `secrets`/`config`.",
74
+ );
75
+ r.uiHints({
76
+ displayLabel: "2FA / TOTP",
77
+ category: "identity",
78
+ recommended: true,
79
+ });
80
+ r.requires("user");
81
+ r.requires("config");
82
+ r.config({ keys: { required: mfaRequiredConfigKey() } });
83
+
84
+ // Dormant custom-screen — the client maps MFA_ENABLE_SCREEN_ID to
85
+ // MfaEnableScreen (see personal-access-tokens/feature.ts for the same
86
+ // convention). App places it via r.nav in its logged-in settings area.
87
+ r.screen({
88
+ id: MFA_ENABLE_SCREEN_ID,
89
+ type: "custom",
90
+ renderer: { react: { __component: "MfaEnableScreen" } },
91
+ access: { openToAll: true },
92
+ });
93
+ r.translations({ keys: AUTH_MFA_FEATURE_I18N });
94
+
95
+ // KEK-rotation for totpSecret (entity-field encryption). Manual
96
+ // trigger — ops runs it once after adding a new master key version,
97
+ // same operator workflow as config's own reencrypt job.
98
+ r.job("reencrypt", { trigger: { manual: true } }, mfaReencryptJob);
99
+
100
+ r.entity("user-mfa", userMfaEntity);
101
+
102
+ // Late-bound by run-prod-app once the sessions feature (if mounted) has
103
+ // produced a concrete `sessionRevokeAllOthers` callback — mirrors
104
+ // sessions' own bindAutoRevokeOnPasswordChange. Shared by every handler
105
+ // that changes MFA state (enable/disable/regenerate), so each one's
106
+ // "log out every other session" behavior tracks the same wiring.
107
+ let revokeAllOtherSessions:
108
+ | ((userId: string, currentSid: string | undefined) => Promise<number>)
109
+ | undefined;
110
+ const sharedRevoker = (userId: string, currentSid: string | undefined): Promise<number> =>
111
+ revokeAllOtherSessions?.(userId, currentSid) ?? Promise.resolve(0);
112
+
113
+ const handlers = {
114
+ enableStart: r.writeHandler(
115
+ createEnableStartHandler({ setupTokenSecret: opts.setupTokenSecret, issuer: opts.issuer }),
116
+ ),
117
+ enableConfirm: r.writeHandler(
118
+ createEnableConfirmHandler({
119
+ setupTokenSecret: opts.setupTokenSecret,
120
+ revokeAllOtherSessions: sharedRevoker,
121
+ }),
122
+ ),
123
+ disable: r.writeHandler(createDisableHandler({ revokeAllOtherSessions: sharedRevoker })),
124
+ regenerateRecovery: r.writeHandler(
125
+ createRegenerateRecoveryHandler({ revokeAllOtherSessions: sharedRevoker }),
126
+ ),
127
+ verify: r.writeHandler(
128
+ createMfaVerifyHandler({ challengeTokenSecret: opts.challengeTokenSecret }),
129
+ ),
130
+ };
131
+
132
+ // No late-bind needed (unlike sharedRevoker) — this checker only needs
133
+ // the HandlerContext the CALLER already has (login.write.ts runs it
134
+ // from inside its own dispatcher call), not a raw db handle assembled
135
+ // at app-boot time.
136
+ const checkMfaStatus = createMfaStatusChecker({
137
+ challengeTokenSecret: opts.challengeTokenSecret,
138
+ });
139
+
140
+ const bindRevokeAllOtherSessions: BindMfaRevokeAllOtherSessions = (revoker) => {
141
+ revokeAllOtherSessions = revoker;
142
+ };
143
+
144
+ return { handlers, bindRevokeAllOtherSessions, checkMfaStatus };
145
+ });
146
+ }
@@ -0,0 +1,51 @@
1
+ import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
2
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { z } from "zod";
4
+ import { findUserMfaRow } from "../db/queries";
5
+ import { invalidTotpCode, mfaNotEnabled } from "../errors";
6
+ import { userMfaEntity, userMfaTable } from "../schema/user-mfa";
7
+ import { verifyMfaFactor } from "../verify-factor";
8
+
9
+ export type DisableOptions = {
10
+ readonly revokeAllOtherSessions?: (
11
+ userId: string,
12
+ currentSid: string | undefined,
13
+ ) => Promise<number>;
14
+ };
15
+
16
+ const executor = createEventStoreExecutor(userMfaTable, userMfaEntity, {
17
+ entityName: "user-mfa",
18
+ });
19
+
20
+ // A TOTP code or a recovery code both prove possession of the second
21
+ // factor — either is accepted to turn MFA back off. Password alone is NOT
22
+ // enough: that would make MFA worthless against exactly the "stolen
23
+ // password" scenario it exists to defend.
24
+ export function createDisableHandler(opts: DisableOptions) {
25
+ return defineWriteHandler({
26
+ name: "disable",
27
+ schema: z.object({ code: z.string().min(6).max(9) }),
28
+ access: { openToAll: true },
29
+ handler: async (event, ctx) => {
30
+ const row = await findUserMfaRow(ctx.db, event.user);
31
+ if (!row) return mfaNotEnabled();
32
+
33
+ const verify = await verifyMfaFactor(row, event.payload.code);
34
+ if (!verify.ok) return invalidTotpCode();
35
+
36
+ const result = await executor.delete({ id: row.id }, event.user, ctx.db);
37
+ if (!result.isSuccess) return result;
38
+
39
+ // Disabling MFA is a security-relevant state change — same
40
+ // auto-revoke as enable, in case the person doing this isn't the
41
+ // legitimate account owner (attacker with a stolen-but-not-yet-
42
+ // MFA-locked session, or a recovery code obtained via social
43
+ // engineering).
44
+ if (opts.revokeAllOtherSessions) {
45
+ await opts.revokeAllOtherSessions(event.user.id, event.user.sid);
46
+ }
47
+
48
+ return { isSuccess: true, data: { disabled: true } };
49
+ },
50
+ });
51
+ }
@@ -0,0 +1,68 @@
1
+ import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
2
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { Temporal } from "temporal-polyfill";
4
+ import { z } from "zod";
5
+ import { base32Decode } from "../base32";
6
+ import { invalidSetupToken, invalidTotpCode } from "../errors";
7
+ import { verifyMfaSetupToken } from "../mfa-setup-token";
8
+ import { userMfaEntity, userMfaTable } from "../schema/user-mfa";
9
+ import { verifyTotp } from "../totp";
10
+
11
+ export type EnableConfirmOptions = {
12
+ readonly setupTokenSecret: string;
13
+ // Wired late by run-prod-app once the sessions feature (if mounted) is
14
+ // concrete — see sessions/session-callbacks.ts. Absent when sessions
15
+ // isn't mounted: enabling MFA just doesn't revoke other sessions.
16
+ readonly revokeAllOtherSessions?: (
17
+ userId: string,
18
+ currentSid: string | undefined,
19
+ ) => Promise<number>;
20
+ };
21
+
22
+ const executor = createEventStoreExecutor(userMfaTable, userMfaEntity, {
23
+ entityName: "user-mfa",
24
+ });
25
+
26
+ export function createEnableConfirmHandler(opts: EnableConfirmOptions) {
27
+ return defineWriteHandler({
28
+ name: "enable-confirm",
29
+ schema: z.object({
30
+ setupToken: z.string().min(1),
31
+ code: z.string().length(6),
32
+ }),
33
+ access: { openToAll: true },
34
+ handler: async (event, ctx) => {
35
+ const verify = verifyMfaSetupToken(event.payload.setupToken, opts.setupTokenSecret);
36
+ if (!verify.ok) return invalidSetupToken();
37
+ // A setup token minted for one user can't be redeemed by another —
38
+ // guards against a leaked/shared token, not just tampering (the HMAC
39
+ // already rules that out).
40
+ if (verify.payload.userId !== event.user.id) return invalidSetupToken();
41
+
42
+ const secret = base32Decode(verify.payload.totpSecretBase32);
43
+ if (!verifyTotp(secret, event.payload.code)) return invalidTotpCode();
44
+
45
+ const result = await executor.create(
46
+ {
47
+ userId: event.user.id,
48
+ totpSecret: verify.payload.totpSecretBase32,
49
+ recoveryCodes: { hashes: verify.payload.recoveryCodeHashes },
50
+ enabledAt: Temporal.Now.instant(),
51
+ lastUsedAt: null,
52
+ },
53
+ event.user,
54
+ ctx.db,
55
+ );
56
+ if (!result.isSuccess) return result;
57
+
58
+ // Confirming enable proves possession of a second factor — every
59
+ // other session (a stolen-cookie attacker included) gets logged out.
60
+ // The session that just did this confirm keeps running.
61
+ if (opts.revokeAllOtherSessions) {
62
+ await opts.revokeAllOtherSessions(event.user.id, event.user.sid);
63
+ }
64
+
65
+ return { isSuccess: true, data: { enabled: true } };
66
+ },
67
+ });
68
+ }
@@ -0,0 +1,66 @@
1
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { z } from "zod";
3
+ import { base32Encode } from "../base32";
4
+ import { MFA_SETUP_TOKEN_TTL_MINUTES } from "../constants";
5
+ import { findUserMfaRow } from "../db/queries";
6
+ import { mfaAlreadyEnabled } from "../errors";
7
+ import { signMfaSetupToken } from "../mfa-setup-token";
8
+ import { buildOtpauthUri } from "../otpauth-uri";
9
+ import { generateRecoveryCodes, hashRecoveryCodes } from "../recovery-codes";
10
+ import { generateTotpSecret } from "../totp";
11
+
12
+ export type EnableStartOptions = {
13
+ readonly setupTokenSecret: string;
14
+ readonly issuer: string;
15
+ };
16
+
17
+ // Stateless setup: no `userMfa` row is created here. The generated secret +
18
+ // recovery-code hashes are signed into a short-lived `setupToken` (see
19
+ // mfa-setup-token.ts) that `enable-confirm` verifies. An abandoned setup
20
+ // (user closes the tab without entering a code) leaves zero trace — no
21
+ // cleanup job needed for orphaned "pending MFA" rows.
22
+ export function createEnableStartHandler(opts: EnableStartOptions) {
23
+ return defineWriteHandler({
24
+ name: "enable-start",
25
+ schema: z.object({
26
+ // Client-supplied label for the otpauth:// URI / authenticator-app
27
+ // entry (typically the user's own email) — avoids an extra DB lookup
28
+ // for something the already-authenticated client already knows about
29
+ // itself.
30
+ accountLabel: z.string().min(1).max(200),
31
+ }),
32
+ access: { openToAll: true },
33
+ handler: async (event, ctx) => {
34
+ const existing = await findUserMfaRow(ctx.db, event.user);
35
+ if (existing) return mfaAlreadyEnabled();
36
+
37
+ const secret = generateTotpSecret();
38
+ const recoveryCodes = generateRecoveryCodes();
39
+ const recoveryCodeHashes = await hashRecoveryCodes(recoveryCodes);
40
+
41
+ const { token: setupToken } = signMfaSetupToken(
42
+ {
43
+ userId: event.user.id,
44
+ totpSecretBase32: base32Encode(secret),
45
+ recoveryCodeHashes,
46
+ },
47
+ MFA_SETUP_TOKEN_TTL_MINUTES,
48
+ opts.setupTokenSecret,
49
+ );
50
+
51
+ return {
52
+ isSuccess: true,
53
+ data: {
54
+ setupToken,
55
+ otpauthUri: buildOtpauthUri({
56
+ issuer: opts.issuer,
57
+ accountLabel: event.payload.accountLabel,
58
+ secret,
59
+ }),
60
+ // Plaintext — this is the one and only time these are shown.
61
+ recoveryCodes,
62
+ },
63
+ };
64
+ },
65
+ });
66
+ }