@cosmicdrift/kumiko-bundled-features 0.147.1 → 0.147.3
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/__tests__/managed-pages.integration.test.ts +111 -0
- package/src/managed-pages/feature.ts +44 -44
- package/src/seo/__tests__/seo.integration.test.ts +68 -0
- package/src/seo/feature.ts +28 -41
- 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,185 @@
|
|
|
1
|
+
// KEK-rotation job for userMfa.totpSecret (entity-field encryption, same
|
|
2
|
+
// MasterKeyProvider as secrets/config — see schema/user-mfa.ts). Modeled
|
|
3
|
+
// directly on config/handlers/reencrypt.job.ts: entity-field encryption
|
|
4
|
+
// stores a StoredEnvelope JSON string per value (no separate kekVersion
|
|
5
|
+
// column, unlike secrets' DEK-wrap model), so rotation means decrypt under
|
|
6
|
+
// the OLD key, then let the executor's own write-path re-encrypt under
|
|
7
|
+
// provider.currentVersion() — the same auto-encrypt every enable-confirm
|
|
8
|
+
// write already goes through (see enable-confirm.write.ts: it passes
|
|
9
|
+
// PLAINTEXT to executor.create, never a manually-built envelope).
|
|
10
|
+
//
|
|
11
|
+
// Idempotent: re-running skips rows already on the current kekVersion.
|
|
12
|
+
// Every write is a normal executor.update — after a full run even a
|
|
13
|
+
// from-scratch projection rebuild only ever sees the current-KEK envelope
|
|
14
|
+
// (the very risk this job's plan entry called out: a rebuild resurrecting
|
|
15
|
+
// an old-KEK wrap would happen if rotation only touched the read-side
|
|
16
|
+
// projection instead of appending a real `.updated` event).
|
|
17
|
+
|
|
18
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
19
|
+
import {
|
|
20
|
+
configuredEntityFieldEncryption,
|
|
21
|
+
createEventStoreExecutor,
|
|
22
|
+
createTenantDb,
|
|
23
|
+
type DbConnection,
|
|
24
|
+
type TenantDb,
|
|
25
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
26
|
+
import type { JobHandlerFn, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
27
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
28
|
+
import { type EnvelopeCipher, isStoredEnvelope } from "@cosmicdrift/kumiko-framework/secrets";
|
|
29
|
+
import { type ChunkedMigrationStopReason, runChunkedMigration } from "../../shared";
|
|
30
|
+
import { userMfaEntity, userMfaTable } from "../schema/user-mfa";
|
|
31
|
+
|
|
32
|
+
const DEFAULT_BATCH_SIZE = 100;
|
|
33
|
+
const DEFAULT_MAX_FAILURES = 10;
|
|
34
|
+
const SYSTEM_ROLES = ["system"] as const;
|
|
35
|
+
|
|
36
|
+
const executor = createEventStoreExecutor(userMfaTable, userMfaEntity, {
|
|
37
|
+
entityName: "user-mfa",
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export type MfaReencryptJobPayload = {
|
|
41
|
+
readonly batchSize?: number;
|
|
42
|
+
readonly maxDurationMs?: number;
|
|
43
|
+
readonly maxFailures?: number;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type MfaReencryptJobResult = {
|
|
47
|
+
readonly migrated: number;
|
|
48
|
+
readonly failed: number;
|
|
49
|
+
readonly alreadyCurrent: number;
|
|
50
|
+
readonly batchesProcessed: number;
|
|
51
|
+
readonly stoppedReason: ChunkedMigrationStopReason;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function needsReencrypt(value: string, targetVersion: number): boolean {
|
|
55
|
+
// legacy single-key format (base64 — can never start with "{")
|
|
56
|
+
if (!value.startsWith("{")) return true;
|
|
57
|
+
try {
|
|
58
|
+
const parsed: unknown = JSON.parse(value);
|
|
59
|
+
if (!isStoredEnvelope(parsed)) return true;
|
|
60
|
+
return parsed.kekVersion !== targetVersion;
|
|
61
|
+
} catch {
|
|
62
|
+
// malformed JSON — let the decrypt attempt surface the real error
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const mfaReencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> => {
|
|
68
|
+
const payload = rawPayload as MfaReencryptJobPayload; // @cast-boundary engine-payload
|
|
69
|
+
const maybeCipher = configuredEntityFieldEncryption();
|
|
70
|
+
if (!maybeCipher) {
|
|
71
|
+
throw new InternalError({
|
|
72
|
+
message:
|
|
73
|
+
"[auth-mfa:reencrypt] entity-field encryption is not configured — provide a master key " +
|
|
74
|
+
"(KUMIKO_SECRETS_MASTER_KEY_V<n>); run{Prod,Dev}App wire this automatically, custom boots " +
|
|
75
|
+
"call configureEntityFieldEncryption(cipher).",
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
// hoisted function declarations below capture this — pin the narrowed
|
|
79
|
+
// type explicitly so TS keeps it inside the closures.
|
|
80
|
+
const cipher: EnvelopeCipher = maybeCipher;
|
|
81
|
+
if (!ctx.masterKeyProvider) {
|
|
82
|
+
throw new InternalError({
|
|
83
|
+
message:
|
|
84
|
+
"[auth-mfa:reencrypt] ctx.masterKeyProvider missing — wire it via extraContext.masterKeyProvider at boot.",
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
const provider = ctx.masterKeyProvider;
|
|
88
|
+
if (!ctx.db) {
|
|
89
|
+
throw new InternalError({
|
|
90
|
+
message: "[auth-mfa:reencrypt] ctx.db missing — job context requires a database connection.",
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
const db = ctx.db as DbConnection; // @cast-boundary db-operator
|
|
94
|
+
|
|
95
|
+
const batchSize = payload.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
96
|
+
const maxFailures = payload.maxFailures ?? DEFAULT_MAX_FAILURES;
|
|
97
|
+
const deadline = payload.maxDurationMs
|
|
98
|
+
? Date.now() + payload.maxDurationMs
|
|
99
|
+
: Number.POSITIVE_INFINITY;
|
|
100
|
+
|
|
101
|
+
const tdbCache = new Map<TenantId, TenantDb>();
|
|
102
|
+
function tdbFor(tenantId: TenantId): TenantDb {
|
|
103
|
+
let existing = tdbCache.get(tenantId);
|
|
104
|
+
if (!existing) {
|
|
105
|
+
existing = createTenantDb(db, tenantId, "system");
|
|
106
|
+
tdbCache.set(tenantId, existing);
|
|
107
|
+
}
|
|
108
|
+
return existing;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
type UserMfaRow = {
|
|
112
|
+
id: string;
|
|
113
|
+
tenantId: string;
|
|
114
|
+
version: number;
|
|
115
|
+
totpSecret: string;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
let alreadyCurrent = 0;
|
|
119
|
+
const targetVersion = provider.currentVersion();
|
|
120
|
+
|
|
121
|
+
// ponytail: one full candidate scan — MFA rows are user-scale, not
|
|
122
|
+
// tenant-scale like config; cursor pagination when that ever matters.
|
|
123
|
+
// Served in slices so runChunkedMigration's deadline/signal/failure
|
|
124
|
+
// checks run between chunks, not only once.
|
|
125
|
+
let pending: UserMfaRow[] | undefined;
|
|
126
|
+
async function nextBatch(): Promise<readonly UserMfaRow[]> {
|
|
127
|
+
if (pending === undefined) {
|
|
128
|
+
pending = [...(await selectMany<UserMfaRow>(db, userMfaTable, {}))];
|
|
129
|
+
}
|
|
130
|
+
return pending.splice(0, batchSize);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function migrateRow(row: UserMfaRow): Promise<"migrated" | "skipped" | "failed"> {
|
|
134
|
+
if (!needsReencrypt(row.totpSecret, targetVersion)) {
|
|
135
|
+
alreadyCurrent++;
|
|
136
|
+
return "skipped";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const tenantId = row.tenantId as TenantId; // @cast-boundary db-row
|
|
140
|
+
// decrypt failure (missing legacy key, unknown kekVersion, tamper)
|
|
141
|
+
// throws → counted as failed via onRowError; the row stays untouched.
|
|
142
|
+
const plaintext = await cipher.decrypt(row.totpSecret, { tenantId });
|
|
143
|
+
|
|
144
|
+
const actor: SessionUser = { id: "system", tenantId, roles: SYSTEM_ROLES };
|
|
145
|
+
// PLAINTEXT here, not a manually-built envelope — the executor's own
|
|
146
|
+
// write-path (encryptForStorage) re-encrypts under the CURRENT
|
|
147
|
+
// injected cipher/KEK, exactly like every other write to this field.
|
|
148
|
+
const result = await executor.update(
|
|
149
|
+
{ id: row.id, version: row.version, changes: { totpSecret: plaintext } },
|
|
150
|
+
actor,
|
|
151
|
+
tdbFor(tenantId),
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
// version_conflict == a concurrent enable/disable/regenerate beat us;
|
|
155
|
+
// the row now holds a fresh write, already fine.
|
|
156
|
+
if (!result.isSuccess) {
|
|
157
|
+
if (result.error.code === "version_conflict") return "skipped";
|
|
158
|
+
ctx.log?.warn?.(`[auth-mfa:reencrypt] executor rejected row ${row.id}`, {
|
|
159
|
+
code: result.error.code,
|
|
160
|
+
});
|
|
161
|
+
return "failed";
|
|
162
|
+
}
|
|
163
|
+
return "migrated";
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const outcome = await runChunkedMigration<UserMfaRow>({
|
|
167
|
+
nextBatch,
|
|
168
|
+
migrateRow,
|
|
169
|
+
maxFailures,
|
|
170
|
+
deadlineAt: deadline,
|
|
171
|
+
signal: ctx.signal,
|
|
172
|
+
onRowError: (row, err) => {
|
|
173
|
+
ctx.log?.warn?.(`[auth-mfa:reencrypt] failed to re-encrypt row ${row.id}`, { err });
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const result: MfaReencryptJobResult = {
|
|
178
|
+
migrated: outcome.migrated,
|
|
179
|
+
failed: outcome.failed,
|
|
180
|
+
alreadyCurrent,
|
|
181
|
+
batchesProcessed: outcome.batchesProcessed,
|
|
182
|
+
stoppedReason: outcome.stoppedReason,
|
|
183
|
+
};
|
|
184
|
+
ctx.log?.info?.(`[auth-mfa:reencrypt] complete: ${JSON.stringify(result)}`);
|
|
185
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
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 { generateRecoveryCodes, hashRecoveryCodes } from "../recovery-codes";
|
|
7
|
+
import { userMfaEntity, userMfaTable } from "../schema/user-mfa";
|
|
8
|
+
import { verifyMfaFactor } from "../verify-factor";
|
|
9
|
+
|
|
10
|
+
export type RegenerateRecoveryOptions = {
|
|
11
|
+
readonly revokeAllOtherSessions?: (
|
|
12
|
+
userId: string,
|
|
13
|
+
currentSid: string | undefined,
|
|
14
|
+
) => Promise<number>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const executor = createEventStoreExecutor(userMfaTable, userMfaEntity, {
|
|
18
|
+
entityName: "user-mfa",
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Invalidates ALL existing recovery codes (even unused ones) and issues a
|
|
22
|
+
// fresh set of 8 — the standard response to "I think my recovery codes
|
|
23
|
+
// leaked" without having to disable+re-enable TOTP itself.
|
|
24
|
+
export function createRegenerateRecoveryHandler(opts: RegenerateRecoveryOptions) {
|
|
25
|
+
return defineWriteHandler({
|
|
26
|
+
name: "regenerate-recovery",
|
|
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
|
+
// Proof of possession before wiping the old codes — a TOTP code or
|
|
34
|
+
// (deliberately) one of the very codes about to be replaced both
|
|
35
|
+
// count, since either way the presented code is consumed/irrelevant
|
|
36
|
+
// afterward.
|
|
37
|
+
const verify = await verifyMfaFactor(row, event.payload.code);
|
|
38
|
+
if (!verify.ok) return invalidTotpCode();
|
|
39
|
+
|
|
40
|
+
const newCodes = generateRecoveryCodes();
|
|
41
|
+
const newHashes = await hashRecoveryCodes(newCodes);
|
|
42
|
+
|
|
43
|
+
const result = await executor.update(
|
|
44
|
+
{
|
|
45
|
+
id: row.id,
|
|
46
|
+
version: row.version,
|
|
47
|
+
changes: { recoveryCodes: { hashes: newHashes } },
|
|
48
|
+
},
|
|
49
|
+
event.user,
|
|
50
|
+
ctx.db,
|
|
51
|
+
);
|
|
52
|
+
if (!result.isSuccess) return result;
|
|
53
|
+
|
|
54
|
+
if (opts.revokeAllOtherSessions) {
|
|
55
|
+
await opts.revokeAllOtherSessions(event.user.id, event.user.sid);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Plaintext — shown once, same as the initial enable-flow codes.
|
|
59
|
+
return { isSuccess: true, data: { recoveryCodes: newCodes } };
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import {
|
|
3
|
+
buildSessionRoles,
|
|
4
|
+
createSystemUser,
|
|
5
|
+
defineWriteHandler,
|
|
6
|
+
type SessionUser,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { burnToken } from "../../shared";
|
|
11
|
+
import { UserQueries } from "../../user";
|
|
12
|
+
import { MFA_VERIFY_LOCKOUT_MINUTES, MFA_VERIFY_MAX_ATTEMPTS } from "../constants";
|
|
13
|
+
import { findUserMfaRow } from "../db/queries";
|
|
14
|
+
import { invalidChallengeToken, invalidTotpCode, tooManyAttempts } from "../errors";
|
|
15
|
+
import { verifyMfaChallengeToken } from "../mfa-challenge-token";
|
|
16
|
+
import {
|
|
17
|
+
clearMfaVerifyAttempts,
|
|
18
|
+
getMfaVerifyLockoutState,
|
|
19
|
+
recordFailedMfaVerifyAttempt,
|
|
20
|
+
} from "../mfa-verify-attempts";
|
|
21
|
+
import { userMfaEntity, userMfaTable } from "../schema/user-mfa";
|
|
22
|
+
import { verifyMfaFactor } from "../verify-factor";
|
|
23
|
+
|
|
24
|
+
export type MfaVerifyOptions = {
|
|
25
|
+
// Must match the secret the login handler signs mfa-challenge tokens
|
|
26
|
+
// with — distinct from setupTokenSecret (different token purpose).
|
|
27
|
+
readonly challengeTokenSecret: string;
|
|
28
|
+
readonly maxAttempts?: number;
|
|
29
|
+
readonly lockoutMinutes?: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const executor = createEventStoreExecutor(userMfaTable, userMfaEntity, {
|
|
33
|
+
entityName: "user-mfa",
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Completes the two-step login. Runs pre-session (dispatched by the
|
|
37
|
+
// framework's /auth/mfa/verify route with a guest identity, same as
|
|
38
|
+
// login.write.ts) — everything it needs (which user, which tenant) comes
|
|
39
|
+
// from the challenge token, not from an authenticated caller.
|
|
40
|
+
export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
|
|
41
|
+
const maxAttempts = opts.maxAttempts ?? MFA_VERIFY_MAX_ATTEMPTS;
|
|
42
|
+
const lockoutMinutes = opts.lockoutMinutes ?? MFA_VERIFY_LOCKOUT_MINUTES;
|
|
43
|
+
|
|
44
|
+
return defineWriteHandler({
|
|
45
|
+
name: "verify",
|
|
46
|
+
schema: z.object({ challengeToken: z.string().min(1), code: z.string().min(6).max(9) }),
|
|
47
|
+
access: { roles: ["all"] },
|
|
48
|
+
handler: async (event, ctx) => {
|
|
49
|
+
const verified = verifyMfaChallengeToken(
|
|
50
|
+
event.payload.challengeToken,
|
|
51
|
+
opts.challengeTokenSecret,
|
|
52
|
+
);
|
|
53
|
+
if (!verified.ok) return invalidChallengeToken();
|
|
54
|
+
const { userId, tenantId } = verified.payload;
|
|
55
|
+
|
|
56
|
+
// Per-account brute-force cap — survives challenge-token reissuance
|
|
57
|
+
// on purpose (see mfa-verify-attempts.ts header). Checked BEFORE the
|
|
58
|
+
// TOTP/recovery verify so a locked account can't be guessed against.
|
|
59
|
+
if (ctx.redis) {
|
|
60
|
+
const state = await getMfaVerifyLockoutState(ctx.redis, userId);
|
|
61
|
+
if (state?.lockedUntil !== null && state?.lockedUntil !== undefined) {
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
if (state.lockedUntil > now) {
|
|
64
|
+
const retryAfterSeconds = Math.max(1, Math.ceil((state.lockedUntil - now) / 1000));
|
|
65
|
+
return tooManyAttempts(retryAfterSeconds);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// "system" mode: this handler runs with a guest identity whose own
|
|
71
|
+
// tenantId is meaningless here — the challenge token is the source
|
|
72
|
+
// of truth for which tenant's row to read.
|
|
73
|
+
const scopedDb = createTenantDb(ctx.db.raw, tenantId, "system");
|
|
74
|
+
const scopedUser: SessionUser = { id: userId, tenantId, roles: ["User"] };
|
|
75
|
+
const row = await findUserMfaRow(scopedDb, scopedUser);
|
|
76
|
+
// MFA got disabled between login and verify (race, or a stale
|
|
77
|
+
// challenge token from before a disable) — same generic error as a
|
|
78
|
+
// bad code, no detail leak about account state.
|
|
79
|
+
if (!row) return invalidChallengeToken();
|
|
80
|
+
|
|
81
|
+
const verify = await verifyMfaFactor(row, event.payload.code);
|
|
82
|
+
if (!verify.ok) {
|
|
83
|
+
if (ctx.redis) {
|
|
84
|
+
await recordFailedMfaVerifyAttempt(ctx.redis, userId, maxAttempts, lockoutMinutes);
|
|
85
|
+
}
|
|
86
|
+
return invalidTotpCode();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Recovery-code use consumes the code — persist the reduced hash-list
|
|
90
|
+
// immediately so it can't be replayed. Without this a recovery code
|
|
91
|
+
// would work forever instead of being single-use.
|
|
92
|
+
if (verify.method === "recovery") {
|
|
93
|
+
const updateResult = await executor.update(
|
|
94
|
+
{
|
|
95
|
+
id: row.id,
|
|
96
|
+
version: row.version,
|
|
97
|
+
changes: { recoveryCodes: { hashes: verify.remainingHashes } },
|
|
98
|
+
},
|
|
99
|
+
scopedUser,
|
|
100
|
+
scopedDb,
|
|
101
|
+
);
|
|
102
|
+
if (!updateResult.isSuccess) return updateResult;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (ctx.redis) {
|
|
106
|
+
// Burn AFTER a successful verify — a still-failing attempt must not
|
|
107
|
+
// consume the token, or a fat-fingered code would strand the user.
|
|
108
|
+
const burnResult = await burnToken(
|
|
109
|
+
ctx.redis,
|
|
110
|
+
"mfa-challenge",
|
|
111
|
+
userId,
|
|
112
|
+
verified.expiresAtMs,
|
|
113
|
+
);
|
|
114
|
+
if (burnResult === "already-used") return invalidChallengeToken();
|
|
115
|
+
await clearMfaVerifyAttempts(ctx.redis, userId);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Re-derive the full session the way login.write.ts would have, for
|
|
119
|
+
// the SAME tenant the challenge token already committed to (no
|
|
120
|
+
// "pick a tenant" step here — that already happened at login time).
|
|
121
|
+
const systemUser = createSystemUser(tenantId, ["SystemAdmin"]);
|
|
122
|
+
const userRow = (await ctx.queryAs(systemUser, UserQueries.detail, {
|
|
123
|
+
id: userId,
|
|
124
|
+
})) as { roles?: string | null } | null; // @cast-boundary engine-payload
|
|
125
|
+
const globalRoles = parseRoles(userRow?.roles ?? null);
|
|
126
|
+
|
|
127
|
+
const memberships = (await ctx.queryAs(systemUser, "tenant:query:memberships", {
|
|
128
|
+
userId,
|
|
129
|
+
})) as ReadonlyArray<{ tenantId: string; roles: readonly string[] }>; // @cast-boundary engine-payload
|
|
130
|
+
const membership = memberships.find((m) => m.tenantId === tenantId);
|
|
131
|
+
const mergedRoles = buildSessionRoles(globalRoles, membership?.roles ?? []);
|
|
132
|
+
|
|
133
|
+
const baseSession: SessionUser = { id: userId, tenantId, roles: mergedRoles };
|
|
134
|
+
const claims = await ctx.resolveAuthClaims(baseSession);
|
|
135
|
+
const session: SessionUser =
|
|
136
|
+
Object.keys(claims).length > 0 ? { ...baseSession, claims } : baseSession;
|
|
137
|
+
|
|
138
|
+
return { isSuccess: true, data: { kind: "mfa-verify-success", session } };
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Server-side, boot-validated i18n keys — screen titles/descriptions the
|
|
2
|
+
// engine requires every registered r.screen to declare. Distinct from
|
|
3
|
+
// web/i18n.ts (the client's free-form UI-string bundle) — see
|
|
4
|
+
// personal-access-tokens/i18n.ts for the same split.
|
|
5
|
+
type LocalizedString = { readonly de: string; readonly en: string };
|
|
6
|
+
|
|
7
|
+
export const AUTH_MFA_FEATURE_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
8
|
+
"screen:auth-mfa-enable.title": {
|
|
9
|
+
de: "Zwei-Faktor-Authentifizierung",
|
|
10
|
+
en: "Two-factor authentication",
|
|
11
|
+
},
|
|
12
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { base32Decode } from "./base32";
|
|
2
|
+
export { type MfaRequiredPolicy, mfaRequiredConfigHandle } from "./config";
|
|
3
|
+
export { AUTH_MFA_FEATURE, AuthMfaHandlers, MFA_ENABLE_SCREEN_ID } from "./constants";
|
|
4
|
+
export type {
|
|
5
|
+
AuthMfaFeatureOptions,
|
|
6
|
+
BindMfaRevokeAllOtherSessions,
|
|
7
|
+
} from "./feature";
|
|
8
|
+
export {
|
|
9
|
+
bindMfaRevokeAllOtherSessionsFromFeature,
|
|
10
|
+
createAuthMfaFeature,
|
|
11
|
+
mfaStatusCheckerFromFeature,
|
|
12
|
+
} from "./feature";
|
|
13
|
+
export type { MfaStatusChecker, MfaStatusCheckResult } from "./mfa-status-checker";
|
|
14
|
+
export { userMfaEntity, userMfaTable } from "./schema/user-mfa";
|
|
15
|
+
export { currentTotpCode } from "./totp";
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// HMAC-signed token minted by the login handler once the password check
|
|
2
|
+
// succeeds but a second factor is still owed. Carries just enough to look
|
|
3
|
+
// the user back up at `/auth/mfa/verify` — no secret material (unlike
|
|
4
|
+
// mfa-setup-token.ts, this token is never shown to the user, only round-
|
|
5
|
+
// tripped by the client between the two login requests).
|
|
6
|
+
//
|
|
7
|
+
// Format: <base64url(JSON body)>.<hmac-base64url> — same shape as
|
|
8
|
+
// mfa-setup-token.ts, distinct HMAC domain-separation string so a setup
|
|
9
|
+
// token can never be replayed as a challenge token or vice versa.
|
|
10
|
+
//
|
|
11
|
+
// Replay protection: burnToken()/unburnToken() from shared/token-burn-store
|
|
12
|
+
// (purpose "mfa-challenge") make the token single-use on a SUCCESSFUL
|
|
13
|
+
// verify. Brute-force protection is a SEPARATE mechanism — see
|
|
14
|
+
// mfa-verify-attempts.ts — because burn-on-success alone doesn't cap wrong
|
|
15
|
+
// guesses against a still-valid token.
|
|
16
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
17
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
18
|
+
import { Temporal } from "temporal-polyfill";
|
|
19
|
+
|
|
20
|
+
export type MfaChallengePayload = {
|
|
21
|
+
readonly userId: string;
|
|
22
|
+
readonly tenantId: TenantId;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type EncodedBody = MfaChallengePayload & { readonly expiresAtMs: number };
|
|
26
|
+
|
|
27
|
+
function sign(input: string, secret: string): string {
|
|
28
|
+
return createHmac("sha256", secret).update(input).digest("base64url");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function signMfaChallengeToken(
|
|
32
|
+
payload: MfaChallengePayload,
|
|
33
|
+
ttlMinutes: number,
|
|
34
|
+
secret: string,
|
|
35
|
+
now: Temporal.Instant = Temporal.Now.instant(),
|
|
36
|
+
): { token: string; expiresAt: Temporal.Instant } {
|
|
37
|
+
const expiresAt = now.add({ minutes: ttlMinutes });
|
|
38
|
+
const body: EncodedBody = { ...payload, expiresAtMs: expiresAt.epochMilliseconds };
|
|
39
|
+
const bodyB64 = Buffer.from(JSON.stringify(body)).toString("base64url");
|
|
40
|
+
const signature = sign(`mfa-challenge:${bodyB64}`, secret);
|
|
41
|
+
return { token: `${bodyB64}.${signature}`, expiresAt };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type VerifyMfaChallengeResult =
|
|
45
|
+
| { readonly ok: true; readonly payload: MfaChallengePayload; readonly expiresAtMs: number }
|
|
46
|
+
| { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
|
|
47
|
+
|
|
48
|
+
function isEncodedBody(value: unknown): value is EncodedBody {
|
|
49
|
+
if (typeof value !== "object" || value === null) return false;
|
|
50
|
+
const v = value as Record<string, unknown>;
|
|
51
|
+
return (
|
|
52
|
+
typeof v["userId"] === "string" &&
|
|
53
|
+
typeof v["tenantId"] === "string" &&
|
|
54
|
+
typeof v["expiresAtMs"] === "number"
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function verifyMfaChallengeToken(
|
|
59
|
+
token: string,
|
|
60
|
+
secret: string,
|
|
61
|
+
now: Temporal.Instant = Temporal.Now.instant(),
|
|
62
|
+
): VerifyMfaChallengeResult {
|
|
63
|
+
const parts = token.split(".");
|
|
64
|
+
if (parts.length !== 2) return { ok: false, reason: "malformed" };
|
|
65
|
+
const [bodyB64, providedSig] = parts;
|
|
66
|
+
if (!bodyB64 || !providedSig) return { ok: false, reason: "malformed" };
|
|
67
|
+
|
|
68
|
+
const expected = sign(`mfa-challenge:${bodyB64}`, secret);
|
|
69
|
+
const expectedBuf = Buffer.from(expected, "base64url");
|
|
70
|
+
const providedBuf = Buffer.from(providedSig, "base64url");
|
|
71
|
+
if (expectedBuf.length !== providedBuf.length) return { ok: false, reason: "bad_signature" };
|
|
72
|
+
if (!timingSafeEqual(expectedBuf, providedBuf)) return { ok: false, reason: "bad_signature" };
|
|
73
|
+
|
|
74
|
+
let parsed: unknown;
|
|
75
|
+
try {
|
|
76
|
+
parsed = JSON.parse(Buffer.from(bodyB64, "base64url").toString("utf8"));
|
|
77
|
+
} catch {
|
|
78
|
+
return { ok: false, reason: "malformed" };
|
|
79
|
+
}
|
|
80
|
+
if (!isEncodedBody(parsed)) return { ok: false, reason: "malformed" };
|
|
81
|
+
|
|
82
|
+
if (
|
|
83
|
+
Temporal.Instant.compare(now, Temporal.Instant.fromEpochMilliseconds(parsed.expiresAtMs)) > 0
|
|
84
|
+
) {
|
|
85
|
+
return { ok: false, reason: "expired" };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const { expiresAtMs, ...payload } = parsed;
|
|
89
|
+
return { ok: true, payload, expiresAtMs };
|
|
90
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// HMAC-signed token carrying the pending MFA setup state between
|
|
2
|
+
// `mfa.enable.start` and `mfa.enable.confirm` — no DB row exists until
|
|
3
|
+
// confirm succeeds, so an abandoned setup leaves no trace. The token embeds
|
|
4
|
+
// the TOTP secret and recovery-code hashes; this is not a confidentiality
|
|
5
|
+
// leak because the client already displayed the same secret as a QR code in
|
|
6
|
+
// the same response — the token exists to avoid a second round-trip to
|
|
7
|
+
// regenerate it, not to hide it.
|
|
8
|
+
//
|
|
9
|
+
// Format: <base64url(JSON body)>.<hmac-base64url>. Distinct from
|
|
10
|
+
// signed-token.ts (userId-only payload) because setup needs to carry
|
|
11
|
+
// generated secret material, not just re-derive it from a lookup.
|
|
12
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
13
|
+
import { Temporal } from "temporal-polyfill";
|
|
14
|
+
|
|
15
|
+
export type MfaSetupPayload = {
|
|
16
|
+
readonly userId: string;
|
|
17
|
+
readonly totpSecretBase32: string;
|
|
18
|
+
readonly recoveryCodeHashes: readonly string[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type EncodedBody = MfaSetupPayload & { readonly expiresAtMs: number };
|
|
22
|
+
|
|
23
|
+
function sign(input: string, secret: string): string {
|
|
24
|
+
return createHmac("sha256", secret).update(input).digest("base64url");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function signMfaSetupToken(
|
|
28
|
+
payload: MfaSetupPayload,
|
|
29
|
+
ttlMinutes: number,
|
|
30
|
+
secret: string,
|
|
31
|
+
now: Temporal.Instant = Temporal.Now.instant(),
|
|
32
|
+
): { token: string; expiresAt: Temporal.Instant } {
|
|
33
|
+
const expiresAt = now.add({ minutes: ttlMinutes });
|
|
34
|
+
const body: EncodedBody = { ...payload, expiresAtMs: expiresAt.epochMilliseconds };
|
|
35
|
+
const bodyB64 = Buffer.from(JSON.stringify(body)).toString("base64url");
|
|
36
|
+
const signature = sign(`mfa-setup:${bodyB64}`, secret);
|
|
37
|
+
return { token: `${bodyB64}.${signature}`, expiresAt };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type VerifyMfaSetupResult =
|
|
41
|
+
| { readonly ok: true; readonly payload: MfaSetupPayload; readonly expiresAtMs: number }
|
|
42
|
+
| { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
|
|
43
|
+
|
|
44
|
+
function isEncodedBody(value: unknown): value is EncodedBody {
|
|
45
|
+
if (typeof value !== "object" || value === null) return false;
|
|
46
|
+
const v = value as Record<string, unknown>;
|
|
47
|
+
return (
|
|
48
|
+
typeof v["userId"] === "string" &&
|
|
49
|
+
typeof v["totpSecretBase32"] === "string" &&
|
|
50
|
+
Array.isArray(v["recoveryCodeHashes"]) &&
|
|
51
|
+
v["recoveryCodeHashes"].every((h) => typeof h === "string") &&
|
|
52
|
+
typeof v["expiresAtMs"] === "number"
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function verifyMfaSetupToken(
|
|
57
|
+
token: string,
|
|
58
|
+
secret: string,
|
|
59
|
+
now: Temporal.Instant = Temporal.Now.instant(),
|
|
60
|
+
): VerifyMfaSetupResult {
|
|
61
|
+
const parts = token.split(".");
|
|
62
|
+
if (parts.length !== 2) return { ok: false, reason: "malformed" };
|
|
63
|
+
const [bodyB64, providedSig] = parts;
|
|
64
|
+
if (!bodyB64 || !providedSig) return { ok: false, reason: "malformed" };
|
|
65
|
+
|
|
66
|
+
const expected = sign(`mfa-setup:${bodyB64}`, secret);
|
|
67
|
+
const expectedBuf = Buffer.from(expected, "base64url");
|
|
68
|
+
const providedBuf = Buffer.from(providedSig, "base64url");
|
|
69
|
+
// Length check before timingSafeEqual — a length mismatch throws inside
|
|
70
|
+
// timingSafeEqual, and that throw is itself a timing signal.
|
|
71
|
+
if (expectedBuf.length !== providedBuf.length) return { ok: false, reason: "bad_signature" };
|
|
72
|
+
if (!timingSafeEqual(expectedBuf, providedBuf)) return { ok: false, reason: "bad_signature" };
|
|
73
|
+
|
|
74
|
+
let parsed: unknown;
|
|
75
|
+
try {
|
|
76
|
+
parsed = JSON.parse(Buffer.from(bodyB64, "base64url").toString("utf8"));
|
|
77
|
+
} catch {
|
|
78
|
+
return { ok: false, reason: "malformed" };
|
|
79
|
+
}
|
|
80
|
+
if (!isEncodedBody(parsed)) return { ok: false, reason: "malformed" };
|
|
81
|
+
|
|
82
|
+
if (
|
|
83
|
+
Temporal.Instant.compare(now, Temporal.Instant.fromEpochMilliseconds(parsed.expiresAtMs)) > 0
|
|
84
|
+
) {
|
|
85
|
+
return { ok: false, reason: "expired" };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const { expiresAtMs, ...payload } = parsed;
|
|
89
|
+
return { ok: true, payload, expiresAtMs };
|
|
90
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import {
|
|
3
|
+
access,
|
|
4
|
+
type HandlerContext,
|
|
5
|
+
type SessionUser,
|
|
6
|
+
type TenantId,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { type MfaRequiredPolicy, mfaRequiredConfigHandle, mfaRequiredConfigKey } from "./config";
|
|
9
|
+
import { MFA_CHALLENGE_TOKEN_TTL_MINUTES } from "./constants";
|
|
10
|
+
import { findUserMfaRow } from "./db/queries";
|
|
11
|
+
import { signMfaChallengeToken } from "./mfa-challenge-token";
|
|
12
|
+
|
|
13
|
+
export type MfaStatusCheckResult =
|
|
14
|
+
| { readonly required: false }
|
|
15
|
+
| { readonly required: true; readonly challengeToken: string }
|
|
16
|
+
// Enforcement policy demands MFA for this user, but they haven't enrolled
|
|
17
|
+
// yet. Distinct from `required: true` (which means "enrolled, prove it
|
|
18
|
+
// now") — see config.ts's ponytail note on why this hard-blocks login
|
|
19
|
+
// with no in-band recovery until PR3's enrollment-during-login UI ships.
|
|
20
|
+
| { readonly setupRequired: true };
|
|
21
|
+
|
|
22
|
+
// Consumed by auth-email-password's login.write.ts via `mfaStatusChecker`
|
|
23
|
+
// (a generic callback type declared THERE, not here — auth-email-password
|
|
24
|
+
// must not import auth-mfa's config, only the shape).
|
|
25
|
+
export type MfaStatusChecker = (
|
|
26
|
+
ctx: HandlerContext,
|
|
27
|
+
userId: string,
|
|
28
|
+
tenantId: TenantId,
|
|
29
|
+
// Merged global+tenant roles — needed to evaluate the "admins" policy
|
|
30
|
+
// value. Must be the MERGED set (see login.write.ts caller): a
|
|
31
|
+
// SystemAdmin whose admin-ness lives only in global roles would be
|
|
32
|
+
// missed if the caller passed tenant-membership roles alone.
|
|
33
|
+
roles: readonly string[],
|
|
34
|
+
) => Promise<MfaStatusCheckResult>;
|
|
35
|
+
|
|
36
|
+
export function createMfaStatusChecker(opts: {
|
|
37
|
+
readonly challengeTokenSecret: string;
|
|
38
|
+
}): MfaStatusChecker {
|
|
39
|
+
return async (ctx, userId, tenantId, roles) => {
|
|
40
|
+
const scopedDb = createTenantDb(ctx.db.raw, tenantId, "system");
|
|
41
|
+
const scopedUser: SessionUser = { id: userId, tenantId, roles: ["User"] };
|
|
42
|
+
const row = await findUserMfaRow(scopedDb, scopedUser);
|
|
43
|
+
|
|
44
|
+
if (row) {
|
|
45
|
+
// Enrolled — always enforce regardless of policy. The user opted in
|
|
46
|
+
// themselves; policy only governs whether enrollment is MANDATORY
|
|
47
|
+
// for those who haven't.
|
|
48
|
+
const { token } = signMfaChallengeToken(
|
|
49
|
+
{ userId, tenantId },
|
|
50
|
+
MFA_CHALLENGE_TOKEN_TTL_MINUTES,
|
|
51
|
+
opts.challengeTokenSecret,
|
|
52
|
+
);
|
|
53
|
+
return { required: true, challengeToken: token };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ctx.config is bound to the CALLING user's tenant (GUEST_USER for
|
|
57
|
+
// login) — wrong tenant here. ctx.configResolver takes tenantId
|
|
58
|
+
// explicitly, which is what a pre-session check against the LOGGING-IN
|
|
59
|
+
// user's tenant needs.
|
|
60
|
+
// @cast-boundary engine-payload — "select" config values are validated
|
|
61
|
+
// against the declared `options` at write-time by the config feature.
|
|
62
|
+
const policy = ((await ctx.configResolver?.get(
|
|
63
|
+
mfaRequiredConfigHandle.name,
|
|
64
|
+
mfaRequiredConfigKey(),
|
|
65
|
+
tenantId,
|
|
66
|
+
userId,
|
|
67
|
+
scopedDb,
|
|
68
|
+
)) ?? "optional") as MfaRequiredPolicy | "optional";
|
|
69
|
+
if (policy === "optional") return { required: false };
|
|
70
|
+
if (policy === "all") return { setupRequired: true };
|
|
71
|
+
// policy === "admins"
|
|
72
|
+
const isAdmin = roles.some((role) => access.admin.includes(role));
|
|
73
|
+
return isAdmin ? { setupRequired: true } : { required: false };
|
|
74
|
+
};
|
|
75
|
+
}
|