@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.160.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.160.0",
121
- "@cosmicdrift/kumiko-framework": "0.160.0",
122
- "@cosmicdrift/kumiko-headless": "0.160.0",
123
- "@cosmicdrift/kumiko-renderer": "0.160.0",
124
- "@cosmicdrift/kumiko-renderer-web": "0.160.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 { isSuccess: true, data: { kind: "mfa-setup-required" } };
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({ isSuccess: true, mfaSetupRequired: true }),
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 () => ({ kind: "mfa-setup-required" })),
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 () => ({ kind: "mfa-setup-required" })),
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("Zwei-Faktor-Bestätigung");
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. Same fallback behavior as onMfaChallenge when unset. */
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: "mfa_not_supported" });
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 { EXT_SESSION_STORE, EXT_TOKEN_VERIFIER } from "../types";
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 (tokenVerifier + sessionStore)", () => {
31
- expect(authFoundationFeature.bootChecks.length).toBeGreaterThanOrEqual(2);
43
+ test("registers a bootCheck per extension-point (token/session/tenant×2)", () => {
44
+ expect(authFoundationFeature.bootChecks.length).toBeGreaterThanOrEqual(4);
32
45
  });
33
46
  });
@@ -17,6 +17,7 @@ function mockStore(): SessionStore {
17
17
  revoker: async () => undefined,
18
18
  checker: async () => "live",
19
19
  massRevoker: async () => 0,
20
+ revokeAllOthers: async () => 0,
20
21
  };
21
22
  }
22
23
 
@@ -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 the `tokenVerifier` extension point for Bearer-
4
- // token verification. Provider-features (auth-provider-jwt, auth-provider-
5
- // pat, ...) register via `r.useExtension(EXT_TOKEN_VERIFIER, "<name>", plugin)`.
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
- // **Scaffold only:** not wired into `api/auth-middleware.ts` yet
8
- // personal-access-tokens still uses its own direct `patResolver` callback
9
- // (kumiko-framework#745). A future issue migrates the middleware onto this
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 { validateSessionStoreMultiplicity, validateTokenVerifierMultiplicity } from "./boot-checks";
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 { EXT_SESSION_STORE, EXT_TOKEN_VERIFIER } from "./types";
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 the `tokenVerifier` (Bearer-token auth) and `sessionStore` extension points. Provider-features register a static `shape` plus a `build()` for tokenVerifier — mount together with at least one auth-provider-* feature (e.g. personal-access-tokens) and call `resolveTokenVerifier(deps, rawToken)` to find the matching one; sessions self-registers as the single sessionStore provider.",
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 — registry stores the usage,
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
- if (policy === "all") return { setupRequired: true };
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 ? { setupRequired: true } : { required: false };
80
+ return isAdmin
81
+ ? { setupRequired: true, preauthSetupToken: buildSetupToken() }
82
+ : { required: false };
74
83
  };
75
84
  }
@@ -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({ db: deps.db });
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);