@cosmicdrift/kumiko-bundled-features 0.147.1 → 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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.147.1",
3
+ "version": "0.147.2",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -88,6 +88,9 @@
88
88
  "./presets": "./src/presets/index.ts",
89
89
  "./secrets": "./src/secrets/index.ts",
90
90
  "./sessions": "./src/sessions/index.ts",
91
+ "./auth-mfa": "./src/auth-mfa/index.ts",
92
+ "./auth-mfa-user-data": "./src/auth-mfa-user-data/index.ts",
93
+ "./auth-mfa/web": "./src/auth-mfa/web/index.ts",
91
94
  "./sessions/testing": "./src/sessions/testing.ts",
92
95
  "./personal-access-tokens": "./src/personal-access-tokens/index.ts",
93
96
  "./personal-access-tokens/web": "./src/personal-access-tokens/web/index.ts",
@@ -111,11 +114,11 @@
111
114
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
112
115
  },
113
116
  "dependencies": {
114
- "@cosmicdrift/kumiko-dispatcher-live": "0.147.1",
115
- "@cosmicdrift/kumiko-framework": "0.147.1",
116
- "@cosmicdrift/kumiko-headless": "0.147.1",
117
- "@cosmicdrift/kumiko-renderer": "0.147.1",
118
- "@cosmicdrift/kumiko-renderer-web": "0.147.1",
117
+ "@cosmicdrift/kumiko-dispatcher-live": "0.147.2",
118
+ "@cosmicdrift/kumiko-framework": "0.147.2",
119
+ "@cosmicdrift/kumiko-headless": "0.147.2",
120
+ "@cosmicdrift/kumiko-renderer": "0.147.2",
121
+ "@cosmicdrift/kumiko-renderer-web": "0.147.2",
119
122
  "@mollie/api-client": "^4.5.0",
120
123
  "imapflow": "^1.3.3",
121
124
  "mailparser": "^3.9.8",
@@ -125,6 +128,7 @@
125
128
  "lucide-react": "^1.14.0",
126
129
  "marked": "^18.0.3",
127
130
  "nodemailer": "^9.0.1",
131
+ "qrcode": "^1.5.4",
128
132
  "react": "^19.2.6",
129
133
  "stripe": "^22.1.1",
130
134
  "tailwind-merge": "^3.6.0"
@@ -140,6 +144,7 @@
140
144
  ],
141
145
  "devDependencies": {
142
146
  "@testing-library/user-event": "^14.6.1",
143
- "@types/mailparser": "^3.4.6"
147
+ "@types/mailparser": "^3.4.6",
148
+ "@types/qrcode": "^1.5.5"
144
149
  }
145
150
  }
@@ -10,7 +10,7 @@ import {
10
10
  type InviteCreateOptions,
11
11
  } from "./handlers/invite-create.write";
12
12
  import { createInviteSignupCompleteHandler } from "./handlers/invite-signup-complete.write";
13
- import { createLoginHandler } from "./handlers/login.write";
13
+ import { createLoginHandler, type LoginHandlerOptions } from "./handlers/login.write";
14
14
  import { logoutWrite } from "./handlers/logout.write";
15
15
  import { createRequestEmailVerificationHandler } from "./handlers/request-email-verification.write";
16
16
  import { createRequestPasswordResetHandler } from "./handlers/request-password-reset.write";
@@ -110,6 +110,10 @@ export type SignupOptions = SignupRequestOptions;
110
110
  export type InviteOptions = InviteCreateOptions;
111
111
 
112
112
  export type AuthEmailPasswordOptions = {
113
+ // Second-factor gate. auth-mfa (if mounted) wires this in via
114
+ // mfaStatusCheckerFromFeature() at app-composition time — see
115
+ // LoginHandlerOptions.mfaStatusChecker for the contract it must satisfy.
116
+ readonly mfaStatusChecker?: LoginHandlerOptions["mfaStatusChecker"];
113
117
  readonly passwordReset?: PasswordResetOptions;
114
118
  readonly emailVerification?: EmailVerificationOptions;
115
119
  readonly accountLockout?: AccountLockoutOptions;
@@ -175,6 +179,7 @@ export function createAuthEmailPasswordFeature(
175
179
  createLoginHandler({
176
180
  strictEmailVerification: strictVerification,
177
181
  accountLockout: opts.accountLockout,
182
+ mfaStatusChecker: opts.mfaStatusChecker,
178
183
  }),
179
184
  ),
180
185
  changePassword: r.writeHandler(changePasswordWrite),
@@ -31,10 +31,10 @@ import {
31
31
  } from "@cosmicdrift/kumiko-framework/engine";
32
32
  import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
33
33
  import type Redis from "ioredis";
34
+ import { burnToken, unburnToken } from "../../shared";
34
35
  import { UserHandlers, UserQueries } from "../../user";
35
36
  import type { AuthUserRow } from "../auth-user-row";
36
37
  import { parseAuthUserRow } from "../auth-user-row";
37
- import { burnToken, unburnToken } from "../token-burn-store";
38
38
 
39
39
  export type ConfirmTokenFlowSpec<TSuccessData> = {
40
40
  // Short purpose-tag used in the burn-store key. Must NOT overlap with
@@ -1,9 +1,11 @@
1
+ import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
1
2
  import {
2
3
  buildSessionRoles,
3
4
  createSystemUser,
4
5
  defineWriteHandler,
5
6
  type SessionUser,
6
7
  type TenantId,
8
+ type WriteResult,
7
9
  } from "@cosmicdrift/kumiko-framework/engine";
8
10
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
9
11
  import { z } from "zod";
@@ -40,10 +42,36 @@ export type LoginHandlerOptions = {
40
42
  readonly maxFailedAttempts?: number;
41
43
  readonly lockoutDurationMinutes?: number;
42
44
  };
45
+ // Optional second-factor gate. auth-mfa (if mounted) wires this in at
46
+ // app-composition time. Deliberately generic here — auth-email-password
47
+ // must not import auth-mfa's config, only this shape. Called AFTER a
48
+ // successful password verification (never before — the point is to add
49
+ // a second factor to a real credential match, not to gate on identity
50
+ // alone).
51
+ readonly mfaStatusChecker?: (
52
+ ctx: HandlerContext,
53
+ userId: string,
54
+ tenantId: TenantId,
55
+ // Merged global+tenant roles, computed by this handler BEFORE the
56
+ // gate runs — needed for policy values like "admins" that key off role.
57
+ roles: readonly string[],
58
+ ) => Promise<
59
+ | { readonly required: false }
60
+ | { readonly required: true; readonly challengeToken: string }
61
+ | { readonly setupRequired: true }
62
+ >;
43
63
  };
44
64
 
45
65
  const SYSTEM_USER_ID = "00000000-0000-4000-8000-000000000000";
46
66
 
67
+ // Two possible success shapes: a straight login mints a session; a login
68
+ // gated by MFA hands back a challenge instead — auth-routes.ts branches on
69
+ // `kind` to decide whether to mint a JWT now or wait for /auth/mfa/verify.
70
+ type LoginResult =
71
+ | { readonly kind: "auth-session"; readonly session: SessionUser }
72
+ | { readonly kind: "mfa-challenge"; readonly challengeToken: string }
73
+ | { readonly kind: "mfa-setup-required" };
74
+
47
75
  // Login — unauthenticated entry point. The route is wired public (no JWT
48
76
  // middleware), synthesising a guest SessionUser for the handler's access
49
77
  // check. Everything inside the handler goes through ctx.queryAs(system, ...)
@@ -62,7 +90,7 @@ export function createLoginHandler(opts: LoginHandlerOptions = {}) {
62
90
  password: z.string().min(1),
63
91
  }),
64
92
  access: { roles: ["all"] },
65
- handler: async (event, ctx) => {
93
+ handler: async (event, ctx): Promise<WriteResult<LoginResult>> => {
66
94
  const systemUser = createSystemUser(SYSTEM_USER_ID);
67
95
 
68
96
  const found = parseAuthUserRow(
@@ -110,6 +138,17 @@ export function createLoginHandler(opts: LoginHandlerOptions = {}) {
110
138
  return invalidCredentials();
111
139
  }
112
140
 
141
+ // Clear the lockout state as soon as the password is proven — the
142
+ // password itself is the thing this counter guards, and MFA (if
143
+ // gated below) has its own separate attempt-cap. Doing this before
144
+ // the MFA gate matters: without it, an MFA user who occasionally
145
+ // mistypes their password accumulates failures across otherwise-
146
+ // successful logins and eventually gets password-locked out even
147
+ // though every login they completed was legitimate.
148
+ if (ctx.redis) {
149
+ await clearLockoutState(ctx.redis, found.id);
150
+ }
151
+
113
152
  // Strict verification gate — runs AFTER password check so an attacker
114
153
  // probing "email_not_verified" needs valid credentials first. The
115
154
  // remaining enumeration surface is "valid-cred + unverified" → accepted
@@ -155,24 +194,42 @@ export function createLoginHandler(opts: LoginHandlerOptions = {}) {
155
194
  return noMembership();
156
195
  }
157
196
 
158
- // Clear the lockout state on success. DEL is idempotent, so no need
159
- // to gate on "was there a counter?" — skipping the Redis round-trip
160
- // entirely for users who never failed a login would optimise the hot
161
- // path, but the call is microseconds and the branch isn't free either.
162
- if (ctx.redis) {
163
- await clearLockoutState(ctx.redis, found.id);
164
- }
165
-
166
197
  // Globale Rollen aus user.roles + tenant-membership-roles mergen.
167
198
  // Globale Rollen (SystemAdmin etc.) bleiben so über alle tenants
168
199
  // gleich; tenant-spezifische Rollen (Admin, User) kommen aus der
169
200
  // membership. Dedupe via Set damit eine Rolle die in beiden Quellen
170
201
  // steht nicht doppelt im Session-Roles landet.
202
+ //
203
+ // Computed BEFORE the MFA gate (moved up from its original spot below
204
+ // baseSession) because the gate's "admins" enforcement policy needs
205
+ // the MERGED set — a SystemAdmin whose admin-ness lives only in
206
+ // globalRoles would be missed if only chosen.roles were passed.
171
207
  const globalRoles = parseRoles(found.roles ?? null);
172
208
  // buildSessionRoles calls stripForbiddenMembershipRoles to strip reserved
173
209
  // only (globalRoles keeps SystemAdmin) — read-time backstop against a
174
210
  // rebuild-resurrected role.
175
211
  const mergedRoles = buildSessionRoles(globalRoles, chosen.roles);
212
+
213
+ // Second-factor gate. Runs AFTER password verification (correct
214
+ // credentials proven) and AFTER tenant resolution (need chosen.tenantId
215
+ // to scope the MFA-enabled check). Three outcomes: enrolled → mint a
216
+ // challenge instead of a session (/auth/mfa/verify completes the
217
+ // login); policy demands MFA but the user never enrolled → block with
218
+ // mfa-setup-required (no session, no challenge — see auth-mfa's
219
+ // config.ts for why this deliberately hard-blocks); neither → proceed.
220
+ if (opts.mfaStatusChecker) {
221
+ const mfaStatus = await opts.mfaStatusChecker(ctx, found.id, chosen.tenantId, mergedRoles);
222
+ if ("challengeToken" in mfaStatus) {
223
+ return {
224
+ isSuccess: true,
225
+ data: { kind: "mfa-challenge", challengeToken: mfaStatus.challengeToken },
226
+ };
227
+ }
228
+ if ("setupRequired" in mfaStatus) {
229
+ return { isSuccess: true, data: { kind: "mfa-setup-required" } };
230
+ }
231
+ }
232
+
176
233
  const baseSession: SessionUser = {
177
234
  id: found.id,
178
235
  tenantId: chosen.tenantId,
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
2
  import { fireEvent, screen, waitFor } from "@testing-library/react";
3
3
 
4
4
  import { LoginScreen } from "../login-screen";
5
+ import type { SessionApi } from "../session";
5
6
  import { makeSessionApi, renderWithProviders } from "./test-utils";
6
7
 
7
8
  const requestEmailVerificationMock = mock<() => Promise<unknown>>(() => Promise.resolve());
@@ -48,7 +49,10 @@ describe("LoginScreen", () => {
48
49
  const session = makeSessionApi({
49
50
  status: "unauthenticated",
50
51
  user: null,
51
- login: mock(async () => ({ ok: false, error: { reason: "invalid_credentials" } })),
52
+ login: mock<SessionApi["login"]>(async () => ({
53
+ kind: "failure",
54
+ error: { reason: "invalid_credentials" },
55
+ })),
52
56
  });
53
57
  renderWithProviders(<LoginScreen />, { session });
54
58
 
@@ -70,8 +74,8 @@ describe("LoginScreen", () => {
70
74
  const session = makeSessionApi({
71
75
  status: "unauthenticated",
72
76
  user: null,
73
- login: mock(async () => ({
74
- ok: false,
77
+ login: mock<SessionApi["login"]>(async () => ({
78
+ kind: "failure",
75
79
  error: { reason: "account_locked", retryAfterSeconds: 540 },
76
80
  })),
77
81
  });
@@ -148,7 +152,10 @@ describe("LoginScreen", () => {
148
152
  return makeSessionApi({
149
153
  status: "unauthenticated",
150
154
  user: null,
151
- login: mock(async () => ({ ok: false, error: { reason: "email_not_verified" } })),
155
+ login: mock<SessionApi["login"]>(async () => ({
156
+ kind: "failure",
157
+ error: { reason: "email_not_verified" },
158
+ })),
152
159
  });
153
160
  }
154
161
 
@@ -230,7 +237,10 @@ describe("LoginScreen", () => {
230
237
  const session = makeSessionApi({
231
238
  status: "unauthenticated",
232
239
  user: null,
233
- login: mock(async () => ({ ok: false, error: { reason: "invalid_credentials" } })),
240
+ login: mock<SessionApi["login"]>(async () => ({
241
+ kind: "failure",
242
+ error: { reason: "invalid_credentials" },
243
+ })),
234
244
  });
235
245
  renderWithProviders(<LoginScreen />, { session });
236
246
  await loginUntilEmailNotVerified();
@@ -26,9 +26,10 @@ export type MakeSessionApiOptions = Partial<SessionState> & {
26
26
  readonly login?: SessionApi["login"];
27
27
  readonly logout?: SessionApi["logout"];
28
28
  readonly switchTenant?: SessionApi["switchTenant"];
29
+ readonly refresh?: SessionApi["refresh"];
29
30
  };
30
31
  export function makeSessionApi(overrides: MakeSessionApiOptions = {}): SessionApi {
31
- const { login, logout, switchTenant, ...stateOverrides } = overrides;
32
+ const { login, logout, switchTenant, refresh, ...stateOverrides } = overrides;
32
33
  const base: SessionState = {
33
34
  status: "authenticated",
34
35
  user: {
@@ -44,9 +45,18 @@ export function makeSessionApi(overrides: MakeSessionApiOptions = {}): SessionAp
44
45
  };
45
46
  return {
46
47
  ...base,
47
- login: login ?? mock<SessionApi["login"]>(async () => ({ ok: true })),
48
+ login:
49
+ login ??
50
+ mock<SessionApi["login"]>(async () => ({
51
+ kind: "success",
52
+ data: {
53
+ token: "test-token",
54
+ user: { id: "test-user", tenantId: "tenant-1", roles: ["Admin"] },
55
+ },
56
+ })),
48
57
  logout: logout ?? mock<SessionApi["logout"]>(async () => {}),
49
58
  switchTenant: switchTenant ?? mock<SessionApi["switchTenant"]>(async () => {}),
59
+ refresh: refresh ?? mock<SessionApi["refresh"]>(async () => {}),
50
60
  };
51
61
  }
52
62
  export function renderWithProviders(
@@ -45,13 +45,24 @@ export function csrfHeader(): Record<string, string> {
45
45
  return token !== undefined ? { [CSRF_HEADER_NAME]: token } : {};
46
46
  }
47
47
 
48
- // POST /api/auth/login. Erfolg token + user; Fehler → strukturiertes
49
- // failure-objekt mit reason (invalid_credentials, account_locked,
50
- // no_membership, rate_limited). Das UI rendert darüber eine passende
51
- // Fehler-Meldung; der Server setzt Cookies bei 200 automatisch.
52
- export async function login(
53
- req: LoginRequest,
54
- ): Promise<{ ok: true; data: LoginResponse } | { ok: false; error: LoginFailure }> {
48
+ // LoginResult mirrors the two-step login contract auth-routes.ts owns: a
49
+ // straight success, an MFA challenge (auth-mfa mints the token), an
50
+ // MFA-setup-required block (enforcement policy blocks an unenrolled user),
51
+ // or a plain failure. One discriminated union, not `{ok}` booleans, because
52
+ // callers branch on 4 arms.
53
+ export type LoginResult =
54
+ | { readonly kind: "success"; readonly data: LoginResponse }
55
+ | { readonly kind: "mfa-challenge"; readonly challengeToken: string }
56
+ | { readonly kind: "mfa-setup-required" }
57
+ | { readonly kind: "failure"; readonly error: LoginFailure };
58
+
59
+ // POST /api/auth/login. Success → token + user; MFA-enrolled user → a
60
+ // challenge token the caller completes via auth-mfa's verify screen;
61
+ // unenrolled user blocked by enforcement policy → mfa-setup-required;
62
+ // otherwise a structured failure with reason (invalid_credentials,
63
+ // account_locked, no_membership, rate_limited). The server sets cookies
64
+ // automatically on any 200.
65
+ export async function login(req: LoginRequest): Promise<LoginResult> {
55
66
  const res = await fetch("/api/auth/login", {
56
67
  method: "POST",
57
68
  credentials: "same-origin",
@@ -59,13 +70,16 @@ export async function login(
59
70
  body: JSON.stringify(req),
60
71
  });
61
72
  if (res.status === 429) {
62
- return { ok: false, error: { reason: "rate_limited" } };
73
+ return { kind: "failure", error: { reason: "rate_limited" } };
63
74
  }
64
75
  // @cast-boundary engine-payload — HTTP-API contract, server-side schema-validated
65
76
  const body = (await res.json().catch(() => ({}))) as {
66
77
  isSuccess?: boolean;
67
78
  token?: string;
68
79
  user?: LoginResponse["user"];
80
+ mfaRequired?: boolean;
81
+ challengeToken?: string;
82
+ mfaSetupRequired?: boolean;
69
83
  error?:
70
84
  | {
71
85
  code?: string;
@@ -74,19 +88,27 @@ export async function login(
74
88
  }
75
89
  | string;
76
90
  };
77
- if (body.isSuccess === true && body.token !== undefined && body.user !== undefined) {
78
- return { ok: true, data: { token: body.token, user: body.user } };
91
+ if (body.isSuccess === true) {
92
+ if (body.mfaRequired === true && typeof body.challengeToken === "string") {
93
+ return { kind: "mfa-challenge", challengeToken: body.challengeToken };
94
+ }
95
+ if (body.mfaSetupRequired === true) {
96
+ return { kind: "mfa-setup-required" };
97
+ }
98
+ if (body.token !== undefined && body.user !== undefined) {
99
+ return { kind: "success", data: { token: body.token, user: body.user } };
100
+ }
79
101
  }
80
102
  // Der Server schickt error entweder als string ("invalid_body") oder als
81
103
  // strukturiertes Objekt. Wir ziehen uns den sprechendsten Reason raus.
82
104
  const err = body.error;
83
105
  if (typeof err === "string") {
84
- return { ok: false, error: { reason: err } };
106
+ return { kind: "failure", error: { reason: err } };
85
107
  }
86
108
  const reason = err?.details?.reason ?? err?.code ?? "login_failed";
87
109
  const retry = err?.details?.retryAfterSeconds;
88
110
  return {
89
- ok: false,
111
+ kind: "failure",
90
112
  error: {
91
113
  reason,
92
114
  ...(err?.message !== undefined && { message: err.message }),
@@ -9,23 +9,51 @@
9
9
  // (nur `{ children }`-Prop). Der Sample kann so einen eigenen Login-
10
10
  // Screen rein konfigurieren, ohne den Gate selbst ersetzen zu müssen.
11
11
 
12
- import type { ComponentType, ReactNode } from "react";
12
+ import { type ComponentType, type ReactNode, useState } from "react";
13
13
  import { LoginScreen, type LoginScreenProps } from "./login-screen";
14
14
  import { SessionProvider, useSession } from "./session";
15
15
 
16
+ // Generic — NOT auth-mfa's MfaVerifyScreenProps directly, so this feature
17
+ // stays unaware of auth-mfa's concrete shape (same coupling direction as
18
+ // login.write.ts's mfaStatusChecker callback on the server side). Apps
19
+ // wire auth-mfa's MfaVerifyScreen in here via EmailPasswordClientOptions.
20
+ export type MfaVerifyComponentProps = {
21
+ readonly challengeToken: string;
22
+ readonly onSuccess?: () => void;
23
+ };
24
+
16
25
  export function makeAuthGate(
17
26
  LoginComponent: ComponentType<LoginScreenProps> = LoginScreen,
18
27
  loginProps?: LoginScreenProps,
28
+ MfaVerifyComponent?: ComponentType<MfaVerifyComponentProps>,
19
29
  ): ComponentType<{ children: ReactNode }> {
20
30
  function AuthGate({ children }: { readonly children: ReactNode }): ReactNode {
21
31
  const { status } = useSession();
32
+ // Pending challenge-token from LoginScreen's onMfaChallenge. Lives here
33
+ // (not in SessionState) because it's a UI-only transition — the server
34
+ // never considers this session authenticated until verify succeeds.
35
+ const [challengeToken, setChallengeToken] = useState<string | null>(null);
36
+
22
37
  if (status === "loading") {
23
38
  return (
24
39
  <div className="min-h-screen flex items-center justify-center text-muted-foreground text-sm" />
25
40
  );
26
41
  }
27
42
  if (status === "unauthenticated") {
28
- return <LoginComponent {...loginProps} />;
43
+ if (challengeToken !== null && MfaVerifyComponent) {
44
+ return (
45
+ <MfaVerifyComponent
46
+ challengeToken={challengeToken}
47
+ onSuccess={() => setChallengeToken(null)}
48
+ />
49
+ );
50
+ }
51
+ return (
52
+ <LoginComponent
53
+ {...loginProps}
54
+ onMfaChallenge={MfaVerifyComponent ? setChallengeToken : loginProps?.onMfaChallenge}
55
+ />
56
+ );
29
57
  }
30
58
  return <>{children}</>;
31
59
  }
@@ -38,8 +66,9 @@ export function makeAuthGate(
38
66
  export function makeSessionAuthGate(
39
67
  LoginComponent: ComponentType<LoginScreenProps> = LoginScreen,
40
68
  loginProps?: LoginScreenProps,
69
+ MfaVerifyComponent?: ComponentType<MfaVerifyComponentProps>,
41
70
  ): ComponentType<{ children: ReactNode }> {
42
- const AuthGate = makeAuthGate(LoginComponent, loginProps);
71
+ const AuthGate = makeAuthGate(LoginComponent, loginProps, MfaVerifyComponent);
43
72
  function SessionAuthGate({ children }: { readonly children: ReactNode }): ReactNode {
44
73
  return (
45
74
  <SessionProvider>
@@ -8,7 +8,7 @@
8
8
  import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
9
9
  import type { ComponentType, ReactNode } from "react";
10
10
  import { defaultTranslations, mergeTranslations } from "../i18n";
11
- import { makeSessionAuthGate } from "./auth-gate";
11
+ import { type MfaVerifyComponentProps, makeSessionAuthGate } from "./auth-gate";
12
12
  import type { LoginScreenProps } from "./login-screen";
13
13
 
14
14
  export type EmailPasswordClientOptions = {
@@ -17,6 +17,13 @@ export type EmailPasswordClientOptions = {
17
17
  * eine eigene Komponente mit derselben Signatur reichen. */
18
18
  readonly loginScreen?: ComponentType<LoginScreenProps>;
19
19
  readonly loginScreenProps?: LoginScreenProps;
20
+ /** auth-mfa's MfaVerifyScreen, wired in from the app so this feature
21
+ * stays unaware of auth-mfa's concrete shape. When LoginScreen's
22
+ * onMfaChallenge fires, the gate swaps to this component instead of
23
+ * requiring the app to own that state itself. Apps not mounting
24
+ * auth-mfa simply don't pass this — LoginScreen's built-in fallback
25
+ * error covers that case. */
26
+ readonly mfaVerifyScreen?: ComponentType<MfaVerifyComponentProps>;
20
27
  /** Key-Overrides pro Locale. Wird mit den Default-Bundles (de/en)
21
28
  * aus `translations.ts` gemerged — jeder hier gesetzte Key gewinnt.
22
29
  * Für Branding ("Sign in" → "Login to Acme") oder weitere Sprachen
@@ -41,7 +48,9 @@ export function emailPasswordClient(
41
48
  return {
42
49
  name: "auth-email-password",
43
50
  providers: [],
44
- gates: [makeSessionAuthGate(options.loginScreen, options.loginScreenProps)],
51
+ gates: [
52
+ makeSessionAuthGate(options.loginScreen, options.loginScreenProps, options.mfaVerifyScreen),
53
+ ],
45
54
  translations,
46
55
  };
47
56
  }
@@ -11,6 +11,7 @@ export type {
11
11
  CurrentUserProfile,
12
12
  LoginFailure,
13
13
  LoginRequest,
14
+ LoginResponse,
14
15
  ResetPasswordFailure,
15
16
  SignupConfirmSuccess,
16
17
  TenantSummary,
@@ -24,8 +25,9 @@ export {
24
25
  resetPassword,
25
26
  verifyEmail,
26
27
  } from "./auth-client";
27
- export type { AuthShellRenderer } from "./auth-form-primitives";
28
- export { AuthShellProvider, useAuthShell } from "./auth-form-primitives";
28
+ export type { AuthCardProps, AuthShellRenderer } from "./auth-form-primitives";
29
+ export { AuthCard, AuthShellProvider, useAuthShell } from "./auth-form-primitives";
30
+ export type { MfaVerifyComponentProps } from "./auth-gate";
29
31
  export { makeAuthGate, makeSessionAuthGate } from "./auth-gate";
30
32
  export type {
31
33
  EmailPasswordClientFeature,
@@ -50,6 +50,16 @@ export type LoginScreenProps = {
50
50
  * Labels kommen vom Caller (typisch schon übersetzt bzw. Eigennamen
51
51
  * wie "Impressum"). */
52
52
  readonly legalLinks?: readonly AuthLegalLink[];
53
+ /** Called when the server responds with an MFA challenge instead of a
54
+ * session. Apps typically swap this screen out for auth-mfa's
55
+ * MfaVerifyScreen(challengeToken). Without a handler, MFA-enrolled
56
+ * users see a generic "not supported" error — better than a silent
57
+ * hang, but apps mounting auth-mfa must wire this. */
58
+ readonly onMfaChallenge?: (challengeToken: string) => void;
59
+ /** Called when the tenant's enforcement policy requires MFA but this
60
+ * user has no factor enrolled yet. Apps typically route to an
61
+ * enrollment flow. Same fallback behavior as onMfaChallenge when unset. */
62
+ readonly onMfaSetupRequired?: () => void;
53
63
  };
54
64
 
55
65
  // Map vom Reason-Code des Login-Handlers auf einen i18n-Key plus
@@ -90,6 +100,8 @@ export function LoginScreen({
90
100
  forgotPasswordHref,
91
101
  signupHref,
92
102
  legalLinks,
103
+ onMfaChallenge,
104
+ onMfaSetupRequired,
93
105
  }: LoginScreenProps): ReactNode {
94
106
  const t = useTranslation();
95
107
  const { Form, Field, Input, Button, Banner, Link } = usePrimitives();
@@ -110,10 +122,27 @@ export function LoginScreen({
110
122
  setResendStatus({ kind: "idle" });
111
123
  const res = await session.login({ email, password });
112
124
  setSubmitting(false);
113
- if (!res.ok) {
114
- setError(res.error);
125
+ if (res.kind === "success") return;
126
+ if (res.kind === "mfa-challenge") {
127
+ if (onMfaChallenge) {
128
+ onMfaChallenge(res.challengeToken);
129
+ return;
130
+ }
131
+ setError({ reason: "mfa_not_supported" });
132
+ setFailedLoginEmail(email);
133
+ return;
134
+ }
135
+ if (res.kind === "mfa-setup-required") {
136
+ if (onMfaSetupRequired) {
137
+ onMfaSetupRequired();
138
+ return;
139
+ }
140
+ setError({ reason: "mfa_not_supported" });
115
141
  setFailedLoginEmail(email);
142
+ return;
116
143
  }
144
+ setError(res.error);
145
+ setFailedLoginEmail(email);
117
146
  };
118
147
 
119
148
  const onSubmit = (e?: FormEvent): void => {
@@ -15,8 +15,8 @@ import {
15
15
  type CurrentUserProfile,
16
16
  fetchCurrentUser,
17
17
  fetchTenants,
18
- type LoginFailure,
19
18
  type LoginRequest,
19
+ type LoginResult,
20
20
  login as loginApi,
21
21
  logout as logoutApi,
22
22
  switchTenant as switchTenantApi,
@@ -39,9 +39,13 @@ export type SessionState = {
39
39
  };
40
40
 
41
41
  export type SessionApi = SessionState & {
42
- readonly login: (req: LoginRequest) => Promise<{ ok: true } | { ok: false; error: LoginFailure }>;
42
+ readonly login: (req: LoginRequest) => Promise<LoginResult>;
43
43
  readonly logout: () => Promise<void>;
44
44
  readonly switchTenant: (tenantId: string) => Promise<void>;
45
+ /** Re-runs the /auth/tenants + user:me refresh round without a full page
46
+ * reload. Used by auth-mfa's verify screen after a challenge completes
47
+ * and mints cookies via a route session.tsx never calls directly. */
48
+ readonly refresh: () => Promise<void>;
45
49
  };
46
50
 
47
51
  // Exported so screens that render on BOTH authenticated and anonymous public
@@ -131,9 +135,10 @@ export function SessionProvider({ children }: { readonly children: ReactNode }):
131
135
  const login = useCallback<SessionApi["login"]>(
132
136
  async (req) => {
133
137
  const res = await loginApi(req);
134
- if (!res.ok) return { ok: false, error: res.error };
135
- await doRefresh();
136
- return { ok: true };
138
+ if (res.kind === "success") {
139
+ await doRefresh();
140
+ }
141
+ return res;
137
142
  },
138
143
  [doRefresh],
139
144
  );
@@ -161,7 +166,7 @@ export function SessionProvider({ children }: { readonly children: ReactNode }):
161
166
  }
162
167
  }, []);
163
168
 
164
- const api: SessionApi = { ...state, login, logout, switchTenant };
169
+ const api: SessionApi = { ...state, login, logout, switchTenant, refresh: doRefresh };
165
170
  return <SessionContext.Provider value={api}>{children}</SessionContext.Provider>;
166
171
  }
167
172