@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,90 @@
1
+ // Redis-backed brute-force cap for `/auth/mfa/verify`, keyed by userId (NOT
2
+ // by challenge token). Modeled on auth-email-password/lockout-store.ts.
3
+ //
4
+ // Why keyed by userId, not challengeToken: a challenge token is reissued on
5
+ // every successful password login (see mfa-challenge-token.ts) — a fresh
6
+ // token would reset a token-keyed counter to zero, letting an attacker who
7
+ // already has the password just re-login repeatedly to get unlimited TOTP
8
+ // guesses. A 6-digit code with a ±1-step window has 3 valid values out of
9
+ // 10^6; without a cap that survives across challenge reissuance, online
10
+ // brute-force is practical. The counter's lifetime crosses token
11
+ // boundaries on purpose.
12
+ //
13
+ // This is INDEPENDENT of the AuthRoutesConfig.mfaVerifyRateLimit on the
14
+ // framework route (IP-scoped abuse protection for the endpoint itself) —
15
+ // both are needed, neither substitutes for the other.
16
+
17
+ import type Redis from "ioredis";
18
+
19
+ export type MfaVerifyLockoutState = {
20
+ readonly failureCount: number;
21
+ readonly lockedUntil: number | null;
22
+ };
23
+
24
+ const COUNT_KEY_PREFIX = "kumiko:auth:mfa-verify:count:";
25
+ const UNTIL_KEY_PREFIX = "kumiko:auth:mfa-verify:until:";
26
+
27
+ function countKey(userId: string): string {
28
+ return `${COUNT_KEY_PREFIX}${userId}`;
29
+ }
30
+ function untilKey(userId: string): string {
31
+ return `${UNTIL_KEY_PREFIX}${userId}`;
32
+ }
33
+
34
+ export async function getMfaVerifyLockoutState(
35
+ redis: Redis,
36
+ userId: string,
37
+ ): Promise<MfaVerifyLockoutState | null> {
38
+ const [countRaw, untilRaw] = await redis.mget(countKey(userId), untilKey(userId));
39
+ if (countRaw === null) return null;
40
+ const failureCount = Number(countRaw);
41
+ if (!Number.isFinite(failureCount)) return null;
42
+ const lockedUntil = untilRaw !== null ? Number(untilRaw) : null;
43
+ return {
44
+ failureCount,
45
+ lockedUntil: lockedUntil !== null && Number.isFinite(lockedUntil) ? lockedUntil : null,
46
+ };
47
+ }
48
+
49
+ // Race-free: INCR is atomic, NX on the until-key means only the attempt
50
+ // that first crosses the threshold anchors the lock window — see
51
+ // lockout-store.ts's recordFailedAttempt for the identical reasoning.
52
+ export async function recordFailedMfaVerifyAttempt(
53
+ redis: Redis,
54
+ userId: string,
55
+ maxAttempts: number,
56
+ lockoutMinutes: number,
57
+ ): Promise<MfaVerifyLockoutState> {
58
+ const lockDurationMs = lockoutMinutes * 60 * 1000;
59
+ const ttlSec = Math.max(lockoutMinutes * 60, 24 * 3600);
60
+
61
+ const count = await redis.incr(countKey(userId));
62
+ if (count === 1) {
63
+ await redis.expire(countKey(userId), ttlSec);
64
+ }
65
+
66
+ let lockedUntil: number | null = null;
67
+ if (count >= maxAttempts) {
68
+ const computedUntil = Date.now() + lockDurationMs;
69
+ const setOk = await redis.set(
70
+ untilKey(userId),
71
+ String(computedUntil),
72
+ "PX",
73
+ lockDurationMs,
74
+ "NX",
75
+ );
76
+ if (setOk === "OK") {
77
+ lockedUntil = computedUntil;
78
+ } else {
79
+ const existing = await redis.get(untilKey(userId));
80
+ lockedUntil = existing !== null ? Number(existing) : null;
81
+ }
82
+ }
83
+
84
+ return { failureCount: count, lockedUntil };
85
+ }
86
+
87
+ // Called on a successful verify. The only path that resets the streak.
88
+ export async function clearMfaVerifyAttempts(redis: Redis, userId: string): Promise<void> {
89
+ await redis.del(countKey(userId), untilKey(userId));
90
+ }
@@ -0,0 +1,44 @@
1
+ import { randomInt } from "node:crypto";
2
+ import { hashPassword, verifyPassword } from "../shared";
3
+
4
+ const RECOVERY_CODE_COUNT = 8;
5
+ // No ambiguous chars (0/O, 1/I) — these get read aloud or copy-typed from a
6
+ // screen during account-recovery, a context where a misread costs a support
7
+ // ticket, not just a retry.
8
+ const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
9
+
10
+ function randomCodeGroup(): string {
11
+ let out = "";
12
+ for (let i = 0; i < 4; i++) {
13
+ out += CODE_ALPHABET[randomInt(CODE_ALPHABET.length)];
14
+ }
15
+ return out;
16
+ }
17
+
18
+ function generateRecoveryCode(): string {
19
+ return `${randomCodeGroup()}-${randomCodeGroup()}`;
20
+ }
21
+
22
+ export function generateRecoveryCodes(count: number = RECOVERY_CODE_COUNT): string[] {
23
+ return Array.from({ length: count }, generateRecoveryCode);
24
+ }
25
+
26
+ // Reuses the same argon2id hashing as passwords — recovery codes are
27
+ // bearer-secrets of comparable sensitivity (whoever has one can log in).
28
+ export async function hashRecoveryCodes(codes: readonly string[]): Promise<string[]> {
29
+ return Promise.all(codes.map((code) => hashPassword(code)));
30
+ }
31
+
32
+ // Sequential (not Promise.all) — recovery-code lists are ≤8 entries, and a
33
+ // user typing a wrong code should not pay 8x argon2 cost when the first
34
+ // hash already matches.
35
+ export async function findMatchingRecoveryCodeIndex(
36
+ code: string,
37
+ hashes: readonly string[],
38
+ ): Promise<number> {
39
+ for (let i = 0; i < hashes.length; i++) {
40
+ const hash = hashes[i];
41
+ if (hash !== undefined && (await verifyPassword(hash, code))) return i;
42
+ }
43
+ return -1;
44
+ }
@@ -0,0 +1,40 @@
1
+ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
2
+ import {
3
+ createEntity,
4
+ createJsonbField,
5
+ createTextField,
6
+ createTimestampField,
7
+ } from "@cosmicdrift/kumiko-framework/engine";
8
+
9
+ // One row per (userId, tenantId) — presence of a row IS "MFA enabled" for
10
+ // that user. Event-sourced (not unmanagedTable): enable/disable/regenerate
11
+ // are rare, auditable state changes, not a hot-path lookup like sessions —
12
+ // mfa.enabled/disabled fall out of the event stream instead of being
13
+ // manually emitted.
14
+ export const userMfaEntity = createEntity({
15
+ table: "read_user_mfa",
16
+ fields: {
17
+ // FK to the user row, not content — PII-naming heuristic would flag
18
+ // "userId" as content; it's a pseudonymous reference, matching the same
19
+ // annotation `config`'s userId column uses.
20
+ userId: createTextField({ required: true, allowPlaintext: "pseudonymous-fk" }),
21
+ // Envelope-encrypted via the same MasterKeyProvider as secrets/config
22
+ // (entity-field-encryption.ts) — no manual wrapDek/unwrapDek needed.
23
+ // userOwned: crypto-shredding this field is exactly "revoke this
24
+ // user's 2FA on GDPR forget", which is the correct behavior anyway.
25
+ totpSecret: createTextField({
26
+ required: true,
27
+ encrypted: true,
28
+ userOwned: { ownerField: "userId" },
29
+ }),
30
+ // { hashes: string[] } — argon2id hashes of unredeemed recovery codes.
31
+ // A redeemed code's hash is removed from the array (see disable/regen
32
+ // handlers), so array length also IS the remaining-codes count.
33
+ recoveryCodes: createJsonbField({ userOwned: { ownerField: "userId" } }),
34
+ enabledAt: createTimestampField({ required: true }),
35
+ lastUsedAt: createTimestampField(),
36
+ },
37
+ indexes: [{ unique: true, columns: ["userId", "tenantId"], name: "read_user_mfa_user_unique" }],
38
+ });
39
+
40
+ export const userMfaTable = buildEntityTable("user-mfa", userMfaEntity);
@@ -27,6 +27,14 @@ function hotp(secret: Buffer, counter: number): string {
27
27
  return String(truncated % 10 ** DIGITS).padStart(DIGITS, "0");
28
28
  }
29
29
 
30
+ // Exposed for callers that need to derive "the code right now" — the
31
+ // enable-confirm/verify UI flows show a live-updating code client-side
32
+ // (via their own TOTP lib or a countdown), and tests need a code to submit
33
+ // without hand-rolling the HMAC math again.
34
+ export function currentTotpCode(secret: Buffer, nowMs: number = Date.now()): string {
35
+ return totpAt(secret, Math.floor(nowMs / 1000));
36
+ }
37
+
30
38
  function totpAt(secret: Buffer, epochSeconds: number): string {
31
39
  return hotp(secret, Math.floor(epochSeconds / STEP_SECONDS));
32
40
  }
@@ -0,0 +1,30 @@
1
+ import { base32Decode } from "./base32";
2
+ import type { UserMfaRow } from "./db/queries";
3
+ import { findMatchingRecoveryCodeIndex } from "./recovery-codes";
4
+ import { verifyTotp } from "./totp";
5
+
6
+ export type MfaFactorVerifyResult =
7
+ | { readonly ok: true; readonly method: "totp" }
8
+ | { readonly ok: true; readonly method: "recovery"; readonly remainingHashes: readonly string[] }
9
+ | { readonly ok: false };
10
+
11
+ // Shared by disable/regenerate-recovery (and later /auth/mfa/verify): tries
12
+ // TOTP first (cheap HMAC), falls back to recovery codes (argon2, slower —
13
+ // this is the rare path) only when the code isn't a valid TOTP. Callers
14
+ // that get `method: "recovery"` must persist `remainingHashes` as the new
15
+ // recoveryCodes.hashes — the matched code is single-use and already
16
+ // excluded from the returned array.
17
+ export async function verifyMfaFactor(
18
+ row: UserMfaRow,
19
+ code: string,
20
+ ): Promise<MfaFactorVerifyResult> {
21
+ const secret = base32Decode(row.totpSecret);
22
+ if (verifyTotp(secret, code)) return { ok: true, method: "totp" };
23
+
24
+ const hashes = row.recoveryCodes.hashes;
25
+ const matchIndex = await findMatchingRecoveryCodeIndex(code, hashes);
26
+ if (matchIndex === -1) return { ok: false };
27
+
28
+ const remainingHashes = hashes.filter((_, i) => i !== matchIndex);
29
+ return { ok: true, method: "recovery", remainingHashes };
30
+ }
@@ -0,0 +1,37 @@
1
+ // @runtime client
2
+ // Client-Feature-Factory für auth-mfa — mirrors emailPasswordClient()'s
3
+ // shape so apps register both the same way:
4
+ // createKumikoApp({ clientFeatures: [emailPasswordClient(), authMfaClient()] }).
5
+ // No gates: MfaVerifyScreen isn't a route gate, it's a state swap wired
6
+ // via EmailPasswordClientOptions.mfaVerifyScreen. This factory merges the
7
+ // default de/en translations and maps MFA_ENABLE_SCREEN_ID to
8
+ // MfaEnableScreen (same "components" convention as personal-access-tokens).
9
+
10
+ import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
11
+ import type { ComponentType, ReactNode } from "react";
12
+ import { MFA_ENABLE_SCREEN_ID } from "../constants";
13
+ import { defaultTranslations, mergeTranslations } from "./i18n";
14
+ import { MfaEnableScreen } from "./mfa-enable-screen";
15
+
16
+ export type AuthMfaClientOptions = {
17
+ /** Key-Overrides pro Locale, gemerged mit den Default-Bundles (de/en). */
18
+ readonly translations?: TranslationsByLocale;
19
+ };
20
+
21
+ export type AuthMfaClientFeature = {
22
+ readonly name: "auth-mfa";
23
+ readonly providers: readonly ComponentType<{ children: ReactNode }>[];
24
+ readonly gates: readonly ComponentType<{ children: ReactNode }>[];
25
+ readonly translations: TranslationsByLocale;
26
+ readonly components: Readonly<Record<string, ComponentType>>;
27
+ };
28
+
29
+ export function authMfaClient(options: AuthMfaClientOptions = {}): AuthMfaClientFeature {
30
+ return {
31
+ name: "auth-mfa",
32
+ providers: [],
33
+ gates: [],
34
+ translations: mergeTranslations(defaultTranslations, options.translations ?? {}),
35
+ components: { [MFA_ENABLE_SCREEN_ID]: MfaEnableScreen },
36
+ };
37
+ }
@@ -0,0 +1,69 @@
1
+ // @runtime client
2
+ // Default-Bundles für die auth-mfa Feature-UI. Merged in mit
3
+ // authMfaClient() analog zu emailPasswordClient() aus auth-email-password.
4
+ // Keys folgen `auth.mfa.<area>.<slug>`.
5
+
6
+ import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
7
+
8
+ export const defaultTranslations: TranslationsByLocale = {
9
+ de: {
10
+ "auth.mfa.verify.title": "Zwei-Faktor-Bestätigung",
11
+ "auth.mfa.verify.subtitle": "Gib den 6-stelligen Code aus deiner Authenticator-App ein.",
12
+ "auth.mfa.verify.code": "Code",
13
+ "auth.mfa.verify.submit": "Bestätigen",
14
+ "auth.mfa.verify.submitting": "…",
15
+ "auth.mfa.errors.invalidCode": "Ungültiger Code. Bitte erneut versuchen.",
16
+ "auth.mfa.errors.challengeExpired": "Die Anmeldung ist abgelaufen. Bitte erneut einloggen.",
17
+ "auth.mfa.errors.tooManyAttempts": "Zu viele Fehlversuche. Bitte erneut einloggen.",
18
+ "auth.mfa.errors.verifyFailed": "Bestätigung fehlgeschlagen.",
19
+ "auth.mfa.errors.mfaAlreadyEnabled": "Zwei-Faktor-Authentifizierung ist bereits aktiv.",
20
+ "auth.mfa.errors.mfaNotEnabled": "Zwei-Faktor-Authentifizierung ist nicht aktiv.",
21
+ "auth.mfa.errors.invalidSetupToken": "Die Einrichtung ist abgelaufen. Bitte erneut starten.",
22
+ "auth.mfa.errors.invalidRecoveryCode": "Ungültiger Recovery-Code.",
23
+ "auth.mfa.enable.title": "Zwei-Faktor-Authentifizierung",
24
+ "auth.mfa.enable.intro":
25
+ "Schütze dein Konto zusätzlich mit einer Authenticator-App (z.B. Google Authenticator, 1Password).",
26
+ "auth.mfa.enable.start": "Einrichtung starten",
27
+ "auth.mfa.enable.scanTitle": "QR-Code scannen",
28
+ "auth.mfa.enable.manualEntry": "Oder manuell eingeben:",
29
+ "auth.mfa.enable.recoveryTitle": "Recovery-Codes",
30
+ "auth.mfa.enable.recoveryHint":
31
+ "Speichere diese Codes an einem sicheren Ort. Sie werden nur dieses eine Mal angezeigt und erlauben dir den Zugriff, falls du dein Gerät verlierst.",
32
+ "auth.mfa.enable.acknowledge": "Ich habe die Recovery-Codes gespeichert.",
33
+ "auth.mfa.enable.code": "Code aus der Authenticator-App",
34
+ "auth.mfa.enable.cancel": "Abbrechen",
35
+ "auth.mfa.enable.confirm": "Aktivieren",
36
+ "auth.mfa.enable.success": "Zwei-Faktor-Authentifizierung ist jetzt aktiv.",
37
+ },
38
+ en: {
39
+ "auth.mfa.verify.title": "Two-factor verification",
40
+ "auth.mfa.verify.subtitle": "Enter the 6-digit code from your authenticator app.",
41
+ "auth.mfa.verify.code": "Code",
42
+ "auth.mfa.verify.submit": "Verify",
43
+ "auth.mfa.verify.submitting": "…",
44
+ "auth.mfa.errors.invalidCode": "Invalid code. Please try again.",
45
+ "auth.mfa.errors.challengeExpired": "Your sign-in has expired. Please sign in again.",
46
+ "auth.mfa.errors.tooManyAttempts": "Too many failed attempts. Please sign in again.",
47
+ "auth.mfa.errors.verifyFailed": "Verification failed.",
48
+ "auth.mfa.errors.mfaAlreadyEnabled": "Two-factor authentication is already enabled.",
49
+ "auth.mfa.errors.mfaNotEnabled": "Two-factor authentication is not enabled.",
50
+ "auth.mfa.errors.invalidSetupToken": "Setup expired. Please start again.",
51
+ "auth.mfa.errors.invalidRecoveryCode": "Invalid recovery code.",
52
+ "auth.mfa.enable.title": "Two-factor authentication",
53
+ "auth.mfa.enable.intro":
54
+ "Add an extra layer of protection with an authenticator app (e.g. Google Authenticator, 1Password).",
55
+ "auth.mfa.enable.start": "Start setup",
56
+ "auth.mfa.enable.scanTitle": "Scan the QR code",
57
+ "auth.mfa.enable.manualEntry": "Or enter manually:",
58
+ "auth.mfa.enable.recoveryTitle": "Recovery codes",
59
+ "auth.mfa.enable.recoveryHint":
60
+ "Save these codes somewhere safe. They're shown only this once and let you back in if you lose your device.",
61
+ "auth.mfa.enable.acknowledge": "I've saved my recovery codes.",
62
+ "auth.mfa.enable.code": "Code from your authenticator app",
63
+ "auth.mfa.enable.cancel": "Cancel",
64
+ "auth.mfa.enable.confirm": "Enable",
65
+ "auth.mfa.enable.success": "Two-factor authentication is now enabled.",
66
+ },
67
+ };
68
+
69
+ export { mergeTranslations } from "@cosmicdrift/kumiko-renderer";
@@ -0,0 +1,15 @@
1
+ // @runtime client
2
+ // Public exports für die Browser-Seite des auth-mfa Features. Sub-Path-
3
+ // Export `@cosmicdrift/kumiko-bundled-features/auth-mfa/web` — Server-Seite
4
+ // (defineFeature) bleibt frei von React-/DOM-Deps, siehe auth-email-
5
+ // password/web/index.ts für die selbe Trennung.
6
+
7
+ export type { AuthMfaClientFeature, AuthMfaClientOptions } from "./client-plugin";
8
+ export { authMfaClient } from "./client-plugin";
9
+ export { defaultTranslations, mergeTranslations } from "./i18n";
10
+ export type { MfaVerifyResult } from "./mfa-client";
11
+ export { verifyMfaChallenge } from "./mfa-client";
12
+ export type { MfaEnableScreenProps } from "./mfa-enable-screen";
13
+ export { MfaEnableScreen } from "./mfa-enable-screen";
14
+ export type { MfaVerifyScreenProps } from "./mfa-verify-screen";
15
+ export { MfaVerifyScreen } from "./mfa-verify-screen";
@@ -0,0 +1,60 @@
1
+ // @runtime client
2
+ // Browser-Seite von POST /api/auth/mfa/verify — completes the two-step
3
+ // login LoginScreen started when the server responded mfaRequired. Same
4
+ // fetch/CSRF pattern as auth-email-password's login() (pre-session, no
5
+ // csrfHeader() — there's no session yet to protect). Response shape is
6
+ // byte-identical to /auth/login's ({isSuccess, token, user} | {isSuccess:
7
+ // false, error}) since auth-routes.ts mints via the same
8
+ // mintSessionAndRespond() for both routes.
9
+
10
+ // kumiko-lint-ignore cross-feature-import client-only types, the feature's server barrel has no web/ re-export
11
+ import type { LoginFailure, LoginResponse } from "../../auth-email-password/web";
12
+
13
+ export type MfaVerifyResult =
14
+ | { readonly kind: "success"; readonly data: LoginResponse }
15
+ | { readonly kind: "failure"; readonly error: LoginFailure };
16
+
17
+ export async function verifyMfaChallenge(
18
+ challengeToken: string,
19
+ code: string,
20
+ ): Promise<MfaVerifyResult> {
21
+ const res = await fetch("/api/auth/mfa/verify", {
22
+ method: "POST",
23
+ credentials: "same-origin",
24
+ headers: { "Content-Type": "application/json" },
25
+ body: JSON.stringify({ challengeToken, code }),
26
+ });
27
+ if (res.status === 429) {
28
+ return { kind: "failure", error: { reason: "rate_limited" } };
29
+ }
30
+ // @cast-boundary engine-payload — HTTP-API contract, server-side schema-validated
31
+ const body = (await res.json().catch(() => ({}))) as {
32
+ isSuccess?: boolean;
33
+ token?: string;
34
+ user?: LoginResponse["user"];
35
+ error?:
36
+ | {
37
+ code?: string;
38
+ message?: string;
39
+ details?: { reason?: string; retryAfterSeconds?: number };
40
+ }
41
+ | string;
42
+ };
43
+ if (body.isSuccess === true && body.token !== undefined && body.user !== undefined) {
44
+ return { kind: "success", data: { token: body.token, user: body.user } };
45
+ }
46
+ const err = body.error;
47
+ if (typeof err === "string") {
48
+ return { kind: "failure", error: { reason: err } };
49
+ }
50
+ const reason = err?.details?.reason ?? err?.code ?? "mfa_verify_failed";
51
+ const retry = err?.details?.retryAfterSeconds;
52
+ return {
53
+ kind: "failure",
54
+ error: {
55
+ reason,
56
+ ...(err?.message !== undefined && { message: err.message }),
57
+ ...(retry !== undefined && { retryAfterSeconds: retry }),
58
+ },
59
+ };
60
+ }
@@ -0,0 +1,187 @@
1
+ // @runtime client
2
+ // MfaEnableScreen — logged-in self-service TOTP-Enrollment. Kein Dialog:
3
+ // DefaultDialog schließt nach JEDEM onConfirm (siehe renderer-web/src/
4
+ // primitives/dialog.tsx — onOpenChange(false) im finally-Block), das passt
5
+ // nicht zu einem Mehrschritt-Flow (Secret zeigen → Recovery-Codes zeigen →
6
+ // Code bestätigen). Folgt stattdessen pat-tokens-screen.tsx's embedded-
7
+ // Screen-Konvention: Feature registriert das dormant via r.screen, App
8
+ // platziert es via r.nav.
9
+
10
+ import { useDispatcher, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
11
+ import { FormScreenShell } from "@cosmicdrift/kumiko-renderer-web";
12
+ import QRCode from "qrcode";
13
+ import { type ReactNode, useState } from "react";
14
+ // kumiko-lint-ignore cross-feature-import client-only hook, the feature's server barrel has no web/ re-export
15
+ import { useSession } from "../../auth-email-password/web";
16
+ import { AuthMfaHandlers } from "../constants";
17
+
18
+ type EnableStartResponse = {
19
+ readonly setupToken: string;
20
+ readonly otpauthUri: string;
21
+ readonly recoveryCodes: readonly string[];
22
+ };
23
+
24
+ type SetupState = {
25
+ readonly setupToken: string;
26
+ readonly secretParam: string;
27
+ readonly recoveryCodes: readonly string[];
28
+ readonly qrSvg: string;
29
+ };
30
+
31
+ // otpauth:// URIs put the secret in the query string — extract it for the
32
+ // manual-entry fallback (authenticator apps without a camera / desktop use).
33
+ function extractSecret(otpauthUri: string): string {
34
+ const query = otpauthUri.split("?")[1] ?? "";
35
+ return new URLSearchParams(query).get("secret") ?? "";
36
+ }
37
+
38
+ export type MfaEnableScreenProps = { readonly embedded?: boolean };
39
+
40
+ export function MfaEnableScreen({ embedded = false }: MfaEnableScreenProps = {}): ReactNode {
41
+ const t = useTranslation();
42
+ const { Button, Banner, Field, Input, Card, Heading } = usePrimitives();
43
+ const dispatcher = useDispatcher();
44
+ const session = useSession();
45
+
46
+ const [setup, setSetup] = useState<SetupState | null>(null);
47
+ const [acknowledged, setAcknowledged] = useState(false);
48
+ const [code, setCode] = useState("");
49
+ const [busy, setBusy] = useState(false);
50
+ const [error, setError] = useState<string | null>(null);
51
+ const [enabled, setEnabled] = useState(false);
52
+
53
+ const startSetup = async (): Promise<void> => {
54
+ setBusy(true);
55
+ setError(null);
56
+ const res = await dispatcher.write<EnableStartResponse>(AuthMfaHandlers.enableStart, {
57
+ accountLabel: session.user?.email ?? "",
58
+ });
59
+ if (!res.isSuccess) {
60
+ setBusy(false);
61
+ setError(res.error.code);
62
+ return;
63
+ }
64
+ const qrSvg = await QRCode.toString(res.data.otpauthUri, { type: "svg" });
65
+ setBusy(false);
66
+ setSetup({
67
+ setupToken: res.data.setupToken,
68
+ secretParam: extractSecret(res.data.otpauthUri),
69
+ recoveryCodes: res.data.recoveryCodes,
70
+ qrSvg,
71
+ });
72
+ setAcknowledged(false);
73
+ setCode("");
74
+ };
75
+
76
+ const confirmSetup = async (): Promise<void> => {
77
+ if (!setup) return;
78
+ setBusy(true);
79
+ setError(null);
80
+ const res = await dispatcher.write(AuthMfaHandlers.enableConfirm, {
81
+ setupToken: setup.setupToken,
82
+ code,
83
+ });
84
+ setBusy(false);
85
+ if (!res.isSuccess) {
86
+ setError(res.error.code);
87
+ return;
88
+ }
89
+ setEnabled(true);
90
+ setSetup(null);
91
+ };
92
+
93
+ const content = (
94
+ <div className="flex flex-col gap-6">
95
+ <Heading>{t("auth.mfa.enable.title")}</Heading>
96
+
97
+ {enabled && <Banner variant="info">{t("auth.mfa.enable.success")}</Banner>}
98
+ {error !== null && <Banner variant="error">{t(`auth.mfa.errors.${error}`)}</Banner>}
99
+
100
+ {!setup && !enabled && (
101
+ <Card options={{ padded: true }} className="flex flex-col gap-3">
102
+ <span className="text-sm text-muted-foreground">{t("auth.mfa.enable.intro")}</span>
103
+ <Button
104
+ variant="primary"
105
+ onClick={() => void startSetup()}
106
+ loading={busy}
107
+ disabled={busy}
108
+ >
109
+ {t("auth.mfa.enable.start")}
110
+ </Button>
111
+ </Card>
112
+ )}
113
+
114
+ {setup && (
115
+ <Card options={{ padded: true }} className="flex flex-col gap-4">
116
+ <div className="flex flex-col gap-2">
117
+ <span className="text-sm font-semibold">{t("auth.mfa.enable.scanTitle")}</span>
118
+ {/* qrcode's own SVG string output, not user input — safe to inline */}
119
+ <div
120
+ className="h-40 w-40"
121
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: qrcode-generated SVG, no user input
122
+ dangerouslySetInnerHTML={{ __html: setup.qrSvg }}
123
+ />
124
+ <span className="text-xs text-muted-foreground">
125
+ {t("auth.mfa.enable.manualEntry")}
126
+ </span>
127
+ <code className="block break-all rounded bg-muted px-3 py-2 font-mono text-sm">
128
+ {setup.secretParam}
129
+ </code>
130
+ </div>
131
+
132
+ <div className="flex flex-col gap-2">
133
+ <span className="text-sm font-semibold">{t("auth.mfa.enable.recoveryTitle")}</span>
134
+ <span className="text-xs text-muted-foreground">
135
+ {t("auth.mfa.enable.recoveryHint")}
136
+ </span>
137
+ <code className="block whitespace-pre-wrap break-all rounded bg-muted px-3 py-2 font-mono text-sm">
138
+ {setup.recoveryCodes.join("\n")}
139
+ </code>
140
+ <Field id="mfa-enable-ack" label={t("auth.mfa.enable.acknowledge")}>
141
+ <Input
142
+ kind="boolean"
143
+ id="mfa-enable-ack"
144
+ name="mfa-enable-ack"
145
+ value={acknowledged}
146
+ onChange={setAcknowledged}
147
+ />
148
+ </Field>
149
+ </div>
150
+
151
+ <Field id="mfa-enable-code" label={t("auth.mfa.enable.code")} required>
152
+ <Input
153
+ kind="text"
154
+ id="mfa-enable-code"
155
+ name="mfa-enable-code"
156
+ value={code}
157
+ onChange={setCode}
158
+ disabled={busy || !acknowledged}
159
+ autoComplete="one-time-code"
160
+ />
161
+ </Field>
162
+
163
+ <div className="flex justify-end gap-2">
164
+ <Button variant="secondary" onClick={() => setSetup(null)} disabled={busy}>
165
+ {t("auth.mfa.enable.cancel")}
166
+ </Button>
167
+ <Button
168
+ variant="primary"
169
+ onClick={() => void confirmSetup()}
170
+ loading={busy}
171
+ disabled={busy || !acknowledged || code.length < 6}
172
+ >
173
+ {t("auth.mfa.enable.confirm")}
174
+ </Button>
175
+ </div>
176
+ </Card>
177
+ )}
178
+ </div>
179
+ );
180
+
181
+ if (embedded) return content;
182
+ return (
183
+ <FormScreenShell testId="mfa-enable-screen" maxWidth="3xl">
184
+ {content}
185
+ </FormScreenShell>
186
+ );
187
+ }