@cosmicdrift/kumiko-bundled-features 0.160.0 → 0.161.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__/session-callbacks.integration.test.ts +5 -0
- package/src/auth-email-password/handlers/login.write.ts +6 -3
- package/src/auth-email-password/i18n.ts +4 -0
- package/src/auth-email-password/web/__tests__/auth-client.test.ts +6 -2
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +12 -3
- package/src/auth-email-password/web/auth-client.ts +4 -3
- package/src/auth-email-password/web/login-screen.tsx +5 -2
- 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__/mfa-preauth-setup-token.test.ts +87 -0
- package/src/auth-mfa/constants.ts +1 -0
- package/src/auth-mfa/mfa-preauth-setup-token.ts +86 -0
- package/src/auth-mfa/mfa-status-checker.ts +13 -4
- package/src/sessions/feature.ts +8 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.161.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -117,11 +117,11 @@
|
|
|
117
117
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
118
118
|
},
|
|
119
119
|
"dependencies": {
|
|
120
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
121
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
122
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
123
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
124
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
120
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.161.0",
|
|
121
|
+
"@cosmicdrift/kumiko-framework": "0.161.0",
|
|
122
|
+
"@cosmicdrift/kumiko-headless": "0.161.0",
|
|
123
|
+
"@cosmicdrift/kumiko-renderer": "0.161.0",
|
|
124
|
+
"@cosmicdrift/kumiko-renderer-web": "0.161.0",
|
|
125
125
|
"@mollie/api-client": "^4.5.0",
|
|
126
126
|
"imapflow": "^1.3.3",
|
|
127
127
|
"mailparser": "^3.9.8",
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// #1455: enforcement policy blocks an unenrolled user at login
|
|
2
|
+
// (mfa-setup-required) — the response must carry a preauthSetupToken so a
|
|
3
|
+
// later pre-auth enroll step (#1231) can look the user back up without a
|
|
4
|
+
// session. Real HTTP through setupTestStack, both auth-mfa and
|
|
5
|
+
// auth-email-password mounted together (mirrors auth.integration.test.ts).
|
|
6
|
+
|
|
7
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
9
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
10
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
11
|
+
import {
|
|
12
|
+
createTestUser,
|
|
13
|
+
setupTestStack,
|
|
14
|
+
type TestStack,
|
|
15
|
+
testTenantId,
|
|
16
|
+
unsafeCreateEntityTable,
|
|
17
|
+
unsafePushTables,
|
|
18
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
19
|
+
import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
|
|
20
|
+
import {
|
|
21
|
+
createAuthMfaFeature,
|
|
22
|
+
mfaRequiredConfigHandle,
|
|
23
|
+
mfaStatusCheckerFromFeature,
|
|
24
|
+
} from "../../auth-mfa";
|
|
25
|
+
import { userMfaEntity } from "../../auth-mfa/schema/user-mfa";
|
|
26
|
+
import { ConfigHandlers, createConfigFeature } from "../../config";
|
|
27
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
28
|
+
import { configValuesTable } from "../../config/table";
|
|
29
|
+
import { hashPassword } from "../../shared";
|
|
30
|
+
import { createTenantFeature } from "../../tenant";
|
|
31
|
+
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
32
|
+
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
33
|
+
import { seedTenantMembership } from "../../tenant/testing";
|
|
34
|
+
import { UserHandlers } from "../../user";
|
|
35
|
+
import { createUserFeature } from "../../user/feature";
|
|
36
|
+
import { userEntity, userTable } from "../../user/schema/user";
|
|
37
|
+
import { AuthErrors, AuthHandlers } from "../constants";
|
|
38
|
+
import { createAuthEmailPasswordFeature } from "../feature";
|
|
39
|
+
|
|
40
|
+
let stack: TestStack;
|
|
41
|
+
|
|
42
|
+
const CHALLENGE_TOKEN_SECRET = "test-mfa-challenge-secret-at-least-32-bytes!!";
|
|
43
|
+
const TENANT_ID: TenantId = testTenantId(400);
|
|
44
|
+
|
|
45
|
+
beforeAll(async () => {
|
|
46
|
+
const encryption = createTestEnvelopeCipher(randomBytes(32).toString("base64"));
|
|
47
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
48
|
+
const authMfaFeature = createAuthMfaFeature({
|
|
49
|
+
setupTokenSecret: "test-mfa-setup-token-secret-at-least-32-bytes!!",
|
|
50
|
+
issuer: "Kumiko Test",
|
|
51
|
+
challengeTokenSecret: CHALLENGE_TOKEN_SECRET,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
stack = await setupTestStack({
|
|
55
|
+
features: [
|
|
56
|
+
createConfigFeature(),
|
|
57
|
+
createUserFeature(),
|
|
58
|
+
createTenantFeature(),
|
|
59
|
+
authMfaFeature,
|
|
60
|
+
createAuthEmailPasswordFeature({
|
|
61
|
+
mfaStatusChecker: mfaStatusCheckerFromFeature(authMfaFeature),
|
|
62
|
+
}),
|
|
63
|
+
],
|
|
64
|
+
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
65
|
+
authConfig: {
|
|
66
|
+
membershipQuery: "tenant:query:memberships",
|
|
67
|
+
loginHandler: AuthHandlers.login,
|
|
68
|
+
loginErrorStatusMap: {
|
|
69
|
+
[AuthErrors.invalidCredentials]: 401,
|
|
70
|
+
[AuthErrors.noMembership]: 403,
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
76
|
+
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
77
|
+
await unsafeCreateEntityTable(stack.db, userMfaEntity);
|
|
78
|
+
await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
afterAll(async () => {
|
|
82
|
+
await stack.cleanup();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
beforeEach(async () => {
|
|
86
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
|
|
87
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("login: mfa-setup-required carries a verifiable preauthSetupToken", () => {
|
|
91
|
+
test("unenrolled user blocked by 'all' policy gets a token instead of a session", async () => {
|
|
92
|
+
const password = "correct-horse-battery-2026";
|
|
93
|
+
const hash = await hashPassword(password);
|
|
94
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
95
|
+
UserHandlers.create,
|
|
96
|
+
{ email: "unenrolled@example.com", passwordHash: hash, displayName: "Unenrolled" },
|
|
97
|
+
createTestUser({ id: 401, tenantId: TENANT_ID, roles: ["SystemAdmin"] }),
|
|
98
|
+
);
|
|
99
|
+
await seedTenantMembership(stack.db, {
|
|
100
|
+
userId: created.id,
|
|
101
|
+
tenantId: TENANT_ID,
|
|
102
|
+
roles: ["User"],
|
|
103
|
+
});
|
|
104
|
+
await stack.http.writeOk(
|
|
105
|
+
ConfigHandlers.set,
|
|
106
|
+
{ key: mfaRequiredConfigHandle.name, value: "all" },
|
|
107
|
+
createTestUser({ id: 402, tenantId: TENANT_ID, roles: ["Admin"] }),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const res = await stack.http.raw("POST", "/api/auth/login", {
|
|
111
|
+
email: "unenrolled@example.com",
|
|
112
|
+
password,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
expect(res.status).toBe(200);
|
|
116
|
+
const body = await res.json();
|
|
117
|
+
expect(body.isSuccess).toBe(true);
|
|
118
|
+
expect(body.mfaSetupRequired).toBe(true);
|
|
119
|
+
expect(body.token).toBeUndefined();
|
|
120
|
+
expect(typeof body.preauthSetupToken).toBe("string");
|
|
121
|
+
|
|
122
|
+
const { verifyMfaPreauthSetupToken } = await import("../../auth-mfa/mfa-preauth-setup-token");
|
|
123
|
+
const verified = verifyMfaPreauthSetupToken(body.preauthSetupToken, CHALLENGE_TOKEN_SECRET);
|
|
124
|
+
expect(verified.ok).toBe(true);
|
|
125
|
+
if (verified.ok) {
|
|
126
|
+
expect(verified.payload).toEqual({ userId: created.id, tenantId: TENANT_ID });
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -158,6 +158,11 @@ async function seedUser(opts: {
|
|
|
158
158
|
roles: t.roles,
|
|
159
159
|
});
|
|
160
160
|
}
|
|
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;
|
|
161
166
|
return { userId: created.id };
|
|
162
167
|
}
|
|
163
168
|
|
|
@@ -58,7 +58,7 @@ export type LoginHandlerOptions = {
|
|
|
58
58
|
) => Promise<
|
|
59
59
|
| { readonly required: false }
|
|
60
60
|
| { readonly required: true; readonly challengeToken: string }
|
|
61
|
-
| { readonly setupRequired: true }
|
|
61
|
+
| { readonly setupRequired: true; readonly preauthSetupToken: string }
|
|
62
62
|
>;
|
|
63
63
|
};
|
|
64
64
|
|
|
@@ -70,7 +70,7 @@ const SYSTEM_USER_ID = "00000000-0000-4000-8000-000000000000";
|
|
|
70
70
|
type LoginResult =
|
|
71
71
|
| { readonly kind: "auth-session"; readonly session: SessionUser }
|
|
72
72
|
| { readonly kind: "mfa-challenge"; readonly challengeToken: string }
|
|
73
|
-
| { readonly kind: "mfa-setup-required" };
|
|
73
|
+
| { readonly kind: "mfa-setup-required"; readonly preauthSetupToken: string };
|
|
74
74
|
|
|
75
75
|
// Login — unauthenticated entry point. The route is wired public (no JWT
|
|
76
76
|
// middleware), synthesising a guest SessionUser for the handler's access
|
|
@@ -226,7 +226,10 @@ export function createLoginHandler(opts: LoginHandlerOptions = {}) {
|
|
|
226
226
|
};
|
|
227
227
|
}
|
|
228
228
|
if ("setupRequired" in mfaStatus) {
|
|
229
|
-
return {
|
|
229
|
+
return {
|
|
230
|
+
isSuccess: true,
|
|
231
|
+
data: { kind: "mfa-setup-required", preauthSetupToken: mfaStatus.preauthSetupToken },
|
|
232
|
+
};
|
|
230
233
|
}
|
|
231
234
|
}
|
|
232
235
|
|
|
@@ -35,6 +35,8 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
35
35
|
"auth.errors.loginFailed": "Login fehlgeschlagen.",
|
|
36
36
|
"auth.errors.mfaNotSupported":
|
|
37
37
|
"Diese App unterstützt keine Zwei-Faktor-Bestätigung. Bitte an den Support wenden.",
|
|
38
|
+
"auth.errors.mfaSetupRequired":
|
|
39
|
+
"Zwei-Faktor-Authentifizierung erforderlich. Bitte an den Administrator wenden.",
|
|
38
40
|
"auth.errors.invalidResetToken":
|
|
39
41
|
"Der Link ist ungültig oder abgelaufen. Bitte fordere einen neuen an.",
|
|
40
42
|
"auth.errors.invalidVerificationToken": "Der Bestätigungs-Link ist ungültig oder abgelaufen.",
|
|
@@ -161,6 +163,8 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
161
163
|
"auth.errors.loginFailed": "Login failed.",
|
|
162
164
|
"auth.errors.mfaNotSupported":
|
|
163
165
|
"This app doesn't support two-factor verification. Please contact support.",
|
|
166
|
+
"auth.errors.mfaSetupRequired":
|
|
167
|
+
"Two-factor authentication required. Please contact your administrator.",
|
|
164
168
|
"auth.errors.invalidResetToken": "Link is invalid or expired. Please request a new one.",
|
|
165
169
|
"auth.errors.invalidVerificationToken": "Verification link is invalid or expired.",
|
|
166
170
|
"auth.errors.invalidUnlockToken":
|
|
@@ -95,12 +95,16 @@ describe("login", () => {
|
|
|
95
95
|
|
|
96
96
|
test("MFA enforcement blocks unenrolled user → kind:mfa-setup-required", async () => {
|
|
97
97
|
globalThis.fetch = mock(async () =>
|
|
98
|
-
jsonResponse({
|
|
98
|
+
jsonResponse({
|
|
99
|
+
isSuccess: true,
|
|
100
|
+
mfaSetupRequired: true,
|
|
101
|
+
preauthSetupToken: "setup-token-value",
|
|
102
|
+
}),
|
|
99
103
|
) as unknown as typeof fetch;
|
|
100
104
|
|
|
101
105
|
const res = await login({ email: "a@b.c", password: "secret" });
|
|
102
106
|
|
|
103
|
-
expect(res).toEqual({ kind: "mfa-setup-required" });
|
|
107
|
+
expect(res).toEqual({ kind: "mfa-setup-required", preauthSetupToken: "setup-token-value" });
|
|
104
108
|
});
|
|
105
109
|
|
|
106
110
|
test("string error → kind:failure with reason from string", async () => {
|
|
@@ -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({
|
|
@@ -339,7 +340,10 @@ describe("LoginScreen", () => {
|
|
|
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} />,
|
|
@@ -356,14 +360,19 @@ describe("LoginScreen", () => {
|
|
|
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 } };
|
|
@@ -65,7 +65,8 @@ export type LoginScreenProps = {
|
|
|
65
65
|
readonly onMfaChallenge?: (challengeToken: string) => void;
|
|
66
66
|
/** Called when the tenant's enforcement policy requires MFA but this
|
|
67
67
|
* user has no factor enrolled yet. Apps typically route to an
|
|
68
|
-
* enrollment flow.
|
|
68
|
+
* enrollment flow. Without a handler, the user sees a "setup required,
|
|
69
|
+
* contact your administrator" error. */
|
|
69
70
|
readonly onMfaSetupRequired?: () => void;
|
|
70
71
|
};
|
|
71
72
|
|
|
@@ -97,6 +98,8 @@ function reasonToKey(failure: LoginFailure): {
|
|
|
97
98
|
return { key: "auth.errors.invalidBody" };
|
|
98
99
|
case "mfa_not_supported":
|
|
99
100
|
return { key: "auth.errors.mfaNotSupported" };
|
|
101
|
+
case "mfa_setup_required":
|
|
102
|
+
return { key: "auth.errors.mfaSetupRequired" };
|
|
100
103
|
default:
|
|
101
104
|
return { key: "auth.errors.loginFailed" };
|
|
102
105
|
}
|
|
@@ -147,7 +150,7 @@ export function LoginScreen({
|
|
|
147
150
|
onMfaSetupRequired();
|
|
148
151
|
return;
|
|
149
152
|
}
|
|
150
|
-
setError({ reason: "
|
|
153
|
+
setError({ reason: "mfa_setup_required" });
|
|
151
154
|
setFailedLoginEmail(email);
|
|
152
155
|
return;
|
|
153
156
|
}
|
|
@@ -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,
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merge app-facing AnonymousAccessConfig with auth-foundation tenant providers.
|
|
3
|
+
* Providers win over any leftover callback fields (hard cutover #1374).
|
|
4
|
+
* Test stacks may still pass Resolved callbacks when no provider is mounted.
|
|
5
|
+
*/
|
|
6
|
+
import type {
|
|
7
|
+
AnonymousAccessConfig,
|
|
8
|
+
AnonymousAccessResolved,
|
|
9
|
+
TenantExists,
|
|
10
|
+
TenantResolver,
|
|
11
|
+
} from "@cosmicdrift/kumiko-framework/api";
|
|
12
|
+
import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
13
|
+
import { resolveTenantExistence, resolveTenantResolver } from "./feature";
|
|
14
|
+
import type { AuthProviderBuildDeps } from "./types";
|
|
15
|
+
|
|
16
|
+
export async function resolveAnonymousAccessFromRegistry(
|
|
17
|
+
base: AnonymousAccessResolved | AnonymousAccessConfig | undefined,
|
|
18
|
+
deps: AuthProviderBuildDeps & { readonly registry: Registry },
|
|
19
|
+
): Promise<AnonymousAccessResolved | undefined> {
|
|
20
|
+
const [tenantResolver, tenantExists] = await Promise.all([
|
|
21
|
+
resolveTenantResolver(deps),
|
|
22
|
+
resolveTenantExistence(deps),
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
if (!base && !tenantResolver && !tenantExists) return undefined;
|
|
26
|
+
|
|
27
|
+
// Fail-closed (#1374 security): inline tenantResolver without trust is the
|
|
28
|
+
// old ambiguous config — providers always carry trust; test stacks must too.
|
|
29
|
+
if (
|
|
30
|
+
!tenantResolver &&
|
|
31
|
+
base &&
|
|
32
|
+
"tenantResolver" in base &&
|
|
33
|
+
base.tenantResolver !== undefined &&
|
|
34
|
+
base.resolverTrust === undefined
|
|
35
|
+
) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
"[auth-foundation] anonymousAccess.tenantResolver is set without resolverTrust — " +
|
|
38
|
+
'declare "authoritative" or "fallback-only" (or mount an EXT_TENANT_RESOLVER provider).',
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Providers win (#1374). When none are mounted, keep any test-injected
|
|
43
|
+
// Resolved callbacks so framework middleware suites stay self-contained.
|
|
44
|
+
return {
|
|
45
|
+
...(base ?? {}),
|
|
46
|
+
...(tenantResolver
|
|
47
|
+
? {
|
|
48
|
+
tenantResolver: tenantResolver.resolve as TenantResolver,
|
|
49
|
+
resolverTrust: tenantResolver.trust,
|
|
50
|
+
}
|
|
51
|
+
: {}),
|
|
52
|
+
...(tenantExists ? { tenantExists: tenantExists as TenantExists } : {}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -86,11 +86,18 @@ export const EXT_SESSION_STORE = "sessionStore";
|
|
|
86
86
|
/** Mass-revoke every live session for a user. Used by password-change and "sign out everywhere". */
|
|
87
87
|
export type SessionMassRevoker = (userId: string) => Promise<number>;
|
|
88
88
|
|
|
89
|
+
/** Keep current sid (if any); revoke every other live session. Used by auth-mfa. */
|
|
90
|
+
export type SessionRevokeAllOthers = (
|
|
91
|
+
userId: string,
|
|
92
|
+
currentSid: string | undefined,
|
|
93
|
+
) => Promise<number>;
|
|
94
|
+
|
|
89
95
|
export type SessionStore = {
|
|
90
96
|
readonly creator: SessionCreator;
|
|
91
97
|
readonly revoker: SessionRevoker;
|
|
92
98
|
readonly checker: SessionChecker;
|
|
93
99
|
readonly massRevoker: SessionMassRevoker;
|
|
100
|
+
readonly revokeAllOthers: SessionRevokeAllOthers;
|
|
94
101
|
};
|
|
95
102
|
|
|
96
103
|
export type SessionStoreProvider = {
|
|
@@ -105,3 +112,59 @@ export function isSessionStoreProvider(o: unknown): o is SessionStoreProvider {
|
|
|
105
112
|
typeof (o as { build: unknown }).build === "function"
|
|
106
113
|
);
|
|
107
114
|
}
|
|
115
|
+
|
|
116
|
+
// Tenant-resolution extension points (#1373). Both are single-provider and
|
|
117
|
+
// optional — zero registered means today's single-tenant / header-cookie
|
|
118
|
+
// path (no subdomain resolver, no existence check). ≥2 fails boot.
|
|
119
|
+
|
|
120
|
+
export const EXT_TENANT_RESOLVER = "tenantResolver";
|
|
121
|
+
export const EXT_TENANT_EXISTENCE = "tenantExistence";
|
|
122
|
+
|
|
123
|
+
/** Trust mode for a registered tenantResolver — mirrors AnonymousAccessConfig.resolverTrust. */
|
|
124
|
+
export type TenantResolverTrust = "authoritative" | "fallback-only";
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Resolves the tenant for an unauthenticated request. Return null when no
|
|
128
|
+
* tenant can be determined — middleware rejects with 400. Throw only on
|
|
129
|
+
* infrastructure failures.
|
|
130
|
+
*/
|
|
131
|
+
export type TenantResolverFn = (
|
|
132
|
+
// Hono Context — typed loosely so auth-foundation stays free of a hono dep.
|
|
133
|
+
// Callers cast at the boundary (same shape as framework TenantResolver).
|
|
134
|
+
c: unknown,
|
|
135
|
+
) => Promise<string | null> | string | null;
|
|
136
|
+
|
|
137
|
+
export type TenantExistsFn = (tenantId: string) => Promise<boolean> | boolean;
|
|
138
|
+
|
|
139
|
+
export type TenantResolverProvider = {
|
|
140
|
+
readonly trust: TenantResolverTrust;
|
|
141
|
+
readonly build: (deps: AuthProviderBuildDeps) => Promise<TenantResolverFn> | TenantResolverFn;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export type TenantExistenceProvider = {
|
|
145
|
+
readonly build: (deps: AuthProviderBuildDeps) => Promise<TenantExistsFn> | TenantExistsFn;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export function isTenantResolverTrust(v: unknown): v is TenantResolverTrust {
|
|
149
|
+
return v === "authoritative" || v === "fallback-only";
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function isTenantResolverProvider(o: unknown): o is TenantResolverProvider {
|
|
153
|
+
return (
|
|
154
|
+
typeof o === "object" &&
|
|
155
|
+
o !== null &&
|
|
156
|
+
"build" in o &&
|
|
157
|
+
typeof (o as { build: unknown }).build === "function" &&
|
|
158
|
+
"trust" in o &&
|
|
159
|
+
isTenantResolverTrust((o as { trust: unknown }).trust)
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function isTenantExistenceProvider(o: unknown): o is TenantExistenceProvider {
|
|
164
|
+
return (
|
|
165
|
+
typeof o === "object" &&
|
|
166
|
+
o !== null &&
|
|
167
|
+
"build" in o &&
|
|
168
|
+
typeof (o as { build: unknown }).build === "function"
|
|
169
|
+
);
|
|
170
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { Temporal } from "temporal-polyfill";
|
|
3
|
+
import { signMfaPreauthSetupToken, verifyMfaPreauthSetupToken } from "../mfa-preauth-setup-token";
|
|
4
|
+
|
|
5
|
+
const SECRET = "test-mfa-preauth-setup-secret-at-least-32-bytes!!";
|
|
6
|
+
const USER_ID = "11111111-1111-4111-8111-111111111111";
|
|
7
|
+
const TENANT_ID = "22222222-2222-4222-8222-222222222222";
|
|
8
|
+
|
|
9
|
+
describe("mfa-preauth-setup-token", () => {
|
|
10
|
+
test("round-trip: sign → verify → payload", () => {
|
|
11
|
+
const { token } = signMfaPreauthSetupToken(
|
|
12
|
+
{ userId: USER_ID, tenantId: TENANT_ID },
|
|
13
|
+
10,
|
|
14
|
+
SECRET,
|
|
15
|
+
);
|
|
16
|
+
const result = verifyMfaPreauthSetupToken(token, SECRET);
|
|
17
|
+
expect(result.ok).toBe(true);
|
|
18
|
+
if (result.ok) {
|
|
19
|
+
expect(result.payload).toEqual({ userId: USER_ID, tenantId: TENANT_ID });
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("cross-domain replay from a challenge-shaped signature is rejected", () => {
|
|
24
|
+
const { token } = signMfaPreauthSetupToken(
|
|
25
|
+
{ userId: USER_ID, tenantId: TENANT_ID },
|
|
26
|
+
10,
|
|
27
|
+
SECRET,
|
|
28
|
+
);
|
|
29
|
+
const [bodyB64] = token.split(".");
|
|
30
|
+
const forged = `${bodyB64}.${Buffer.from("not-the-real-signature-bytes").toString("base64url")}`;
|
|
31
|
+
expect(verifyMfaPreauthSetupToken(forged, SECRET)).toEqual({
|
|
32
|
+
ok: false,
|
|
33
|
+
reason: "bad_signature",
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("tampered signature → bad_signature", () => {
|
|
38
|
+
const { token } = signMfaPreauthSetupToken(
|
|
39
|
+
{ userId: USER_ID, tenantId: TENANT_ID },
|
|
40
|
+
10,
|
|
41
|
+
SECRET,
|
|
42
|
+
);
|
|
43
|
+
const tampered = `${token.slice(0, -3)}XXX`;
|
|
44
|
+
expect(verifyMfaPreauthSetupToken(tampered, SECRET)).toEqual({
|
|
45
|
+
ok: false,
|
|
46
|
+
reason: "bad_signature",
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("different secret → bad_signature", () => {
|
|
51
|
+
const { token } = signMfaPreauthSetupToken(
|
|
52
|
+
{ userId: USER_ID, tenantId: TENANT_ID },
|
|
53
|
+
10,
|
|
54
|
+
SECRET,
|
|
55
|
+
);
|
|
56
|
+
expect(verifyMfaPreauthSetupToken(token, "other-secret-not-the-same-one!!!!")).toEqual({
|
|
57
|
+
ok: false,
|
|
58
|
+
reason: "bad_signature",
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("expired → expired", () => {
|
|
63
|
+
const t0 = Temporal.Instant.fromEpochMilliseconds(1_700_000_000_000);
|
|
64
|
+
const laterThanTtl = t0.add({ minutes: 11 });
|
|
65
|
+
const { token } = signMfaPreauthSetupToken(
|
|
66
|
+
{ userId: USER_ID, tenantId: TENANT_ID },
|
|
67
|
+
10,
|
|
68
|
+
SECRET,
|
|
69
|
+
t0,
|
|
70
|
+
);
|
|
71
|
+
expect(verifyMfaPreauthSetupToken(token, SECRET, laterThanTtl)).toEqual({
|
|
72
|
+
ok: false,
|
|
73
|
+
reason: "expired",
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("malformed: wrong part count", () => {
|
|
78
|
+
expect(verifyMfaPreauthSetupToken("not-a-token", SECRET)).toEqual({
|
|
79
|
+
ok: false,
|
|
80
|
+
reason: "malformed",
|
|
81
|
+
});
|
|
82
|
+
expect(verifyMfaPreauthSetupToken("a.b.c", SECRET)).toEqual({
|
|
83
|
+
ok: false,
|
|
84
|
+
reason: "malformed",
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -34,6 +34,7 @@ export const AuthMfaErrorCodes = {
|
|
|
34
34
|
|
|
35
35
|
export const MFA_SETUP_TOKEN_TTL_MINUTES = 10;
|
|
36
36
|
export const MFA_CHALLENGE_TOKEN_TTL_MINUTES = 10;
|
|
37
|
+
export const MFA_PREAUTH_SETUP_TOKEN_TTL_MINUTES = 10;
|
|
37
38
|
export const MFA_VERIFY_MAX_ATTEMPTS = 5;
|
|
38
39
|
export const MFA_VERIFY_LOCKOUT_MINUTES = 5;
|
|
39
40
|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// HMAC-signed token minted by login.write.ts when enforcement policy blocks
|
|
2
|
+
// an unenrolled user (mfa-setup-required) — proves the password check
|
|
3
|
+
// already succeeded, so a later pre-auth enable-start variant (#1231) can
|
|
4
|
+
// let the user enroll without a full session. No secret material (unlike
|
|
5
|
+
// mfa-setup-token.ts) — just enough to look the user back up.
|
|
6
|
+
//
|
|
7
|
+
// Same domain-separated HMAC shape as mfa-challenge-token.ts, distinct
|
|
8
|
+
// signing string so this can never be replayed as a login challenge or
|
|
9
|
+
// vice versa. Reuses challengeTokenSecret rather than adding a third
|
|
10
|
+
// feature secret: both tokens assert the identical trust boundary
|
|
11
|
+
// ("password already verified, short-lived follow-up allowed").
|
|
12
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
13
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
|
+
import { Temporal } from "temporal-polyfill";
|
|
15
|
+
|
|
16
|
+
export type MfaPreauthSetupPayload = {
|
|
17
|
+
readonly userId: string;
|
|
18
|
+
readonly tenantId: TenantId;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type EncodedBody = MfaPreauthSetupPayload & { readonly expiresAtMs: number };
|
|
22
|
+
|
|
23
|
+
function sign(input: string, secret: string): string {
|
|
24
|
+
return createHmac("sha256", secret).update(input).digest("base64url");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function signMfaPreauthSetupToken(
|
|
28
|
+
payload: MfaPreauthSetupPayload,
|
|
29
|
+
ttlMinutes: number,
|
|
30
|
+
secret: string,
|
|
31
|
+
now: Temporal.Instant = Temporal.Now.instant(),
|
|
32
|
+
): { token: string; expiresAt: Temporal.Instant } {
|
|
33
|
+
const expiresAt = now.add({ minutes: ttlMinutes });
|
|
34
|
+
const body: EncodedBody = { ...payload, expiresAtMs: expiresAt.epochMilliseconds };
|
|
35
|
+
const bodyB64 = Buffer.from(JSON.stringify(body)).toString("base64url");
|
|
36
|
+
const signature = sign(`mfa-preauth-setup:${bodyB64}`, secret);
|
|
37
|
+
return { token: `${bodyB64}.${signature}`, expiresAt };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type VerifyMfaPreauthSetupResult =
|
|
41
|
+
| { readonly ok: true; readonly payload: MfaPreauthSetupPayload; readonly expiresAtMs: number }
|
|
42
|
+
| { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
|
|
43
|
+
|
|
44
|
+
function isEncodedBody(value: unknown): value is EncodedBody {
|
|
45
|
+
if (typeof value !== "object" || value === null) return false;
|
|
46
|
+
const v = value as Record<string, unknown>;
|
|
47
|
+
return (
|
|
48
|
+
typeof v["userId"] === "string" &&
|
|
49
|
+
typeof v["tenantId"] === "string" &&
|
|
50
|
+
typeof v["expiresAtMs"] === "number"
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function verifyMfaPreauthSetupToken(
|
|
55
|
+
token: string,
|
|
56
|
+
secret: string,
|
|
57
|
+
now: Temporal.Instant = Temporal.Now.instant(),
|
|
58
|
+
): VerifyMfaPreauthSetupResult {
|
|
59
|
+
const parts = token.split(".");
|
|
60
|
+
if (parts.length !== 2) return { ok: false, reason: "malformed" };
|
|
61
|
+
const [bodyB64, providedSig] = parts;
|
|
62
|
+
if (!bodyB64 || !providedSig) return { ok: false, reason: "malformed" };
|
|
63
|
+
|
|
64
|
+
const expected = sign(`mfa-preauth-setup:${bodyB64}`, secret);
|
|
65
|
+
const expectedBuf = Buffer.from(expected, "base64url");
|
|
66
|
+
const providedBuf = Buffer.from(providedSig, "base64url");
|
|
67
|
+
if (expectedBuf.length !== providedBuf.length) return { ok: false, reason: "bad_signature" };
|
|
68
|
+
if (!timingSafeEqual(expectedBuf, providedBuf)) return { ok: false, reason: "bad_signature" };
|
|
69
|
+
|
|
70
|
+
let parsed: unknown;
|
|
71
|
+
try {
|
|
72
|
+
parsed = JSON.parse(Buffer.from(bodyB64, "base64url").toString("utf8"));
|
|
73
|
+
} catch {
|
|
74
|
+
return { ok: false, reason: "malformed" };
|
|
75
|
+
}
|
|
76
|
+
if (!isEncodedBody(parsed)) return { ok: false, reason: "malformed" };
|
|
77
|
+
|
|
78
|
+
if (
|
|
79
|
+
Temporal.Instant.compare(now, Temporal.Instant.fromEpochMilliseconds(parsed.expiresAtMs)) > 0
|
|
80
|
+
) {
|
|
81
|
+
return { ok: false, reason: "expired" };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const { expiresAtMs, ...payload } = parsed;
|
|
85
|
+
return { ok: true, payload, expiresAtMs };
|
|
86
|
+
}
|
|
@@ -6,9 +6,10 @@ import {
|
|
|
6
6
|
type TenantId,
|
|
7
7
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
8
|
import { type MfaRequiredPolicy, mfaRequiredConfigHandle, mfaRequiredConfigKey } from "./config";
|
|
9
|
-
import { MFA_CHALLENGE_TOKEN_TTL_MINUTES } from "./constants";
|
|
9
|
+
import { MFA_CHALLENGE_TOKEN_TTL_MINUTES, MFA_PREAUTH_SETUP_TOKEN_TTL_MINUTES } from "./constants";
|
|
10
10
|
import { findUserMfaRow } from "./db/queries";
|
|
11
11
|
import { signMfaChallengeToken } from "./mfa-challenge-token";
|
|
12
|
+
import { signMfaPreauthSetupToken } from "./mfa-preauth-setup-token";
|
|
12
13
|
|
|
13
14
|
export type MfaStatusCheckResult =
|
|
14
15
|
| { readonly required: false }
|
|
@@ -17,7 +18,7 @@ export type MfaStatusCheckResult =
|
|
|
17
18
|
// yet. Distinct from `required: true` (which means "enrolled, prove it
|
|
18
19
|
// now") — see config.ts's ponytail note on why this hard-blocks login
|
|
19
20
|
// with no in-band recovery until PR3's enrollment-during-login UI ships.
|
|
20
|
-
| { readonly setupRequired: true };
|
|
21
|
+
| { readonly setupRequired: true; readonly preauthSetupToken: string };
|
|
21
22
|
|
|
22
23
|
// Consumed by auth-email-password's login.write.ts via `mfaStatusChecker`
|
|
23
24
|
// (a generic callback type declared THERE, not here — auth-email-password
|
|
@@ -67,9 +68,17 @@ export function createMfaStatusChecker(opts: {
|
|
|
67
68
|
scopedDb,
|
|
68
69
|
)) ?? "optional") as MfaRequiredPolicy | "optional";
|
|
69
70
|
if (policy === "optional") return { required: false };
|
|
70
|
-
|
|
71
|
+
const buildSetupToken = (): string =>
|
|
72
|
+
signMfaPreauthSetupToken(
|
|
73
|
+
{ userId, tenantId },
|
|
74
|
+
MFA_PREAUTH_SETUP_TOKEN_TTL_MINUTES,
|
|
75
|
+
opts.challengeTokenSecret,
|
|
76
|
+
).token;
|
|
77
|
+
if (policy === "all") return { setupRequired: true, preauthSetupToken: buildSetupToken() };
|
|
71
78
|
// policy === "admins"
|
|
72
79
|
const isAdmin = roles.some((role) => access.admin.includes(role));
|
|
73
|
-
return isAdmin
|
|
80
|
+
return isAdmin
|
|
81
|
+
? { setupRequired: true, preauthSetupToken: buildSetupToken() }
|
|
82
|
+
: { required: false };
|
|
74
83
|
};
|
|
75
84
|
}
|
package/src/sessions/feature.ts
CHANGED
|
@@ -42,6 +42,9 @@ export type SessionsFeatureOptions = {
|
|
|
42
42
|
// only to supply a custom revoker — an explicit value wins over the
|
|
43
43
|
// runtime binding.
|
|
44
44
|
readonly autoRevokeOnPasswordChange?: SessionMassRevoker;
|
|
45
|
+
// Session JWT / store_user_sessions TTL. Was previously auth.sessions.expiresInMs
|
|
46
|
+
// (#1372) — lives on the sessions feature now that sessionStore is the wiring.
|
|
47
|
+
readonly expiresInMs?: number;
|
|
45
48
|
};
|
|
46
49
|
|
|
47
50
|
export type BindAutoRevokeOnPasswordChange = (revoker: SessionMassRevoker) => void;
|
|
@@ -120,12 +123,16 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
|
|
|
120
123
|
// via resolveSessionStore().
|
|
121
124
|
r.useExtension(EXT_SESSION_STORE, "default", {
|
|
122
125
|
build: (deps): SessionStore => {
|
|
123
|
-
const callbacks = createSessionCallbacks({
|
|
126
|
+
const callbacks = createSessionCallbacks({
|
|
127
|
+
db: deps.db,
|
|
128
|
+
...(options?.expiresInMs !== undefined && { expiresInMs: options.expiresInMs }),
|
|
129
|
+
});
|
|
124
130
|
return {
|
|
125
131
|
creator: callbacks.sessionCreator,
|
|
126
132
|
revoker: callbacks.sessionRevoker,
|
|
127
133
|
checker: callbacks.sessionChecker,
|
|
128
134
|
massRevoker: callbacks.sessionMassRevoker,
|
|
135
|
+
revokeAllOthers: callbacks.sessionRevokeAllOthers,
|
|
129
136
|
};
|
|
130
137
|
},
|
|
131
138
|
} satisfies SessionStoreProvider);
|