@cosmicdrift/kumiko-bundled-features 0.159.1 → 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.
Files changed (38) hide show
  1. package/package.json +6 -6
  2. package/src/auth-email-password/__tests__/login-mfa-setup-required.integration.test.ts +129 -0
  3. package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +5 -0
  4. package/src/auth-email-password/handlers/login.write.ts +6 -3
  5. package/src/auth-email-password/i18n.ts +4 -0
  6. package/src/auth-email-password/web/__tests__/auth-client.test.ts +529 -0
  7. package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +57 -0
  8. package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +126 -2
  9. package/src/auth-email-password/web/__tests__/login-screen.test.tsx +134 -0
  10. package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +131 -0
  11. package/src/auth-email-password/web/auth-client.ts +4 -3
  12. package/src/auth-email-password/web/login-screen.tsx +5 -2
  13. package/src/auth-foundation/__tests__/feature.test.ts +16 -3
  14. package/src/auth-foundation/__tests__/session-store.test.ts +1 -0
  15. package/src/auth-foundation/__tests__/tenant-providers.test.ts +136 -0
  16. package/src/auth-foundation/boot-checks.ts +54 -0
  17. package/src/auth-foundation/feature.ts +77 -23
  18. package/src/auth-foundation/index.ts +14 -0
  19. package/src/auth-foundation/resolve-anonymous-access.ts +54 -0
  20. package/src/auth-foundation/types.ts +63 -0
  21. package/src/auth-mfa/__tests__/mfa-preauth-setup-token.test.ts +87 -0
  22. package/src/auth-mfa/__tests__/verify.integration.test.ts +24 -0
  23. package/src/auth-mfa/constants.ts +1 -0
  24. package/src/auth-mfa/handlers/verify.write.ts +1 -1
  25. package/src/auth-mfa/mfa-preauth-setup-token.ts +86 -0
  26. package/src/auth-mfa/mfa-status-checker.ts +13 -4
  27. package/src/config/__tests__/app-overrides.test.ts +11 -0
  28. package/src/config/__tests__/backing-secrets.integration.test.ts +18 -0
  29. package/src/config/__tests__/cascade.integration.test.ts +39 -0
  30. package/src/config/__tests__/pii-encrypted.integration.test.ts +20 -0
  31. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +154 -0
  32. package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +331 -0
  33. package/src/personal-access-tokens/feature.ts +1 -1
  34. package/src/sessions/feature.ts +8 -1
  35. package/src/tags/web/__tests__/tag-manager.test.tsx +277 -8
  36. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +180 -0
  37. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +1 -0
  38. package/src/user-profile/__tests__/profile-screen.test.tsx +1 -0
@@ -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
+ });
@@ -292,4 +292,28 @@ describe("mfa verify — re-checks account state the way login.write.ts does", (
292
292
  );
293
293
  expectErrorIncludes(err, "invalid_challenge_token");
294
294
  });
295
+
296
+ test("user row homed in a DIFFERENT tenant than the challenge → verify succeeds (#1235)", async () => {
297
+ const { user, secret } = await enableMfaFor(8);
298
+
299
+ // Re-home the read_users row into another tenant — the #1235 layout:
300
+ // the user entity is tenant-agnostic (systemStream), so its row's
301
+ // tenant_id need not match the tenant being logged into. The old
302
+ // tenant-scoped UserQueries.detail lookup returned null here and every
303
+ // verify died with invalid_challenge_token; the unscoped findForAuth
304
+ // must resolve the row regardless.
305
+ await asRawClient(stack.db).unsafe(
306
+ `UPDATE "${userTable.tableName}" SET tenant_id = $1 WHERE id = $2`,
307
+ ["00000000-0000-4000-8000-0000000000fe", user.id],
308
+ );
309
+
310
+ const challengeToken = challengeFor(user.id, user.tenantId);
311
+ const res = await stack.http.writeOk<{ session: SessionUser }>(
312
+ AuthMfaHandlers.verify,
313
+ { challengeToken, code: currentTotpCode(secret) },
314
+ GUEST,
315
+ );
316
+ expect(res.session.id).toBe(user.id);
317
+ expect(res.session.tenantId).toBe(user.tenantId);
318
+ });
295
319
  });
@@ -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
 
@@ -129,7 +129,7 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
129
129
  // the SAME tenant the challenge token already committed to (no
130
130
  // "pick a tenant" step here — that already happened at login time).
131
131
  const systemUser = createSystemUser(tenantId, ["SystemAdmin"]);
132
- const userRow = (await ctx.queryAs(systemUser, UserQueries.detail, {
132
+ const userRow = (await ctx.queryAs(systemUser, UserQueries.findForAuth, {
133
133
  id: userId,
134
134
  })) as { roles?: string | null; status?: string } | null; // @cast-boundary engine-payload
135
135
 
@@ -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
  }
@@ -115,4 +115,15 @@ describe("validateAppOverrides", () => {
115
115
  }),
116
116
  ).toThrow(/computed resolver.*app-overrides would silently bypass/i);
117
117
  });
118
+
119
+ test("throws on type mismatch — text key, number value", () => {
120
+ const reg = registryStub({
121
+ "a:config:s": createTenantConfig("text"),
122
+ });
123
+ expect(() =>
124
+ validateAppOverrides(reg, {
125
+ "a:config:s": 42,
126
+ }),
127
+ ).toThrow(/expected string, got number/i);
128
+ });
118
129
  });