@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.
- package/package.json +6 -6
- package/src/auth-email-password/__tests__/login-mfa-setup-required.integration.test.ts +129 -0
- package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +168 -0
- package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +5 -0
- package/src/auth-email-password/constants.ts +6 -0
- package/src/auth-email-password/feature.ts +8 -1
- package/src/auth-email-password/handlers/login.write.ts +6 -3
- package/src/auth-email-password/handlers/self-registration-status.query.ts +20 -0
- package/src/auth-email-password/handlers/signup-request.write.ts +11 -0
- package/src/auth-email-password/i18n.ts +4 -0
- package/src/auth-email-password/index.ts +5 -1
- package/src/auth-email-password/self-registration-toggle.ts +25 -0
- package/src/auth-email-password/web/__tests__/auth-client.test.ts +6 -2
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +79 -0
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +14 -5
- package/src/auth-email-password/web/auth-client.ts +4 -3
- package/src/auth-email-password/web/auth-gate.tsx +44 -2
- package/src/auth-email-password/web/login-screen.tsx +11 -5
- package/src/auth-foundation/__tests__/feature.test.ts +16 -3
- package/src/auth-foundation/__tests__/session-store.test.ts +1 -0
- package/src/auth-foundation/__tests__/tenant-providers.test.ts +136 -0
- package/src/auth-foundation/boot-checks.ts +54 -0
- package/src/auth-foundation/feature.ts +77 -23
- package/src/auth-foundation/index.ts +14 -0
- package/src/auth-foundation/resolve-anonymous-access.ts +54 -0
- package/src/auth-foundation/types.ts +63 -0
- package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +253 -0
- package/src/auth-mfa/__tests__/enable-start-preauth.integration.test.ts +214 -0
- package/src/auth-mfa/__tests__/mfa-preauth-setup-token.test.ts +87 -0
- package/src/auth-mfa/constants.ts +3 -0
- package/src/auth-mfa/feature.ts +15 -0
- package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +170 -0
- package/src/auth-mfa/handlers/enable-start-preauth.write.ts +87 -0
- package/src/auth-mfa/mfa-preauth-setup-token.ts +86 -0
- package/src/auth-mfa/mfa-setup-token.ts +7 -1
- package/src/auth-mfa/mfa-status-checker.ts +13 -4
- package/src/auth-mfa/web/__tests__/mfa-client.test.ts +144 -1
- package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +202 -0
- package/src/auth-mfa/web/i18n.ts +14 -0
- package/src/auth-mfa/web/index.ts +8 -2
- package/src/auth-mfa/web/mfa-client.ts +113 -0
- package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +244 -0
- package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +96 -0
- package/src/feature-toggles/compose-tier-resolver.ts +41 -0
- package/src/feature-toggles/index.ts +1 -0
- package/src/feature-toggles/toggle-runtime.ts +14 -0
- package/src/sessions/feature.ts +8 -1
|
@@ -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
|
+
});
|
package/src/auth-mfa/web/i18n.ts
CHANGED
|
@@ -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 {
|
|
11
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Pre-auth twin of MfaEnableScreen: a user blocked at login by MFA
|
|
3
|
+
// enforcement and not yet enrolled lands here straight out of the login
|
|
4
|
+
// flow — there is no session yet. Identity comes entirely from the
|
|
5
|
+
// preauthSetupToken login()'s mfa-setup-required result carries (see
|
|
6
|
+
// auth-client.ts's LoginResult). Calls startMfaSetupPreauth/confirmMfa-
|
|
7
|
+
// SetupPreauth directly via fetch (mfa-client.ts) — no useSession(), no
|
|
8
|
+
// useDispatcher(), both assume a real session that doesn't exist here.
|
|
9
|
+
//
|
|
10
|
+
// Session contract: a successful confirm mints cookie+JWT server-side, but
|
|
11
|
+
// THIS component does not refresh the session itself (unlike MfaVerify-
|
|
12
|
+
// Screen, which calls session.refresh() internally). onSuccess fires right
|
|
13
|
+
// after confirm — the caller (e.g. auth-gate's LoginRoute, which already
|
|
14
|
+
// has useSession() in scope) must call refresh() there, otherwise the app
|
|
15
|
+
// stays "unauthenticated" until the next reload.
|
|
16
|
+
|
|
17
|
+
import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
18
|
+
// qrcode's package.json#browser remap avoids Node-only deps (yargs/pngjs)
|
|
19
|
+
// for bundlers that honor it — Metro doesn't, hence the explicit subpath.
|
|
20
|
+
import QRCode from "qrcode/lib/browser";
|
|
21
|
+
import { type FormEvent, type ReactNode, useState } from "react";
|
|
22
|
+
// kumiko-lint-ignore cross-feature-import client-only component, the feature's server barrel has no web/ re-export
|
|
23
|
+
import { AuthCard } from "../../auth-email-password/web";
|
|
24
|
+
import { confirmMfaSetupPreauth, startMfaSetupPreauth } from "./mfa-client";
|
|
25
|
+
|
|
26
|
+
type SetupState = {
|
|
27
|
+
readonly setupToken: string;
|
|
28
|
+
readonly secretParam: string;
|
|
29
|
+
readonly recoveryCodes: readonly string[];
|
|
30
|
+
readonly qrSvg: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// otpauth:// URIs put the secret in the query string — extract it for the
|
|
34
|
+
// manual-entry fallback (authenticator apps without a camera / desktop use).
|
|
35
|
+
function extractSecret(otpauthUri: string): string {
|
|
36
|
+
const query = otpauthUri.split("?")[1] ?? "";
|
|
37
|
+
return new URLSearchParams(query).get("secret") ?? "";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function reasonToKey(reason: string): string {
|
|
41
|
+
switch (reason) {
|
|
42
|
+
case "invalid_totp_code":
|
|
43
|
+
return "auth.mfa.errors.invalidCode";
|
|
44
|
+
case "mfa_already_enabled":
|
|
45
|
+
return "auth.mfa.errors.mfaAlreadyEnabled";
|
|
46
|
+
case "invalid_setup_token":
|
|
47
|
+
return "auth.mfa.errors.invalidSetupToken";
|
|
48
|
+
case "invalid_challenge_token":
|
|
49
|
+
return "auth.mfa.errors.challengeExpired";
|
|
50
|
+
case "too_many_attempts":
|
|
51
|
+
return "auth.mfa.errors.tooManyAttempts";
|
|
52
|
+
case "rate_limited":
|
|
53
|
+
return "auth.errors.rateLimited";
|
|
54
|
+
default:
|
|
55
|
+
return "auth.mfa.errors.setupFailed";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type MfaSetupPreauthScreenProps = {
|
|
60
|
+
readonly preauthSetupToken: string;
|
|
61
|
+
/** Account label for the otpauth:// URI (typically the email typed at
|
|
62
|
+
* login) — without a session there's no session.user.email to derive it from. */
|
|
63
|
+
readonly accountLabel: string;
|
|
64
|
+
readonly title?: string;
|
|
65
|
+
readonly subtitle?: ReactNode;
|
|
66
|
+
/** Fired once confirm succeeds — the caller owns session refresh, see the
|
|
67
|
+
* file-level comment above. */
|
|
68
|
+
readonly onSuccess?: () => void;
|
|
69
|
+
readonly onCancel?: () => void;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export function MfaSetupPreauthScreen({
|
|
73
|
+
preauthSetupToken,
|
|
74
|
+
accountLabel,
|
|
75
|
+
title,
|
|
76
|
+
subtitle,
|
|
77
|
+
onSuccess,
|
|
78
|
+
onCancel,
|
|
79
|
+
}: MfaSetupPreauthScreenProps): ReactNode {
|
|
80
|
+
const t = useTranslation();
|
|
81
|
+
const { Button, Banner, Field, Form, Input, Section } = usePrimitives();
|
|
82
|
+
|
|
83
|
+
const [setup, setSetup] = useState<SetupState | null>(null);
|
|
84
|
+
const [acknowledged, setAcknowledged] = useState(false);
|
|
85
|
+
const [code, setCode] = useState("");
|
|
86
|
+
const [busy, setBusy] = useState(false);
|
|
87
|
+
const [error, setError] = useState<string | null>(null);
|
|
88
|
+
|
|
89
|
+
const startSetup = async (): Promise<void> => {
|
|
90
|
+
setBusy(true);
|
|
91
|
+
setError(null);
|
|
92
|
+
try {
|
|
93
|
+
const res = await startMfaSetupPreauth(preauthSetupToken, accountLabel);
|
|
94
|
+
if (res.kind !== "success") {
|
|
95
|
+
setError(res.error.reason);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
// errorCorrectionLevel "H" (~30% redundancy) — more resilient to
|
|
99
|
+
// camera/lighting issues than the default, no downside for a code
|
|
100
|
+
// this short-lived.
|
|
101
|
+
const qrSvg = await QRCode.toString(res.data.otpauthUri, {
|
|
102
|
+
type: "svg",
|
|
103
|
+
errorCorrectionLevel: "H",
|
|
104
|
+
});
|
|
105
|
+
setSetup({
|
|
106
|
+
setupToken: res.data.setupToken,
|
|
107
|
+
secretParam: extractSecret(res.data.otpauthUri),
|
|
108
|
+
recoveryCodes: res.data.recoveryCodes,
|
|
109
|
+
qrSvg,
|
|
110
|
+
});
|
|
111
|
+
setAcknowledged(false);
|
|
112
|
+
setCode("");
|
|
113
|
+
} catch {
|
|
114
|
+
setError("setup_failed");
|
|
115
|
+
} finally {
|
|
116
|
+
setBusy(false);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const confirmSetup = async (): Promise<void> => {
|
|
121
|
+
if (!setup) return;
|
|
122
|
+
setBusy(true);
|
|
123
|
+
setError(null);
|
|
124
|
+
try {
|
|
125
|
+
const res = await confirmMfaSetupPreauth(setup.setupToken, code);
|
|
126
|
+
if (res.kind !== "success") {
|
|
127
|
+
setError(res.error.reason);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
onSuccess?.();
|
|
131
|
+
} catch {
|
|
132
|
+
setError("setup_failed");
|
|
133
|
+
} finally {
|
|
134
|
+
setBusy(false);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const onSubmit = (e?: FormEvent): void => {
|
|
139
|
+
e?.preventDefault();
|
|
140
|
+
void confirmSetup();
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<AuthCard
|
|
145
|
+
title={title ?? t("auth.mfa.setup.title")}
|
|
146
|
+
subtitle={subtitle ?? t("auth.mfa.setup.subtitle")}
|
|
147
|
+
>
|
|
148
|
+
<div className="p-6 pt-0 flex flex-col gap-4">
|
|
149
|
+
{error !== null ? <Banner variant="error">{t(reasonToKey(error))}</Banner> : null}
|
|
150
|
+
|
|
151
|
+
{!setup && (
|
|
152
|
+
<Section
|
|
153
|
+
testId="mfa-setup-preauth-intro"
|
|
154
|
+
actions={
|
|
155
|
+
<Button
|
|
156
|
+
variant="primary"
|
|
157
|
+
onClick={() => void startSetup()}
|
|
158
|
+
loading={busy}
|
|
159
|
+
disabled={busy}
|
|
160
|
+
>
|
|
161
|
+
{t("auth.mfa.setup.start")}
|
|
162
|
+
</Button>
|
|
163
|
+
}
|
|
164
|
+
>
|
|
165
|
+
<span className="text-sm text-muted-foreground">{t("auth.mfa.setup.intro")}</span>
|
|
166
|
+
</Section>
|
|
167
|
+
)}
|
|
168
|
+
|
|
169
|
+
{setup && (
|
|
170
|
+
<Form onSubmit={onSubmit}>
|
|
171
|
+
<Section
|
|
172
|
+
testId="mfa-setup-preauth-setup"
|
|
173
|
+
actions={
|
|
174
|
+
<Button
|
|
175
|
+
type="submit"
|
|
176
|
+
variant="primary"
|
|
177
|
+
loading={busy}
|
|
178
|
+
disabled={busy || !acknowledged || code.length < 6}
|
|
179
|
+
>
|
|
180
|
+
{t("auth.mfa.setup.confirm")}
|
|
181
|
+
</Button>
|
|
182
|
+
}
|
|
183
|
+
>
|
|
184
|
+
<div className="flex flex-col items-center gap-2 text-center">
|
|
185
|
+
<span className="text-sm font-semibold">{t("auth.mfa.enable.scanTitle")}</span>
|
|
186
|
+
{/* qrcode's own SVG string output, not user input — safe to inline */}
|
|
187
|
+
<div
|
|
188
|
+
className="h-40 w-40"
|
|
189
|
+
// biome-ignore lint/security/noDangerouslySetInnerHtml: qrcode-generated SVG, no user input
|
|
190
|
+
dangerouslySetInnerHTML={{ __html: setup.qrSvg }}
|
|
191
|
+
/>
|
|
192
|
+
<span className="text-xs text-muted-foreground">
|
|
193
|
+
{t("auth.mfa.enable.manualEntry")}
|
|
194
|
+
</span>
|
|
195
|
+
<code className="inline-block break-all rounded bg-muted px-3 py-2 font-mono text-sm">
|
|
196
|
+
{setup.secretParam}
|
|
197
|
+
</code>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
<div className="flex flex-col gap-2">
|
|
201
|
+
<span className="text-sm font-semibold">{t("auth.mfa.enable.recoveryTitle")}</span>
|
|
202
|
+
<span className="text-xs text-muted-foreground">
|
|
203
|
+
{t("auth.mfa.enable.recoveryHint")}
|
|
204
|
+
</span>
|
|
205
|
+
<code className="block whitespace-pre-wrap break-all rounded bg-muted px-3 py-2 font-mono text-sm">
|
|
206
|
+
{setup.recoveryCodes.join("\n")}
|
|
207
|
+
</code>
|
|
208
|
+
<Field id="mfa-setup-preauth-ack" label={t("auth.mfa.enable.acknowledge")}>
|
|
209
|
+
<Input
|
|
210
|
+
kind="boolean"
|
|
211
|
+
id="mfa-setup-preauth-ack"
|
|
212
|
+
name="mfa-setup-preauth-ack"
|
|
213
|
+
value={acknowledged}
|
|
214
|
+
onChange={setAcknowledged}
|
|
215
|
+
/>
|
|
216
|
+
</Field>
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
<Field id="mfa-setup-preauth-code" label={t("auth.mfa.enable.code")} required>
|
|
220
|
+
<Input
|
|
221
|
+
kind="text"
|
|
222
|
+
id="mfa-setup-preauth-code"
|
|
223
|
+
name="mfa-setup-preauth-code"
|
|
224
|
+
value={code}
|
|
225
|
+
onChange={setCode}
|
|
226
|
+
disabled={busy || !acknowledged}
|
|
227
|
+
autoComplete="one-time-code"
|
|
228
|
+
/>
|
|
229
|
+
</Field>
|
|
230
|
+
</Section>
|
|
231
|
+
</Form>
|
|
232
|
+
)}
|
|
233
|
+
|
|
234
|
+
{onCancel ? (
|
|
235
|
+
<div className="flex justify-center">
|
|
236
|
+
<Button type="button" variant="secondary" onClick={onCancel} disabled={busy}>
|
|
237
|
+
{t("auth.mfa.verify.backToLogin")}
|
|
238
|
+
</Button>
|
|
239
|
+
</div>
|
|
240
|
+
) : null}
|
|
241
|
+
</div>
|
|
242
|
+
</AuthCard>
|
|
243
|
+
);
|
|
244
|
+
}
|