@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
|
@@ -11,7 +11,11 @@
|
|
|
11
11
|
|
|
12
12
|
import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
13
13
|
import { type FormEvent, type ReactNode, useState } from "react";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
type KnownLoginFailureReason,
|
|
16
|
+
type LoginFailure,
|
|
17
|
+
requestEmailVerification,
|
|
18
|
+
} from "./auth-client";
|
|
15
19
|
import { AuthCard } from "./auth-form-primitives";
|
|
16
20
|
import { useSession } from "./session";
|
|
17
21
|
|
|
@@ -144,7 +148,7 @@ export function LoginScreen({
|
|
|
144
148
|
onMfaChallenge(res.challengeToken);
|
|
145
149
|
return;
|
|
146
150
|
}
|
|
147
|
-
setError({ reason: "mfa_not_supported" });
|
|
151
|
+
setError({ reason: "mfa_not_supported" satisfies KnownLoginFailureReason });
|
|
148
152
|
setFailedLoginEmail(email);
|
|
149
153
|
return;
|
|
150
154
|
}
|
|
@@ -153,7 +157,7 @@ export function LoginScreen({
|
|
|
153
157
|
onMfaSetupRequired(res.preauthSetupToken, email);
|
|
154
158
|
return;
|
|
155
159
|
}
|
|
156
|
-
setError({ reason: "mfa_setup_required" });
|
|
160
|
+
setError({ reason: "mfa_setup_required" satisfies KnownLoginFailureReason });
|
|
157
161
|
setFailedLoginEmail(email);
|
|
158
162
|
return;
|
|
159
163
|
}
|
|
@@ -44,11 +44,9 @@ describe("resolveSessionStore — finds the single registered provider", () => {
|
|
|
44
44
|
});
|
|
45
45
|
|
|
46
46
|
describe("validateSessionStoreMultiplicity", () => {
|
|
47
|
-
test("0 providers registered →
|
|
47
|
+
test("0 providers registered → does not throw (valid: PAT-only / machine-API deployments mount no sessionStore)", () => {
|
|
48
48
|
const registry = createRegistry([authFoundationFeature]);
|
|
49
|
-
expect(() => validateSessionStoreMultiplicity([...registry.features.values()])).toThrow(
|
|
50
|
-
/no sessionStore provider registered/,
|
|
51
|
-
);
|
|
49
|
+
expect(() => validateSessionStoreMultiplicity([...registry.features.values()])).not.toThrow();
|
|
52
50
|
});
|
|
53
51
|
|
|
54
52
|
test("exactly 1 provider → no throw", () => {
|
|
@@ -134,3 +134,27 @@ describe("resolveAnonymousAccessFromRegistry — fail-closed trust", () => {
|
|
|
134
134
|
).rejects.toThrow(/resolverTrust/);
|
|
135
135
|
});
|
|
136
136
|
});
|
|
137
|
+
|
|
138
|
+
describe("resolveAnonymousAccessFromRegistry — opt-in only (#1452)", () => {
|
|
139
|
+
test("a mounted tenantResolver provider alone does not make anonymousAccess defined", async () => {
|
|
140
|
+
const registry = createRegistry([authFoundationFeature, resolverProvider("subdomain")]);
|
|
141
|
+
const result = await resolveAnonymousAccessFromRegistry(undefined, { db: fakeDb, registry });
|
|
142
|
+
expect(result).toBeUndefined();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("a mounted tenantExists provider alone does not make anonymousAccess defined", async () => {
|
|
146
|
+
const registry = createRegistry([authFoundationFeature, existenceProvider("db")]);
|
|
147
|
+
const result = await resolveAnonymousAccessFromRegistry(undefined, { db: fakeDb, registry });
|
|
148
|
+
expect(result).toBeUndefined();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("an app that explicitly opts in still gets providers merged", async () => {
|
|
152
|
+
const registry = createRegistry([authFoundationFeature, resolverProvider("subdomain")]);
|
|
153
|
+
const result = await resolveAnonymousAccessFromRegistry(
|
|
154
|
+
{ defaultTenantId: undefined },
|
|
155
|
+
{ db: fakeDb, registry },
|
|
156
|
+
);
|
|
157
|
+
expect(result?.tenantResolver).toBeDefined();
|
|
158
|
+
expect(result?.resolverTrust).toBe("authoritative");
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -8,7 +8,13 @@ import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
8
8
|
import { createRegistry, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
9
|
import { validateTokenVerifierMultiplicity } from "../boot-checks";
|
|
10
10
|
import { authFoundationFeature, resolveTokenVerifier } from "../feature";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
type AuthProviderPlugin,
|
|
13
|
+
EXT_SESSION_STORE,
|
|
14
|
+
EXT_TOKEN_VERIFIER,
|
|
15
|
+
type SessionStore,
|
|
16
|
+
type SessionStoreProvider,
|
|
17
|
+
} from "../types";
|
|
12
18
|
|
|
13
19
|
// Mock providers never read `deps.db` — a real provider (personal-access-
|
|
14
20
|
// tokens/resolver.ts) does a point-read + live-role-resolution against it.
|
|
@@ -38,6 +44,23 @@ function jwtProvider(entityName = "jwt-mock"): ReturnType<typeof defineFeature>
|
|
|
38
44
|
});
|
|
39
45
|
}
|
|
40
46
|
|
|
47
|
+
function mockStore(): SessionStore {
|
|
48
|
+
return {
|
|
49
|
+
creator: async () => "sid-1",
|
|
50
|
+
revoker: async () => undefined,
|
|
51
|
+
checker: async () => "live",
|
|
52
|
+
massRevoker: async () => 0,
|
|
53
|
+
revokeAllOthers: async () => 0,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function storeProvider(entityName = "mock-store"): ReturnType<typeof defineFeature> {
|
|
58
|
+
const plugin: SessionStoreProvider = { build: () => mockStore() };
|
|
59
|
+
return defineFeature(`mock-${entityName}`, (r) => {
|
|
60
|
+
r.useExtension(EXT_SESSION_STORE, entityName, plugin);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
41
64
|
describe("resolveTokenVerifier — middleware finds the matching provider", () => {
|
|
42
65
|
test("routes a prefixed token to the prefix-shape provider", async () => {
|
|
43
66
|
const registry = createRegistry([authFoundationFeature, prefixProvider("pat-mock", "kpat_")]);
|
|
@@ -65,13 +88,23 @@ describe("resolveTokenVerifier — middleware finds the matching provider", () =
|
|
|
65
88
|
});
|
|
66
89
|
|
|
67
90
|
describe("validateTokenVerifierMultiplicity", () => {
|
|
68
|
-
test("0
|
|
91
|
+
test("0 tokenVerifier + 0 sessionStore → throws (neither auth path mounted)", () => {
|
|
69
92
|
const registry = createRegistry([authFoundationFeature]);
|
|
70
93
|
expect(() => validateTokenVerifierMultiplicity([...registry.features.values()])).toThrow(
|
|
71
|
-
/no tokenVerifier providers registered/,
|
|
94
|
+
/no tokenVerifier providers and no sessionStore registered/,
|
|
72
95
|
);
|
|
73
96
|
});
|
|
74
97
|
|
|
98
|
+
test("0 tokenVerifier + sessionStore → no throw (session-only / #1570)", () => {
|
|
99
|
+
const registry = createRegistry([authFoundationFeature, storeProvider()]);
|
|
100
|
+
expect(() => validateTokenVerifierMultiplicity([...registry.features.values()])).not.toThrow();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("tokenVerifier alone (PAT-only) → no throw", () => {
|
|
104
|
+
const registry = createRegistry([authFoundationFeature, prefixProvider("pat-mock", "kpat_")]);
|
|
105
|
+
expect(() => validateTokenVerifierMultiplicity([...registry.features.values()])).not.toThrow();
|
|
106
|
+
});
|
|
107
|
+
|
|
75
108
|
test("1 provider per shape → no throw", () => {
|
|
76
109
|
const registry = createRegistry([
|
|
77
110
|
authFoundationFeature,
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
// Multiplicity boot-check for auth-foundation's `tokenVerifier` extension
|
|
2
|
-
// point (#1368). Two static-shape conflicts a boot-check CAN catch (a
|
|
2
|
+
// point (#1368, #1570). Two static-shape conflicts a boot-check CAN catch (a
|
|
3
3
|
// runtime shape-match predicate's overlap can't be proven at boot): a
|
|
4
4
|
// malformed plugin registration, and two providers both claiming the same
|
|
5
|
-
// shape (resolveTokenVerifier can't tell them apart). Zero
|
|
6
|
-
// providers is
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// which
|
|
11
|
-
//
|
|
5
|
+
// shape (resolveTokenVerifier can't tell them apart). Zero tokenVerifier
|
|
6
|
+
// providers is OK when ≥1 sessionStore is mounted (session-only cookie
|
|
7
|
+
// apps never use Bearer routing). Zero of both is caught here — fail-fast
|
|
8
|
+
// at boot ("you mounted the foundation but forgot sessions or a provider")
|
|
9
|
+
// rather than a runtime 401 nobody can attribute, unlike
|
|
10
|
+
// file-foundation/mail-foundation which defer that check to request-time
|
|
11
|
+
// (their provider is picked from tenant config, which doesn't exist at
|
|
12
|
+
// boot; auth-foundation's providers are static per-deployment, so boot is
|
|
13
|
+
// the right time to catch it).
|
|
12
14
|
|
|
13
15
|
import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
16
|
import {
|
|
@@ -43,9 +45,18 @@ export function validateTokenVerifierMultiplicity(features: readonly FeatureDefi
|
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
if (namesByShape.size === 0) {
|
|
48
|
+
// Session-only apps (email-password + sessions) never hit tokenVerifier —
|
|
49
|
+
// cookie JWT auth uses sessionStore alone (#1570 / phronexsis#296).
|
|
50
|
+
for (const feature of features) {
|
|
51
|
+
for (const usage of feature.extensionUsages) {
|
|
52
|
+
// skip: sessionStore present — tokenVerifier optional for cookie-session apps.
|
|
53
|
+
if (usage.extensionName === EXT_SESSION_STORE) return;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
46
56
|
throw new Error(
|
|
47
|
-
"[auth-foundation] no tokenVerifier providers registered — mount
|
|
48
|
-
"auth-provider-* feature (e.g. auth-provider-jwt
|
|
57
|
+
"[auth-foundation] no tokenVerifier providers and no sessionStore registered — mount " +
|
|
58
|
+
"at least one auth-provider-* feature (e.g. auth-provider-jwt / personal-access-tokens) " +
|
|
59
|
+
"or a sessionStore (e.g. sessions) alongside auth-foundation.",
|
|
49
60
|
);
|
|
50
61
|
}
|
|
51
62
|
|
|
@@ -60,86 +71,80 @@ export function validateTokenVerifierMultiplicity(features: readonly FeatureDefi
|
|
|
60
71
|
}
|
|
61
72
|
}
|
|
62
73
|
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
|
|
74
|
+
// Shared by the three single-provider extension points below: collect every
|
|
75
|
+
// registration for `ext`, validating each usage's options against `guard`,
|
|
76
|
+
// then fail boot on ≥2 (resolve*() has no way to route between them). Zero
|
|
77
|
+
// registrations is NOT checked here — it's valid for all three (a
|
|
78
|
+
// machine-API-only auth-foundation with PAT-bearer auth and no browser
|
|
79
|
+
// sessions, single-tenant deployments with no tenantResolver, etc.); the
|
|
80
|
+
// runtime already models "extension point unmounted" as a legal
|
|
81
|
+
// configuration (run-prod-app.ts only wires sessions when
|
|
82
|
+
// EXT_SESSION_STORE has ≥1 usage), and resolve*() throws its own clear
|
|
83
|
+
// error if a caller requests a provider that was never registered.
|
|
84
|
+
function validateSingleProvider(
|
|
85
|
+
features: readonly FeatureDefinition[],
|
|
86
|
+
opts: {
|
|
87
|
+
readonly ext: string;
|
|
88
|
+
readonly label: string;
|
|
89
|
+
readonly guard: (options: unknown) => boolean;
|
|
90
|
+
readonly shapeHint: string;
|
|
91
|
+
},
|
|
92
|
+
): void {
|
|
93
|
+
const { ext, label, guard, shapeHint } = opts;
|
|
67
94
|
const names: string[] = [];
|
|
68
95
|
|
|
69
96
|
for (const feature of features) {
|
|
70
97
|
for (const usage of feature.extensionUsages) {
|
|
71
|
-
if (usage.extensionName !==
|
|
72
|
-
if (!
|
|
98
|
+
if (usage.extensionName !== ext) continue;
|
|
99
|
+
if (!guard(usage.options)) {
|
|
73
100
|
throw new Error(
|
|
74
|
-
`[auth-foundation]
|
|
75
|
-
`registered without a valid
|
|
101
|
+
`[auth-foundation] ${label} provider "${usage.entityName}" (feature "${feature.name}") ` +
|
|
102
|
+
`registered without a valid ${shapeHint}.`,
|
|
76
103
|
);
|
|
77
104
|
}
|
|
78
105
|
names.push(usage.entityName);
|
|
79
106
|
}
|
|
80
107
|
}
|
|
81
108
|
|
|
82
|
-
if (names.length === 0) {
|
|
83
|
-
throw new Error(
|
|
84
|
-
"[auth-foundation] no sessionStore provider registered — mount a feature that " +
|
|
85
|
-
"registers one via r.useExtension(EXT_SESSION_STORE, ...) alongside auth-foundation.",
|
|
86
|
-
);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
109
|
if (names.length >= 2) {
|
|
90
110
|
throw new Error(
|
|
91
|
-
`[auth-foundation] ${names.length}
|
|
92
|
-
`only one
|
|
111
|
+
`[auth-foundation] ${names.length} ${label} providers registered (${names.join(", ")}) — ` +
|
|
112
|
+
`only one ${label} provider may be mounted at a time.`,
|
|
93
113
|
);
|
|
94
114
|
}
|
|
95
115
|
}
|
|
96
116
|
|
|
117
|
+
// Multiplicity boot-check for the `sessionStore` extension point (#1370).
|
|
118
|
+
// Single-provider, unlike tokenVerifier — no shape to route on, so ≥2 fails
|
|
119
|
+
// boot. Zero registrations is a valid configuration (see
|
|
120
|
+
// validateSingleProvider doc) — a pure machine API using auth-foundation for
|
|
121
|
+
// PAT-bearer auth alone can mount it without also pulling in the whole
|
|
122
|
+
// sessions feature.
|
|
123
|
+
export function validateSessionStoreMultiplicity(features: readonly FeatureDefinition[]): void {
|
|
124
|
+
validateSingleProvider(features, {
|
|
125
|
+
ext: EXT_SESSION_STORE,
|
|
126
|
+
label: "sessionStore",
|
|
127
|
+
guard: isSessionStoreProvider,
|
|
128
|
+
shapeHint: "SessionStoreProvider — options must have a { build } shape",
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
97
132
|
// Optional single-provider (#1373). Zero registrations = OK (single-tenant /
|
|
98
133
|
// header-cookie path). ≥2 or malformed = boot fail.
|
|
99
134
|
export function validateTenantResolverMultiplicity(features: readonly FeatureDefinition[]): void {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
throw new Error(
|
|
107
|
-
`[auth-foundation] tenantResolver provider "${usage.entityName}" (feature "${feature.name}") ` +
|
|
108
|
-
`registered without a valid TenantResolverProvider — options must have { trust, build }.`,
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
names.push(usage.entityName);
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
if (names.length >= 2) {
|
|
116
|
-
throw new Error(
|
|
117
|
-
`[auth-foundation] ${names.length} tenantResolver providers registered (${names.join(", ")}) — ` +
|
|
118
|
-
`only one tenantResolver provider may be mounted at a time.`,
|
|
119
|
-
);
|
|
120
|
-
}
|
|
135
|
+
validateSingleProvider(features, {
|
|
136
|
+
ext: EXT_TENANT_RESOLVER,
|
|
137
|
+
label: "tenantResolver",
|
|
138
|
+
guard: isTenantResolverProvider,
|
|
139
|
+
shapeHint: "TenantResolverProvider — options must have { trust, build }",
|
|
140
|
+
});
|
|
121
141
|
}
|
|
122
142
|
|
|
123
143
|
export function validateTenantExistenceMultiplicity(features: readonly FeatureDefinition[]): void {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
throw new Error(
|
|
131
|
-
`[auth-foundation] tenantExistence provider "${usage.entityName}" (feature "${feature.name}") ` +
|
|
132
|
-
`registered without a valid TenantExistenceProvider — options must have a { build } shape.`,
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
names.push(usage.entityName);
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
if (names.length >= 2) {
|
|
140
|
-
throw new Error(
|
|
141
|
-
`[auth-foundation] ${names.length} tenantExistence providers registered (${names.join(", ")}) — ` +
|
|
142
|
-
`only one tenantExistence provider may be mounted at a time.`,
|
|
143
|
-
);
|
|
144
|
-
}
|
|
144
|
+
validateSingleProvider(features, {
|
|
145
|
+
ext: EXT_TENANT_EXISTENCE,
|
|
146
|
+
label: "tenantExistence",
|
|
147
|
+
guard: isTenantExistenceProvider,
|
|
148
|
+
shapeHint: "TenantExistenceProvider — options must have a { build } shape",
|
|
149
|
+
});
|
|
145
150
|
}
|
|
@@ -6,8 +6,6 @@
|
|
|
6
6
|
import type {
|
|
7
7
|
AnonymousAccessConfig,
|
|
8
8
|
AnonymousAccessResolved,
|
|
9
|
-
TenantExists,
|
|
10
|
-
TenantResolver,
|
|
11
9
|
} from "@cosmicdrift/kumiko-framework/api";
|
|
12
10
|
import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
13
11
|
import { resolveTenantExistence, resolveTenantResolver } from "./feature";
|
|
@@ -22,7 +20,12 @@ export async function resolveAnonymousAccessFromRegistry(
|
|
|
22
20
|
resolveTenantExistence(deps),
|
|
23
21
|
]);
|
|
24
22
|
|
|
25
|
-
|
|
23
|
+
// Anonymous access is app opt-in only: a mounted routing provider
|
|
24
|
+
// (EXT_TENANT_RESOLVER/EXT_TENANT_EXISTENCE) alone must never make
|
|
25
|
+
// anonymousAccess become defined for an app that never set it — that
|
|
26
|
+
// would upgrade every roles:["anonymous"] handler to reachable without
|
|
27
|
+
// a JWT the moment such a routing feature is mounted (#1452).
|
|
28
|
+
if (!base) return undefined;
|
|
26
29
|
|
|
27
30
|
// Fail-closed (#1374 security): inline tenantResolver without trust is the
|
|
28
31
|
// old ambiguous config — providers always carry trust; test stacks must too.
|
|
@@ -45,10 +48,15 @@ export async function resolveAnonymousAccessFromRegistry(
|
|
|
45
48
|
...(base ?? {}),
|
|
46
49
|
...(tenantResolver
|
|
47
50
|
? {
|
|
48
|
-
|
|
51
|
+
// No cast: TenantResolverFn's `(c: unknown) => string | null`
|
|
52
|
+
// and the framework's TenantResolver `(c: Context) => TenantId
|
|
53
|
+
// | null` are structurally identical (TenantId is a plain
|
|
54
|
+
// string alias, no brand) — `unknown` is a supertype of
|
|
55
|
+
// Context, so this is already sound by parameter contravariance.
|
|
56
|
+
tenantResolver: tenantResolver.resolve,
|
|
49
57
|
resolverTrust: tenantResolver.trust,
|
|
50
58
|
}
|
|
51
59
|
: {}),
|
|
52
|
-
...(tenantExists ? { tenantExists
|
|
60
|
+
...(tenantExists ? { tenantExists } : {}),
|
|
53
61
|
};
|
|
54
62
|
}
|
|
@@ -14,11 +14,14 @@
|
|
|
14
14
|
import type {
|
|
15
15
|
SessionChecker,
|
|
16
16
|
SessionCreator,
|
|
17
|
+
SessionMassRevoker,
|
|
17
18
|
SessionRevoker,
|
|
18
19
|
} from "@cosmicdrift/kumiko-framework/api";
|
|
19
20
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
20
21
|
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
21
22
|
|
|
23
|
+
export type { SessionMassRevoker };
|
|
24
|
+
|
|
22
25
|
export const EXT_TOKEN_VERIFIER = "tokenVerifier";
|
|
23
26
|
|
|
24
27
|
/**
|
|
@@ -83,9 +86,6 @@ export function tokenShapeKey(shape: TokenShape): string {
|
|
|
83
86
|
|
|
84
87
|
export const EXT_SESSION_STORE = "sessionStore";
|
|
85
88
|
|
|
86
|
-
/** Mass-revoke every live session for a user. Used by password-change and "sign out everywhere". */
|
|
87
|
-
export type SessionMassRevoker = (userId: string) => Promise<number>;
|
|
88
|
-
|
|
89
89
|
/** Keep current sid (if any); revoke every other live session. Used by auth-mfa. */
|
|
90
90
|
export type SessionRevokeAllOthers = (
|
|
91
91
|
userId: string,
|
|
@@ -37,11 +37,16 @@ import { userEntity, userTable } from "../../user/schema/user";
|
|
|
37
37
|
import { base32Decode } from "../base32";
|
|
38
38
|
import { mfaRequiredConfigHandle } from "../config";
|
|
39
39
|
import { AuthMfaHandlers } from "../constants";
|
|
40
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
bindMfaRevokeAllOtherSessionsFromFeature,
|
|
42
|
+
createAuthMfaFeature,
|
|
43
|
+
mfaStatusCheckerFromFeature,
|
|
44
|
+
} from "../feature";
|
|
41
45
|
import { userMfaEntity, userMfaTable } from "../schema/user-mfa";
|
|
42
46
|
import { currentTotpCode } from "../totp";
|
|
43
47
|
|
|
44
48
|
let stack: TestStack;
|
|
49
|
+
const revokedForUserIds: string[] = [];
|
|
45
50
|
|
|
46
51
|
const CHALLENGE_TOKEN_SECRET = "integration-test-challenge-token-secret-not-real-0001";
|
|
47
52
|
const SETUP_TOKEN_SECRET = "integration-test-setup-token-secret-not-real-0002";
|
|
@@ -84,6 +89,20 @@ beforeAll(async () => {
|
|
|
84
89
|
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
85
90
|
await unsafeCreateEntityTable(stack.db, userMfaEntity);
|
|
86
91
|
await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
92
|
+
|
|
93
|
+
// Wire a spy in place of the real session-revoke callback (run-prod-app
|
|
94
|
+
// only binds this once the sessions feature is mounted — not the case in
|
|
95
|
+
// this test stack) so #1467's regression can observe whether
|
|
96
|
+
// revokeAllOtherSessions actually fires, instead of silently no-op'ing.
|
|
97
|
+
const bind = bindMfaRevokeAllOtherSessionsFromFeature(authMfaFeature);
|
|
98
|
+
// If the binder ever returns undefined, the spy below never wires up and
|
|
99
|
+
// the #1467 regression check further down would pass vacuously (nothing
|
|
100
|
+
// to revoke because nothing observed) instead of catching a real re-break.
|
|
101
|
+
expect(bind).toBeDefined();
|
|
102
|
+
bind?.((userId) => {
|
|
103
|
+
revokedForUserIds.push(userId);
|
|
104
|
+
return Promise.resolve(0);
|
|
105
|
+
});
|
|
87
106
|
});
|
|
88
107
|
|
|
89
108
|
afterAll(async () => {
|
|
@@ -93,6 +112,7 @@ afterAll(async () => {
|
|
|
93
112
|
beforeEach(async () => {
|
|
94
113
|
await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
|
|
95
114
|
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
|
|
115
|
+
revokedForUserIds.length = 0;
|
|
96
116
|
});
|
|
97
117
|
|
|
98
118
|
async function loginBlockedByEnforcement(
|
|
@@ -159,6 +179,10 @@ describe("POST /auth/mfa/preauth-confirm", () => {
|
|
|
159
179
|
{ Authorization: `Bearer ${body.token}` },
|
|
160
180
|
);
|
|
161
181
|
expect(meRes.status).toBe(200);
|
|
182
|
+
|
|
183
|
+
// Positive control for the #1467 regression below — proves the spy
|
|
184
|
+
// wiring actually observes a real revoke call, not just silent no-ops.
|
|
185
|
+
expect(revokedForUserIds).toContain(body.user.id);
|
|
162
186
|
});
|
|
163
187
|
|
|
164
188
|
test("wrong TOTP code → invalid_totp_code, no enrollment persisted", async () => {
|
|
@@ -247,6 +271,42 @@ describe("POST /auth/mfa/preauth-confirm", () => {
|
|
|
247
271
|
expect(body.error.details.reason).toBe("invalid_setup_token");
|
|
248
272
|
});
|
|
249
273
|
|
|
274
|
+
// #1467: findForAuth's status check + membership lookup used to run AFTER
|
|
275
|
+
// the entity-write and the revokeAllOtherSessions call — an admin
|
|
276
|
+
// restricting/deleting the account between preauth-enable-start and
|
|
277
|
+
// preauth-confirm still got invalid_setup_token back correctly (the
|
|
278
|
+
// handler's whole DB transaction rolls back on a non-success return, so
|
|
279
|
+
// the MFA row itself never actually persisted), but revokeAllOtherSessions
|
|
280
|
+
// is an external side effect outside that transaction — it fired
|
|
281
|
+
// regardless, logging the user's other devices out over an enrollment
|
|
282
|
+
// attempt that was rejected. Regression: with status/membership checked
|
|
283
|
+
// BEFORE the write, the revoke call must not fire at all for this case.
|
|
284
|
+
test("user restricted between start and confirm → invalid_setup_token, no session revoked (#1467)", async () => {
|
|
285
|
+
const email = "restricted-midflight@example.com";
|
|
286
|
+
const { preauthSetupToken, userId } = await loginBlockedByEnforcement(email);
|
|
287
|
+
const { setupToken, secret } = await startEnrollment(preauthSetupToken, email);
|
|
288
|
+
|
|
289
|
+
await asRawClient(stack.db).unsafe(
|
|
290
|
+
`UPDATE "${userTable.tableName}" SET "status" = 'restricted' WHERE "id" = $1`,
|
|
291
|
+
[userId],
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
const res = await stack.http.raw("POST", "/api/auth/mfa/preauth-confirm", {
|
|
295
|
+
setupToken,
|
|
296
|
+
code: currentTotpCode(secret),
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
expect(res.status).not.toBe(200);
|
|
300
|
+
const body = await res.json();
|
|
301
|
+
expect(body.error.details.reason).toBe("invalid_setup_token");
|
|
302
|
+
expect(revokedForUserIds).not.toContain(userId);
|
|
303
|
+
// Enrollment write itself rolls back with the handler's failure return —
|
|
304
|
+
// pinned here too so a future change to that transactional behavior
|
|
305
|
+
// doesn't silently reintroduce the original "row persists" half of the bug.
|
|
306
|
+
const rows = await selectMany(stack.db, userMfaTable, { userId });
|
|
307
|
+
expect(rows).toHaveLength(0);
|
|
308
|
+
});
|
|
309
|
+
|
|
250
310
|
test("expired/garbage setupToken → invalid_setup_token, no session minted", async () => {
|
|
251
311
|
const res = await stack.http.raw("POST", "/api/auth/mfa/preauth-confirm", {
|
|
252
312
|
setupToken: "not-a-real-token",
|