@cosmicdrift/kumiko-bundled-features 0.165.0 → 0.165.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 +10 -6
- package/src/__tests__/access-invalidation-consumer.integration.test.ts +279 -0
- package/src/__tests__/access-invalidation-stream.integration.test.ts +205 -0
- package/src/audit/__tests__/audit.integration.test.ts +52 -21
- package/src/audit/handlers/list.query.ts +1 -0
- package/src/auth-email-password/__tests__/auth.integration.test.ts +6 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +34 -0
- package/src/auth-email-password/__tests__/login-gates.test.ts +39 -0
- package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +9 -14
- package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +16 -5
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +54 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -1
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -1
- package/src/auth-email-password/handlers/login.write.ts +196 -155
- package/src/auth-email-password/handlers/self-registration-status.query.ts +6 -3
- package/src/auth-email-password/handlers/signup-request.write.ts +12 -1
- package/src/auth-email-password/i18n.ts +2 -0
- package/src/auth-email-password/seeding.ts +41 -23
- package/src/auth-email-password/web/__tests__/auth-client.test.ts +13 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +55 -8
- package/src/auth-email-password/web/__tests__/client-plugin.test.tsx +34 -0
- package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +8 -1
- package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +5 -1
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +3 -0
- package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +8 -2
- package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +5 -1
- package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +5 -1
- package/src/auth-email-password/web/auth-client.ts +32 -10
- package/src/auth-email-password/web/auth-gate.tsx +17 -21
- package/src/auth-email-password/web/client-plugin.ts +15 -2
- package/src/auth-email-password/web/index.ts +5 -1
- package/src/auth-email-password/web/login-screen.tsx +7 -3
- package/src/auth-email-password/web/request-account-unlock-screen.tsx +1 -1
- package/src/auth-foundation/__tests__/session-store.test.ts +2 -4
- package/src/auth-foundation/__tests__/tenant-providers.test.ts +24 -0
- package/src/auth-foundation/__tests__/token-verifier.test.ts +36 -3
- package/src/auth-foundation/boot-checks.ts +74 -69
- package/src/auth-foundation/resolve-anonymous-access.ts +13 -5
- package/src/auth-foundation/types.ts +3 -3
- package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +61 -1
- package/src/auth-mfa/__tests__/no-redis.integration.test.ts +209 -0
- package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +157 -4
- package/src/auth-mfa/handlers/disable.write.ts +16 -1
- package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +72 -42
- package/src/auth-mfa/handlers/enable-confirm.write.ts +23 -12
- package/src/auth-mfa/handlers/reencrypt.job.ts +26 -17
- package/src/auth-mfa/handlers/regenerate-recovery.write.ts +15 -1
- package/src/auth-mfa/handlers/verify.write.ts +28 -24
- package/src/auth-mfa/web/__tests__/mfa-client.test.ts +5 -1
- package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +10 -1
- package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +104 -1
- package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +5 -1
- package/src/auth-mfa/web/i18n.ts +4 -0
- package/src/auth-mfa/web/mfa-client.ts +13 -2
- package/src/auth-mfa/web/mfa-enable-screen.tsx +1 -1
- package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +25 -2
- package/src/billing-foundation/__tests__/subscription-provider-contract.ts +36 -12
- package/src/config/__tests__/backing-secrets.integration.test.ts +4 -0
- package/src/config/__tests__/cascade.integration.test.ts +13 -2
- package/src/config/__tests__/pii-encrypted.integration.test.ts +6 -6
- package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +78 -5
- package/src/config/db/queries/resolver.ts +3 -3
- package/src/config/handlers/reencrypt.job.ts +36 -24
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +14 -0
- package/src/document-ingest-foundation/__tests__/feature.test.ts +38 -0
- package/src/document-ingest-foundation/__tests__/pages.test.ts +37 -0
- package/src/document-ingest-foundation/entity.ts +10 -3
- package/src/document-ingest-foundation/feature.ts +26 -8
- package/src/document-ingest-foundation/index.ts +1 -0
- package/src/document-ingest-foundation/pages.ts +34 -0
- package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +114 -11
- package/src/feature-toggles/compose-tier-resolver.ts +77 -8
- package/src/feature-toggles/global-feature-state-table.ts +15 -12
- package/src/files-provider-s3/s3-provider.ts +12 -1
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +78 -1
- package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +58 -40
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +9 -1
- package/src/inbound-mail-foundation/entities.ts +3 -3
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +22 -12
- package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +1 -1
- package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +35 -8
- package/src/inbound-provider-inmemory/__tests__/feature.test.ts +37 -33
- package/src/jobs/__tests__/job-instant.test.ts +15 -0
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +19 -9
- package/src/jobs/feature.ts +6 -5
- package/src/jobs/job-instant.ts +9 -0
- package/src/mail-foundation/__tests__/mail-transport-contract.ts +27 -11
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +10 -6
- package/src/mail-transport-smtp/__tests__/feature.test.ts +10 -5
- package/src/page-render/__tests__/markdown.test.ts +29 -0
- package/src/page-render/markdown.ts +1 -9
- package/src/personal-access-tokens/feature.ts +3 -3
- package/src/personal-access-tokens/schema/api-token.ts +3 -3
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +3 -1
- package/src/sessions/__tests__/sessions-screens.boot.test.ts +5 -10
- package/src/sessions/__tests__/sessions.integration.test.ts +88 -1
- package/src/sessions/feature.ts +21 -6
- package/src/sessions/handlers/revoke-all-for-user.write.ts +51 -12
- package/src/sessions/handlers/revoke-all-others.write.ts +19 -1
- package/src/sessions/handlers/revoke.write.ts +13 -0
- package/src/sessions/index.ts +7 -0
- package/src/sessions/schema/user-session.ts +6 -7
- package/src/sessions/session-callbacks.ts +58 -5
- package/src/sessions/session-revoked-event.ts +29 -0
- package/src/shared/__tests__/chunked-entity-migration.test.ts +4 -4
- package/src/shared/chunked-entity-migration.ts +2 -2
- package/src/shared/grace-period.test.ts +26 -0
- package/src/shared/grace-period.ts +20 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/map-with-concurrency.test.ts +104 -0
- package/src/shared/map-with-concurrency.ts +38 -0
- package/src/subscription-mollie/__tests__/feature.test.ts +16 -6
- package/src/subscription-mollie/__tests__/verify-webhook.test.ts +21 -0
- package/src/subscription-mollie/verify-webhook.ts +1 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +14 -7
- package/src/subscription-stripe/__tests__/verify-webhook.test.ts +20 -0
- package/src/subscription-stripe/verify-webhook.ts +3 -2
- package/src/tenant/__tests__/members-query-kms.integration.test.ts +2 -2
- package/src/tenant/__tests__/multi-tenant.integration.test.ts +1 -1
- package/src/tenant/handlers/active-tenant-ids.query.ts +8 -2
- package/src/tenant/handlers/invitations.query.ts +13 -15
- package/src/tenant/handlers/members.query.ts +12 -12
- package/src/tenant/seeding.ts +110 -16
- package/src/tenant-lifecycle/boot-checks.ts +11 -4
- package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +2 -6
- package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +94 -0
- package/src/tenant-settings/config.ts +44 -0
- package/src/tenant-settings/constants.ts +16 -0
- package/src/tenant-settings/feature.ts +49 -0
- package/src/tenant-settings/index.ts +8 -0
- package/src/tenant-settings/tenant-defaults.ts +72 -0
- package/src/text-content/__tests__/seed-legal-content.integration.test.ts +33 -6
- package/src/text-content/handlers/set.write.ts +2 -2
- package/src/text-content/seeding.ts +31 -29
- package/src/user/handlers/find-for-auth.query.ts +1 -1
- package/src/user/seeding.ts +8 -0
- package/src/user-data-rights/COMPLIANCE.md +1 -1
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +65 -0
- package/src/user-data-rights/__tests__/boot-checks.test.ts +15 -17
- package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +9 -5
- package/src/user-data-rights/__tests__/download.integration.test.ts +25 -41
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +7 -10
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +15 -1
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +140 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +9 -4
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +74 -8
- package/src/user-data-rights/db/queries/__tests__/forget-cleanup.integration.test.ts +69 -0
- package/src/user-data-rights/db/queries/forget-cleanup.ts +9 -2
- package/src/user-data-rights/feature.ts +45 -45
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +6 -11
- package/src/user-data-rights/handlers/download-by-job.query.ts +6 -6
- package/src/user-data-rights/handlers/download-by-token.query.ts +6 -6
- package/src/user-data-rights/handlers/lift-restriction.write.ts +33 -2
- package/src/user-data-rights/handlers/list-download-attempts.query.ts +1 -0
- package/src/user-data-rights/handlers/my-audit-log.query.ts +1 -0
- package/src/user-data-rights/handlers/restrict-account.write.ts +33 -4
- package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +2 -0
- package/src/user-data-rights/lib/is-admin-actor.ts +19 -0
- package/src/user-data-rights/run-forget-cleanup.ts +27 -7
|
@@ -362,6 +362,25 @@ describe("invite-accept-with-login (Branch 2: anon + existing email)", () => {
|
|
|
362
362
|
const body = (await res.json()) as { error?: { details?: { reason?: string } } };
|
|
363
363
|
expect(body.error?.details?.reason).toBe(AuthErrors.invalidInviteToken);
|
|
364
364
|
});
|
|
365
|
+
|
|
366
|
+
test("common password → 400 (schema rejects breach-list password) (#1340)", async () => {
|
|
367
|
+
const token = await inviteEmail(BOB_EMAIL, "Editor");
|
|
368
|
+
const res = await stack.http.raw("POST", "/api/auth/invite-accept-with-login", {
|
|
369
|
+
token,
|
|
370
|
+
email: BOB_EMAIL,
|
|
371
|
+
password: "password1",
|
|
372
|
+
});
|
|
373
|
+
expect(res.status).toBe(400);
|
|
374
|
+
// A 400 for a completely different reason (bad token format, email
|
|
375
|
+
// shape) would also pass a bare status assertion — pin the field-level
|
|
376
|
+
// reason so the test actually proves the breach-list rejection fired.
|
|
377
|
+
const body = (await res.json()) as {
|
|
378
|
+
error?: { details?: { fields?: ReadonlyArray<{ path?: string; code?: string }> } };
|
|
379
|
+
};
|
|
380
|
+
expect(body.error?.details?.fields).toContainEqual(
|
|
381
|
+
expect.objectContaining({ path: "password", code: "custom" }),
|
|
382
|
+
);
|
|
383
|
+
});
|
|
365
384
|
});
|
|
366
385
|
|
|
367
386
|
describe("invite-signup-complete (Branch 3: anon + new email)", () => {
|
|
@@ -397,6 +416,21 @@ describe("invite-signup-complete (Branch 3: anon + new email)", () => {
|
|
|
397
416
|
expect(loginRes.status).toBe(200);
|
|
398
417
|
});
|
|
399
418
|
|
|
419
|
+
test("common password → 400 (schema rejects breach-list password) (#1340)", async () => {
|
|
420
|
+
const token = await inviteEmail(CAROL_EMAIL, "Editor");
|
|
421
|
+
const res = await stack.http.raw("POST", "/api/auth/invite-signup-complete", {
|
|
422
|
+
token,
|
|
423
|
+
password: "password1",
|
|
424
|
+
});
|
|
425
|
+
expect(res.status).toBe(400);
|
|
426
|
+
const body = (await res.json()) as {
|
|
427
|
+
error?: { details?: { fields?: ReadonlyArray<{ path?: string; code?: string }> } };
|
|
428
|
+
};
|
|
429
|
+
expect(body.error?.details?.fields).toContainEqual(
|
|
430
|
+
expect.objectContaining({ path: "password", code: "custom" }),
|
|
431
|
+
);
|
|
432
|
+
});
|
|
433
|
+
|
|
400
434
|
test("Existing email → invalid_invite_token (User soll Branch 2 nutzen)", async () => {
|
|
401
435
|
const token = await inviteEmail(BOB_EMAIL, "Admin");
|
|
402
436
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { USER_STATUS } from "../../user";
|
|
3
|
+
import type { AuthUserRow } from "../auth-user-row";
|
|
4
|
+
import { gateEnforceAccountStatus, gateEnforceEmailVerified } from "../handlers/login.write";
|
|
5
|
+
|
|
6
|
+
function row(over: Partial<AuthUserRow> = {}): AuthUserRow {
|
|
7
|
+
return { id: "00000000-0000-4000-8000-000000000001", passwordHash: "x", ...over };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("login.write gates (fw#1284)", () => {
|
|
11
|
+
test("gateEnforceEmailVerified rejects when strict and unverified", () => {
|
|
12
|
+
const g = gateEnforceEmailVerified(row({ emailVerified: false }), true);
|
|
13
|
+
expect(g.ok).toBe(false);
|
|
14
|
+
if (!g.ok) expect(g.result.isSuccess).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("gateEnforceEmailVerified passes when not strict", () => {
|
|
18
|
+
expect(gateEnforceEmailVerified(row({ emailVerified: false }), false).ok).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("gateEnforceAccountStatus rejects restricted", () => {
|
|
22
|
+
const g = gateEnforceAccountStatus(row({ status: USER_STATUS.Restricted }));
|
|
23
|
+
expect(g.ok).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("gateEnforceAccountStatus rejects deletion_requested as invalid_creds shape", () => {
|
|
27
|
+
const g = gateEnforceAccountStatus(row({ status: USER_STATUS.DeletionRequested }));
|
|
28
|
+
expect(g.ok).toBe(false);
|
|
29
|
+
if (!g.ok) {
|
|
30
|
+
expect(g.result.isSuccess).toBe(false);
|
|
31
|
+
// anti-enumeration — same family as invalid credentials
|
|
32
|
+
expect(g.result).toMatchObject({ isSuccess: false });
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("gateEnforceAccountStatus passes active", () => {
|
|
37
|
+
expect(gateEnforceAccountStatus(row({ status: USER_STATUS.Active })).ok).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -239,31 +239,26 @@ describe("auth-self-registration toggle — companion feature NOT composed", ()
|
|
|
239
239
|
await bareStack.cleanup();
|
|
240
240
|
});
|
|
241
241
|
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
|
|
248
|
-
// moment an app wires a real effectiveFeatures resolver without also
|
|
249
|
-
// composing createAuthSelfRegistrationToggleFeature. This pins the
|
|
250
|
-
// CURRENT (broken) behavior rather than asserting the desired one —
|
|
251
|
-
// flagged for a follow-up decision, not silently fixed here.
|
|
252
|
-
test("[KNOWN GAP] signup-request silently drops mail when the companion toggle feature was never composed", async () => {
|
|
242
|
+
// Fixed (#1468): ctx.hasFeature() is fail-closed for a name nothing ever
|
|
243
|
+
// registered — signup-request and the status query now only gate on the
|
|
244
|
+
// toggle when registry.getFeature(AUTH_SELF_REGISTRATION_FEATURE) proves
|
|
245
|
+
// the companion feature is actually composed, so an app that mounts
|
|
246
|
+
// auth-email-password without it keeps self-signup working.
|
|
247
|
+
test("signup-request still sends mail when the companion toggle feature was never composed", async () => {
|
|
253
248
|
const res = await bareStack.http.raw("POST", "/api/auth/signup-request", {
|
|
254
249
|
email: "not-toggle-aware@example.com",
|
|
255
250
|
});
|
|
256
251
|
expect(res.status).toBe(200);
|
|
257
|
-
expect(bareEmailTransport.sent).toHaveLength(
|
|
252
|
+
expect(bareEmailTransport.sent).toHaveLength(1);
|
|
258
253
|
});
|
|
259
254
|
|
|
260
|
-
test("
|
|
255
|
+
test("signup-registration-status reports enabled:true with no toggle feature mounted", async () => {
|
|
261
256
|
const res = await bareStack.http.raw("POST", "/api/query", {
|
|
262
257
|
type: "auth-email-password:query:signup-registration-status",
|
|
263
258
|
payload: {},
|
|
264
259
|
});
|
|
265
260
|
expect((await res.json()) as { data?: { enabled: boolean } }).toMatchObject({
|
|
266
|
-
data: { enabled:
|
|
261
|
+
data: { enabled: true },
|
|
267
262
|
});
|
|
268
263
|
});
|
|
269
264
|
});
|
|
@@ -120,6 +120,22 @@ beforeAll(async () => {
|
|
|
120
120
|
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
121
121
|
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
122
122
|
await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
123
|
+
|
|
124
|
+
// Prime request-helper's per-user sid cache for systemAdmin (the seed
|
|
125
|
+
// actor every test's seedUser() call authenticates as) here, once,
|
|
126
|
+
// before any test's observation window opens — otherwise whichever
|
|
127
|
+
// test happens to call seedUser() first would observe an extra
|
|
128
|
+
// store.created entry for seed plumbing, not the auth-route behavior
|
|
129
|
+
// under test (#1452: authHeader() now mints one sid per user.id, not
|
|
130
|
+
// one per call, so this only ever fires here).
|
|
131
|
+
await seedUser({
|
|
132
|
+
email: "warmup-seed-actor@example.com",
|
|
133
|
+
password: "warmup-password-not-asserted-on",
|
|
134
|
+
tenants: [],
|
|
135
|
+
});
|
|
136
|
+
store.live.clear();
|
|
137
|
+
store.created.length = 0;
|
|
138
|
+
store.revoked.length = 0;
|
|
123
139
|
});
|
|
124
140
|
|
|
125
141
|
afterAll(async () => {
|
|
@@ -158,11 +174,6 @@ async function seedUser(opts: {
|
|
|
158
174
|
roles: t.roles,
|
|
159
175
|
});
|
|
160
176
|
}
|
|
161
|
-
// request-helper mints a sid for systemAdmin when sessionCreator is wired
|
|
162
|
-
// (#1372) — that is seed plumbing, not the auth-route behavior under test.
|
|
163
|
-
store.live.clear();
|
|
164
|
-
store.created.length = 0;
|
|
165
|
-
store.revoked.length = 0;
|
|
166
177
|
return { userId: created.id };
|
|
167
178
|
}
|
|
168
179
|
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
28
28
|
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
29
29
|
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
30
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
30
31
|
import {
|
|
31
32
|
setupTestStack,
|
|
32
33
|
type TestStack,
|
|
@@ -57,6 +58,41 @@ import { createAuthEmailPasswordFeature } from "../feature";
|
|
|
57
58
|
|
|
58
59
|
const tierAssignmentTable = buildEntityTable("tier-assignment", tierAssignmentEntity);
|
|
59
60
|
|
|
61
|
+
// Regression-proof for #1478: provisionSignupAccount only threaded `hooks`
|
|
62
|
+
// into seedTenant, leaving seedUserWithPassword's `user`-entity and
|
|
63
|
+
// seedTenantMembership's `tenantMembership`-entity postSave hooks unfired
|
|
64
|
+
// on self-signup — same bug class as #1463, just for the other two
|
|
65
|
+
// entities in the same call. This vendor feature's hook is the user-side
|
|
66
|
+
// analog of tier-engine's tenant hook above.
|
|
67
|
+
const userPostSaveFired = { count: 0 };
|
|
68
|
+
const userHookVendorFeature = defineFeature("user-hook-vendor-test", (r) => {
|
|
69
|
+
r.requires("user");
|
|
70
|
+
r.hook("postSave", { allOf: "user" }, async (ctx) => {
|
|
71
|
+
if (ctx.isNew) userPostSaveFired.count++;
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Regression-proof for #1478/#1526: seedTenantMembership must fire the
|
|
76
|
+
// `tenantMembership` entity's postSave hooks (e.g. a Welcome-notification
|
|
77
|
+
// hook) on self-signup too — pins the `fireEntityPostSave` call added at
|
|
78
|
+
// seeding.ts's seedTenantMembership, not just the `user`/`tenant` ones.
|
|
79
|
+
// #1566: also record ctx._tenantId so a shallow-only rescope regresses.
|
|
80
|
+
const tenantMembershipPostSaveFired = { count: 0, seenTenantIds: [] as string[] };
|
|
81
|
+
const tenantMembershipHookVendorFeature = defineFeature(
|
|
82
|
+
"tenant-membership-hook-vendor-test",
|
|
83
|
+
(r) => {
|
|
84
|
+
r.requires("tenant");
|
|
85
|
+
r.hook("postSave", { allOf: "tenant-membership" }, async (result, context) => {
|
|
86
|
+
if (result.isNew) {
|
|
87
|
+
tenantMembershipPostSaveFired.count++;
|
|
88
|
+
if (context._tenantId !== undefined) {
|
|
89
|
+
tenantMembershipPostSaveFired.seenTenantIds.push(String(context._tenantId));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
|
|
60
96
|
const APP_ACTIVATION_URL = "https://app.example.com/signup/complete";
|
|
61
97
|
|
|
62
98
|
// Activation mails now go through delivery (ctx.notify → channel-email). The
|
|
@@ -92,6 +128,8 @@ beforeAll(async () => {
|
|
|
92
128
|
defaultTier: "free",
|
|
93
129
|
tierMap: { free: { features: [], caps: {} } },
|
|
94
130
|
}),
|
|
131
|
+
userHookVendorFeature,
|
|
132
|
+
tenantMembershipHookVendorFeature,
|
|
95
133
|
],
|
|
96
134
|
extraContext: (deps) => ({
|
|
97
135
|
...createDeliveryTestContext(deps),
|
|
@@ -130,6 +168,9 @@ beforeEach(async () => {
|
|
|
130
168
|
await asRawClient(stack.db).unsafe(`DELETE FROM "${tierAssignmentTable.tableName}"`);
|
|
131
169
|
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantTable.tableName}"`);
|
|
132
170
|
emailTransport.sent.length = 0;
|
|
171
|
+
userPostSaveFired.count = 0;
|
|
172
|
+
tenantMembershipPostSaveFired.count = 0;
|
|
173
|
+
tenantMembershipPostSaveFired.seenTenantIds = [];
|
|
133
174
|
// Redis-cleanup damit Resend-Tests keine state-leaks haben.
|
|
134
175
|
const allKeys = await stack.redis.redis.keys("signup:*");
|
|
135
176
|
if (allKeys.length > 0) await stack.redis.redis.del(...allKeys);
|
|
@@ -245,6 +286,19 @@ describe("POST /api/auth/signup-confirm", () => {
|
|
|
245
286
|
expect(tierRows).toHaveLength(1);
|
|
246
287
|
expect(tierRows[0]?.["tier"]).toBe("free");
|
|
247
288
|
|
|
289
|
+
// #1478 regression: seedUserWithPassword (called from
|
|
290
|
+
// provisionSignupAccount) fires the `user` entity's postSave hooks too —
|
|
291
|
+
// before this fix, only seedTenant's hooks param was wired through.
|
|
292
|
+
expect(userPostSaveFired.count).toBe(1);
|
|
293
|
+
|
|
294
|
+
// #1478/#1526 regression: seedTenantMembership (called from
|
|
295
|
+
// provisionSignupAccount) fires the `tenant-membership` entity's postSave
|
|
296
|
+
// hooks too — before this fix, only seedTenant's hooks param was wired.
|
|
297
|
+
expect(tenantMembershipPostSaveFired.count).toBe(1);
|
|
298
|
+
// #1566: rescope must put the new tenant on the hook context, not the
|
|
299
|
+
// anonymous/system caller's tenantId.
|
|
300
|
+
expect(tenantMembershipPostSaveFired.seenTenantIds).toEqual([body.user?.tenantId ?? ""]);
|
|
301
|
+
|
|
248
302
|
// Authority-Beweis: Login mit dem gesetzten Password funktioniert.
|
|
249
303
|
const loginRes = await postLogin(email, password);
|
|
250
304
|
expect(loginRes.status).toBe(200);
|
|
@@ -46,11 +46,12 @@ import {
|
|
|
46
46
|
getInvitationIdForToken,
|
|
47
47
|
unburnInviteToken,
|
|
48
48
|
} from "../invite-token-store";
|
|
49
|
+
import { passwordSchema } from "../password-policy";
|
|
49
50
|
|
|
50
51
|
const InviteAcceptWithLoginSchema = z.object({
|
|
51
52
|
token: z.string().min(1),
|
|
52
53
|
email: z.email(),
|
|
53
|
-
password:
|
|
54
|
+
password: passwordSchema,
|
|
54
55
|
});
|
|
55
56
|
|
|
56
57
|
export type InviteAcceptWithLoginData = {
|
|
@@ -50,12 +50,13 @@ import {
|
|
|
50
50
|
getInvitationIdForToken,
|
|
51
51
|
unburnInviteToken,
|
|
52
52
|
} from "../invite-token-store";
|
|
53
|
+
import { passwordSchema } from "../password-policy";
|
|
53
54
|
// kumiko-lint-ignore cross-feature-import provisioning needs cross-feature seeding helpers
|
|
54
55
|
import { seedUserWithPassword } from "../seeding";
|
|
55
56
|
|
|
56
57
|
const InviteSignupCompleteSchema = z.object({
|
|
57
58
|
token: z.string().min(1),
|
|
58
|
-
password:
|
|
59
|
+
password: passwordSchema,
|
|
59
60
|
});
|
|
60
61
|
|
|
61
62
|
export type InviteSignupCompleteData = {
|
|
@@ -11,7 +11,7 @@ import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
|
11
11
|
import { z } from "zod";
|
|
12
12
|
import { verifyDummyPassword, verifyPassword } from "../../shared";
|
|
13
13
|
import { USER_STATUS, UserQueries } from "../../user";
|
|
14
|
-
import { parseAuthUserRow } from "../auth-user-row";
|
|
14
|
+
import { type AuthUserRow, parseAuthUserRow } from "../auth-user-row";
|
|
15
15
|
import {
|
|
16
16
|
AUTH_LOCKOUT_DEFAULT_DURATION_MINUTES,
|
|
17
17
|
AUTH_LOCKOUT_DEFAULT_MAX_FAILED_ATTEMPTS,
|
|
@@ -72,6 +72,170 @@ type LoginResult =
|
|
|
72
72
|
| { readonly kind: "mfa-challenge"; readonly challengeToken: string }
|
|
73
73
|
| { readonly kind: "mfa-setup-required"; readonly preauthSetupToken: string };
|
|
74
74
|
|
|
75
|
+
type Membership = { readonly tenantId: TenantId; readonly roles: readonly string[] };
|
|
76
|
+
|
|
77
|
+
type GateReject = { readonly ok: false; readonly result: WriteResult<LoginResult> };
|
|
78
|
+
type GateOk<T> = { readonly ok: true; readonly value: T };
|
|
79
|
+
type GateOutcome<T> = GateReject | GateOk<T>;
|
|
80
|
+
|
|
81
|
+
function reject(result: WriteResult<LoginResult>): GateReject {
|
|
82
|
+
return { ok: false, result };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function ok<T>(value: T): GateOk<T> {
|
|
86
|
+
return { ok: true, value };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Uniform response on any credential miss — burns argon2 cost (#774). */
|
|
90
|
+
export async function gateResolveAuthUser(
|
|
91
|
+
ctx: HandlerContext,
|
|
92
|
+
systemUser: SessionUser,
|
|
93
|
+
email: string,
|
|
94
|
+
password: string,
|
|
95
|
+
): Promise<GateOutcome<AuthUserRow>> {
|
|
96
|
+
const found = parseAuthUserRow(await ctx.queryAs(systemUser, UserQueries.findForAuth, { email }));
|
|
97
|
+
if (!found?.passwordHash || found.isDeleted) {
|
|
98
|
+
await verifyDummyPassword(password);
|
|
99
|
+
return reject(invalidCredentials());
|
|
100
|
+
}
|
|
101
|
+
return ok(found);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Lockout BEFORE password verify — locked accounts can't be password-probed.
|
|
106
|
+
* Fail-open without Redis (IP rate-limit still covers partially).
|
|
107
|
+
*/
|
|
108
|
+
export async function gateEnforceLockout(
|
|
109
|
+
ctx: HandlerContext,
|
|
110
|
+
userId: string,
|
|
111
|
+
): Promise<GateOutcome<undefined>> {
|
|
112
|
+
if (!ctx.redis) return ok(undefined);
|
|
113
|
+
const state = await getLockoutState(ctx.redis, userId);
|
|
114
|
+
if (state?.lockedUntil !== null && state?.lockedUntil !== undefined) {
|
|
115
|
+
const now = Date.now();
|
|
116
|
+
if (state.lockedUntil > now) {
|
|
117
|
+
const retryAfterSeconds = Math.max(1, Math.ceil((state.lockedUntil - now) / 1000));
|
|
118
|
+
return reject(accountLocked(retryAfterSeconds));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return ok(undefined);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Verify password; record miss / clear lockout on hit. */
|
|
125
|
+
export async function gateVerifyPassword(
|
|
126
|
+
ctx: HandlerContext,
|
|
127
|
+
found: AuthUserRow,
|
|
128
|
+
password: string,
|
|
129
|
+
maxFailedAttempts: number,
|
|
130
|
+
lockoutDurationMinutes: number,
|
|
131
|
+
): Promise<GateOutcome<undefined>> {
|
|
132
|
+
const passwordHash = found.passwordHash;
|
|
133
|
+
if (!passwordHash) return reject(invalidCredentials());
|
|
134
|
+
const passwordOk = await verifyPassword(passwordHash, password);
|
|
135
|
+
if (!passwordOk) {
|
|
136
|
+
if (ctx.redis) {
|
|
137
|
+
await recordFailedAttempt(ctx.redis, found.id, maxFailedAttempts, lockoutDurationMinutes);
|
|
138
|
+
}
|
|
139
|
+
return reject(invalidCredentials());
|
|
140
|
+
}
|
|
141
|
+
// Clear before MFA — MFA has its own attempt-cap; don't password-lock
|
|
142
|
+
// users who occasionally mistype across otherwise-successful logins.
|
|
143
|
+
if (ctx.redis) {
|
|
144
|
+
await clearLockoutState(ctx.redis, found.id);
|
|
145
|
+
}
|
|
146
|
+
return ok(undefined);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Strict email verification — after password, before session. */
|
|
150
|
+
export function gateEnforceEmailVerified(
|
|
151
|
+
found: AuthUserRow,
|
|
152
|
+
strictVerification: boolean,
|
|
153
|
+
): GateOutcome<undefined> {
|
|
154
|
+
if (strictVerification && found.emailVerified !== true) {
|
|
155
|
+
return reject(emailNotVerified());
|
|
156
|
+
}
|
|
157
|
+
return ok(undefined);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** DSGVO Art. 18 freeze + forget-path anti-enumeration. */
|
|
161
|
+
export function gateEnforceAccountStatus(found: AuthUserRow): GateOutcome<undefined> {
|
|
162
|
+
if (found.status === USER_STATUS.Restricted) {
|
|
163
|
+
return reject(accountRestricted());
|
|
164
|
+
}
|
|
165
|
+
if (found.status === USER_STATUS.DeletionRequested || found.status === USER_STATUS.Deleted) {
|
|
166
|
+
return reject(invalidCredentials());
|
|
167
|
+
}
|
|
168
|
+
return ok(undefined);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Pick membership (last-active preferred); merge global + tenant roles. */
|
|
172
|
+
export async function gateResolveMembership(
|
|
173
|
+
ctx: HandlerContext,
|
|
174
|
+
systemUser: SessionUser,
|
|
175
|
+
found: AuthUserRow,
|
|
176
|
+
): Promise<GateOutcome<{ readonly chosen: Membership; readonly mergedRoles: readonly string[] }>> {
|
|
177
|
+
const memberships = (await ctx.queryAs(systemUser, "tenant:query:memberships", {
|
|
178
|
+
userId: found.id,
|
|
179
|
+
})) as Array<Membership>; // @cast-boundary db-runner
|
|
180
|
+
|
|
181
|
+
if (memberships.length === 0) {
|
|
182
|
+
return reject(noMembership());
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const preferred =
|
|
186
|
+
found.lastActiveTenantId !== null && found.lastActiveTenantId !== undefined
|
|
187
|
+
? memberships.find((m) => m.tenantId === found.lastActiveTenantId)
|
|
188
|
+
: undefined;
|
|
189
|
+
const chosen = preferred ?? memberships[0];
|
|
190
|
+
if (!chosen) {
|
|
191
|
+
return reject(noMembership());
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const globalRoles = parseRoles(found.roles ?? null);
|
|
195
|
+
// buildSessionRoles calls stripForbiddenMembershipRoles to strip reserved
|
|
196
|
+
// roles only (globalRoles keeps SystemAdmin) — read-time backstop against a
|
|
197
|
+
// rebuild-resurrected role.
|
|
198
|
+
const mergedRoles = buildSessionRoles(globalRoles, chosen.roles);
|
|
199
|
+
return ok({ chosen, mergedRoles });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** MFA challenge / setup-required / proceed. */
|
|
203
|
+
export async function gateEnforceMfa(
|
|
204
|
+
ctx: HandlerContext,
|
|
205
|
+
opts: LoginHandlerOptions,
|
|
206
|
+
userId: string,
|
|
207
|
+
tenantId: TenantId,
|
|
208
|
+
mergedRoles: readonly string[],
|
|
209
|
+
): Promise<GateOutcome<LoginResult | undefined>> {
|
|
210
|
+
if (!opts.mfaStatusChecker) return ok(undefined);
|
|
211
|
+
const mfaStatus = await opts.mfaStatusChecker(ctx, userId, tenantId, mergedRoles);
|
|
212
|
+
if ("challengeToken" in mfaStatus) {
|
|
213
|
+
return ok({ kind: "mfa-challenge", challengeToken: mfaStatus.challengeToken });
|
|
214
|
+
}
|
|
215
|
+
if ("setupRequired" in mfaStatus) {
|
|
216
|
+
return ok({ kind: "mfa-setup-required", preauthSetupToken: mfaStatus.preauthSetupToken });
|
|
217
|
+
}
|
|
218
|
+
return ok(undefined);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Auth-claims hooks → session. */
|
|
222
|
+
export async function gateBuildSession(
|
|
223
|
+
ctx: HandlerContext,
|
|
224
|
+
userId: string,
|
|
225
|
+
tenantId: TenantId,
|
|
226
|
+
mergedRoles: readonly string[],
|
|
227
|
+
): Promise<GateOutcome<{ readonly kind: "auth-session"; readonly session: SessionUser }>> {
|
|
228
|
+
const baseSession: SessionUser = {
|
|
229
|
+
id: userId,
|
|
230
|
+
tenantId,
|
|
231
|
+
roles: mergedRoles,
|
|
232
|
+
};
|
|
233
|
+
const claims = await ctx.resolveAuthClaims(baseSession);
|
|
234
|
+
const session: SessionUser =
|
|
235
|
+
Object.keys(claims).length > 0 ? { ...baseSession, claims } : baseSession;
|
|
236
|
+
return ok({ kind: "auth-session", session });
|
|
237
|
+
}
|
|
238
|
+
|
|
75
239
|
// Login — unauthenticated entry point. The route is wired public (no JWT
|
|
76
240
|
// middleware), synthesising a guest SessionUser for the handler's access
|
|
77
241
|
// check. Everything inside the handler goes through ctx.queryAs(system, ...)
|
|
@@ -93,169 +257,46 @@ export function createLoginHandler(opts: LoginHandlerOptions = {}) {
|
|
|
93
257
|
handler: async (event, ctx): Promise<WriteResult<LoginResult>> => {
|
|
94
258
|
const systemUser = createSystemUser(SYSTEM_USER_ID);
|
|
95
259
|
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
260
|
+
const userGate = await gateResolveAuthUser(
|
|
261
|
+
ctx,
|
|
262
|
+
systemUser,
|
|
263
|
+
event.payload.email,
|
|
264
|
+
event.payload.password,
|
|
100
265
|
);
|
|
266
|
+
if (!userGate.ok) return userGate.result;
|
|
267
|
+
const found = userGate.value;
|
|
101
268
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
if (!found?.passwordHash || found.isDeleted) {
|
|
105
|
-
// Burn the same argon2 verify cost as the hit path so response
|
|
106
|
-
// latency doesn't reveal whether the email is registered (#774).
|
|
107
|
-
await verifyDummyPassword(event.payload.password);
|
|
108
|
-
return invalidCredentials();
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Lockout gate — runs BEFORE password verification so a locked account
|
|
112
|
-
// can't be bruteforce-probed for passwords (and also can't be probed
|
|
113
|
-
// for a timing-oracle on the bcrypt verify). If Redis isn't wired,
|
|
114
|
-
// lockout is silently skipped — login still works, brute-force
|
|
115
|
-
// protection just degrades to the IP-rate-limiter at the edge.
|
|
116
|
-
if (ctx.redis) {
|
|
117
|
-
const state = await getLockoutState(ctx.redis, found.id);
|
|
118
|
-
if (state?.lockedUntil !== null && state?.lockedUntil !== undefined) {
|
|
119
|
-
const now = Date.now();
|
|
120
|
-
if (state.lockedUntil > now) {
|
|
121
|
-
const retryAfterSeconds = Math.max(1, Math.ceil((state.lockedUntil - now) / 1000));
|
|
122
|
-
return accountLocked(retryAfterSeconds);
|
|
123
|
-
}
|
|
124
|
-
// lockedUntil in the past — shouldn't normally happen because the
|
|
125
|
-
// Redis TTL on the until-key expires the key at the same moment
|
|
126
|
-
// as the value. Clock skew / replication lag could surface this;
|
|
127
|
-
// fall through to password verification. The counter is NOT
|
|
128
|
-
// reset — next miss re-locks immediately (strict-semantic, see
|
|
129
|
-
// lockout-store.ts).
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const passwordOk = await verifyPassword(found.passwordHash, event.payload.password);
|
|
134
|
-
if (!passwordOk) {
|
|
135
|
-
if (ctx.redis) {
|
|
136
|
-
await recordFailedAttempt(ctx.redis, found.id, maxFailedAttempts, lockoutDurationMinutes);
|
|
137
|
-
}
|
|
138
|
-
return invalidCredentials();
|
|
139
|
-
}
|
|
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
|
-
}
|
|
269
|
+
const lockoutGate = await gateEnforceLockout(ctx, found.id);
|
|
270
|
+
if (!lockoutGate.ok) return lockoutGate.result;
|
|
151
271
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
// S2.U6 — DSGVO Art. 18 Account-Freeze. Restricted users koennen sich
|
|
161
|
-
// nicht einloggen; lift-restriction-Endpoint ist der einzige Ausgang
|
|
162
|
-
// (siehe lift-restriction.write.ts Header — typisch via Magic-Link
|
|
163
|
-
// oder Operator-Tool, da Login geblockt). Auth-side Block ist hard-
|
|
164
|
-
// requirement; ohne den koennte der User mit Login-Sessions trotz
|
|
165
|
-
// Restriction-Flag durchschreiben.
|
|
166
|
-
//
|
|
167
|
-
// DeletionRequested + Deleted kollabieren bewusst auf invalid_creds
|
|
168
|
-
// (anti-enumeration im Forget-Pfad) — Restricted ist user-initiiert,
|
|
169
|
-
// distinct error ist hier safe.
|
|
170
|
-
if (found.status === USER_STATUS.Restricted) {
|
|
171
|
-
return accountRestricted();
|
|
172
|
-
}
|
|
173
|
-
if (found.status === USER_STATUS.DeletionRequested || found.status === USER_STATUS.Deleted) {
|
|
174
|
-
return invalidCredentials();
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// Resolve tenant + roles via the tenant feature's memberships query.
|
|
178
|
-
// Returns [] if the user has no memberships — MVP: no login without an
|
|
179
|
-
// invitation, so we refuse with a dedicated error.
|
|
180
|
-
const memberships = (await ctx.queryAs(systemUser, "tenant:query:memberships", {
|
|
181
|
-
userId: found.id,
|
|
182
|
-
})) as Array<{ tenantId: TenantId; roles: readonly string[] }>; // @cast-boundary db-runner
|
|
272
|
+
const passwordGate = await gateVerifyPassword(
|
|
273
|
+
ctx,
|
|
274
|
+
found,
|
|
275
|
+
event.payload.password,
|
|
276
|
+
maxFailedAttempts,
|
|
277
|
+
lockoutDurationMinutes,
|
|
278
|
+
);
|
|
279
|
+
if (!passwordGate.ok) return passwordGate.result;
|
|
183
280
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
281
|
+
const emailGate = gateEnforceEmailVerified(found, strictVerification);
|
|
282
|
+
if (!emailGate.ok) return emailGate.result;
|
|
187
283
|
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
? memberships.find((m) => m.tenantId === found.lastActiveTenantId)
|
|
191
|
-
: undefined;
|
|
192
|
-
const chosen = preferred ?? memberships[0];
|
|
193
|
-
if (!chosen) {
|
|
194
|
-
return noMembership();
|
|
195
|
-
}
|
|
284
|
+
const statusGate = gateEnforceAccountStatus(found);
|
|
285
|
+
if (!statusGate.ok) return statusGate.result;
|
|
196
286
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
// membership. Dedupe via Set damit eine Rolle die in beiden Quellen
|
|
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.
|
|
207
|
-
const globalRoles = parseRoles(found.roles ?? null);
|
|
208
|
-
// buildSessionRoles calls stripForbiddenMembershipRoles to strip reserved
|
|
209
|
-
// only (globalRoles keeps SystemAdmin) — read-time backstop against a
|
|
210
|
-
// rebuild-resurrected role.
|
|
211
|
-
const mergedRoles = buildSessionRoles(globalRoles, chosen.roles);
|
|
287
|
+
const membershipGate = await gateResolveMembership(ctx, systemUser, found);
|
|
288
|
+
if (!membershipGate.ok) return membershipGate.result;
|
|
289
|
+
const { chosen, mergedRoles } = membershipGate.value;
|
|
212
290
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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 {
|
|
230
|
-
isSuccess: true,
|
|
231
|
-
data: { kind: "mfa-setup-required", preauthSetupToken: mfaStatus.preauthSetupToken },
|
|
232
|
-
};
|
|
233
|
-
}
|
|
291
|
+
const mfaGate = await gateEnforceMfa(ctx, opts, found.id, chosen.tenantId, mergedRoles);
|
|
292
|
+
if (!mfaGate.ok) return mfaGate.result;
|
|
293
|
+
if (mfaGate.value !== undefined) {
|
|
294
|
+
return { isSuccess: true, data: mfaGate.value };
|
|
234
295
|
}
|
|
235
296
|
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
roles: mergedRoles,
|
|
240
|
-
};
|
|
241
|
-
|
|
242
|
-
// Features can contribute identity facts (team IDs, feature flags, ...)
|
|
243
|
-
// via r.authClaims(). ctx.resolveAuthClaims is a thin pass-through to
|
|
244
|
-
// dispatcher.resolveAuthClaims — same impl also used by the switch-tenant
|
|
245
|
-
// route, so login + tenant-switch stay in sync.
|
|
246
|
-
//
|
|
247
|
-
// Best-effort: if no feature registered a hook, we get an empty record
|
|
248
|
-
// back and simply omit the `claims` field from the session (keeps the
|
|
249
|
-
// shape clean for the JWT layer, which already spreads claims
|
|
250
|
-
// conditionally based on presence).
|
|
251
|
-
const claims = await ctx.resolveAuthClaims(baseSession);
|
|
252
|
-
const session: SessionUser =
|
|
253
|
-
Object.keys(claims).length > 0 ? { ...baseSession, claims } : baseSession;
|
|
254
|
-
|
|
255
|
-
return {
|
|
256
|
-
isSuccess: true,
|
|
257
|
-
data: { kind: "auth-session", session },
|
|
258
|
-
};
|
|
297
|
+
const sessionGate = await gateBuildSession(ctx, found.id, chosen.tenantId, mergedRoles);
|
|
298
|
+
if (!sessionGate.ok) return sessionGate.result;
|
|
299
|
+
return { isSuccess: true, data: sessionGate.value };
|
|
259
300
|
},
|
|
260
301
|
});
|
|
261
302
|
}
|