@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.
- package/package.json +12 -7
- package/src/auth-email-password/feature.ts +6 -1
- package/src/auth-email-password/handlers/confirm-token-flow.ts +1 -1
- package/src/auth-email-password/handlers/login.write.ts +66 -9
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +15 -5
- package/src/auth-email-password/web/__tests__/test-utils.tsx +12 -2
- package/src/auth-email-password/web/auth-client.ts +34 -12
- package/src/auth-email-password/web/auth-gate.tsx +32 -3
- package/src/auth-email-password/web/client-plugin.ts +11 -2
- package/src/auth-email-password/web/index.ts +4 -2
- package/src/auth-email-password/web/login-screen.tsx +31 -2
- package/src/auth-email-password/web/session.tsx +11 -6
- package/src/auth-mfa/__tests__/disable-and-regenerate.integration.test.ts +202 -0
- package/src/auth-mfa/__tests__/enable-flow.integration.test.ts +150 -0
- package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +167 -0
- package/src/auth-mfa/__tests__/session-auto-revoke.integration.test.ts +168 -0
- package/src/auth-mfa/__tests__/verify.integration.test.ts +186 -0
- package/src/auth-mfa/config.ts +26 -0
- package/src/auth-mfa/constants.ts +25 -0
- package/src/auth-mfa/db/queries.ts +34 -0
- package/src/auth-mfa/errors.ts +73 -0
- package/src/auth-mfa/feature.ts +146 -0
- package/src/auth-mfa/handlers/disable.write.ts +51 -0
- package/src/auth-mfa/handlers/enable-confirm.write.ts +68 -0
- package/src/auth-mfa/handlers/enable-start.write.ts +66 -0
- package/src/auth-mfa/handlers/reencrypt.job.ts +185 -0
- package/src/auth-mfa/handlers/regenerate-recovery.write.ts +62 -0
- package/src/auth-mfa/handlers/verify.write.ts +141 -0
- package/src/auth-mfa/i18n.ts +12 -0
- package/src/auth-mfa/index.ts +15 -0
- package/src/auth-mfa/mfa-challenge-token.ts +90 -0
- package/src/auth-mfa/mfa-setup-token.ts +90 -0
- package/src/auth-mfa/mfa-status-checker.ts +75 -0
- package/src/auth-mfa/mfa-verify-attempts.ts +90 -0
- package/src/auth-mfa/recovery-codes.ts +44 -0
- package/src/auth-mfa/schema/user-mfa.ts +40 -0
- package/src/auth-mfa/totp.ts +8 -0
- package/src/auth-mfa/verify-factor.ts +30 -0
- package/src/auth-mfa/web/client-plugin.ts +37 -0
- package/src/auth-mfa/web/i18n.ts +69 -0
- package/src/auth-mfa/web/index.ts +15 -0
- package/src/auth-mfa/web/mfa-client.ts +60 -0
- package/src/auth-mfa/web/mfa-enable-screen.tsx +187 -0
- package/src/auth-mfa/web/mfa-verify-screen.tsx +106 -0
- package/src/auth-mfa-user-data/__tests__/hooks.integration.test.ts +132 -0
- package/src/auth-mfa-user-data/hooks.ts +53 -0
- package/src/auth-mfa-user-data/index.ts +21 -0
- package/src/legal-pages/__tests__/legal-pages.integration.test.ts +4 -0
- package/src/legal-pages/feature.ts +19 -28
- package/src/managed-pages/feature.ts +1 -1
- package/src/sessions/feature.ts +2 -1
- package/src/sessions/session-callbacks.ts +23 -0
- package/src/shared/index.ts +1 -0
- 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.
|
|
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.
|
|
115
|
-
"@cosmicdrift/kumiko-framework": "0.147.
|
|
116
|
-
"@cosmicdrift/kumiko-headless": "0.147.
|
|
117
|
-
"@cosmicdrift/kumiko-renderer": "0.147.
|
|
118
|
-
"@cosmicdrift/kumiko-renderer-web": "0.147.
|
|
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 () => ({
|
|
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
|
-
|
|
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 () => ({
|
|
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 () => ({
|
|
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:
|
|
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
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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 {
|
|
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
|
|
78
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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: [
|
|
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 (
|
|
114
|
-
|
|
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<
|
|
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 (
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|