@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.
- package/package.json +12 -7
- package/src/auth-email-password/feature.ts +6 -1
- package/src/auth-email-password/handlers/confirm-token-flow.ts +1 -1
- package/src/auth-email-password/handlers/login.write.ts +66 -9
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +15 -5
- package/src/auth-email-password/web/__tests__/test-utils.tsx +12 -2
- package/src/auth-email-password/web/auth-client.ts +34 -12
- package/src/auth-email-password/web/auth-gate.tsx +32 -3
- package/src/auth-email-password/web/client-plugin.ts +11 -2
- package/src/auth-email-password/web/index.ts +4 -2
- package/src/auth-email-password/web/login-screen.tsx +31 -2
- package/src/auth-email-password/web/session.tsx +11 -6
- package/src/auth-mfa/__tests__/disable-and-regenerate.integration.test.ts +202 -0
- package/src/auth-mfa/__tests__/enable-flow.integration.test.ts +150 -0
- package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +167 -0
- package/src/auth-mfa/__tests__/session-auto-revoke.integration.test.ts +168 -0
- package/src/auth-mfa/__tests__/verify.integration.test.ts +186 -0
- package/src/auth-mfa/config.ts +26 -0
- package/src/auth-mfa/constants.ts +25 -0
- package/src/auth-mfa/db/queries.ts +34 -0
- package/src/auth-mfa/errors.ts +73 -0
- package/src/auth-mfa/feature.ts +146 -0
- package/src/auth-mfa/handlers/disable.write.ts +51 -0
- package/src/auth-mfa/handlers/enable-confirm.write.ts +68 -0
- package/src/auth-mfa/handlers/enable-start.write.ts +66 -0
- package/src/auth-mfa/handlers/reencrypt.job.ts +185 -0
- package/src/auth-mfa/handlers/regenerate-recovery.write.ts +62 -0
- package/src/auth-mfa/handlers/verify.write.ts +141 -0
- package/src/auth-mfa/i18n.ts +12 -0
- package/src/auth-mfa/index.ts +15 -0
- package/src/auth-mfa/mfa-challenge-token.ts +90 -0
- package/src/auth-mfa/mfa-setup-token.ts +90 -0
- package/src/auth-mfa/mfa-status-checker.ts +75 -0
- package/src/auth-mfa/mfa-verify-attempts.ts +90 -0
- package/src/auth-mfa/recovery-codes.ts +44 -0
- package/src/auth-mfa/schema/user-mfa.ts +40 -0
- package/src/auth-mfa/totp.ts +8 -0
- package/src/auth-mfa/verify-factor.ts +30 -0
- package/src/auth-mfa/web/client-plugin.ts +37 -0
- package/src/auth-mfa/web/i18n.ts +69 -0
- package/src/auth-mfa/web/index.ts +15 -0
- package/src/auth-mfa/web/mfa-client.ts +60 -0
- package/src/auth-mfa/web/mfa-enable-screen.tsx +187 -0
- package/src/auth-mfa/web/mfa-verify-screen.tsx +106 -0
- package/src/auth-mfa-user-data/__tests__/hooks.integration.test.ts +132 -0
- package/src/auth-mfa-user-data/hooks.ts +53 -0
- package/src/auth-mfa-user-data/index.ts +21 -0
- package/src/legal-pages/__tests__/legal-pages.integration.test.ts +4 -0
- package/src/legal-pages/feature.ts +19 -28
- package/src/managed-pages/feature.ts +1 -1
- package/src/sessions/feature.ts +2 -1
- package/src/sessions/session-callbacks.ts +23 -0
- package/src/shared/index.ts +1 -0
- 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
|
+
}
|