@cosmicdrift/kumiko-bundled-features 0.161.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 (29) hide show
  1. package/package.json +6 -6
  2. package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +168 -0
  3. package/src/auth-email-password/constants.ts +6 -0
  4. package/src/auth-email-password/feature.ts +8 -1
  5. package/src/auth-email-password/handlers/self-registration-status.query.ts +20 -0
  6. package/src/auth-email-password/handlers/signup-request.write.ts +11 -0
  7. package/src/auth-email-password/index.ts +5 -1
  8. package/src/auth-email-password/self-registration-toggle.ts +25 -0
  9. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +79 -0
  10. package/src/auth-email-password/web/__tests__/login-screen.test.tsx +2 -2
  11. package/src/auth-email-password/web/auth-gate.tsx +44 -2
  12. package/src/auth-email-password/web/login-screen.tsx +8 -5
  13. package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +253 -0
  14. package/src/auth-mfa/__tests__/enable-start-preauth.integration.test.ts +214 -0
  15. package/src/auth-mfa/constants.ts +2 -0
  16. package/src/auth-mfa/feature.ts +15 -0
  17. package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +170 -0
  18. package/src/auth-mfa/handlers/enable-start-preauth.write.ts +87 -0
  19. package/src/auth-mfa/mfa-setup-token.ts +7 -1
  20. package/src/auth-mfa/web/__tests__/mfa-client.test.ts +144 -1
  21. package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +202 -0
  22. package/src/auth-mfa/web/i18n.ts +14 -0
  23. package/src/auth-mfa/web/index.ts +8 -2
  24. package/src/auth-mfa/web/mfa-client.ts +113 -0
  25. package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +244 -0
  26. package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +96 -0
  27. package/src/feature-toggles/compose-tier-resolver.ts +41 -0
  28. package/src/feature-toggles/index.ts +1 -0
  29. package/src/feature-toggles/toggle-runtime.ts +14 -0
@@ -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
+ }
@@ -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
 
@@ -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
+ });
@@ -0,0 +1,202 @@
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import {
3
+ createStaticLocaleResolver,
4
+ LocaleProvider,
5
+ PrimitivesProvider,
6
+ } from "@cosmicdrift/kumiko-renderer";
7
+ import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
8
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
9
+ import type { ReactNode } from "react";
10
+ import { defaultTranslations } from "../i18n";
11
+
12
+ mock.module("qrcode/lib/browser", () => ({
13
+ default: { toString: mock(async () => "<svg></svg>") },
14
+ }));
15
+
16
+ const { MfaSetupPreauthScreen } = await import("../mfa-setup-preauth-screen");
17
+
18
+ function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
19
+ return (
20
+ <PrimitivesProvider value={defaultPrimitives}>
21
+ <LocaleProvider
22
+ resolver={createStaticLocaleResolver({ locale: "de" })}
23
+ fallbackBundles={[defaultTranslations]}
24
+ >
25
+ {children}
26
+ </LocaleProvider>
27
+ </PrimitivesProvider>
28
+ );
29
+ }
30
+
31
+ function jsonResponse(body: unknown, status = 200): Response {
32
+ return new Response(JSON.stringify(body), { status });
33
+ }
34
+
35
+ const startBody = {
36
+ isSuccess: true,
37
+ setupToken: "setup-token-1",
38
+ otpauthUri: "otpauth://totp/App:user@example.com?secret=ABCD1234&issuer=App",
39
+ recoveryCodes: ["code-1", "code-2"],
40
+ };
41
+ const confirmBody = {
42
+ isSuccess: true,
43
+ token: "t",
44
+ user: { id: "u1", tenantId: "t1", roles: [] },
45
+ };
46
+
47
+ beforeEach(() => {
48
+ globalThis.fetch = mock(async (input: RequestInfo | URL) => {
49
+ const url = String(input);
50
+ if (url.includes("preauth-enable-start")) return jsonResponse(startBody);
51
+ if (url.includes("preauth-confirm")) return jsonResponse(confirmBody);
52
+ return jsonResponse({ isSuccess: false, error: "unexpected_url" }, 500);
53
+ }) as unknown as typeof fetch;
54
+ });
55
+
56
+ describe("MfaSetupPreauthScreen", () => {
57
+ test("rendert Titel + Start-Button, kein Setup-Bereich vor dem Start", () => {
58
+ render(
59
+ <Wrapper>
60
+ <MfaSetupPreauthScreen preauthSetupToken="preauth-1" accountLabel="user@example.com" />
61
+ </Wrapper>,
62
+ );
63
+ expect(screen.getByText("Zwei-Faktor-Authentifizierung erforderlich")).toBeTruthy();
64
+ expect(screen.getByRole("button", { name: "Einrichtung starten" })).toBeTruthy();
65
+ expect(screen.queryByLabelText(/^Code aus der Authenticator-App/)).toBeNull();
66
+ });
67
+
68
+ test("Start-Klick → postet preauthSetupToken+accountLabel, danach QR + Recovery-Codes", async () => {
69
+ const fetchMock = mock(async () => jsonResponse(startBody));
70
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
71
+ render(
72
+ <Wrapper>
73
+ <MfaSetupPreauthScreen preauthSetupToken="preauth-1" accountLabel="user@example.com" />
74
+ </Wrapper>,
75
+ );
76
+
77
+ fireEvent.click(screen.getByRole("button", { name: "Einrichtung starten" }));
78
+
79
+ await waitFor(() => {
80
+ expect(fetchMock).toHaveBeenCalledWith(
81
+ "/api/auth/mfa/preauth-enable-start",
82
+ expect.objectContaining({
83
+ method: "POST",
84
+ body: JSON.stringify({
85
+ preauthSetupToken: "preauth-1",
86
+ accountLabel: "user@example.com",
87
+ }),
88
+ }),
89
+ );
90
+ });
91
+ await waitFor(() => {
92
+ expect(screen.getByText("ABCD1234")).toBeTruthy();
93
+ });
94
+ expect(screen.getByText(/code-1/)).toBeTruthy();
95
+ expect(screen.getByText(/code-2/)).toBeTruthy();
96
+ });
97
+
98
+ test("Bestätigen-Button bleibt disabled bis Recovery-Codes bestätigt + 6-stelliger Code eingegeben", async () => {
99
+ render(
100
+ <Wrapper>
101
+ <MfaSetupPreauthScreen preauthSetupToken="preauth-1" accountLabel="user@example.com" />
102
+ </Wrapper>,
103
+ );
104
+ fireEvent.click(screen.getByRole("button", { name: "Einrichtung starten" }));
105
+ await waitFor(() => {
106
+ expect(screen.getByText("ABCD1234")).toBeTruthy();
107
+ });
108
+
109
+ const confirm = screen.getByRole("button", {
110
+ name: "Einrichtung abschließen",
111
+ }) as HTMLButtonElement;
112
+ expect(confirm.disabled).toBe(true);
113
+
114
+ fireEvent.click(screen.getByLabelText("Ich habe die Recovery-Codes gespeichert."));
115
+ expect(confirm.disabled).toBe(true);
116
+
117
+ fireEvent.change(screen.getByLabelText(/^Code aus der Authenticator-App/), {
118
+ target: { value: "123456" },
119
+ });
120
+ expect(confirm.disabled).toBe(false);
121
+ });
122
+
123
+ test("Bestätigen → postet setupToken+code, danach onSuccess", async () => {
124
+ const onSuccess = mock(() => {});
125
+ render(
126
+ <Wrapper>
127
+ <MfaSetupPreauthScreen
128
+ preauthSetupToken="preauth-1"
129
+ accountLabel="user@example.com"
130
+ onSuccess={onSuccess}
131
+ />
132
+ </Wrapper>,
133
+ );
134
+ fireEvent.click(screen.getByRole("button", { name: "Einrichtung starten" }));
135
+ await waitFor(() => {
136
+ expect(screen.getByText("ABCD1234")).toBeTruthy();
137
+ });
138
+ fireEvent.click(screen.getByLabelText("Ich habe die Recovery-Codes gespeichert."));
139
+ fireEvent.change(screen.getByLabelText(/^Code aus der Authenticator-App/), {
140
+ target: { value: "123456" },
141
+ });
142
+
143
+ const fetchMock = globalThis.fetch as unknown as ReturnType<typeof mock>;
144
+ fireEvent.click(screen.getByRole("button", { name: "Einrichtung abschließen" }));
145
+
146
+ await waitFor(() => {
147
+ expect(fetchMock).toHaveBeenCalledWith(
148
+ "/api/auth/mfa/preauth-confirm",
149
+ expect.objectContaining({
150
+ method: "POST",
151
+ body: JSON.stringify({ setupToken: "setup-token-1", code: "123456" }),
152
+ }),
153
+ );
154
+ });
155
+ await waitFor(() => {
156
+ expect(onSuccess).toHaveBeenCalled();
157
+ });
158
+ });
159
+
160
+ test("enable-start schlägt fehl → übersetztes Fehler-Banner, kein Setup-Bereich", async () => {
161
+ globalThis.fetch = mock(async () =>
162
+ jsonResponse({ isSuccess: false, error: "mfa_already_enabled" }),
163
+ ) as unknown as typeof fetch;
164
+ render(
165
+ <Wrapper>
166
+ <MfaSetupPreauthScreen preauthSetupToken="preauth-1" accountLabel="user@example.com" />
167
+ </Wrapper>,
168
+ );
169
+
170
+ fireEvent.click(screen.getByRole("button", { name: "Einrichtung starten" }));
171
+
172
+ await waitFor(() => {
173
+ expect(screen.getByText("Zwei-Faktor-Authentifizierung ist bereits aktiv.")).toBeTruthy();
174
+ });
175
+ expect(screen.queryByText("ABCD1234")).toBeNull();
176
+ });
177
+
178
+ test("onCancel gesetzt → 'Zurück zum Login'-Button vorhanden, klick ruft onCancel", () => {
179
+ const onCancel = mock(() => {});
180
+ render(
181
+ <Wrapper>
182
+ <MfaSetupPreauthScreen
183
+ preauthSetupToken="preauth-1"
184
+ accountLabel="user@example.com"
185
+ onCancel={onCancel}
186
+ />
187
+ </Wrapper>,
188
+ );
189
+
190
+ fireEvent.click(screen.getByRole("button", { name: "Zurück zum Login" }));
191
+ expect(onCancel).toHaveBeenCalled();
192
+ });
193
+
194
+ test("ohne onCancel → kein 'Zurück zum Login'-Button", () => {
195
+ render(
196
+ <Wrapper>
197
+ <MfaSetupPreauthScreen preauthSetupToken="preauth-1" accountLabel="user@example.com" />
198
+ </Wrapper>,
199
+ );
200
+ expect(screen.queryByRole("button", { name: "Zurück zum Login" })).toBeNull();
201
+ });
202
+ });
@@ -40,6 +40,13 @@ export const defaultTranslations: TranslationsByLocale = {
40
40
  "auth.mfa.enable.cancel": "Abbrechen",
41
41
  "auth.mfa.enable.confirm": "Aktivieren",
42
42
  "auth.mfa.enable.success": "Zwei-Faktor-Authentifizierung ist jetzt aktiv.",
43
+ "auth.mfa.setup.title": "Zwei-Faktor-Authentifizierung erforderlich",
44
+ "auth.mfa.setup.subtitle":
45
+ "Dein Konto verlangt Zwei-Faktor-Authentifizierung. Richte sie jetzt ein, um dich anzumelden.",
46
+ "auth.mfa.setup.intro":
47
+ "Scanne den QR-Code mit einer Authenticator-App (z.B. Google Authenticator, 1Password).",
48
+ "auth.mfa.setup.start": "Einrichtung starten",
49
+ "auth.mfa.setup.confirm": "Einrichtung abschließen",
43
50
  "auth.mfa.disable.title": "Zwei-Faktor-Authentifizierung deaktivieren",
44
51
  "auth.mfa.disable.description":
45
52
  "Bestätige mit einem Code aus deiner Authenticator-App oder einem Recovery-Code. Dein Konto ist danach nur noch durch dein Passwort geschützt.",
@@ -93,6 +100,13 @@ export const defaultTranslations: TranslationsByLocale = {
93
100
  "auth.mfa.enable.cancel": "Cancel",
94
101
  "auth.mfa.enable.confirm": "Enable",
95
102
  "auth.mfa.enable.success": "Two-factor authentication is now enabled.",
103
+ "auth.mfa.setup.title": "Two-factor authentication required",
104
+ "auth.mfa.setup.subtitle":
105
+ "Your account requires two-factor authentication. Set it up now to sign in.",
106
+ "auth.mfa.setup.intro":
107
+ "Scan the QR code with an authenticator app (e.g. Google Authenticator, 1Password).",
108
+ "auth.mfa.setup.start": "Start setup",
109
+ "auth.mfa.setup.confirm": "Complete setup",
96
110
  "auth.mfa.disable.title": "Disable two-factor authentication",
97
111
  "auth.mfa.disable.description":
98
112
  "Confirm with a code from your authenticator app or a recovery code. Your account will then be protected by your password alone.",
@@ -7,8 +7,12 @@
7
7
  export type { AuthMfaClientFeature, AuthMfaClientOptions } from "./client-plugin";
8
8
  export { authMfaClient } from "./client-plugin";
9
9
  export { defaultTranslations, mergeTranslations } from "./i18n";
10
- export type { MfaVerifyResult } from "./mfa-client";
11
- export { verifyMfaChallenge } from "./mfa-client";
10
+ export type {
11
+ MfaSetupPreauthStart,
12
+ MfaSetupPreauthStartResult,
13
+ MfaVerifyResult,
14
+ } from "./mfa-client";
15
+ export { confirmMfaSetupPreauth, startMfaSetupPreauth, verifyMfaChallenge } from "./mfa-client";
12
16
  export type { MfaDisableDialogProps } from "./mfa-disable-dialog";
13
17
  export { MfaDisableDialog } from "./mfa-disable-dialog";
14
18
  export type { MfaEnableScreenProps } from "./mfa-enable-screen";
@@ -17,5 +21,7 @@ export type { MfaRecoveryCodesRevealProps } from "./mfa-recovery-codes-reveal";
17
21
  export { MfaRecoveryCodesReveal } from "./mfa-recovery-codes-reveal";
18
22
  export type { MfaRegenerateRecoveryDialogProps } from "./mfa-regenerate-recovery-dialog";
19
23
  export { MfaRegenerateRecoveryDialog } from "./mfa-regenerate-recovery-dialog";
24
+ export type { MfaSetupPreauthScreenProps } from "./mfa-setup-preauth-screen";
25
+ export { MfaSetupPreauthScreen } from "./mfa-setup-preauth-screen";
20
26
  export type { MfaVerifyScreenProps } from "./mfa-verify-screen";
21
27
  export { MfaVerifyScreen } from "./mfa-verify-screen";
@@ -58,3 +58,116 @@ export async function verifyMfaChallenge(
58
58
  },
59
59
  };
60
60
  }
61
+
62
+ export type MfaSetupPreauthStart = {
63
+ readonly setupToken: string;
64
+ readonly otpauthUri: string;
65
+ readonly recoveryCodes: readonly string[];
66
+ };
67
+
68
+ export type MfaSetupPreauthStartResult =
69
+ | { readonly kind: "success"; readonly data: MfaSetupPreauthStart }
70
+ | { readonly kind: "failure"; readonly error: LoginFailure };
71
+
72
+ // Browser-Seite von POST /api/auth/mfa/preauth-enable-start — lets a user
73
+ // blocked at login by MFA enforcement generate a TOTP secret/QR without a
74
+ // session, using the preauthSetupToken login()'s mfa-setup-required result
75
+ // carries. No JWT/cookie here (see auth-routes.ts's route comment) — the
76
+ // response's setupToken is consumed by confirmMfaSetupPreauth below.
77
+ export async function startMfaSetupPreauth(
78
+ preauthSetupToken: string,
79
+ accountLabel: string,
80
+ ): Promise<MfaSetupPreauthStartResult> {
81
+ const res = await fetch("/api/auth/mfa/preauth-enable-start", {
82
+ method: "POST",
83
+ credentials: "same-origin",
84
+ headers: { "Content-Type": "application/json" },
85
+ body: JSON.stringify({ preauthSetupToken, accountLabel }),
86
+ });
87
+ if (res.status === 429) {
88
+ return { kind: "failure", error: { reason: "rate_limited" } };
89
+ }
90
+ // @cast-boundary engine-payload — HTTP-API contract, server-side schema-validated
91
+ const body = (await res.json().catch(() => ({}))) as {
92
+ isSuccess?: boolean;
93
+ setupToken?: string;
94
+ otpauthUri?: string;
95
+ recoveryCodes?: readonly string[];
96
+ error?: { code?: string; message?: string; details?: { reason?: string } } | string;
97
+ };
98
+ if (
99
+ body.isSuccess === true &&
100
+ body.setupToken !== undefined &&
101
+ body.otpauthUri !== undefined &&
102
+ body.recoveryCodes !== undefined
103
+ ) {
104
+ return {
105
+ kind: "success",
106
+ data: {
107
+ setupToken: body.setupToken,
108
+ otpauthUri: body.otpauthUri,
109
+ recoveryCodes: body.recoveryCodes,
110
+ },
111
+ };
112
+ }
113
+ const err = body.error;
114
+ if (typeof err === "string") {
115
+ return { kind: "failure", error: { reason: err } };
116
+ }
117
+ const reason = err?.details?.reason ?? err?.code ?? "setup_failed";
118
+ return {
119
+ kind: "failure",
120
+ error: { reason, ...(err?.message !== undefined && { message: err.message }) },
121
+ };
122
+ }
123
+
124
+ // Browser-Seite von POST /api/auth/mfa/preauth-confirm — completes both
125
+ // the enrollment startMfaSetupPreauth began and the login mfa-setup-
126
+ // required block. Response shape mirrors /auth/mfa/verify's (mints via
127
+ // the same mintSessionAndRespond()), same as verifyMfaChallenge above —
128
+ // kept as its own function rather than a shared helper so this file's
129
+ // two already-tested request shapes stay independently editable.
130
+ export async function confirmMfaSetupPreauth(
131
+ setupToken: string,
132
+ code: string,
133
+ ): Promise<MfaVerifyResult> {
134
+ const res = await fetch("/api/auth/mfa/preauth-confirm", {
135
+ method: "POST",
136
+ credentials: "same-origin",
137
+ headers: { "Content-Type": "application/json" },
138
+ body: JSON.stringify({ setupToken, code }),
139
+ });
140
+ if (res.status === 429) {
141
+ return { kind: "failure", error: { reason: "rate_limited" } };
142
+ }
143
+ // @cast-boundary engine-payload — HTTP-API contract, server-side schema-validated
144
+ const body = (await res.json().catch(() => ({}))) as {
145
+ isSuccess?: boolean;
146
+ token?: string;
147
+ user?: LoginResponse["user"];
148
+ error?:
149
+ | {
150
+ code?: string;
151
+ message?: string;
152
+ details?: { reason?: string; retryAfterSeconds?: number };
153
+ }
154
+ | string;
155
+ };
156
+ if (body.isSuccess === true && body.token !== undefined && body.user !== undefined) {
157
+ return { kind: "success", data: { token: body.token, user: body.user } };
158
+ }
159
+ const err = body.error;
160
+ if (typeof err === "string") {
161
+ return { kind: "failure", error: { reason: err } };
162
+ }
163
+ const reason = err?.details?.reason ?? err?.code ?? "mfa_setup_confirm_failed";
164
+ const retry = err?.details?.retryAfterSeconds;
165
+ return {
166
+ kind: "failure",
167
+ error: {
168
+ reason,
169
+ ...(err?.message !== undefined && { message: err.message }),
170
+ ...(retry !== undefined && { retryAfterSeconds: retry }),
171
+ },
172
+ };
173
+ }