@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.
- package/package.json +6 -6
- package/src/auth-email-password/__tests__/login-mfa-setup-required.integration.test.ts +129 -0
- package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +5 -0
- package/src/auth-email-password/handlers/login.write.ts +6 -3
- package/src/auth-email-password/i18n.ts +4 -0
- package/src/auth-email-password/web/__tests__/auth-client.test.ts +529 -0
- package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +57 -0
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +126 -2
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +134 -0
- package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +131 -0
- package/src/auth-email-password/web/auth-client.ts +4 -3
- package/src/auth-email-password/web/login-screen.tsx +5 -2
- package/src/auth-foundation/__tests__/feature.test.ts +16 -3
- package/src/auth-foundation/__tests__/session-store.test.ts +1 -0
- package/src/auth-foundation/__tests__/tenant-providers.test.ts +136 -0
- package/src/auth-foundation/boot-checks.ts +54 -0
- package/src/auth-foundation/feature.ts +77 -23
- package/src/auth-foundation/index.ts +14 -0
- package/src/auth-foundation/resolve-anonymous-access.ts +54 -0
- package/src/auth-foundation/types.ts +63 -0
- package/src/auth-mfa/__tests__/mfa-preauth-setup-token.test.ts +87 -0
- package/src/auth-mfa/__tests__/verify.integration.test.ts +24 -0
- package/src/auth-mfa/constants.ts +1 -0
- package/src/auth-mfa/handlers/verify.write.ts +1 -1
- package/src/auth-mfa/mfa-preauth-setup-token.ts +86 -0
- package/src/auth-mfa/mfa-status-checker.ts +13 -4
- package/src/config/__tests__/app-overrides.test.ts +11 -0
- package/src/config/__tests__/backing-secrets.integration.test.ts +18 -0
- package/src/config/__tests__/cascade.integration.test.ts +39 -0
- package/src/config/__tests__/pii-encrypted.integration.test.ts +20 -0
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +154 -0
- package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +331 -0
- package/src/personal-access-tokens/feature.ts +1 -1
- package/src/sessions/feature.ts +8 -1
- package/src/tags/web/__tests__/tag-manager.test.tsx +277 -8
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +180 -0
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +1 -0
- 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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// auth-foundation — declares auth-middleware extension points:
|
|
4
|
+
// - tokenVerifier (multi, shape-routed)
|
|
5
|
+
// - sessionStore (single, required when foundation mounts)
|
|
6
|
+
// - tenantResolver (single, optional)
|
|
7
|
+
// - tenantExistence (single, optional)
|
|
6
8
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// (
|
|
10
|
-
// extension point; #1368 only lays the groundwork (PAT-Plan-Doc Phase-1-
|
|
11
|
-
// Sub-Step-1).
|
|
12
|
-
//
|
|
13
|
-
// **Pattern precedent:** file-foundation/mail-foundation for the extension-
|
|
14
|
-
// point + plugin-registry shape. Diverges on ROUTING: those pick one active
|
|
15
|
-
// provider per tenant via a config-key; auth-foundation has no tenant yet
|
|
16
|
-
// at verify-time, so providers are tried in shape-match order instead (see
|
|
17
|
-
// types.ts).
|
|
9
|
+
// Provider-features register via `r.useExtension(EXT_*, "<name>", plugin)`.
|
|
10
|
+
// Middleware / runProdApp resolve providers from the registry at boot
|
|
11
|
+
// (tokenVerifier/sessionStore) or request-time (tokenVerifier shape match).
|
|
18
12
|
|
|
19
13
|
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
20
14
|
import { defineFeature, type Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
21
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
validateSessionStoreMultiplicity,
|
|
17
|
+
validateTenantExistenceMultiplicity,
|
|
18
|
+
validateTenantResolverMultiplicity,
|
|
19
|
+
validateTokenVerifierMultiplicity,
|
|
20
|
+
} from "./boot-checks";
|
|
22
21
|
import {
|
|
23
22
|
type AuthProviderBuildDeps,
|
|
24
23
|
EXT_SESSION_STORE,
|
|
24
|
+
EXT_TENANT_EXISTENCE,
|
|
25
|
+
EXT_TENANT_RESOLVER,
|
|
25
26
|
EXT_TOKEN_VERIFIER,
|
|
26
27
|
isAuthProviderPlugin,
|
|
27
28
|
isSessionStoreProvider,
|
|
29
|
+
isTenantExistenceProvider,
|
|
30
|
+
isTenantResolverProvider,
|
|
28
31
|
type SessionStore,
|
|
32
|
+
type TenantExistsFn,
|
|
33
|
+
type TenantResolverFn,
|
|
34
|
+
type TenantResolverTrust,
|
|
29
35
|
tokenShapeMatches,
|
|
30
36
|
} from "./types";
|
|
31
37
|
|
|
32
|
-
export {
|
|
38
|
+
export {
|
|
39
|
+
EXT_SESSION_STORE,
|
|
40
|
+
EXT_TENANT_EXISTENCE,
|
|
41
|
+
EXT_TENANT_RESOLVER,
|
|
42
|
+
EXT_TOKEN_VERIFIER,
|
|
43
|
+
} from "./types";
|
|
33
44
|
|
|
34
45
|
const FEATURE_NAME = "auth-foundation";
|
|
35
46
|
|
|
36
47
|
export const authFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
37
48
|
r.describe(
|
|
38
|
-
"Declares
|
|
49
|
+
"Declares auth-middleware extension points: `tokenVerifier` (Bearer), `sessionStore` (revocable JWT sid), optional `tenantResolver` + `tenantExistence` for anonymous multi-tenant. Provider-features register via r.useExtension; mount auth-foundation with the matching provider features.",
|
|
39
50
|
);
|
|
40
51
|
r.uiHints({
|
|
41
52
|
displayLabel: "Auth Provider Foundation",
|
|
@@ -49,19 +60,28 @@ export const authFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
49
60
|
// resolveTokenVerifier looks it up at request-time.
|
|
50
61
|
},
|
|
51
62
|
});
|
|
52
|
-
|
|
53
|
-
// Owns EXT_TOKEN_VERIFIER, so its own mount is the trigger — same
|
|
54
|
-
// convention as tenant-lifecycle/user-data-rights' self-bootCheck.
|
|
55
63
|
r.bootCheck(({ features }) => validateTokenVerifierMultiplicity(features));
|
|
56
64
|
|
|
57
65
|
r.extendsRegistrar(EXT_SESSION_STORE, {
|
|
58
66
|
onRegister: () => {
|
|
59
|
-
// No side-effects at register-time —
|
|
60
|
-
// resolveSessionStore looks it up at request-time.
|
|
67
|
+
// No side-effects at register-time — resolveSessionStore at boot/request.
|
|
61
68
|
},
|
|
62
69
|
});
|
|
63
|
-
|
|
64
70
|
r.bootCheck(({ features }) => validateSessionStoreMultiplicity(features));
|
|
71
|
+
|
|
72
|
+
r.extendsRegistrar(EXT_TENANT_RESOLVER, {
|
|
73
|
+
onRegister: () => {
|
|
74
|
+
// Optional — zero providers is fine (single-tenant / header-cookie).
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
r.bootCheck(({ features }) => validateTenantResolverMultiplicity(features));
|
|
78
|
+
|
|
79
|
+
r.extendsRegistrar(EXT_TENANT_EXISTENCE, {
|
|
80
|
+
onRegister: () => {
|
|
81
|
+
// Optional — zero providers skips the existence check.
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
r.bootCheck(({ features }) => validateTenantExistenceMultiplicity(features));
|
|
65
85
|
});
|
|
66
86
|
|
|
67
87
|
/**
|
|
@@ -101,3 +121,37 @@ export async function resolveSessionStore(
|
|
|
101
121
|
}
|
|
102
122
|
return usage.options.build(deps);
|
|
103
123
|
}
|
|
124
|
+
|
|
125
|
+
export type ResolvedTenantResolver = {
|
|
126
|
+
readonly resolve: TenantResolverFn;
|
|
127
|
+
readonly trust: TenantResolverTrust;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/** Optional tenantResolver — null when no provider registered. */
|
|
131
|
+
export async function resolveTenantResolver(
|
|
132
|
+
deps: AuthProviderBuildDeps & { readonly registry: Registry },
|
|
133
|
+
): Promise<ResolvedTenantResolver | null> {
|
|
134
|
+
const usage = deps.registry.getExtensionUsages(EXT_TENANT_RESOLVER)[0];
|
|
135
|
+
if (!usage) return null;
|
|
136
|
+
if (!isTenantResolverProvider(usage.options)) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
"[auth-foundation] tenantResolver provider registered without a valid TenantResolverProvider",
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
const resolve = await usage.options.build(deps);
|
|
142
|
+
return { resolve, trust: usage.options.trust };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Optional tenantExistence — null when no provider registered. */
|
|
146
|
+
export async function resolveTenantExistence(
|
|
147
|
+
deps: AuthProviderBuildDeps & { readonly registry: Registry },
|
|
148
|
+
): Promise<TenantExistsFn | null> {
|
|
149
|
+
const usage = deps.registry.getExtensionUsages(EXT_TENANT_EXISTENCE)[0];
|
|
150
|
+
if (!usage) return null;
|
|
151
|
+
if (!isTenantExistenceProvider(usage.options)) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
"[auth-foundation] tenantExistence provider registered without a valid TenantExistenceProvider",
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
return usage.options.build(deps);
|
|
157
|
+
}
|
|
@@ -2,21 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
export {
|
|
4
4
|
authFoundationFeature,
|
|
5
|
+
type ResolvedTenantResolver,
|
|
5
6
|
resolveSessionStore,
|
|
7
|
+
resolveTenantExistence,
|
|
8
|
+
resolveTenantResolver,
|
|
6
9
|
resolveTokenVerifier,
|
|
7
10
|
} from "./feature";
|
|
11
|
+
export { resolveAnonymousAccessFromRegistry } from "./resolve-anonymous-access";
|
|
8
12
|
export {
|
|
9
13
|
type AuthProviderBuildDeps,
|
|
10
14
|
type AuthProviderPlugin,
|
|
11
15
|
type AuthVerifier,
|
|
12
16
|
EXT_SESSION_STORE,
|
|
17
|
+
EXT_TENANT_EXISTENCE,
|
|
18
|
+
EXT_TENANT_RESOLVER,
|
|
13
19
|
EXT_TOKEN_VERIFIER,
|
|
14
20
|
isAuthProviderPlugin,
|
|
15
21
|
isSessionStoreProvider,
|
|
22
|
+
isTenantExistenceProvider,
|
|
23
|
+
isTenantResolverProvider,
|
|
24
|
+
isTenantResolverTrust,
|
|
16
25
|
isValidTokenShape,
|
|
17
26
|
type SessionMassRevoker,
|
|
18
27
|
type SessionStore,
|
|
19
28
|
type SessionStoreProvider,
|
|
29
|
+
type TenantExistenceProvider,
|
|
30
|
+
type TenantExistsFn,
|
|
31
|
+
type TenantResolverFn,
|
|
32
|
+
type TenantResolverProvider,
|
|
33
|
+
type TenantResolverTrust,
|
|
20
34
|
type TokenShape,
|
|
21
35
|
tokenShapeKey,
|
|
22
36
|
tokenShapeMatches,
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merge app-facing AnonymousAccessConfig with auth-foundation tenant providers.
|
|
3
|
+
* Providers win over any leftover callback fields (hard cutover #1374).
|
|
4
|
+
* Test stacks may still pass Resolved callbacks when no provider is mounted.
|
|
5
|
+
*/
|
|
6
|
+
import type {
|
|
7
|
+
AnonymousAccessConfig,
|
|
8
|
+
AnonymousAccessResolved,
|
|
9
|
+
TenantExists,
|
|
10
|
+
TenantResolver,
|
|
11
|
+
} from "@cosmicdrift/kumiko-framework/api";
|
|
12
|
+
import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
13
|
+
import { resolveTenantExistence, resolveTenantResolver } from "./feature";
|
|
14
|
+
import type { AuthProviderBuildDeps } from "./types";
|
|
15
|
+
|
|
16
|
+
export async function resolveAnonymousAccessFromRegistry(
|
|
17
|
+
base: AnonymousAccessResolved | AnonymousAccessConfig | undefined,
|
|
18
|
+
deps: AuthProviderBuildDeps & { readonly registry: Registry },
|
|
19
|
+
): Promise<AnonymousAccessResolved | undefined> {
|
|
20
|
+
const [tenantResolver, tenantExists] = await Promise.all([
|
|
21
|
+
resolveTenantResolver(deps),
|
|
22
|
+
resolveTenantExistence(deps),
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
if (!base && !tenantResolver && !tenantExists) return undefined;
|
|
26
|
+
|
|
27
|
+
// Fail-closed (#1374 security): inline tenantResolver without trust is the
|
|
28
|
+
// old ambiguous config — providers always carry trust; test stacks must too.
|
|
29
|
+
if (
|
|
30
|
+
!tenantResolver &&
|
|
31
|
+
base &&
|
|
32
|
+
"tenantResolver" in base &&
|
|
33
|
+
base.tenantResolver !== undefined &&
|
|
34
|
+
base.resolverTrust === undefined
|
|
35
|
+
) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
"[auth-foundation] anonymousAccess.tenantResolver is set without resolverTrust — " +
|
|
38
|
+
'declare "authoritative" or "fallback-only" (or mount an EXT_TENANT_RESOLVER provider).',
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Providers win (#1374). When none are mounted, keep any test-injected
|
|
43
|
+
// Resolved callbacks so framework middleware suites stay self-contained.
|
|
44
|
+
return {
|
|
45
|
+
...(base ?? {}),
|
|
46
|
+
...(tenantResolver
|
|
47
|
+
? {
|
|
48
|
+
tenantResolver: tenantResolver.resolve as TenantResolver,
|
|
49
|
+
resolverTrust: tenantResolver.trust,
|
|
50
|
+
}
|
|
51
|
+
: {}),
|
|
52
|
+
...(tenantExists ? { tenantExists: tenantExists as TenantExists } : {}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -86,11 +86,18 @@ export const EXT_SESSION_STORE = "sessionStore";
|
|
|
86
86
|
/** Mass-revoke every live session for a user. Used by password-change and "sign out everywhere". */
|
|
87
87
|
export type SessionMassRevoker = (userId: string) => Promise<number>;
|
|
88
88
|
|
|
89
|
+
/** Keep current sid (if any); revoke every other live session. Used by auth-mfa. */
|
|
90
|
+
export type SessionRevokeAllOthers = (
|
|
91
|
+
userId: string,
|
|
92
|
+
currentSid: string | undefined,
|
|
93
|
+
) => Promise<number>;
|
|
94
|
+
|
|
89
95
|
export type SessionStore = {
|
|
90
96
|
readonly creator: SessionCreator;
|
|
91
97
|
readonly revoker: SessionRevoker;
|
|
92
98
|
readonly checker: SessionChecker;
|
|
93
99
|
readonly massRevoker: SessionMassRevoker;
|
|
100
|
+
readonly revokeAllOthers: SessionRevokeAllOthers;
|
|
94
101
|
};
|
|
95
102
|
|
|
96
103
|
export type SessionStoreProvider = {
|
|
@@ -105,3 +112,59 @@ export function isSessionStoreProvider(o: unknown): o is SessionStoreProvider {
|
|
|
105
112
|
typeof (o as { build: unknown }).build === "function"
|
|
106
113
|
);
|
|
107
114
|
}
|
|
115
|
+
|
|
116
|
+
// Tenant-resolution extension points (#1373). Both are single-provider and
|
|
117
|
+
// optional — zero registered means today's single-tenant / header-cookie
|
|
118
|
+
// path (no subdomain resolver, no existence check). ≥2 fails boot.
|
|
119
|
+
|
|
120
|
+
export const EXT_TENANT_RESOLVER = "tenantResolver";
|
|
121
|
+
export const EXT_TENANT_EXISTENCE = "tenantExistence";
|
|
122
|
+
|
|
123
|
+
/** Trust mode for a registered tenantResolver — mirrors AnonymousAccessConfig.resolverTrust. */
|
|
124
|
+
export type TenantResolverTrust = "authoritative" | "fallback-only";
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Resolves the tenant for an unauthenticated request. Return null when no
|
|
128
|
+
* tenant can be determined — middleware rejects with 400. Throw only on
|
|
129
|
+
* infrastructure failures.
|
|
130
|
+
*/
|
|
131
|
+
export type TenantResolverFn = (
|
|
132
|
+
// Hono Context — typed loosely so auth-foundation stays free of a hono dep.
|
|
133
|
+
// Callers cast at the boundary (same shape as framework TenantResolver).
|
|
134
|
+
c: unknown,
|
|
135
|
+
) => Promise<string | null> | string | null;
|
|
136
|
+
|
|
137
|
+
export type TenantExistsFn = (tenantId: string) => Promise<boolean> | boolean;
|
|
138
|
+
|
|
139
|
+
export type TenantResolverProvider = {
|
|
140
|
+
readonly trust: TenantResolverTrust;
|
|
141
|
+
readonly build: (deps: AuthProviderBuildDeps) => Promise<TenantResolverFn> | TenantResolverFn;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export type TenantExistenceProvider = {
|
|
145
|
+
readonly build: (deps: AuthProviderBuildDeps) => Promise<TenantExistsFn> | TenantExistsFn;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export function isTenantResolverTrust(v: unknown): v is TenantResolverTrust {
|
|
149
|
+
return v === "authoritative" || v === "fallback-only";
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function isTenantResolverProvider(o: unknown): o is TenantResolverProvider {
|
|
153
|
+
return (
|
|
154
|
+
typeof o === "object" &&
|
|
155
|
+
o !== null &&
|
|
156
|
+
"build" in o &&
|
|
157
|
+
typeof (o as { build: unknown }).build === "function" &&
|
|
158
|
+
"trust" in o &&
|
|
159
|
+
isTenantResolverTrust((o as { trust: unknown }).trust)
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function isTenantExistenceProvider(o: unknown): o is TenantExistenceProvider {
|
|
164
|
+
return (
|
|
165
|
+
typeof o === "object" &&
|
|
166
|
+
o !== null &&
|
|
167
|
+
"build" in o &&
|
|
168
|
+
typeof (o as { build: unknown }).build === "function"
|
|
169
|
+
);
|
|
170
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { Temporal } from "temporal-polyfill";
|
|
3
|
+
import { signMfaPreauthSetupToken, verifyMfaPreauthSetupToken } from "../mfa-preauth-setup-token";
|
|
4
|
+
|
|
5
|
+
const SECRET = "test-mfa-preauth-setup-secret-at-least-32-bytes!!";
|
|
6
|
+
const USER_ID = "11111111-1111-4111-8111-111111111111";
|
|
7
|
+
const TENANT_ID = "22222222-2222-4222-8222-222222222222";
|
|
8
|
+
|
|
9
|
+
describe("mfa-preauth-setup-token", () => {
|
|
10
|
+
test("round-trip: sign → verify → payload", () => {
|
|
11
|
+
const { token } = signMfaPreauthSetupToken(
|
|
12
|
+
{ userId: USER_ID, tenantId: TENANT_ID },
|
|
13
|
+
10,
|
|
14
|
+
SECRET,
|
|
15
|
+
);
|
|
16
|
+
const result = verifyMfaPreauthSetupToken(token, SECRET);
|
|
17
|
+
expect(result.ok).toBe(true);
|
|
18
|
+
if (result.ok) {
|
|
19
|
+
expect(result.payload).toEqual({ userId: USER_ID, tenantId: TENANT_ID });
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("cross-domain replay from a challenge-shaped signature is rejected", () => {
|
|
24
|
+
const { token } = signMfaPreauthSetupToken(
|
|
25
|
+
{ userId: USER_ID, tenantId: TENANT_ID },
|
|
26
|
+
10,
|
|
27
|
+
SECRET,
|
|
28
|
+
);
|
|
29
|
+
const [bodyB64] = token.split(".");
|
|
30
|
+
const forged = `${bodyB64}.${Buffer.from("not-the-real-signature-bytes").toString("base64url")}`;
|
|
31
|
+
expect(verifyMfaPreauthSetupToken(forged, SECRET)).toEqual({
|
|
32
|
+
ok: false,
|
|
33
|
+
reason: "bad_signature",
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("tampered signature → bad_signature", () => {
|
|
38
|
+
const { token } = signMfaPreauthSetupToken(
|
|
39
|
+
{ userId: USER_ID, tenantId: TENANT_ID },
|
|
40
|
+
10,
|
|
41
|
+
SECRET,
|
|
42
|
+
);
|
|
43
|
+
const tampered = `${token.slice(0, -3)}XXX`;
|
|
44
|
+
expect(verifyMfaPreauthSetupToken(tampered, SECRET)).toEqual({
|
|
45
|
+
ok: false,
|
|
46
|
+
reason: "bad_signature",
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("different secret → bad_signature", () => {
|
|
51
|
+
const { token } = signMfaPreauthSetupToken(
|
|
52
|
+
{ userId: USER_ID, tenantId: TENANT_ID },
|
|
53
|
+
10,
|
|
54
|
+
SECRET,
|
|
55
|
+
);
|
|
56
|
+
expect(verifyMfaPreauthSetupToken(token, "other-secret-not-the-same-one!!!!")).toEqual({
|
|
57
|
+
ok: false,
|
|
58
|
+
reason: "bad_signature",
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("expired → expired", () => {
|
|
63
|
+
const t0 = Temporal.Instant.fromEpochMilliseconds(1_700_000_000_000);
|
|
64
|
+
const laterThanTtl = t0.add({ minutes: 11 });
|
|
65
|
+
const { token } = signMfaPreauthSetupToken(
|
|
66
|
+
{ userId: USER_ID, tenantId: TENANT_ID },
|
|
67
|
+
10,
|
|
68
|
+
SECRET,
|
|
69
|
+
t0,
|
|
70
|
+
);
|
|
71
|
+
expect(verifyMfaPreauthSetupToken(token, SECRET, laterThanTtl)).toEqual({
|
|
72
|
+
ok: false,
|
|
73
|
+
reason: "expired",
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("malformed: wrong part count", () => {
|
|
78
|
+
expect(verifyMfaPreauthSetupToken("not-a-token", SECRET)).toEqual({
|
|
79
|
+
ok: false,
|
|
80
|
+
reason: "malformed",
|
|
81
|
+
});
|
|
82
|
+
expect(verifyMfaPreauthSetupToken("a.b.c", SECRET)).toEqual({
|
|
83
|
+
ok: false,
|
|
84
|
+
reason: "malformed",
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -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.
|
|
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
|
-
|
|
71
|
+
const buildSetupToken = (): string =>
|
|
72
|
+
signMfaPreauthSetupToken(
|
|
73
|
+
{ userId, tenantId },
|
|
74
|
+
MFA_PREAUTH_SETUP_TOKEN_TTL_MINUTES,
|
|
75
|
+
opts.challengeTokenSecret,
|
|
76
|
+
).token;
|
|
77
|
+
if (policy === "all") return { setupRequired: true, preauthSetupToken: buildSetupToken() };
|
|
71
78
|
// policy === "admins"
|
|
72
79
|
const isAdmin = roles.some((role) => access.admin.includes(role));
|
|
73
|
-
return isAdmin
|
|
80
|
+
return isAdmin
|
|
81
|
+
? { setupRequired: true, preauthSetupToken: buildSetupToken() }
|
|
82
|
+
: { required: false };
|
|
74
83
|
};
|
|
75
84
|
}
|
|
@@ -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
|
});
|