@cosmicdrift/kumiko-dev-server 0.147.1 → 0.147.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-dev-server",
|
|
3
|
-
"version": "0.147.
|
|
3
|
+
"version": "0.147.2",
|
|
4
4
|
"description": "Development server bootstrap for Kumiko apps. Bundles the client, mints dev-JWTs, injects the resolved AppSchema, and seeds an admin. Not for production.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -58,8 +58,8 @@
|
|
|
58
58
|
"kumiko-schema-check": "./bin/kumiko-schema-check.ts"
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
|
-
"@cosmicdrift/kumiko-bundled-features": "0.147.
|
|
62
|
-
"@cosmicdrift/kumiko-framework": "0.147.
|
|
61
|
+
"@cosmicdrift/kumiko-bundled-features": "0.147.2",
|
|
62
|
+
"@cosmicdrift/kumiko-framework": "0.147.2",
|
|
63
63
|
"ts-morph": "^28.0.0"
|
|
64
64
|
},
|
|
65
65
|
"publishConfig": {
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// Full-stack wiring test for kumiko-framework#266 Step 6: proves the exact
|
|
2
|
+
// bootstrap path runProdApp/runDevApp produce — composeFeatures() auto-
|
|
3
|
+
// detecting a mounted auth-mfa app-feature and wiring its status-checker
|
|
4
|
+
// into the login handler — actually completes a two-step login over real
|
|
5
|
+
// HTTP. compose-features-wiring.integration.test.ts pins the same pattern
|
|
6
|
+
// for passwordReset/emailVerification; this is the auth-mfa analogue.
|
|
7
|
+
//
|
|
8
|
+
// No mocking: setupTestStack boots a real DB + Redis.
|
|
9
|
+
|
|
10
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
11
|
+
import {
|
|
12
|
+
AuthHandlers,
|
|
13
|
+
hashPassword,
|
|
14
|
+
} from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
|
|
15
|
+
import {
|
|
16
|
+
AuthMfaHandlers,
|
|
17
|
+
base32Decode,
|
|
18
|
+
bindMfaRevokeAllOtherSessionsFromFeature,
|
|
19
|
+
createAuthMfaFeature,
|
|
20
|
+
currentTotpCode,
|
|
21
|
+
mfaRequiredConfigHandle,
|
|
22
|
+
userMfaEntity,
|
|
23
|
+
} from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
|
|
24
|
+
import {
|
|
25
|
+
configValuesTable,
|
|
26
|
+
createConfigResolver,
|
|
27
|
+
} from "@cosmicdrift/kumiko-bundled-features/config";
|
|
28
|
+
import {
|
|
29
|
+
createSessionCallbacks,
|
|
30
|
+
userSessionEntity,
|
|
31
|
+
} from "@cosmicdrift/kumiko-bundled-features/sessions";
|
|
32
|
+
import { tenantEntity, tenantMembershipsTable } from "@cosmicdrift/kumiko-bundled-features/tenant";
|
|
33
|
+
import { seedTenantMembership } from "@cosmicdrift/kumiko-bundled-features/tenant/testing";
|
|
34
|
+
import { UserHandlers, userEntity, userTable } from "@cosmicdrift/kumiko-bundled-features/user";
|
|
35
|
+
import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
|
|
36
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
37
|
+
import {
|
|
38
|
+
setupTestStack,
|
|
39
|
+
type TestStack,
|
|
40
|
+
TestUsers,
|
|
41
|
+
unsafeCreateEntityTable,
|
|
42
|
+
unsafePushTables,
|
|
43
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
44
|
+
import { createTestEnvelopeCipher, deleteRows } from "@cosmicdrift/kumiko-framework/testing";
|
|
45
|
+
import { composeFeatures } from "../compose-features";
|
|
46
|
+
|
|
47
|
+
const CHALLENGE_TOKEN_SECRET = "test-mfa-challenge-secret-at-least-32-bytes!!";
|
|
48
|
+
const SETUP_TOKEN_SECRET = "test-mfa-setup-secret-at-least-32-bytes-long!!";
|
|
49
|
+
const TEST_TENANT_ID: TenantId = "00000000-0000-4000-8000-000000000001" as TenantId;
|
|
50
|
+
const systemAdmin = TestUsers.systemAdmin;
|
|
51
|
+
|
|
52
|
+
async function bootStack(): Promise<TestStack> {
|
|
53
|
+
configureEntityFieldEncryption(createTestEnvelopeCipher());
|
|
54
|
+
const mfaFeature = createAuthMfaFeature({
|
|
55
|
+
setupTokenSecret: SETUP_TOKEN_SECRET,
|
|
56
|
+
challengeTokenSecret: CHALLENGE_TOKEN_SECRET,
|
|
57
|
+
issuer: "Kumiko Test",
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Exactly what runProdApp/runDevApp do: composeFeatures sees auth-mfa in
|
|
61
|
+
// the app-feature list and threads mfaStatusChecker into the bundled
|
|
62
|
+
// auth-email-password feature's login handler on its own — no authOptions
|
|
63
|
+
// override needed here, which is the thing this test proves.
|
|
64
|
+
const features = composeFeatures([mfaFeature], { includeBundled: true });
|
|
65
|
+
|
|
66
|
+
const stack = await setupTestStack({
|
|
67
|
+
features,
|
|
68
|
+
extraContext: () => ({ configResolver: createConfigResolver() }),
|
|
69
|
+
authConfig: {
|
|
70
|
+
membershipQuery: "tenant:query:memberships",
|
|
71
|
+
loginHandler: AuthHandlers.login,
|
|
72
|
+
mfaVerifyHandler: AuthMfaHandlers.verify,
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const sessionCallbacks = createSessionCallbacks({ db: stack.db });
|
|
77
|
+
bindMfaRevokeAllOtherSessionsFromFeature(mfaFeature)?.(sessionCallbacks.sessionRevokeAllOthers);
|
|
78
|
+
|
|
79
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
80
|
+
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
81
|
+
await unsafeCreateEntityTable(stack.db, userMfaEntity);
|
|
82
|
+
await unsafeCreateEntityTable(stack.db, userSessionEntity);
|
|
83
|
+
await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
84
|
+
|
|
85
|
+
return stack;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function seedUser(
|
|
89
|
+
stack: TestStack,
|
|
90
|
+
opts: { email: string; password: string; roles?: readonly string[] },
|
|
91
|
+
): Promise<{ id: string }> {
|
|
92
|
+
const hash = await hashPassword(opts.password);
|
|
93
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
94
|
+
UserHandlers.create,
|
|
95
|
+
{ email: opts.email, passwordHash: hash, displayName: opts.email.split("@")[0] ?? "user" },
|
|
96
|
+
systemAdmin,
|
|
97
|
+
);
|
|
98
|
+
await seedTenantMembership(stack.db, {
|
|
99
|
+
userId: created.id,
|
|
100
|
+
tenantId: TEST_TENANT_ID,
|
|
101
|
+
roles: opts.roles ?? ["User"],
|
|
102
|
+
});
|
|
103
|
+
return { id: created.id };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
describe("composeFeatures wiring — auth-mfa (kumiko-framework#266 Step 6)", () => {
|
|
107
|
+
let stack: TestStack;
|
|
108
|
+
|
|
109
|
+
beforeAll(async () => {
|
|
110
|
+
stack = await bootStack();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
afterAll(async () => {
|
|
114
|
+
await stack.cleanup();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
beforeEach(async () => {
|
|
118
|
+
await deleteRows(stack.db, userTable, {});
|
|
119
|
+
await deleteRows(stack.db, tenantMembershipsTable, {});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("full login-with-MFA flow: password login → mfa-challenge → /auth/mfa/verify → session", async () => {
|
|
123
|
+
const user = await seedUser(stack, {
|
|
124
|
+
email: "alice@example.com",
|
|
125
|
+
password: "correct-password-1234",
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
|
|
129
|
+
AuthMfaHandlers.enableStart,
|
|
130
|
+
{ accountLabel: "alice@example.com" },
|
|
131
|
+
{ id: user.id, tenantId: TEST_TENANT_ID, roles: ["User"] },
|
|
132
|
+
);
|
|
133
|
+
const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
|
|
134
|
+
const secret = base32Decode(secretParam);
|
|
135
|
+
await stack.http.writeOk(
|
|
136
|
+
AuthMfaHandlers.enableConfirm,
|
|
137
|
+
{ setupToken: start.setupToken, code: currentTotpCode(secret) },
|
|
138
|
+
{ id: user.id, tenantId: TEST_TENANT_ID, roles: ["User"] },
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
// Step 1: a correct password no longer mints a session directly — it
|
|
142
|
+
// hands back a challenge, proving composeFeatures actually wired
|
|
143
|
+
// mfaStatusChecker into the login handler (without the wiring this
|
|
144
|
+
// would 200 straight to a session and the test would fail below).
|
|
145
|
+
const loginRes = await stack.http.raw("POST", "/api/auth/login", {
|
|
146
|
+
email: "alice@example.com",
|
|
147
|
+
password: "correct-password-1234",
|
|
148
|
+
});
|
|
149
|
+
expect(loginRes.status).toBe(200);
|
|
150
|
+
const loginBody = (await loginRes.json()) as {
|
|
151
|
+
isSuccess: boolean;
|
|
152
|
+
mfaRequired?: boolean;
|
|
153
|
+
challengeToken?: string;
|
|
154
|
+
};
|
|
155
|
+
expect(loginBody.mfaRequired).toBe(true);
|
|
156
|
+
expect(loginBody.challengeToken).toBeTruthy();
|
|
157
|
+
const challengeToken = loginBody.challengeToken;
|
|
158
|
+
if (!challengeToken) throw new Error("no challengeToken in login response");
|
|
159
|
+
|
|
160
|
+
// Step 2: completing the challenge over the REAL /api/auth/mfa/verify
|
|
161
|
+
// route (not a direct handler dispatch) mints the session.
|
|
162
|
+
const verifyRes = await stack.http.raw("POST", "/api/auth/mfa/verify", {
|
|
163
|
+
challengeToken,
|
|
164
|
+
code: currentTotpCode(secret),
|
|
165
|
+
});
|
|
166
|
+
expect(verifyRes.status).toBe(200);
|
|
167
|
+
const verifyBody = (await verifyRes.json()) as {
|
|
168
|
+
isSuccess: boolean;
|
|
169
|
+
token: string;
|
|
170
|
+
user: { id: string; tenantId: string };
|
|
171
|
+
};
|
|
172
|
+
expect(verifyBody.isSuccess).toBe(true);
|
|
173
|
+
expect(verifyBody.token).toBeTruthy();
|
|
174
|
+
expect(verifyBody.user.id).toBe(user.id);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("a user without MFA enabled logs in with a straight session (no challenge)", async () => {
|
|
178
|
+
await seedUser(stack, { email: "bob@example.com", password: "any-password-1234" });
|
|
179
|
+
|
|
180
|
+
const res = await stack.http.raw("POST", "/api/auth/login", {
|
|
181
|
+
email: "bob@example.com",
|
|
182
|
+
password: "any-password-1234",
|
|
183
|
+
});
|
|
184
|
+
expect(res.status).toBe(200);
|
|
185
|
+
const body = (await res.json()) as {
|
|
186
|
+
isSuccess: boolean;
|
|
187
|
+
mfaRequired?: boolean;
|
|
188
|
+
token?: string;
|
|
189
|
+
};
|
|
190
|
+
expect(body.mfaRequired).toBeUndefined();
|
|
191
|
+
expect(body.token).toBeTruthy();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
async function setPolicy(stack: TestStack, policy: "optional" | "admins" | "all"): Promise<void> {
|
|
196
|
+
await stack.http.writeOk(
|
|
197
|
+
"config:write:set",
|
|
198
|
+
{ key: mfaRequiredConfigHandle.name, value: policy },
|
|
199
|
+
{ id: systemAdmin.id, tenantId: TEST_TENANT_ID, roles: ["SystemAdmin"] },
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Step 7: enforcement policy only matters for UNENROLLED users — an
|
|
204
|
+
// enrolled user always gets a challenge regardless of policy (they opted
|
|
205
|
+
// in themselves). ponytail (see auth-mfa's config.ts): "admins"/"all"
|
|
206
|
+
// hard-block an unenrolled matching user with mfaSetupRequired — there is
|
|
207
|
+
// no enrollment-during-login UI yet (PR3). By design for this backend step.
|
|
208
|
+
describe("composeFeatures wiring — auth-mfa enforcement policy (kumiko-framework#266 Step 7)", () => {
|
|
209
|
+
let stack: TestStack;
|
|
210
|
+
|
|
211
|
+
beforeAll(async () => {
|
|
212
|
+
stack = await bootStack();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
afterAll(async () => {
|
|
216
|
+
await stack.cleanup();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
beforeEach(async () => {
|
|
220
|
+
await deleteRows(stack.db, userTable, {});
|
|
221
|
+
await deleteRows(stack.db, tenantMembershipsTable, {});
|
|
222
|
+
await setPolicy(stack, "optional");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("optional (default): unenrolled user still gets a straight session", async () => {
|
|
226
|
+
await seedUser(stack, { email: "carol@example.com", password: "any-password-1234" });
|
|
227
|
+
const res = await stack.http.raw("POST", "/api/auth/login", {
|
|
228
|
+
email: "carol@example.com",
|
|
229
|
+
password: "any-password-1234",
|
|
230
|
+
});
|
|
231
|
+
expect(res.status).toBe(200);
|
|
232
|
+
const body = (await res.json()) as { mfaSetupRequired?: boolean; token?: string };
|
|
233
|
+
expect(body.mfaSetupRequired).toBeUndefined();
|
|
234
|
+
expect(body.token).toBeTruthy();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("all: unenrolled user is blocked with mfaSetupRequired, no session/challenge", async () => {
|
|
238
|
+
await setPolicy(stack, "all");
|
|
239
|
+
await seedUser(stack, { email: "dave@example.com", password: "any-password-1234" });
|
|
240
|
+
const res = await stack.http.raw("POST", "/api/auth/login", {
|
|
241
|
+
email: "dave@example.com",
|
|
242
|
+
password: "any-password-1234",
|
|
243
|
+
});
|
|
244
|
+
expect(res.status).toBe(200);
|
|
245
|
+
const body = (await res.json()) as {
|
|
246
|
+
mfaSetupRequired?: boolean;
|
|
247
|
+
mfaRequired?: boolean;
|
|
248
|
+
token?: string;
|
|
249
|
+
};
|
|
250
|
+
expect(body.mfaSetupRequired).toBe(true);
|
|
251
|
+
expect(body.mfaRequired).toBeUndefined();
|
|
252
|
+
expect(body.token).toBeUndefined();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("all: an already-enrolled user still gets a challenge, not a block", async () => {
|
|
256
|
+
await setPolicy(stack, "all");
|
|
257
|
+
const user = await seedUser(stack, {
|
|
258
|
+
email: "erin@example.com",
|
|
259
|
+
password: "any-password-1234",
|
|
260
|
+
});
|
|
261
|
+
const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
|
|
262
|
+
AuthMfaHandlers.enableStart,
|
|
263
|
+
{ accountLabel: "erin@example.com" },
|
|
264
|
+
{ id: user.id, tenantId: TEST_TENANT_ID, roles: ["User"] },
|
|
265
|
+
);
|
|
266
|
+
const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
|
|
267
|
+
const secret = base32Decode(secretParam);
|
|
268
|
+
await stack.http.writeOk(
|
|
269
|
+
AuthMfaHandlers.enableConfirm,
|
|
270
|
+
{ setupToken: start.setupToken, code: currentTotpCode(secret) },
|
|
271
|
+
{ id: user.id, tenantId: TEST_TENANT_ID, roles: ["User"] },
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
const res = await stack.http.raw("POST", "/api/auth/login", {
|
|
275
|
+
email: "erin@example.com",
|
|
276
|
+
password: "any-password-1234",
|
|
277
|
+
});
|
|
278
|
+
expect(res.status).toBe(200);
|
|
279
|
+
const body = (await res.json()) as { mfaSetupRequired?: boolean; mfaRequired?: boolean };
|
|
280
|
+
expect(body.mfaRequired).toBe(true);
|
|
281
|
+
expect(body.mfaSetupRequired).toBeUndefined();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("admins: unenrolled admin is blocked, unenrolled non-admin logs in normally", async () => {
|
|
285
|
+
await setPolicy(stack, "admins");
|
|
286
|
+
await seedUser(stack, {
|
|
287
|
+
email: "frank-admin@example.com",
|
|
288
|
+
password: "any-password-1234",
|
|
289
|
+
roles: ["Admin"],
|
|
290
|
+
});
|
|
291
|
+
await seedUser(stack, { email: "gina-user@example.com", password: "any-password-1234" });
|
|
292
|
+
|
|
293
|
+
const adminRes = await stack.http.raw("POST", "/api/auth/login", {
|
|
294
|
+
email: "frank-admin@example.com",
|
|
295
|
+
password: "any-password-1234",
|
|
296
|
+
});
|
|
297
|
+
const adminBody = (await adminRes.json()) as { mfaSetupRequired?: boolean };
|
|
298
|
+
expect(adminBody.mfaSetupRequired).toBe(true);
|
|
299
|
+
|
|
300
|
+
const userRes = await stack.http.raw("POST", "/api/auth/login", {
|
|
301
|
+
email: "gina-user@example.com",
|
|
302
|
+
password: "any-password-1234",
|
|
303
|
+
});
|
|
304
|
+
const userBody = (await userRes.json()) as { mfaSetupRequired?: boolean; token?: string };
|
|
305
|
+
expect(userBody.mfaSetupRequired).toBeUndefined();
|
|
306
|
+
expect(userBody.token).toBeTruthy();
|
|
307
|
+
});
|
|
308
|
+
});
|
package/src/compose-features.ts
CHANGED
|
@@ -20,6 +20,10 @@ import {
|
|
|
20
20
|
type PasswordResetOptions,
|
|
21
21
|
type SignupOptions,
|
|
22
22
|
} from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
|
|
23
|
+
import {
|
|
24
|
+
AUTH_MFA_FEATURE,
|
|
25
|
+
mfaStatusCheckerFromFeature,
|
|
26
|
+
} from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
|
|
23
27
|
import { createConfigFeature } from "@cosmicdrift/kumiko-bundled-features/config";
|
|
24
28
|
import { createTenantFeature } from "@cosmicdrift/kumiko-bundled-features/tenant";
|
|
25
29
|
import { createUserFeature } from "@cosmicdrift/kumiko-bundled-features/user";
|
|
@@ -42,6 +46,12 @@ export function composeFeatures(
|
|
|
42
46
|
appFeatures: readonly FeatureDefinition[],
|
|
43
47
|
options: ComposeFeaturesOptions,
|
|
44
48
|
): FeatureDefinition[] {
|
|
49
|
+
// ponytail: includeBundled:false skips the auth-mfa auto-wiring below —
|
|
50
|
+
// an app composing its own foundation and mounting auth-mfa itself is
|
|
51
|
+
// responsible for threading mfaStatusCheckerFromFeature(...) into its
|
|
52
|
+
// own createAuthEmailPasswordFeature(...) call, or login silently
|
|
53
|
+
// bypasses MFA. Upgrade if this trips someone: warn here when appFeatures
|
|
54
|
+
// contains AUTH_MFA_FEATURE but includeBundled is false.
|
|
45
55
|
if (!options.includeBundled) return [...appFeatures];
|
|
46
56
|
|
|
47
57
|
// Bundled foundation goes first so its instances carry the runDevApp /
|
|
@@ -50,11 +60,20 @@ export function composeFeatures(
|
|
|
50
60
|
// hands back `createAuthEmailPasswordFeature()` because the user ticked
|
|
51
61
|
// it — would otherwise crash createRegistry with "Duplicate feature".
|
|
52
62
|
// Drop the app-side duplicates and warn so the user can clean run-config.
|
|
63
|
+
//
|
|
64
|
+
// auth-mfa is NOT part of the bundled foundation (apps opt in explicitly
|
|
65
|
+
// via APP_FEATURES) — but if it's there, the login handler needs its
|
|
66
|
+
// status-checker wired in at construction time, since createAuthEmail-
|
|
67
|
+
// PasswordFeature is built right here, before the caller ever sees it.
|
|
68
|
+
const mfaFeature = appFeatures.find((f) => f.name === AUTH_MFA_FEATURE);
|
|
69
|
+
const authOptions = mfaFeature
|
|
70
|
+
? { ...options.authOptions, mfaStatusChecker: mfaStatusCheckerFromFeature(mfaFeature) }
|
|
71
|
+
: options.authOptions;
|
|
53
72
|
const bundled = [
|
|
54
73
|
createConfigFeature(),
|
|
55
74
|
createUserFeature(),
|
|
56
75
|
createTenantFeature(),
|
|
57
|
-
createAuthEmailPasswordFeature(
|
|
76
|
+
createAuthEmailPasswordFeature(authOptions ?? {}),
|
|
58
77
|
];
|
|
59
78
|
const bundledNames = new Set(bundled.map((f) => f.name));
|
|
60
79
|
const filteredApp: FeatureDefinition[] = [];
|
package/src/run-dev-app.ts
CHANGED
|
@@ -18,6 +18,11 @@ import {
|
|
|
18
18
|
type SeedAdminOptions,
|
|
19
19
|
seedAdmin,
|
|
20
20
|
} from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
|
|
21
|
+
import {
|
|
22
|
+
AUTH_MFA_FEATURE,
|
|
23
|
+
AuthMfaHandlers,
|
|
24
|
+
bindMfaRevokeAllOtherSessionsFromFeature,
|
|
25
|
+
} from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
|
|
21
26
|
import {
|
|
22
27
|
buildEnvConfigOverrides,
|
|
23
28
|
createConfigResolver,
|
|
@@ -403,6 +408,7 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
403
408
|
let patResolver: PatResolver | undefined;
|
|
404
409
|
let lifecycleDb: DbConnection | undefined;
|
|
405
410
|
const patFeature = features.find((f) => f.name === PAT_FEATURE);
|
|
411
|
+
const mfaFeature = features.find((f) => f.name === AUTH_MFA_FEATURE);
|
|
406
412
|
const tenantLifecycleFeature = features.find((f) => f.name === TENANT_LIFECYCLE_FEATURE);
|
|
407
413
|
const patAuthFragment = patFeature
|
|
408
414
|
? {
|
|
@@ -486,6 +492,7 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
486
492
|
...sessionAuthFragment,
|
|
487
493
|
...patAuthFragment,
|
|
488
494
|
...tenantLifecycleAuthFragment,
|
|
495
|
+
...(mfaFeature && { mfaVerifyHandler: AuthMfaHandlers.verify }),
|
|
489
496
|
...(effectiveAuth.passwordReset && {
|
|
490
497
|
passwordReset: {
|
|
491
498
|
requestHandler: AuthHandlers.requestPasswordReset,
|
|
@@ -537,6 +544,14 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
537
544
|
if (sessionsFeature) {
|
|
538
545
|
bindAutoRevokeFromFeature(sessionsFeature)?.(sessionCallbacks.sessionMassRevoker);
|
|
539
546
|
}
|
|
547
|
+
// MFA enable/disable/regenerate mass-revokes every OTHER live
|
|
548
|
+
// session (stolen-session defense) — only wired when auth-mfa is
|
|
549
|
+
// mounted.
|
|
550
|
+
if (mfaFeature) {
|
|
551
|
+
bindMfaRevokeAllOtherSessionsFromFeature(mfaFeature)?.(
|
|
552
|
+
sessionCallbacks.sessionRevokeAllOthers,
|
|
553
|
+
);
|
|
554
|
+
}
|
|
540
555
|
}
|
|
541
556
|
if (patFeature) {
|
|
542
557
|
patResolver = createPatResolver({ db: stack.db, scopes: patScopesFromFeature(patFeature) });
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { makeAuthPaths } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
|
|
2
|
+
import { bindMfaRevokeAllOtherSessionsFromFeature } from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
|
|
2
3
|
import { createSmtpTransportFromEnv } from "@cosmicdrift/kumiko-bundled-features/channel-email";
|
|
3
4
|
import {
|
|
4
5
|
buildEnvConfigOverrides,
|
|
@@ -210,6 +211,7 @@ export function buildProdSessionAuth(
|
|
|
210
211
|
db: DbConnection,
|
|
211
212
|
opts: ProdSessionsConfig,
|
|
212
213
|
sessionsFeature: FeatureDefinition | undefined,
|
|
214
|
+
mfaFeature: FeatureDefinition | undefined,
|
|
213
215
|
): {
|
|
214
216
|
readonly sessionCreator: ReturnType<typeof createSessionCallbacks>["sessionCreator"];
|
|
215
217
|
readonly sessionRevoker: ReturnType<typeof createSessionCallbacks>["sessionRevoker"];
|
|
@@ -225,6 +227,11 @@ export function buildProdSessionAuth(
|
|
|
225
227
|
if (sessionsFeature) {
|
|
226
228
|
bindAutoRevokeFromFeature(sessionsFeature)?.(cbs.sessionMassRevoker);
|
|
227
229
|
}
|
|
230
|
+
// MFA enable/disable/regenerate mass-revokes every OTHER live session
|
|
231
|
+
// (stolen-session defense) — only wired when auth-mfa is mounted.
|
|
232
|
+
if (mfaFeature) {
|
|
233
|
+
bindMfaRevokeAllOtherSessionsFromFeature(mfaFeature)?.(cbs.sessionRevokeAllOthers);
|
|
234
|
+
}
|
|
228
235
|
return {
|
|
229
236
|
sessionCreator: cbs.sessionCreator,
|
|
230
237
|
sessionRevoker: cbs.sessionRevoker,
|
package/src/run-prod-app.ts
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
type SeedAdminOptions,
|
|
39
39
|
seedAdmin,
|
|
40
40
|
} from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
|
|
41
|
+
import { AUTH_MFA_FEATURE, AuthMfaHandlers } from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
|
|
41
42
|
import {
|
|
42
43
|
createPatResolver,
|
|
43
44
|
PAT_FEATURE,
|
|
@@ -844,12 +845,18 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
844
845
|
// `auth.sessions` only overrides the config, and `auth.sessions: false` is the
|
|
845
846
|
// explicit opt-out (back to stateless JWTs).
|
|
846
847
|
const sessionsFeature = features.find((f) => f.name === SESSIONS_FEATURE);
|
|
848
|
+
const mfaFeature = features.find((f) => f.name === AUTH_MFA_FEATURE);
|
|
847
849
|
const sessionAuthFragment = shouldWireProdSessions(
|
|
848
850
|
Boolean(effectiveAuth),
|
|
849
851
|
sessionsFeature !== undefined,
|
|
850
852
|
effectiveAuth?.sessions,
|
|
851
853
|
)
|
|
852
|
-
? buildProdSessionAuth(
|
|
854
|
+
? buildProdSessionAuth(
|
|
855
|
+
db,
|
|
856
|
+
resolveProdSessionsConfig(effectiveAuth?.sessions),
|
|
857
|
+
sessionsFeature,
|
|
858
|
+
mfaFeature,
|
|
859
|
+
)
|
|
853
860
|
: undefined;
|
|
854
861
|
|
|
855
862
|
// PAT opt-in: if the personal-access-tokens feature is mounted, wire its
|
|
@@ -921,6 +928,7 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
921
928
|
...sessionAuthFragment,
|
|
922
929
|
...patAuthFragment,
|
|
923
930
|
...tenantLifecycleAuthFragment,
|
|
931
|
+
...(mfaFeature && { mfaVerifyHandler: AuthMfaHandlers.verify }),
|
|
924
932
|
...(effectiveAuth.passwordReset && {
|
|
925
933
|
passwordReset: {
|
|
926
934
|
requestHandler: AuthHandlers.requestPasswordReset,
|