@cosmicdrift/kumiko-bundled-features 0.160.0 → 0.162.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/package.json +6 -6
  2. package/src/auth-email-password/__tests__/login-mfa-setup-required.integration.test.ts +129 -0
  3. package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +168 -0
  4. package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +5 -0
  5. package/src/auth-email-password/constants.ts +6 -0
  6. package/src/auth-email-password/feature.ts +8 -1
  7. package/src/auth-email-password/handlers/login.write.ts +6 -3
  8. package/src/auth-email-password/handlers/self-registration-status.query.ts +20 -0
  9. package/src/auth-email-password/handlers/signup-request.write.ts +11 -0
  10. package/src/auth-email-password/i18n.ts +4 -0
  11. package/src/auth-email-password/index.ts +5 -1
  12. package/src/auth-email-password/self-registration-toggle.ts +25 -0
  13. package/src/auth-email-password/web/__tests__/auth-client.test.ts +6 -2
  14. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +79 -0
  15. package/src/auth-email-password/web/__tests__/login-screen.test.tsx +14 -5
  16. package/src/auth-email-password/web/auth-client.ts +4 -3
  17. package/src/auth-email-password/web/auth-gate.tsx +44 -2
  18. package/src/auth-email-password/web/login-screen.tsx +11 -5
  19. package/src/auth-foundation/__tests__/feature.test.ts +16 -3
  20. package/src/auth-foundation/__tests__/session-store.test.ts +1 -0
  21. package/src/auth-foundation/__tests__/tenant-providers.test.ts +136 -0
  22. package/src/auth-foundation/boot-checks.ts +54 -0
  23. package/src/auth-foundation/feature.ts +77 -23
  24. package/src/auth-foundation/index.ts +14 -0
  25. package/src/auth-foundation/resolve-anonymous-access.ts +54 -0
  26. package/src/auth-foundation/types.ts +63 -0
  27. package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +253 -0
  28. package/src/auth-mfa/__tests__/enable-start-preauth.integration.test.ts +214 -0
  29. package/src/auth-mfa/__tests__/mfa-preauth-setup-token.test.ts +87 -0
  30. package/src/auth-mfa/constants.ts +3 -0
  31. package/src/auth-mfa/feature.ts +15 -0
  32. package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +170 -0
  33. package/src/auth-mfa/handlers/enable-start-preauth.write.ts +87 -0
  34. package/src/auth-mfa/mfa-preauth-setup-token.ts +86 -0
  35. package/src/auth-mfa/mfa-setup-token.ts +7 -1
  36. package/src/auth-mfa/mfa-status-checker.ts +13 -4
  37. package/src/auth-mfa/web/__tests__/mfa-client.test.ts +144 -1
  38. package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +202 -0
  39. package/src/auth-mfa/web/i18n.ts +14 -0
  40. package/src/auth-mfa/web/index.ts +8 -2
  41. package/src/auth-mfa/web/mfa-client.ts +113 -0
  42. package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +244 -0
  43. package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +96 -0
  44. package/src/feature-toggles/compose-tier-resolver.ts +41 -0
  45. package/src/feature-toggles/index.ts +1 -0
  46. package/src/feature-toggles/toggle-runtime.ts +14 -0
  47. package/src/sessions/feature.ts +8 -1
@@ -9,7 +9,9 @@ export const AUTH_MFA_FEATURE = "auth-mfa" as const;
9
9
 
10
10
  export const AuthMfaHandlers = {
11
11
  enableStart: "auth-mfa:write:enable-start",
12
+ enableStartPreauth: "auth-mfa:write:enable-start-preauth",
12
13
  enableConfirm: "auth-mfa:write:enable-confirm",
14
+ enableConfirmPreauth: "auth-mfa:write:enable-confirm-preauth",
13
15
  disable: "auth-mfa:write:disable",
14
16
  regenerateRecovery: "auth-mfa:write:regenerate-recovery",
15
17
  verify: "auth-mfa:write:verify",
@@ -34,6 +36,7 @@ export const AuthMfaErrorCodes = {
34
36
 
35
37
  export const MFA_SETUP_TOKEN_TTL_MINUTES = 10;
36
38
  export const MFA_CHALLENGE_TOKEN_TTL_MINUTES = 10;
39
+ export const MFA_PREAUTH_SETUP_TOKEN_TTL_MINUTES = 10;
37
40
  export const MFA_VERIFY_MAX_ATTEMPTS = 5;
38
41
  export const MFA_VERIFY_LOCKOUT_MINUTES = 5;
39
42
 
@@ -3,7 +3,9 @@ import { mfaRequiredConfigKey } from "./config";
3
3
  import { MFA_ENABLE_SCREEN_ID } from "./constants";
4
4
  import { createDisableHandler } from "./handlers/disable.write";
5
5
  import { createEnableConfirmHandler } from "./handlers/enable-confirm.write";
6
+ import { createEnableConfirmPreauthHandler } from "./handlers/enable-confirm-preauth.write";
6
7
  import { createEnableStartHandler } from "./handlers/enable-start.write";
8
+ import { createEnableStartPreauthHandler } from "./handlers/enable-start-preauth.write";
7
9
  import { mfaReencryptJob } from "./handlers/reencrypt.job";
8
10
  import { createRegenerateRecoveryHandler } from "./handlers/regenerate-recovery.write";
9
11
  import { mfaStatusQuery } from "./handlers/status.query";
@@ -119,12 +121,25 @@ export function createAuthMfaFeature(opts: AuthMfaFeatureOptions): FeatureDefini
119
121
  enableStart: r.writeHandler(
120
122
  createEnableStartHandler({ setupTokenSecret: opts.setupTokenSecret, issuer: opts.issuer }),
121
123
  ),
124
+ enableStartPreauth: r.writeHandler(
125
+ createEnableStartPreauthHandler({
126
+ challengeTokenSecret: opts.challengeTokenSecret,
127
+ setupTokenSecret: opts.setupTokenSecret,
128
+ issuer: opts.issuer,
129
+ }),
130
+ ),
122
131
  enableConfirm: r.writeHandler(
123
132
  createEnableConfirmHandler({
124
133
  setupTokenSecret: opts.setupTokenSecret,
125
134
  revokeAllOtherSessions: sharedRevoker,
126
135
  }),
127
136
  ),
137
+ enableConfirmPreauth: r.writeHandler(
138
+ createEnableConfirmPreauthHandler({
139
+ setupTokenSecret: opts.setupTokenSecret,
140
+ revokeAllOtherSessions: sharedRevoker,
141
+ }),
142
+ ),
128
143
  disable: r.writeHandler(createDisableHandler({ revokeAllOtherSessions: sharedRevoker })),
129
144
  regenerateRecovery: r.writeHandler(
130
145
  createRegenerateRecoveryHandler({ revokeAllOtherSessions: sharedRevoker }),
@@ -0,0 +1,170 @@
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 { Temporal } from "temporal-polyfill";
10
+ import { z } from "zod";
11
+ import { burnToken } from "../../shared";
12
+ import { USER_STATUS, UserQueries } from "../../user";
13
+ import { base32Decode } from "../base32";
14
+ import { MFA_VERIFY_LOCKOUT_MINUTES, MFA_VERIFY_MAX_ATTEMPTS } from "../constants";
15
+ import { findUserMfaRow } from "../db/queries";
16
+ import { invalidSetupToken, invalidTotpCode, mfaAlreadyEnabled, tooManyAttempts } from "../errors";
17
+ import { verifyMfaSetupToken } from "../mfa-setup-token";
18
+ import {
19
+ clearMfaVerifyAttempts,
20
+ getMfaVerifyLockoutState,
21
+ recordFailedMfaVerifyAttempt,
22
+ } from "../mfa-verify-attempts";
23
+ import { encodeRecoveryCodes, userMfaEntity, userMfaTable } from "../schema/user-mfa";
24
+ import { verifyTotp } from "../totp";
25
+
26
+ export type EnableConfirmPreauthOptions = {
27
+ // Must match the secret enable-start-preauth.write.ts signs setupTokens
28
+ // with — same secret + format as the session-authed enable-confirm.
29
+ readonly setupTokenSecret: string;
30
+ readonly maxAttempts?: number;
31
+ readonly lockoutMinutes?: number;
32
+ // Wired late by run-prod-app, same shared callback as enable-confirm and
33
+ // enable-start's session-authed siblings.
34
+ readonly revokeAllOtherSessions?: (
35
+ userId: string,
36
+ currentSid: string | undefined,
37
+ ) => Promise<number>;
38
+ };
39
+
40
+ const executor = createEventStoreExecutor(userMfaTable, userMfaEntity, {
41
+ entityName: "user-mfa",
42
+ });
43
+
44
+ // Pre-auth twin of enable-confirm.write.ts: identity comes from the
45
+ // verified setupToken (minted by enable-start-preauth.write.ts), not from
46
+ // event.user — there is no session yet. Combines enable-confirm's
47
+ // enrollment-completion logic with verify.write.ts's session-derivation
48
+ // (membership lookup, status re-check, buildSessionRoles) since this IS
49
+ // the completion of the blocked login, not just an enrollment step — a
50
+ // successful call here logs the user in.
51
+ export function createEnableConfirmPreauthHandler(opts: EnableConfirmPreauthOptions) {
52
+ const maxAttempts = opts.maxAttempts ?? MFA_VERIFY_MAX_ATTEMPTS;
53
+ const lockoutMinutes = opts.lockoutMinutes ?? MFA_VERIFY_LOCKOUT_MINUTES;
54
+
55
+ return defineWriteHandler({
56
+ name: "enable-confirm-preauth",
57
+ schema: z.object({
58
+ setupToken: z.string().min(1),
59
+ code: z.string().length(6),
60
+ }),
61
+ access: { roles: ["all"] },
62
+ handler: async (event, ctx) => {
63
+ const verify = verifyMfaSetupToken(event.payload.setupToken, opts.setupTokenSecret);
64
+ if (!verify.ok) return invalidSetupToken();
65
+ // A setupToken minted for the session-authed enable-start never
66
+ // carries tenantId (see mfa-setup-token.ts) — reject it here rather
67
+ // than falling back to a guest tenant, that token was never meant to
68
+ // reach this pre-auth endpoint.
69
+ const { userId, tenantId } = verify.payload;
70
+ if (tenantId === undefined) return invalidSetupToken();
71
+
72
+ // Same guessing surface as /auth/mfa/verify (a short TOTP code) and
73
+ // the same per-userId cap — deliberately shared counter, see
74
+ // mfa-verify-attempts.ts.
75
+ if (ctx.redis) {
76
+ const state = await getMfaVerifyLockoutState(ctx.redis, userId);
77
+ if (state?.lockedUntil !== null && state?.lockedUntil !== undefined) {
78
+ const now = Date.now();
79
+ if (state.lockedUntil > now) {
80
+ const retryAfterSeconds = Math.max(1, Math.ceil((state.lockedUntil - now) / 1000));
81
+ return tooManyAttempts(retryAfterSeconds);
82
+ }
83
+ }
84
+ }
85
+
86
+ const secret = base32Decode(verify.payload.totpSecretBase32);
87
+ if (verifyTotp(secret, event.payload.code) === false) {
88
+ if (ctx.redis) {
89
+ await recordFailedMfaVerifyAttempt(ctx.redis, userId, maxAttempts, lockoutMinutes);
90
+ }
91
+ return invalidTotpCode();
92
+ }
93
+
94
+ // Burn the setup token on the first successful confirm — same
95
+ // single-use guarantee as enable-confirm.write.ts.
96
+ if (ctx.redis) {
97
+ const burnResult = await burnToken(ctx.redis, "mfa-setup", userId, verify.expiresAtMs);
98
+ if (burnResult === "already-used") return invalidSetupToken();
99
+ }
100
+
101
+ // "system" mode: no session exists yet, tenantId comes from the
102
+ // verified token, mirroring enable-start-preauth.write.ts.
103
+ const scopedDb = createTenantDb(ctx.db.raw, tenantId, "system");
104
+ const scopedUser: SessionUser = { id: userId, tenantId, roles: ["User"] };
105
+ const existing = await findUserMfaRow(scopedDb, scopedUser);
106
+ if (existing) return mfaAlreadyEnabled();
107
+
108
+ const result = await executor.create(
109
+ {
110
+ userId,
111
+ totpSecret: verify.payload.totpSecretBase32,
112
+ recoveryCodes: encodeRecoveryCodes(verify.payload.recoveryCodeHashes),
113
+ enabledAt: Temporal.Now.instant(),
114
+ lastUsedAt: null,
115
+ },
116
+ scopedUser,
117
+ scopedDb,
118
+ );
119
+ if (!result.isSuccess) return result;
120
+
121
+ if (ctx.redis) {
122
+ await clearMfaVerifyAttempts(ctx.redis, userId);
123
+ }
124
+
125
+ // No currentSid to exclude — this is the first session for this
126
+ // login, not a follow-up on an already-running one.
127
+ if (opts.revokeAllOtherSessions) {
128
+ await opts.revokeAllOtherSessions(userId, undefined);
129
+ }
130
+
131
+ // Re-derive the full session the way verify.write.ts does — the
132
+ // setupToken only proves "password + new TOTP secret", not roles;
133
+ // roles come from a fresh membership lookup, same as every other
134
+ // login-completing handler.
135
+ const systemUser = createSystemUser(tenantId, ["SystemAdmin"]);
136
+ const userRow = (await ctx.queryAs(systemUser, UserQueries.findForAuth, {
137
+ id: userId,
138
+ })) as { roles?: string | null; status?: string } | null; // @cast-boundary engine-payload
139
+
140
+ if (!userRow) return invalidSetupToken();
141
+ if (
142
+ userRow?.status === USER_STATUS.Restricted ||
143
+ userRow?.status === USER_STATUS.DeletionRequested ||
144
+ userRow?.status === USER_STATUS.Deleted
145
+ ) {
146
+ return invalidSetupToken();
147
+ }
148
+
149
+ const globalRoles = parseRoles(userRow?.roles ?? null);
150
+
151
+ const memberships = (await ctx.queryAs(systemUser, "tenant:query:memberships", {
152
+ userId,
153
+ })) as ReadonlyArray<{ tenantId: string; roles: readonly string[] }>; // @cast-boundary engine-payload
154
+ const membership = memberships.find((m) => m.tenantId === tenantId);
155
+ if (!membership) return invalidSetupToken();
156
+ // buildSessionRoles calls stripForbiddenMembershipRoles to strip reserved
157
+ // roles from the membership portion (globalRoles keeps SystemAdmin) —
158
+ // read-time backstop against a rebuild-resurrected role, same as
159
+ // verify.write.ts.
160
+ const mergedRoles = buildSessionRoles(globalRoles, membership.roles);
161
+
162
+ const baseSession: SessionUser = { id: userId, tenantId, roles: mergedRoles };
163
+ const claims = await ctx.resolveAuthClaims(baseSession);
164
+ const session: SessionUser =
165
+ Object.keys(claims).length > 0 ? { ...baseSession, claims } : baseSession;
166
+
167
+ return { isSuccess: true, data: { kind: "mfa-preauth-confirm-success", session } };
168
+ },
169
+ });
170
+ }
@@ -0,0 +1,87 @@
1
+ import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
2
+ import { defineWriteHandler, type SessionUser } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { z } from "zod";
4
+ import { base32Encode } from "../base32";
5
+ import { MFA_SETUP_TOKEN_TTL_MINUTES } from "../constants";
6
+ import { findUserMfaRow } from "../db/queries";
7
+ import { invalidChallengeToken, mfaAlreadyEnabled } from "../errors";
8
+ import { verifyMfaPreauthSetupToken } from "../mfa-preauth-setup-token";
9
+ import { signMfaSetupToken } from "../mfa-setup-token";
10
+ import { buildOtpauthUri } from "../otpauth-uri";
11
+ import { generateRecoveryCodes, hashRecoveryCodes } from "../recovery-codes";
12
+ import { generateTotpSecret } from "../totp";
13
+
14
+ export type EnableStartPreauthOptions = {
15
+ // Verifies the incoming preauthSetupToken — must match login.write.ts's
16
+ // mfaStatusChecker (challengeTokenSecret), NOT setupTokenSecret.
17
+ readonly challengeTokenSecret: string;
18
+ // Signs the outgoing secret-carrying setupToken — same secret + token
19
+ // format as enable-start.write.ts, so the later pre-auth confirm step can
20
+ // verify it with the existing verifyMfaSetupToken.
21
+ readonly setupTokenSecret: string;
22
+ readonly issuer: string;
23
+ };
24
+
25
+ // Pre-auth twin of enable-start.write.ts: identity comes from a verified
26
+ // preauthSetupToken (minted by login.write.ts when enforcement policy
27
+ // blocks an unenrolled user), not from event.user — there is no session at
28
+ // this point. Runs pre-session the same way verify.write.ts does (dispatched
29
+ // by the framework's /auth/mfa/preauth-enable-start route with GUEST_USER).
30
+ export function createEnableStartPreauthHandler(opts: EnableStartPreauthOptions) {
31
+ return defineWriteHandler({
32
+ name: "enable-start-preauth",
33
+ schema: z.object({
34
+ preauthSetupToken: z.string().min(1),
35
+ accountLabel: z.string().min(1).max(200),
36
+ }),
37
+ access: { roles: ["all"] },
38
+ handler: async (event, ctx) => {
39
+ const verified = verifyMfaPreauthSetupToken(
40
+ event.payload.preauthSetupToken,
41
+ opts.challengeTokenSecret,
42
+ );
43
+ // Same generic "sign in again" error as an expired/invalid MFA
44
+ // challenge token — both mean the login-flow token expired, no
45
+ // distinct i18n needed (see #1465's scoping comment).
46
+ if (!verified.ok) return invalidChallengeToken();
47
+ const { userId, tenantId } = verified.payload;
48
+
49
+ // "system" mode: the guest dispatch identity's own tenantId is
50
+ // meaningless here — the preauthSetupToken is the source of truth
51
+ // for which tenant's row to read, mirroring verify.write.ts.
52
+ const scopedDb = createTenantDb(ctx.db.raw, tenantId, "system");
53
+ const scopedUser: SessionUser = { id: userId, tenantId, roles: ["User"] };
54
+ const existing = await findUserMfaRow(scopedDb, scopedUser);
55
+ if (existing) return mfaAlreadyEnabled();
56
+
57
+ const secret = generateTotpSecret();
58
+ const recoveryCodes = generateRecoveryCodes();
59
+ const recoveryCodeHashes = await hashRecoveryCodes(recoveryCodes);
60
+
61
+ const { token: setupToken } = signMfaSetupToken(
62
+ {
63
+ userId,
64
+ totpSecretBase32: base32Encode(secret),
65
+ recoveryCodeHashes,
66
+ tenantId,
67
+ },
68
+ MFA_SETUP_TOKEN_TTL_MINUTES,
69
+ opts.setupTokenSecret,
70
+ );
71
+
72
+ return {
73
+ isSuccess: true,
74
+ data: {
75
+ setupToken,
76
+ otpauthUri: buildOtpauthUri({
77
+ issuer: opts.issuer,
78
+ accountLabel: event.payload.accountLabel,
79
+ secret,
80
+ }),
81
+ // Plaintext — this is the one and only time these are shown.
82
+ recoveryCodes,
83
+ },
84
+ };
85
+ },
86
+ });
87
+ }
@@ -0,0 +1,86 @@
1
+ // HMAC-signed token minted by login.write.ts when enforcement policy blocks
2
+ // an unenrolled user (mfa-setup-required) — proves the password check
3
+ // already succeeded, so a later pre-auth enable-start variant (#1231) can
4
+ // let the user enroll without a full session. No secret material (unlike
5
+ // mfa-setup-token.ts) — just enough to look the user back up.
6
+ //
7
+ // Same domain-separated HMAC shape as mfa-challenge-token.ts, distinct
8
+ // signing string so this can never be replayed as a login challenge or
9
+ // vice versa. Reuses challengeTokenSecret rather than adding a third
10
+ // feature secret: both tokens assert the identical trust boundary
11
+ // ("password already verified, short-lived follow-up allowed").
12
+ import { createHmac, timingSafeEqual } from "node:crypto";
13
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
14
+ import { Temporal } from "temporal-polyfill";
15
+
16
+ export type MfaPreauthSetupPayload = {
17
+ readonly userId: string;
18
+ readonly tenantId: TenantId;
19
+ };
20
+
21
+ type EncodedBody = MfaPreauthSetupPayload & { 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 signMfaPreauthSetupToken(
28
+ payload: MfaPreauthSetupPayload,
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-preauth-setup:${bodyB64}`, secret);
37
+ return { token: `${bodyB64}.${signature}`, expiresAt };
38
+ }
39
+
40
+ export type VerifyMfaPreauthSetupResult =
41
+ | { readonly ok: true; readonly payload: MfaPreauthSetupPayload; 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["tenantId"] === "string" &&
50
+ typeof v["expiresAtMs"] === "number"
51
+ );
52
+ }
53
+
54
+ export function verifyMfaPreauthSetupToken(
55
+ token: string,
56
+ secret: string,
57
+ now: Temporal.Instant = Temporal.Now.instant(),
58
+ ): VerifyMfaPreauthSetupResult {
59
+ const parts = token.split(".");
60
+ if (parts.length !== 2) return { ok: false, reason: "malformed" };
61
+ const [bodyB64, providedSig] = parts;
62
+ if (!bodyB64 || !providedSig) return { ok: false, reason: "malformed" };
63
+
64
+ const expected = sign(`mfa-preauth-setup:${bodyB64}`, secret);
65
+ const expectedBuf = Buffer.from(expected, "base64url");
66
+ const providedBuf = Buffer.from(providedSig, "base64url");
67
+ if (expectedBuf.length !== providedBuf.length) return { ok: false, reason: "bad_signature" };
68
+ if (!timingSafeEqual(expectedBuf, providedBuf)) return { ok: false, reason: "bad_signature" };
69
+
70
+ let parsed: unknown;
71
+ try {
72
+ parsed = JSON.parse(Buffer.from(bodyB64, "base64url").toString("utf8"));
73
+ } catch {
74
+ return { ok: false, reason: "malformed" };
75
+ }
76
+ if (!isEncodedBody(parsed)) return { ok: false, reason: "malformed" };
77
+
78
+ if (
79
+ Temporal.Instant.compare(now, Temporal.Instant.fromEpochMilliseconds(parsed.expiresAtMs)) > 0
80
+ ) {
81
+ return { ok: false, reason: "expired" };
82
+ }
83
+
84
+ const { expiresAtMs, ...payload } = parsed;
85
+ return { ok: true, payload, expiresAtMs };
86
+ }
@@ -16,6 +16,11 @@ export type MfaSetupPayload = {
16
16
  readonly userId: string;
17
17
  readonly totpSecretBase32: string;
18
18
  readonly recoveryCodeHashes: readonly string[];
19
+ // Set only by enable-start-preauth.write.ts — the session-authed
20
+ // enable-start.write.ts has no tenantId to carry (event.user.tenantId
21
+ // already covers that path). The pre-auth confirm handler has no session
22
+ // to fall back on, so this is the only source of which tenant to scope to.
23
+ readonly tenantId?: string;
19
24
  };
20
25
 
21
26
  type EncodedBody = MfaSetupPayload & { readonly expiresAtMs: number };
@@ -49,7 +54,8 @@ function isEncodedBody(value: unknown): value is EncodedBody {
49
54
  typeof v["totpSecretBase32"] === "string" &&
50
55
  Array.isArray(v["recoveryCodeHashes"]) &&
51
56
  v["recoveryCodeHashes"].every((h) => typeof h === "string") &&
52
- typeof v["expiresAtMs"] === "number"
57
+ typeof v["expiresAtMs"] === "number" &&
58
+ (v["tenantId"] === undefined || typeof v["tenantId"] === "string")
53
59
  );
54
60
  }
55
61
 
@@ -6,9 +6,10 @@ import {
6
6
  type TenantId,
7
7
  } from "@cosmicdrift/kumiko-framework/engine";
8
8
  import { type MfaRequiredPolicy, mfaRequiredConfigHandle, mfaRequiredConfigKey } from "./config";
9
- import { MFA_CHALLENGE_TOKEN_TTL_MINUTES } from "./constants";
9
+ import { MFA_CHALLENGE_TOKEN_TTL_MINUTES, MFA_PREAUTH_SETUP_TOKEN_TTL_MINUTES } from "./constants";
10
10
  import { findUserMfaRow } from "./db/queries";
11
11
  import { signMfaChallengeToken } from "./mfa-challenge-token";
12
+ import { signMfaPreauthSetupToken } from "./mfa-preauth-setup-token";
12
13
 
13
14
  export type MfaStatusCheckResult =
14
15
  | { readonly required: false }
@@ -17,7 +18,7 @@ export type MfaStatusCheckResult =
17
18
  // yet. Distinct from `required: true` (which means "enrolled, prove it
18
19
  // now") — see config.ts's ponytail note on why this hard-blocks login
19
20
  // with no in-band recovery until PR3's enrollment-during-login UI ships.
20
- | { readonly setupRequired: true };
21
+ | { readonly setupRequired: true; readonly preauthSetupToken: string };
21
22
 
22
23
  // Consumed by auth-email-password's login.write.ts via `mfaStatusChecker`
23
24
  // (a generic callback type declared THERE, not here — auth-email-password
@@ -67,9 +68,17 @@ export function createMfaStatusChecker(opts: {
67
68
  scopedDb,
68
69
  )) ?? "optional") as MfaRequiredPolicy | "optional";
69
70
  if (policy === "optional") return { required: false };
70
- if (policy === "all") return { setupRequired: true };
71
+ const buildSetupToken = (): string =>
72
+ signMfaPreauthSetupToken(
73
+ { userId, tenantId },
74
+ MFA_PREAUTH_SETUP_TOKEN_TTL_MINUTES,
75
+ opts.challengeTokenSecret,
76
+ ).token;
77
+ if (policy === "all") return { setupRequired: true, preauthSetupToken: buildSetupToken() };
71
78
  // policy === "admins"
72
79
  const isAdmin = roles.some((role) => access.admin.includes(role));
73
- return isAdmin ? { setupRequired: true } : { required: false };
80
+ return isAdmin
81
+ ? { setupRequired: true, preauthSetupToken: buildSetupToken() }
82
+ : { required: false };
74
83
  };
75
84
  }
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
- import { verifyMfaChallenge } from "../mfa-client";
2
+ import { confirmMfaSetupPreauth, startMfaSetupPreauth, verifyMfaChallenge } from "../mfa-client";
3
3
 
4
4
  beforeEach(() => {
5
5
  globalThis.fetch = mock(
@@ -116,3 +116,146 @@ describe("verifyMfaChallenge", () => {
116
116
  expect(res).toEqual({ kind: "failure", error: { reason: "mfa_verify_failed" } });
117
117
  });
118
118
  });
119
+
120
+ describe("startMfaSetupPreauth", () => {
121
+ test("posts preauthSetupToken + accountLabel to /api/auth/mfa/preauth-enable-start", async () => {
122
+ const fetchMock = mock(async () =>
123
+ jsonResponse({
124
+ isSuccess: true,
125
+ setupToken: "setup-1",
126
+ otpauthUri: "otpauth://totp/App:user@example.com?secret=ABCD&issuer=App",
127
+ recoveryCodes: ["r1", "r2"],
128
+ }),
129
+ );
130
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
131
+
132
+ await startMfaSetupPreauth("preauth-1", "user@example.com");
133
+
134
+ expect(fetchMock).toHaveBeenCalledWith(
135
+ "/api/auth/mfa/preauth-enable-start",
136
+ expect.objectContaining({
137
+ method: "POST",
138
+ credentials: "same-origin",
139
+ body: JSON.stringify({ preauthSetupToken: "preauth-1", accountLabel: "user@example.com" }),
140
+ }),
141
+ );
142
+ });
143
+
144
+ test("success → kind:success mit setupToken/otpauthUri/recoveryCodes", async () => {
145
+ globalThis.fetch = mock(async () =>
146
+ jsonResponse({
147
+ isSuccess: true,
148
+ setupToken: "setup-1",
149
+ otpauthUri: "otpauth://totp/App:user@example.com?secret=ABCD&issuer=App",
150
+ recoveryCodes: ["r1", "r2"],
151
+ }),
152
+ ) as unknown as typeof fetch;
153
+
154
+ const res = await startMfaSetupPreauth("preauth-1", "user@example.com");
155
+
156
+ expect(res).toEqual({
157
+ kind: "success",
158
+ data: {
159
+ setupToken: "setup-1",
160
+ otpauthUri: "otpauth://totp/App:user@example.com?secret=ABCD&issuer=App",
161
+ recoveryCodes: ["r1", "r2"],
162
+ },
163
+ });
164
+ });
165
+
166
+ test("HTTP 429 → kind:failure reason:rate_limited", async () => {
167
+ globalThis.fetch = mock(
168
+ async () => new Response(null, { status: 429 }),
169
+ ) as unknown as typeof fetch;
170
+
171
+ const res = await startMfaSetupPreauth("preauth-1", "user@example.com");
172
+
173
+ expect(res).toEqual({ kind: "failure", error: { reason: "rate_limited" } });
174
+ });
175
+
176
+ test("error als String → kind:failure reason:<string>", async () => {
177
+ globalThis.fetch = mock(async () =>
178
+ jsonResponse({ isSuccess: false, error: "invalid_challenge_token" }),
179
+ ) as unknown as typeof fetch;
180
+
181
+ const res = await startMfaSetupPreauth("preauth-1", "user@example.com");
182
+
183
+ expect(res).toEqual({ kind: "failure", error: { reason: "invalid_challenge_token" } });
184
+ });
185
+
186
+ test("error-Objekt ohne details.reason → fällt auf error.code zurück", async () => {
187
+ globalThis.fetch = mock(async () =>
188
+ jsonResponse({ isSuccess: false, error: { code: "mfa_already_enabled" } }),
189
+ ) as unknown as typeof fetch;
190
+
191
+ const res = await startMfaSetupPreauth("preauth-1", "user@example.com");
192
+
193
+ expect(res).toEqual({ kind: "failure", error: { reason: "mfa_already_enabled" } });
194
+ });
195
+ });
196
+
197
+ describe("confirmMfaSetupPreauth", () => {
198
+ test("posts setupToken + code to /api/auth/mfa/preauth-confirm", async () => {
199
+ const fetchMock = mock(async () =>
200
+ jsonResponse({ isSuccess: true, token: "t", user: { id: "u1", tenantId: "t1", roles: [] } }),
201
+ );
202
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
203
+
204
+ await confirmMfaSetupPreauth("setup-1", "123456");
205
+
206
+ expect(fetchMock).toHaveBeenCalledWith(
207
+ "/api/auth/mfa/preauth-confirm",
208
+ expect.objectContaining({
209
+ method: "POST",
210
+ credentials: "same-origin",
211
+ body: JSON.stringify({ setupToken: "setup-1", code: "123456" }),
212
+ }),
213
+ );
214
+ });
215
+
216
+ test("success → kind:success mit token+user", async () => {
217
+ globalThis.fetch = mock(async () =>
218
+ jsonResponse({
219
+ isSuccess: true,
220
+ token: "test-token",
221
+ user: { id: "u1", tenantId: "t1", roles: ["User"] },
222
+ }),
223
+ ) as unknown as typeof fetch;
224
+
225
+ const res = await confirmMfaSetupPreauth("setup-1", "123456");
226
+
227
+ expect(res).toEqual({
228
+ kind: "success",
229
+ data: { token: "test-token", user: { id: "u1", tenantId: "t1", roles: ["User"] } },
230
+ });
231
+ });
232
+
233
+ test("HTTP 429 → kind:failure reason:rate_limited", async () => {
234
+ globalThis.fetch = mock(
235
+ async () => new Response(null, { status: 429 }),
236
+ ) as unknown as typeof fetch;
237
+
238
+ const res = await confirmMfaSetupPreauth("setup-1", "000000");
239
+
240
+ expect(res).toEqual({ kind: "failure", error: { reason: "rate_limited" } });
241
+ });
242
+
243
+ test("error-Objekt mit details.reason → reason + retryAfterSeconds übernommen", async () => {
244
+ globalThis.fetch = mock(async () =>
245
+ jsonResponse({
246
+ isSuccess: false,
247
+ error: {
248
+ code: "mfa_setup_confirm_failed",
249
+ details: { reason: "too_many_attempts", retryAfterSeconds: 30 },
250
+ },
251
+ }),
252
+ ) as unknown as typeof fetch;
253
+
254
+ const res = await confirmMfaSetupPreauth("setup-1", "000000");
255
+
256
+ expect(res).toEqual({
257
+ kind: "failure",
258
+ error: { reason: "too_many_attempts", retryAfterSeconds: 30 },
259
+ });
260
+ });
261
+ });