@cosmicdrift/kumiko-bundled-features 0.160.0 → 0.162.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -6
- package/src/auth-email-password/__tests__/login-mfa-setup-required.integration.test.ts +129 -0
- package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +168 -0
- package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +5 -0
- package/src/auth-email-password/constants.ts +6 -0
- package/src/auth-email-password/feature.ts +8 -1
- package/src/auth-email-password/handlers/login.write.ts +6 -3
- package/src/auth-email-password/handlers/self-registration-status.query.ts +20 -0
- package/src/auth-email-password/handlers/signup-request.write.ts +11 -0
- package/src/auth-email-password/i18n.ts +4 -0
- package/src/auth-email-password/index.ts +5 -1
- package/src/auth-email-password/self-registration-toggle.ts +25 -0
- package/src/auth-email-password/web/__tests__/auth-client.test.ts +6 -2
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +79 -0
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +14 -5
- package/src/auth-email-password/web/auth-client.ts +4 -3
- package/src/auth-email-password/web/auth-gate.tsx +44 -2
- package/src/auth-email-password/web/login-screen.tsx +11 -5
- package/src/auth-foundation/__tests__/feature.test.ts +16 -3
- package/src/auth-foundation/__tests__/session-store.test.ts +1 -0
- package/src/auth-foundation/__tests__/tenant-providers.test.ts +136 -0
- package/src/auth-foundation/boot-checks.ts +54 -0
- package/src/auth-foundation/feature.ts +77 -23
- package/src/auth-foundation/index.ts +14 -0
- package/src/auth-foundation/resolve-anonymous-access.ts +54 -0
- package/src/auth-foundation/types.ts +63 -0
- package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +253 -0
- package/src/auth-mfa/__tests__/enable-start-preauth.integration.test.ts +214 -0
- package/src/auth-mfa/__tests__/mfa-preauth-setup-token.test.ts +87 -0
- package/src/auth-mfa/constants.ts +3 -0
- package/src/auth-mfa/feature.ts +15 -0
- package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +170 -0
- package/src/auth-mfa/handlers/enable-start-preauth.write.ts +87 -0
- package/src/auth-mfa/mfa-preauth-setup-token.ts +86 -0
- package/src/auth-mfa/mfa-setup-token.ts +7 -1
- package/src/auth-mfa/mfa-status-checker.ts +13 -4
- package/src/auth-mfa/web/__tests__/mfa-client.test.ts +144 -1
- package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +202 -0
- package/src/auth-mfa/web/i18n.ts +14 -0
- package/src/auth-mfa/web/index.ts +8 -2
- package/src/auth-mfa/web/mfa-client.ts +113 -0
- package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +244 -0
- package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +96 -0
- package/src/feature-toggles/compose-tier-resolver.ts +41 -0
- package/src/feature-toggles/index.ts +1 -0
- package/src/feature-toggles/toggle-runtime.ts +14 -0
- package/src/sessions/feature.ts +8 -1
|
@@ -275,6 +275,7 @@ describe("LoginScreen", () => {
|
|
|
275
275
|
{ reason: "rate_limited", needle: "Zu viele Login-Versuche" },
|
|
276
276
|
{ reason: "invalid_body", needle: "Ungültige Eingabe" },
|
|
277
277
|
{ reason: "weird_unknown", needle: "Login fehlgeschlagen" },
|
|
278
|
+
{ reason: "mfa_setup_required", needle: "Zwei-Faktor-Authentifizierung erforderlich" },
|
|
278
279
|
];
|
|
279
280
|
for (const { reason, needle } of cases) {
|
|
280
281
|
const session = makeSessionApi({
|
|
@@ -335,11 +336,14 @@ describe("LoginScreen", () => {
|
|
|
335
336
|
});
|
|
336
337
|
|
|
337
338
|
test("mfa-setup-required with and without onMfaSetupRequired", async () => {
|
|
338
|
-
const onMfaSetupRequired = mock<() => void>();
|
|
339
|
+
const onMfaSetupRequired = mock<(preauthSetupToken: string, accountLabel: string) => void>();
|
|
339
340
|
const sessionOk = makeSessionApi({
|
|
340
341
|
status: "unauthenticated",
|
|
341
342
|
user: null,
|
|
342
|
-
login: mock<SessionApi["login"]>(async () => ({
|
|
343
|
+
login: mock<SessionApi["login"]>(async () => ({
|
|
344
|
+
kind: "mfa-setup-required",
|
|
345
|
+
preauthSetupToken: "setup-token-value",
|
|
346
|
+
})),
|
|
343
347
|
});
|
|
344
348
|
const { unmount } = renderWithProviders(
|
|
345
349
|
<LoginScreen onMfaSetupRequired={onMfaSetupRequired} />,
|
|
@@ -349,21 +353,26 @@ describe("LoginScreen", () => {
|
|
|
349
353
|
fireEvent.change(screen.getByLabelText(/^Passwort/), { target: { value: "x" } });
|
|
350
354
|
fireEvent.click(screen.getByRole("button", { name: "Einloggen" }));
|
|
351
355
|
await waitFor(() => {
|
|
352
|
-
expect(onMfaSetupRequired).
|
|
356
|
+
expect(onMfaSetupRequired).toHaveBeenCalledWith("setup-token-value", "a@b.c");
|
|
353
357
|
});
|
|
354
358
|
unmount();
|
|
355
359
|
|
|
356
360
|
const sessionBare = makeSessionApi({
|
|
357
361
|
status: "unauthenticated",
|
|
358
362
|
user: null,
|
|
359
|
-
login: mock<SessionApi["login"]>(async () => ({
|
|
363
|
+
login: mock<SessionApi["login"]>(async () => ({
|
|
364
|
+
kind: "mfa-setup-required",
|
|
365
|
+
preauthSetupToken: "setup-token-value",
|
|
366
|
+
})),
|
|
360
367
|
});
|
|
361
368
|
renderWithProviders(<LoginScreen />, { session: sessionBare });
|
|
362
369
|
fireEvent.change(screen.getByLabelText(/^E-Mail/), { target: { value: "a@b.c" } });
|
|
363
370
|
fireEvent.change(screen.getByLabelText(/^Passwort/), { target: { value: "x" } });
|
|
364
371
|
fireEvent.click(screen.getByRole("button", { name: "Einloggen" }));
|
|
365
372
|
await waitFor(() => {
|
|
366
|
-
expect(screen.getByRole("alert").textContent).toContain(
|
|
373
|
+
expect(screen.getByRole("alert").textContent).toContain(
|
|
374
|
+
"Zwei-Faktor-Authentifizierung erforderlich",
|
|
375
|
+
);
|
|
367
376
|
});
|
|
368
377
|
});
|
|
369
378
|
|
|
@@ -53,7 +53,7 @@ export function csrfHeader(): Record<string, string> {
|
|
|
53
53
|
export type LoginResult =
|
|
54
54
|
| { readonly kind: "success"; readonly data: LoginResponse }
|
|
55
55
|
| { readonly kind: "mfa-challenge"; readonly challengeToken: string }
|
|
56
|
-
| { readonly kind: "mfa-setup-required" }
|
|
56
|
+
| { readonly kind: "mfa-setup-required"; readonly preauthSetupToken: string }
|
|
57
57
|
| { readonly kind: "failure"; readonly error: LoginFailure };
|
|
58
58
|
|
|
59
59
|
// POST /api/auth/login. Success → token + user; MFA-enrolled user → a
|
|
@@ -80,6 +80,7 @@ export async function login(req: LoginRequest): Promise<LoginResult> {
|
|
|
80
80
|
mfaRequired?: boolean;
|
|
81
81
|
challengeToken?: string;
|
|
82
82
|
mfaSetupRequired?: boolean;
|
|
83
|
+
preauthSetupToken?: string;
|
|
83
84
|
error?:
|
|
84
85
|
| {
|
|
85
86
|
code?: string;
|
|
@@ -92,8 +93,8 @@ export async function login(req: LoginRequest): Promise<LoginResult> {
|
|
|
92
93
|
if (body.mfaRequired === true && typeof body.challengeToken === "string") {
|
|
93
94
|
return { kind: "mfa-challenge", challengeToken: body.challengeToken };
|
|
94
95
|
}
|
|
95
|
-
if (body.mfaSetupRequired === true) {
|
|
96
|
-
return { kind: "mfa-setup-required" };
|
|
96
|
+
if (body.mfaSetupRequired === true && typeof body.preauthSetupToken === "string") {
|
|
97
|
+
return { kind: "mfa-setup-required", preauthSetupToken: body.preauthSetupToken };
|
|
97
98
|
}
|
|
98
99
|
if (body.token !== undefined && body.user !== undefined) {
|
|
99
100
|
return { kind: "success", data: { token: body.token, user: body.user } };
|
|
@@ -23,10 +23,21 @@ export type MfaVerifyComponentProps = {
|
|
|
23
23
|
readonly onCancel?: () => void;
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
+
// Same coupling reasoning as MfaVerifyComponentProps above — generic, not
|
|
27
|
+
// auth-mfa's MfaSetupPreauthScreenProps directly. Apps wire auth-mfa's
|
|
28
|
+
// MfaSetupPreauthScreen in here via EmailPasswordClientOptions.
|
|
29
|
+
export type MfaSetupComponentProps = {
|
|
30
|
+
readonly preauthSetupToken: string;
|
|
31
|
+
readonly accountLabel: string;
|
|
32
|
+
readonly onSuccess?: () => void;
|
|
33
|
+
readonly onCancel?: () => void;
|
|
34
|
+
};
|
|
35
|
+
|
|
26
36
|
export type LoginRouteOptions = {
|
|
27
37
|
readonly loginScreen?: ComponentType<LoginScreenProps>;
|
|
28
38
|
readonly loginScreenProps?: LoginScreenProps;
|
|
29
39
|
readonly mfaVerifyScreen?: ComponentType<MfaVerifyComponentProps>;
|
|
40
|
+
readonly mfaSetupScreen?: ComponentType<MfaSetupComponentProps>;
|
|
30
41
|
/** Called once the session becomes authenticated. makeAuthGate ignores
|
|
31
42
|
* this (it renders `children` on its own authenticated branch instead);
|
|
32
43
|
* standalone routes — no parent gate, e.g. an anonymous apex/marketing
|
|
@@ -51,14 +62,19 @@ export function createLoginRoute(
|
|
|
51
62
|
): ComponentType<Record<string, never>> {
|
|
52
63
|
const LoginComponent = opts.loginScreen ?? LoginScreen;
|
|
53
64
|
const MfaVerifyComponent = opts.mfaVerifyScreen;
|
|
65
|
+
const MfaSetupComponent = opts.mfaSetupScreen;
|
|
54
66
|
|
|
55
67
|
function LoginRoute(): ReactNode {
|
|
56
|
-
const { status } = useSession();
|
|
68
|
+
const { status, refresh } = useSession();
|
|
57
69
|
const { onAuthenticated } = opts;
|
|
58
70
|
// Pending challenge-token from LoginScreen's onMfaChallenge. Lives here
|
|
59
71
|
// (not in SessionState) because it's a UI-only transition — the server
|
|
60
72
|
// never considers this session authenticated until verify succeeds.
|
|
61
73
|
const [challengeToken, setChallengeToken] = useState<string | null>(null);
|
|
74
|
+
const [setupRequest, setSetupRequest] = useState<{
|
|
75
|
+
readonly preauthSetupToken: string;
|
|
76
|
+
readonly accountLabel: string;
|
|
77
|
+
} | null>(null);
|
|
62
78
|
|
|
63
79
|
useEffect(() => {
|
|
64
80
|
if (status === "authenticated") onAuthenticated?.();
|
|
@@ -85,12 +101,35 @@ export function createLoginRoute(
|
|
|
85
101
|
/>
|
|
86
102
|
);
|
|
87
103
|
}
|
|
104
|
+
// Pending preauthSetupToken from LoginScreen's onMfaSetupRequired. Same
|
|
105
|
+
// reasoning as challengeToken — a UI-only transition, not session state.
|
|
106
|
+
if (setupRequest !== null && MfaSetupComponent) {
|
|
107
|
+
return (
|
|
108
|
+
<MfaSetupComponent
|
|
109
|
+
preauthSetupToken={setupRequest.preauthSetupToken}
|
|
110
|
+
accountLabel={setupRequest.accountLabel}
|
|
111
|
+
onSuccess={() => {
|
|
112
|
+
setSetupRequest(null);
|
|
113
|
+
// MfaSetupPreauthScreen has no session to refresh itself with
|
|
114
|
+
// (it runs pre-auth) — the gate owns the session, so it refreshes.
|
|
115
|
+
void refresh();
|
|
116
|
+
}}
|
|
117
|
+
onCancel={() => setSetupRequest(null)}
|
|
118
|
+
/>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
88
121
|
return (
|
|
89
122
|
<LoginComponent
|
|
90
123
|
{...opts.loginScreenProps}
|
|
91
124
|
onMfaChallenge={
|
|
92
125
|
MfaVerifyComponent ? setChallengeToken : opts.loginScreenProps?.onMfaChallenge
|
|
93
126
|
}
|
|
127
|
+
onMfaSetupRequired={
|
|
128
|
+
MfaSetupComponent
|
|
129
|
+
? (preauthSetupToken, accountLabel) =>
|
|
130
|
+
setSetupRequest({ preauthSetupToken, accountLabel })
|
|
131
|
+
: opts.loginScreenProps?.onMfaSetupRequired
|
|
132
|
+
}
|
|
94
133
|
/>
|
|
95
134
|
);
|
|
96
135
|
}
|
|
@@ -101,11 +140,13 @@ export function makeAuthGate(
|
|
|
101
140
|
LoginComponent: ComponentType<LoginScreenProps> = LoginScreen,
|
|
102
141
|
loginProps?: LoginScreenProps,
|
|
103
142
|
MfaVerifyComponent?: ComponentType<MfaVerifyComponentProps>,
|
|
143
|
+
MfaSetupComponent?: ComponentType<MfaSetupComponentProps>,
|
|
104
144
|
): ComponentType<{ children: ReactNode }> {
|
|
105
145
|
const LoginRoute = createLoginRoute({
|
|
106
146
|
loginScreen: LoginComponent,
|
|
107
147
|
loginScreenProps: loginProps,
|
|
108
148
|
mfaVerifyScreen: MfaVerifyComponent,
|
|
149
|
+
mfaSetupScreen: MfaSetupComponent,
|
|
109
150
|
});
|
|
110
151
|
function AuthGate({ children }: { readonly children: ReactNode }): ReactNode {
|
|
111
152
|
const { status } = useSession();
|
|
@@ -122,8 +163,9 @@ export function makeSessionAuthGate(
|
|
|
122
163
|
LoginComponent: ComponentType<LoginScreenProps> = LoginScreen,
|
|
123
164
|
loginProps?: LoginScreenProps,
|
|
124
165
|
MfaVerifyComponent?: ComponentType<MfaVerifyComponentProps>,
|
|
166
|
+
MfaSetupComponent?: ComponentType<MfaSetupComponentProps>,
|
|
125
167
|
): ComponentType<{ children: ReactNode }> {
|
|
126
|
-
const AuthGate = makeAuthGate(LoginComponent, loginProps, MfaVerifyComponent);
|
|
168
|
+
const AuthGate = makeAuthGate(LoginComponent, loginProps, MfaVerifyComponent, MfaSetupComponent);
|
|
127
169
|
function SessionAuthGate({ children }: { readonly children: ReactNode }): ReactNode {
|
|
128
170
|
return (
|
|
129
171
|
<SessionProvider>
|
|
@@ -64,9 +64,13 @@ export type LoginScreenProps = {
|
|
|
64
64
|
* hang, but apps mounting auth-mfa must wire this. */
|
|
65
65
|
readonly onMfaChallenge?: (challengeToken: string) => void;
|
|
66
66
|
/** Called when the tenant's enforcement policy requires MFA but this
|
|
67
|
-
* user has no factor enrolled yet.
|
|
68
|
-
*
|
|
69
|
-
|
|
67
|
+
* user has no factor enrolled yet. Carries the preauthSetupToken the
|
|
68
|
+
* login response issued plus the typed email (no session to derive it
|
|
69
|
+
* from) — apps typically swap this screen out for auth-mfa's
|
|
70
|
+
* MfaSetupPreauthScreen(preauthSetupToken, accountLabel). Without a
|
|
71
|
+
* handler, the user sees a "setup required, contact your administrator"
|
|
72
|
+
* error. */
|
|
73
|
+
readonly onMfaSetupRequired?: (preauthSetupToken: string, accountLabel: string) => void;
|
|
70
74
|
};
|
|
71
75
|
|
|
72
76
|
// Map vom Reason-Code des Login-Handlers auf einen i18n-Key plus
|
|
@@ -97,6 +101,8 @@ function reasonToKey(failure: LoginFailure): {
|
|
|
97
101
|
return { key: "auth.errors.invalidBody" };
|
|
98
102
|
case "mfa_not_supported":
|
|
99
103
|
return { key: "auth.errors.mfaNotSupported" };
|
|
104
|
+
case "mfa_setup_required":
|
|
105
|
+
return { key: "auth.errors.mfaSetupRequired" };
|
|
100
106
|
default:
|
|
101
107
|
return { key: "auth.errors.loginFailed" };
|
|
102
108
|
}
|
|
@@ -144,10 +150,10 @@ export function LoginScreen({
|
|
|
144
150
|
}
|
|
145
151
|
if (res.kind === "mfa-setup-required") {
|
|
146
152
|
if (onMfaSetupRequired) {
|
|
147
|
-
onMfaSetupRequired();
|
|
153
|
+
onMfaSetupRequired(res.preauthSetupToken, email);
|
|
148
154
|
return;
|
|
149
155
|
}
|
|
150
|
-
setError({ reason: "
|
|
156
|
+
setError({ reason: "mfa_setup_required" });
|
|
151
157
|
setFailedLoginEmail(email);
|
|
152
158
|
return;
|
|
153
159
|
}
|
|
@@ -4,7 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
import { describe, expect, test } from "bun:test";
|
|
6
6
|
import { authFoundationFeature } from "../feature";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
EXT_SESSION_STORE,
|
|
9
|
+
EXT_TENANT_EXISTENCE,
|
|
10
|
+
EXT_TENANT_RESOLVER,
|
|
11
|
+
EXT_TOKEN_VERIFIER,
|
|
12
|
+
} from "../types";
|
|
8
13
|
|
|
9
14
|
describe("authFoundationFeature — shape", () => {
|
|
10
15
|
test("has the expected name", () => {
|
|
@@ -24,10 +29,18 @@ describe("authFoundationFeature — registers extension-point", () => {
|
|
|
24
29
|
test("declares the sessionStore extension-point", () => {
|
|
25
30
|
expect(authFoundationFeature.registrarExtensions[EXT_SESSION_STORE]).toBeDefined();
|
|
26
31
|
});
|
|
32
|
+
|
|
33
|
+
test("declares the tenantResolver extension-point", () => {
|
|
34
|
+
expect(authFoundationFeature.registrarExtensions[EXT_TENANT_RESOLVER]).toBeDefined();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("declares the tenantExistence extension-point", () => {
|
|
38
|
+
expect(authFoundationFeature.registrarExtensions[EXT_TENANT_EXISTENCE]).toBeDefined();
|
|
39
|
+
});
|
|
27
40
|
});
|
|
28
41
|
|
|
29
42
|
describe("authFoundationFeature — multiplicity boot-check", () => {
|
|
30
|
-
test("registers a bootCheck per extension-point (
|
|
31
|
-
expect(authFoundationFeature.bootChecks.length).toBeGreaterThanOrEqual(
|
|
43
|
+
test("registers a bootCheck per extension-point (token/session/tenant×2)", () => {
|
|
44
|
+
expect(authFoundationFeature.bootChecks.length).toBeGreaterThanOrEqual(4);
|
|
32
45
|
});
|
|
33
46
|
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// resolveTenantResolver / resolveTenantExistence + optional multiplicity (#1373).
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
5
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import { createRegistry, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import {
|
|
8
|
+
validateTenantExistenceMultiplicity,
|
|
9
|
+
validateTenantResolverMultiplicity,
|
|
10
|
+
} from "../boot-checks";
|
|
11
|
+
import { authFoundationFeature, resolveTenantExistence, resolveTenantResolver } from "../feature";
|
|
12
|
+
import { resolveAnonymousAccessFromRegistry } from "../resolve-anonymous-access";
|
|
13
|
+
import {
|
|
14
|
+
EXT_TENANT_EXISTENCE,
|
|
15
|
+
EXT_TENANT_RESOLVER,
|
|
16
|
+
type TenantExistenceProvider,
|
|
17
|
+
type TenantResolverProvider,
|
|
18
|
+
} from "../types";
|
|
19
|
+
|
|
20
|
+
const fakeDb = {} as DbConnection;
|
|
21
|
+
|
|
22
|
+
function resolverProvider(
|
|
23
|
+
entityName: string,
|
|
24
|
+
trust: "authoritative" | "fallback-only" = "authoritative",
|
|
25
|
+
): ReturnType<typeof defineFeature> {
|
|
26
|
+
const plugin: TenantResolverProvider = {
|
|
27
|
+
trust,
|
|
28
|
+
build: () => async () => "00000000-0000-4000-8000-000000000001",
|
|
29
|
+
};
|
|
30
|
+
return defineFeature(`mock-resolver-${entityName}`, (r) => {
|
|
31
|
+
r.useExtension(EXT_TENANT_RESOLVER, entityName, plugin);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function existenceProvider(entityName: string): ReturnType<typeof defineFeature> {
|
|
36
|
+
const plugin: TenantExistenceProvider = {
|
|
37
|
+
build: () => async () => true,
|
|
38
|
+
};
|
|
39
|
+
return defineFeature(`mock-existence-${entityName}`, (r) => {
|
|
40
|
+
r.useExtension(EXT_TENANT_EXISTENCE, entityName, plugin);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("resolveTenantResolver — optional single provider", () => {
|
|
45
|
+
test("no provider → null", async () => {
|
|
46
|
+
const registry = createRegistry([authFoundationFeature]);
|
|
47
|
+
expect(await resolveTenantResolver({ db: fakeDb, registry })).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("one provider → resolve + trust", async () => {
|
|
51
|
+
const registry = createRegistry([authFoundationFeature, resolverProvider("subdomain")]);
|
|
52
|
+
const resolved = await resolveTenantResolver({ db: fakeDb, registry });
|
|
53
|
+
expect(resolved?.trust).toBe("authoritative");
|
|
54
|
+
expect(await resolved?.resolve({})).toBe("00000000-0000-4000-8000-000000000001");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("resolveTenantExistence — optional single provider", () => {
|
|
59
|
+
test("no provider → null", async () => {
|
|
60
|
+
const registry = createRegistry([authFoundationFeature]);
|
|
61
|
+
expect(await resolveTenantExistence({ db: fakeDb, registry })).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("one provider → existence fn", async () => {
|
|
65
|
+
const registry = createRegistry([authFoundationFeature, existenceProvider("db")]);
|
|
66
|
+
const exists = await resolveTenantExistence({ db: fakeDb, registry });
|
|
67
|
+
expect(await exists?.("00000000-0000-4000-8000-000000000001")).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("validateTenantResolverMultiplicity", () => {
|
|
72
|
+
test("0 providers → no throw", () => {
|
|
73
|
+
const registry = createRegistry([authFoundationFeature]);
|
|
74
|
+
expect(() => validateTenantResolverMultiplicity([...registry.features.values()])).not.toThrow();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("1 provider → no throw", () => {
|
|
78
|
+
const registry = createRegistry([authFoundationFeature, resolverProvider("a")]);
|
|
79
|
+
expect(() => validateTenantResolverMultiplicity([...registry.features.values()])).not.toThrow();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("≥2 providers → throws", () => {
|
|
83
|
+
const registry = createRegistry([
|
|
84
|
+
authFoundationFeature,
|
|
85
|
+
resolverProvider("a"),
|
|
86
|
+
resolverProvider("b"),
|
|
87
|
+
]);
|
|
88
|
+
expect(() => validateTenantResolverMultiplicity([...registry.features.values()])).toThrow(
|
|
89
|
+
/2 tenantResolver providers/,
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("malformed (no trust) → throws", () => {
|
|
94
|
+
const broken = defineFeature("mock-broken-resolver", (r) => {
|
|
95
|
+
r.useExtension(EXT_TENANT_RESOLVER, "broken", { build: () => () => null });
|
|
96
|
+
});
|
|
97
|
+
const registry = createRegistry([authFoundationFeature, broken]);
|
|
98
|
+
expect(() => validateTenantResolverMultiplicity([...registry.features.values()])).toThrow(
|
|
99
|
+
/without a valid TenantResolverProvider/,
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("validateTenantExistenceMultiplicity", () => {
|
|
105
|
+
test("0 providers → no throw", () => {
|
|
106
|
+
const registry = createRegistry([authFoundationFeature]);
|
|
107
|
+
expect(() =>
|
|
108
|
+
validateTenantExistenceMultiplicity([...registry.features.values()]),
|
|
109
|
+
).not.toThrow();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("≥2 providers → throws", () => {
|
|
113
|
+
const registry = createRegistry([
|
|
114
|
+
authFoundationFeature,
|
|
115
|
+
existenceProvider("a"),
|
|
116
|
+
existenceProvider("b"),
|
|
117
|
+
]);
|
|
118
|
+
expect(() => validateTenantExistenceMultiplicity([...registry.features.values()])).toThrow(
|
|
119
|
+
/2 tenantExistence providers/,
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("resolveAnonymousAccessFromRegistry — fail-closed trust", () => {
|
|
125
|
+
test("inline tenantResolver without resolverTrust throws", async () => {
|
|
126
|
+
const registry = createRegistry([authFoundationFeature]);
|
|
127
|
+
await expect(
|
|
128
|
+
resolveAnonymousAccessFromRegistry(
|
|
129
|
+
{
|
|
130
|
+
tenantResolver: () => "00000000-0000-4000-8000-000000000001" as TenantId,
|
|
131
|
+
},
|
|
132
|
+
{ db: fakeDb, registry },
|
|
133
|
+
),
|
|
134
|
+
).rejects.toThrow(/resolverTrust/);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -13,9 +13,13 @@
|
|
|
13
13
|
import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
14
|
import {
|
|
15
15
|
EXT_SESSION_STORE,
|
|
16
|
+
EXT_TENANT_EXISTENCE,
|
|
17
|
+
EXT_TENANT_RESOLVER,
|
|
16
18
|
EXT_TOKEN_VERIFIER,
|
|
17
19
|
isAuthProviderPlugin,
|
|
18
20
|
isSessionStoreProvider,
|
|
21
|
+
isTenantExistenceProvider,
|
|
22
|
+
isTenantResolverProvider,
|
|
19
23
|
tokenShapeKey,
|
|
20
24
|
} from "./types";
|
|
21
25
|
|
|
@@ -89,3 +93,53 @@ export function validateSessionStoreMultiplicity(features: readonly FeatureDefin
|
|
|
89
93
|
);
|
|
90
94
|
}
|
|
91
95
|
}
|
|
96
|
+
|
|
97
|
+
// Optional single-provider (#1373). Zero registrations = OK (single-tenant /
|
|
98
|
+
// header-cookie path). ≥2 or malformed = boot fail.
|
|
99
|
+
export function validateTenantResolverMultiplicity(features: readonly FeatureDefinition[]): void {
|
|
100
|
+
const names: string[] = [];
|
|
101
|
+
|
|
102
|
+
for (const feature of features) {
|
|
103
|
+
for (const usage of feature.extensionUsages) {
|
|
104
|
+
if (usage.extensionName !== EXT_TENANT_RESOLVER) continue;
|
|
105
|
+
if (!isTenantResolverProvider(usage.options)) {
|
|
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
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function validateTenantExistenceMultiplicity(features: readonly FeatureDefinition[]): void {
|
|
124
|
+
const names: string[] = [];
|
|
125
|
+
|
|
126
|
+
for (const feature of features) {
|
|
127
|
+
for (const usage of feature.extensionUsages) {
|
|
128
|
+
if (usage.extensionName !== EXT_TENANT_EXISTENCE) continue;
|
|
129
|
+
if (!isTenantExistenceProvider(usage.options)) {
|
|
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
|
+
}
|
|
145
|
+
}
|
|
@@ -1,41 +1,52 @@
|
|
|
1
1
|
// kumiko-feature-version: 1
|
|
2
2
|
//
|
|
3
|
-
// auth-foundation — declares
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// auth-foundation — declares auth-middleware extension points:
|
|
4
|
+
// - tokenVerifier (multi, shape-routed)
|
|
5
|
+
// - sessionStore (single, required when foundation mounts)
|
|
6
|
+
// - tenantResolver (single, optional)
|
|
7
|
+
// - tenantExistence (single, optional)
|
|
6
8
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// (
|
|
10
|
-
// extension point; #1368 only lays the groundwork (PAT-Plan-Doc Phase-1-
|
|
11
|
-
// Sub-Step-1).
|
|
12
|
-
//
|
|
13
|
-
// **Pattern precedent:** file-foundation/mail-foundation for the extension-
|
|
14
|
-
// point + plugin-registry shape. Diverges on ROUTING: those pick one active
|
|
15
|
-
// provider per tenant via a config-key; auth-foundation has no tenant yet
|
|
16
|
-
// at verify-time, so providers are tried in shape-match order instead (see
|
|
17
|
-
// types.ts).
|
|
9
|
+
// Provider-features register via `r.useExtension(EXT_*, "<name>", plugin)`.
|
|
10
|
+
// Middleware / runProdApp resolve providers from the registry at boot
|
|
11
|
+
// (tokenVerifier/sessionStore) or request-time (tokenVerifier shape match).
|
|
18
12
|
|
|
19
13
|
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
20
14
|
import { defineFeature, type Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
21
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
validateSessionStoreMultiplicity,
|
|
17
|
+
validateTenantExistenceMultiplicity,
|
|
18
|
+
validateTenantResolverMultiplicity,
|
|
19
|
+
validateTokenVerifierMultiplicity,
|
|
20
|
+
} from "./boot-checks";
|
|
22
21
|
import {
|
|
23
22
|
type AuthProviderBuildDeps,
|
|
24
23
|
EXT_SESSION_STORE,
|
|
24
|
+
EXT_TENANT_EXISTENCE,
|
|
25
|
+
EXT_TENANT_RESOLVER,
|
|
25
26
|
EXT_TOKEN_VERIFIER,
|
|
26
27
|
isAuthProviderPlugin,
|
|
27
28
|
isSessionStoreProvider,
|
|
29
|
+
isTenantExistenceProvider,
|
|
30
|
+
isTenantResolverProvider,
|
|
28
31
|
type SessionStore,
|
|
32
|
+
type TenantExistsFn,
|
|
33
|
+
type TenantResolverFn,
|
|
34
|
+
type TenantResolverTrust,
|
|
29
35
|
tokenShapeMatches,
|
|
30
36
|
} from "./types";
|
|
31
37
|
|
|
32
|
-
export {
|
|
38
|
+
export {
|
|
39
|
+
EXT_SESSION_STORE,
|
|
40
|
+
EXT_TENANT_EXISTENCE,
|
|
41
|
+
EXT_TENANT_RESOLVER,
|
|
42
|
+
EXT_TOKEN_VERIFIER,
|
|
43
|
+
} from "./types";
|
|
33
44
|
|
|
34
45
|
const FEATURE_NAME = "auth-foundation";
|
|
35
46
|
|
|
36
47
|
export const authFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
37
48
|
r.describe(
|
|
38
|
-
"Declares
|
|
49
|
+
"Declares auth-middleware extension points: `tokenVerifier` (Bearer), `sessionStore` (revocable JWT sid), optional `tenantResolver` + `tenantExistence` for anonymous multi-tenant. Provider-features register via r.useExtension; mount auth-foundation with the matching provider features.",
|
|
39
50
|
);
|
|
40
51
|
r.uiHints({
|
|
41
52
|
displayLabel: "Auth Provider Foundation",
|
|
@@ -49,19 +60,28 @@ export const authFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
49
60
|
// resolveTokenVerifier looks it up at request-time.
|
|
50
61
|
},
|
|
51
62
|
});
|
|
52
|
-
|
|
53
|
-
// Owns EXT_TOKEN_VERIFIER, so its own mount is the trigger — same
|
|
54
|
-
// convention as tenant-lifecycle/user-data-rights' self-bootCheck.
|
|
55
63
|
r.bootCheck(({ features }) => validateTokenVerifierMultiplicity(features));
|
|
56
64
|
|
|
57
65
|
r.extendsRegistrar(EXT_SESSION_STORE, {
|
|
58
66
|
onRegister: () => {
|
|
59
|
-
// No side-effects at register-time —
|
|
60
|
-
// resolveSessionStore looks it up at request-time.
|
|
67
|
+
// No side-effects at register-time — resolveSessionStore at boot/request.
|
|
61
68
|
},
|
|
62
69
|
});
|
|
63
|
-
|
|
64
70
|
r.bootCheck(({ features }) => validateSessionStoreMultiplicity(features));
|
|
71
|
+
|
|
72
|
+
r.extendsRegistrar(EXT_TENANT_RESOLVER, {
|
|
73
|
+
onRegister: () => {
|
|
74
|
+
// Optional — zero providers is fine (single-tenant / header-cookie).
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
r.bootCheck(({ features }) => validateTenantResolverMultiplicity(features));
|
|
78
|
+
|
|
79
|
+
r.extendsRegistrar(EXT_TENANT_EXISTENCE, {
|
|
80
|
+
onRegister: () => {
|
|
81
|
+
// Optional — zero providers skips the existence check.
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
r.bootCheck(({ features }) => validateTenantExistenceMultiplicity(features));
|
|
65
85
|
});
|
|
66
86
|
|
|
67
87
|
/**
|
|
@@ -101,3 +121,37 @@ export async function resolveSessionStore(
|
|
|
101
121
|
}
|
|
102
122
|
return usage.options.build(deps);
|
|
103
123
|
}
|
|
124
|
+
|
|
125
|
+
export type ResolvedTenantResolver = {
|
|
126
|
+
readonly resolve: TenantResolverFn;
|
|
127
|
+
readonly trust: TenantResolverTrust;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/** Optional tenantResolver — null when no provider registered. */
|
|
131
|
+
export async function resolveTenantResolver(
|
|
132
|
+
deps: AuthProviderBuildDeps & { readonly registry: Registry },
|
|
133
|
+
): Promise<ResolvedTenantResolver | null> {
|
|
134
|
+
const usage = deps.registry.getExtensionUsages(EXT_TENANT_RESOLVER)[0];
|
|
135
|
+
if (!usage) return null;
|
|
136
|
+
if (!isTenantResolverProvider(usage.options)) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
"[auth-foundation] tenantResolver provider registered without a valid TenantResolverProvider",
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
const resolve = await usage.options.build(deps);
|
|
142
|
+
return { resolve, trust: usage.options.trust };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Optional tenantExistence — null when no provider registered. */
|
|
146
|
+
export async function resolveTenantExistence(
|
|
147
|
+
deps: AuthProviderBuildDeps & { readonly registry: Registry },
|
|
148
|
+
): Promise<TenantExistsFn | null> {
|
|
149
|
+
const usage = deps.registry.getExtensionUsages(EXT_TENANT_EXISTENCE)[0];
|
|
150
|
+
if (!usage) return null;
|
|
151
|
+
if (!isTenantExistenceProvider(usage.options)) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
"[auth-foundation] tenantExistence provider registered without a valid TenantExistenceProvider",
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
return usage.options.build(deps);
|
|
157
|
+
}
|
|
@@ -2,21 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
export {
|
|
4
4
|
authFoundationFeature,
|
|
5
|
+
type ResolvedTenantResolver,
|
|
5
6
|
resolveSessionStore,
|
|
7
|
+
resolveTenantExistence,
|
|
8
|
+
resolveTenantResolver,
|
|
6
9
|
resolveTokenVerifier,
|
|
7
10
|
} from "./feature";
|
|
11
|
+
export { resolveAnonymousAccessFromRegistry } from "./resolve-anonymous-access";
|
|
8
12
|
export {
|
|
9
13
|
type AuthProviderBuildDeps,
|
|
10
14
|
type AuthProviderPlugin,
|
|
11
15
|
type AuthVerifier,
|
|
12
16
|
EXT_SESSION_STORE,
|
|
17
|
+
EXT_TENANT_EXISTENCE,
|
|
18
|
+
EXT_TENANT_RESOLVER,
|
|
13
19
|
EXT_TOKEN_VERIFIER,
|
|
14
20
|
isAuthProviderPlugin,
|
|
15
21
|
isSessionStoreProvider,
|
|
22
|
+
isTenantExistenceProvider,
|
|
23
|
+
isTenantResolverProvider,
|
|
24
|
+
isTenantResolverTrust,
|
|
16
25
|
isValidTokenShape,
|
|
17
26
|
type SessionMassRevoker,
|
|
18
27
|
type SessionStore,
|
|
19
28
|
type SessionStoreProvider,
|
|
29
|
+
type TenantExistenceProvider,
|
|
30
|
+
type TenantExistsFn,
|
|
31
|
+
type TenantResolverFn,
|
|
32
|
+
type TenantResolverProvider,
|
|
33
|
+
type TenantResolverTrust,
|
|
20
34
|
type TokenShape,
|
|
21
35
|
tokenShapeKey,
|
|
22
36
|
tokenShapeMatches,
|