@cosmicdrift/kumiko-bundled-features 0.160.0 → 0.162.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__/self-registration-toggle.integration.test.ts +168 -0
- package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +5 -0
- package/src/auth-email-password/constants.ts +6 -0
- package/src/auth-email-password/feature.ts +8 -1
- package/src/auth-email-password/handlers/login.write.ts +6 -3
- package/src/auth-email-password/handlers/self-registration-status.query.ts +20 -0
- package/src/auth-email-password/handlers/signup-request.write.ts +11 -0
- package/src/auth-email-password/i18n.ts +4 -0
- package/src/auth-email-password/index.ts +5 -1
- package/src/auth-email-password/self-registration-toggle.ts +25 -0
- package/src/auth-email-password/web/__tests__/auth-client.test.ts +6 -2
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +79 -0
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +14 -5
- package/src/auth-email-password/web/auth-client.ts +4 -3
- package/src/auth-email-password/web/auth-gate.tsx +44 -2
- package/src/auth-email-password/web/login-screen.tsx +11 -5
- 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__/enable-confirm-preauth.integration.test.ts +253 -0
- package/src/auth-mfa/__tests__/enable-start-preauth.integration.test.ts +214 -0
- package/src/auth-mfa/__tests__/mfa-preauth-setup-token.test.ts +87 -0
- package/src/auth-mfa/constants.ts +3 -0
- package/src/auth-mfa/feature.ts +15 -0
- package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +170 -0
- package/src/auth-mfa/handlers/enable-start-preauth.write.ts +87 -0
- package/src/auth-mfa/mfa-preauth-setup-token.ts +86 -0
- package/src/auth-mfa/mfa-setup-token.ts +7 -1
- package/src/auth-mfa/mfa-status-checker.ts +13 -4
- package/src/auth-mfa/web/__tests__/mfa-client.test.ts +144 -1
- package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +202 -0
- package/src/auth-mfa/web/i18n.ts +14 -0
- package/src/auth-mfa/web/index.ts +8 -2
- package/src/auth-mfa/web/mfa-client.ts +113 -0
- package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +244 -0
- package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +96 -0
- package/src/feature-toggles/compose-tier-resolver.ts +41 -0
- package/src/feature-toggles/index.ts +1 -0
- package/src/feature-toggles/toggle-runtime.ts +14 -0
- package/src/sessions/feature.ts +8 -1
|
@@ -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,253 @@
|
|
|
1
|
+
// #1456: completes both the enrollment #1465 started AND the login that
|
|
2
|
+
// mfa-setup-required blocked. Real HTTP through setupTestStack, exercising
|
|
3
|
+
// the full chain: login → preauthSetupToken → preauth-enable-start →
|
|
4
|
+
// preauth-confirm → the minted JWT actually authenticates a follow-up
|
|
5
|
+
// request. That end-to-end seam is the thing a mocked test would fake.
|
|
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 { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
|
|
11
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
12
|
+
import {
|
|
13
|
+
createTestUser,
|
|
14
|
+
setupTestStack,
|
|
15
|
+
type TestStack,
|
|
16
|
+
testTenantId,
|
|
17
|
+
unsafeCreateEntityTable,
|
|
18
|
+
unsafePushTables,
|
|
19
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
20
|
+
import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
|
|
21
|
+
import {
|
|
22
|
+
AuthErrors as AuthEmailPasswordErrors,
|
|
23
|
+
AuthHandlers as AuthEmailPasswordHandlers,
|
|
24
|
+
createAuthEmailPasswordFeature,
|
|
25
|
+
} from "../../auth-email-password";
|
|
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 { base32Decode } from "../base32";
|
|
38
|
+
import { mfaRequiredConfigHandle } from "../config";
|
|
39
|
+
import { AuthMfaHandlers } from "../constants";
|
|
40
|
+
import { createAuthMfaFeature, mfaStatusCheckerFromFeature } from "../feature";
|
|
41
|
+
import { userMfaEntity } from "../schema/user-mfa";
|
|
42
|
+
import { currentTotpCode } from "../totp";
|
|
43
|
+
|
|
44
|
+
let stack: TestStack;
|
|
45
|
+
|
|
46
|
+
const CHALLENGE_TOKEN_SECRET = "integration-test-challenge-token-secret-not-real-0001";
|
|
47
|
+
const SETUP_TOKEN_SECRET = "integration-test-setup-token-secret-not-real-0002";
|
|
48
|
+
const TENANT_ID: TenantId = testTenantId(420);
|
|
49
|
+
|
|
50
|
+
beforeAll(async () => {
|
|
51
|
+
const encryption = createTestEnvelopeCipher(randomBytes(32).toString("base64"));
|
|
52
|
+
configureEntityFieldEncryption(encryption);
|
|
53
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
54
|
+
const authMfaFeature = createAuthMfaFeature({
|
|
55
|
+
setupTokenSecret: SETUP_TOKEN_SECRET,
|
|
56
|
+
issuer: "Kumiko Test",
|
|
57
|
+
challengeTokenSecret: CHALLENGE_TOKEN_SECRET,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
stack = await setupTestStack({
|
|
61
|
+
features: [
|
|
62
|
+
createConfigFeature(),
|
|
63
|
+
createUserFeature(),
|
|
64
|
+
createTenantFeature(),
|
|
65
|
+
authMfaFeature,
|
|
66
|
+
createAuthEmailPasswordFeature({
|
|
67
|
+
mfaStatusChecker: mfaStatusCheckerFromFeature(authMfaFeature),
|
|
68
|
+
}),
|
|
69
|
+
],
|
|
70
|
+
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
71
|
+
authConfig: {
|
|
72
|
+
membershipQuery: "tenant:query:memberships",
|
|
73
|
+
loginHandler: AuthEmailPasswordHandlers.login,
|
|
74
|
+
loginErrorStatusMap: {
|
|
75
|
+
[AuthEmailPasswordErrors.invalidCredentials]: 401,
|
|
76
|
+
[AuthEmailPasswordErrors.noMembership]: 403,
|
|
77
|
+
},
|
|
78
|
+
mfaPreauthEnableStartHandler: AuthMfaHandlers.enableStartPreauth,
|
|
79
|
+
mfaPreauthConfirmHandler: AuthMfaHandlers.enableConfirmPreauth,
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
84
|
+
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
85
|
+
await unsafeCreateEntityTable(stack.db, userMfaEntity);
|
|
86
|
+
await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
afterAll(async () => {
|
|
90
|
+
await stack.cleanup();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
beforeEach(async () => {
|
|
94
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
|
|
95
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
async function loginBlockedByEnforcement(
|
|
99
|
+
email = "unenrolled@example.com",
|
|
100
|
+
): Promise<{ preauthSetupToken: string; userId: string }> {
|
|
101
|
+
const password = "correct-horse-battery-2026";
|
|
102
|
+
const hash = await hashPassword(password);
|
|
103
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
104
|
+
UserHandlers.create,
|
|
105
|
+
{ email, passwordHash: hash, displayName: "Unenrolled" },
|
|
106
|
+
createTestUser({ id: 421, tenantId: TENANT_ID, roles: ["SystemAdmin"] }),
|
|
107
|
+
);
|
|
108
|
+
await seedTenantMembership(stack.db, {
|
|
109
|
+
userId: created.id,
|
|
110
|
+
tenantId: TENANT_ID,
|
|
111
|
+
roles: ["User"],
|
|
112
|
+
});
|
|
113
|
+
await stack.http.writeOk(
|
|
114
|
+
ConfigHandlers.set,
|
|
115
|
+
{ key: mfaRequiredConfigHandle.name, value: "all" },
|
|
116
|
+
createTestUser({ id: 422, tenantId: TENANT_ID, roles: ["Admin"] }),
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const loginRes = await stack.http.raw("POST", "/api/auth/login", { email, password });
|
|
120
|
+
const loginBody = await loginRes.json();
|
|
121
|
+
return { preauthSetupToken: loginBody.preauthSetupToken as string, userId: created.id };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function startEnrollment(
|
|
125
|
+
preauthSetupToken: string,
|
|
126
|
+
accountLabel: string,
|
|
127
|
+
): Promise<{ setupToken: string; secret: Buffer }> {
|
|
128
|
+
const startRes = await stack.http.raw("POST", "/api/auth/mfa/preauth-enable-start", {
|
|
129
|
+
preauthSetupToken,
|
|
130
|
+
accountLabel,
|
|
131
|
+
});
|
|
132
|
+
const startBody = await startRes.json();
|
|
133
|
+
const secretParam = new URLSearchParams(startBody.otpauthUri.split("?")[1]).get("secret") ?? "";
|
|
134
|
+
return { setupToken: startBody.setupToken as string, secret: base32Decode(secretParam) };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
describe("POST /auth/mfa/preauth-confirm", () => {
|
|
138
|
+
test("completes enrollment + login: full chain, minted JWT authenticates a follow-up request", async () => {
|
|
139
|
+
const email = "chain@example.com";
|
|
140
|
+
const { preauthSetupToken } = await loginBlockedByEnforcement(email);
|
|
141
|
+
const { setupToken, secret } = await startEnrollment(preauthSetupToken, email);
|
|
142
|
+
|
|
143
|
+
const res = await stack.http.raw("POST", "/api/auth/mfa/preauth-confirm", {
|
|
144
|
+
setupToken,
|
|
145
|
+
code: currentTotpCode(secret),
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
expect(res.status).toBe(200);
|
|
149
|
+
const body = await res.json();
|
|
150
|
+
expect(body.isSuccess).toBe(true);
|
|
151
|
+
expect(typeof body.token).toBe("string");
|
|
152
|
+
expect(body.user.tenantId).toBe(TENANT_ID);
|
|
153
|
+
|
|
154
|
+
// The proof that matters: the minted JWT actually authenticates.
|
|
155
|
+
const meRes = await stack.http.raw(
|
|
156
|
+
"POST",
|
|
157
|
+
"/api/query",
|
|
158
|
+
{ type: "user:query:user:me", payload: {} },
|
|
159
|
+
{ Authorization: `Bearer ${body.token}` },
|
|
160
|
+
);
|
|
161
|
+
expect(meRes.status).toBe(200);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("wrong TOTP code → invalid_totp_code, no enrollment persisted", async () => {
|
|
165
|
+
const email = "wrongcode@example.com";
|
|
166
|
+
const { preauthSetupToken } = await loginBlockedByEnforcement(email);
|
|
167
|
+
const { setupToken } = await startEnrollment(preauthSetupToken, email);
|
|
168
|
+
|
|
169
|
+
const res = await stack.http.raw("POST", "/api/auth/mfa/preauth-confirm", {
|
|
170
|
+
setupToken,
|
|
171
|
+
code: "000000",
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
expect(res.status).not.toBe(200);
|
|
175
|
+
const body = await res.json();
|
|
176
|
+
expect(body.isSuccess).toBe(false);
|
|
177
|
+
expect(body.error.details.reason).toBe("invalid_totp_code");
|
|
178
|
+
expect(body.token).toBeUndefined();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("replaying the same setupToken after a successful confirm → invalid_setup_token (burned), not a second enrollment", async () => {
|
|
182
|
+
const email = "race@example.com";
|
|
183
|
+
const { preauthSetupToken } = await loginBlockedByEnforcement(email);
|
|
184
|
+
const { setupToken, secret } = await startEnrollment(preauthSetupToken, email);
|
|
185
|
+
const code = currentTotpCode(secret);
|
|
186
|
+
|
|
187
|
+
const first = await stack.http.raw("POST", "/api/auth/mfa/preauth-confirm", {
|
|
188
|
+
setupToken,
|
|
189
|
+
code,
|
|
190
|
+
});
|
|
191
|
+
expect(first.status).toBe(200);
|
|
192
|
+
|
|
193
|
+
// Same setupToken again — this integration stack wires real Redis, so
|
|
194
|
+
// the single-use burn from the first call rejects the replay before it
|
|
195
|
+
// ever reaches the already-enrolled DB check.
|
|
196
|
+
const second = await stack.http.raw("POST", "/api/auth/mfa/preauth-confirm", {
|
|
197
|
+
setupToken,
|
|
198
|
+
code,
|
|
199
|
+
});
|
|
200
|
+
expect(second.status).not.toBe(200);
|
|
201
|
+
const secondBody = await second.json();
|
|
202
|
+
expect(secondBody.error.details.reason).toBe("invalid_setup_token");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("a session-issued setupToken (no tenantId) is rejected, not silently accepted", async () => {
|
|
206
|
+
const email = "sessionissued@example.com";
|
|
207
|
+
const password = "correct-horse-battery-2026";
|
|
208
|
+
const hash = await hashPassword(password);
|
|
209
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
210
|
+
UserHandlers.create,
|
|
211
|
+
{ email, passwordHash: hash, displayName: "Session Issued" },
|
|
212
|
+
createTestUser({ id: 423, tenantId: TENANT_ID, roles: ["SystemAdmin"] }),
|
|
213
|
+
);
|
|
214
|
+
await seedTenantMembership(stack.db, {
|
|
215
|
+
userId: created.id,
|
|
216
|
+
tenantId: TENANT_ID,
|
|
217
|
+
roles: ["User"],
|
|
218
|
+
});
|
|
219
|
+
const sessionUser = createTestUser({ id: created.id, tenantId: TENANT_ID, roles: ["User"] });
|
|
220
|
+
|
|
221
|
+
// The session-authed enable-start signs a setupToken with the SAME
|
|
222
|
+
// secret + format, but never carries tenantId (see mfa-setup-token.ts).
|
|
223
|
+
// A malicious or confused client resubmitting it to the pre-auth
|
|
224
|
+
// endpoint must not be able to piggyback on tenantId inference.
|
|
225
|
+
const start = await stack.http.writeOk<{ setupToken: string }>(
|
|
226
|
+
AuthMfaHandlers.enableStart,
|
|
227
|
+
{ accountLabel: email },
|
|
228
|
+
sessionUser,
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
const res = await stack.http.raw("POST", "/api/auth/mfa/preauth-confirm", {
|
|
232
|
+
setupToken: start.setupToken,
|
|
233
|
+
code: "123456",
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
expect(res.status).not.toBe(200);
|
|
237
|
+
const body = await res.json();
|
|
238
|
+
expect(body.isSuccess).toBe(false);
|
|
239
|
+
expect(body.error.details.reason).toBe("invalid_setup_token");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("expired/garbage setupToken → invalid_setup_token, no session minted", async () => {
|
|
243
|
+
const res = await stack.http.raw("POST", "/api/auth/mfa/preauth-confirm", {
|
|
244
|
+
setupToken: "not-a-real-token",
|
|
245
|
+
code: "123456",
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
expect(res.status).not.toBe(200);
|
|
249
|
+
const body = await res.json();
|
|
250
|
+
expect(body.isSuccess).toBe(false);
|
|
251
|
+
expect(body.token).toBeUndefined();
|
|
252
|
+
});
|
|
253
|
+
});
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// #1465: a user blocked at login by MFA enforcement (mfa-setup-required,
|
|
2
|
+
// see #1455) can generate a TOTP secret + QR without a session, using the
|
|
3
|
+
// preauthSetupToken login.write.ts issued. Real HTTP through
|
|
4
|
+
// setupTestStack, exercising the full chain: login → preauthSetupToken →
|
|
5
|
+
// POST /auth/mfa/preauth-enable-start → a setupToken that #1456's
|
|
6
|
+
// (not-yet-built) confirm step will consume via verifyMfaSetupToken.
|
|
7
|
+
|
|
8
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { randomBytes } from "node:crypto";
|
|
10
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
11
|
+
import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
|
|
12
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
13
|
+
import {
|
|
14
|
+
createTestUser,
|
|
15
|
+
setupTestStack,
|
|
16
|
+
type TestStack,
|
|
17
|
+
testTenantId,
|
|
18
|
+
unsafeCreateEntityTable,
|
|
19
|
+
unsafePushTables,
|
|
20
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
21
|
+
import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
|
|
22
|
+
import {
|
|
23
|
+
AuthErrors as AuthEmailPasswordErrors,
|
|
24
|
+
AuthHandlers as AuthEmailPasswordHandlers,
|
|
25
|
+
createAuthEmailPasswordFeature,
|
|
26
|
+
} from "../../auth-email-password";
|
|
27
|
+
import { ConfigHandlers, createConfigFeature } from "../../config";
|
|
28
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
29
|
+
import { configValuesTable } from "../../config/table";
|
|
30
|
+
import { hashPassword } from "../../shared";
|
|
31
|
+
import { createTenantFeature } from "../../tenant";
|
|
32
|
+
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
33
|
+
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
34
|
+
import { seedTenantMembership } from "../../tenant/testing";
|
|
35
|
+
import { UserHandlers } from "../../user";
|
|
36
|
+
import { createUserFeature } from "../../user/feature";
|
|
37
|
+
import { userEntity, userTable } from "../../user/schema/user";
|
|
38
|
+
import { base32Decode } from "../base32";
|
|
39
|
+
import { mfaRequiredConfigHandle } from "../config";
|
|
40
|
+
import { AuthMfaHandlers } from "../constants";
|
|
41
|
+
import { createAuthMfaFeature, mfaStatusCheckerFromFeature } from "../feature";
|
|
42
|
+
import { signMfaPreauthSetupToken } from "../mfa-preauth-setup-token";
|
|
43
|
+
import { verifyMfaSetupToken } from "../mfa-setup-token";
|
|
44
|
+
import { userMfaEntity } from "../schema/user-mfa";
|
|
45
|
+
import { currentTotpCode } from "../totp";
|
|
46
|
+
|
|
47
|
+
let stack: TestStack;
|
|
48
|
+
|
|
49
|
+
const CHALLENGE_TOKEN_SECRET = "test-mfa-challenge-secret-at-least-32-bytes!!";
|
|
50
|
+
const SETUP_TOKEN_SECRET = "test-mfa-setup-token-secret-at-least-32-bytes!!";
|
|
51
|
+
const TENANT_ID: TenantId = testTenantId(410);
|
|
52
|
+
|
|
53
|
+
beforeAll(async () => {
|
|
54
|
+
const encryption = createTestEnvelopeCipher(randomBytes(32).toString("base64"));
|
|
55
|
+
configureEntityFieldEncryption(encryption);
|
|
56
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
57
|
+
const authMfaFeature = createAuthMfaFeature({
|
|
58
|
+
setupTokenSecret: SETUP_TOKEN_SECRET,
|
|
59
|
+
issuer: "Kumiko Test",
|
|
60
|
+
challengeTokenSecret: CHALLENGE_TOKEN_SECRET,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
stack = await setupTestStack({
|
|
64
|
+
features: [
|
|
65
|
+
createConfigFeature(),
|
|
66
|
+
createUserFeature(),
|
|
67
|
+
createTenantFeature(),
|
|
68
|
+
authMfaFeature,
|
|
69
|
+
createAuthEmailPasswordFeature({
|
|
70
|
+
mfaStatusChecker: mfaStatusCheckerFromFeature(authMfaFeature),
|
|
71
|
+
}),
|
|
72
|
+
],
|
|
73
|
+
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
74
|
+
authConfig: {
|
|
75
|
+
membershipQuery: "tenant:query:memberships",
|
|
76
|
+
loginHandler: AuthEmailPasswordHandlers.login,
|
|
77
|
+
loginErrorStatusMap: {
|
|
78
|
+
[AuthEmailPasswordErrors.invalidCredentials]: 401,
|
|
79
|
+
[AuthEmailPasswordErrors.noMembership]: 403,
|
|
80
|
+
},
|
|
81
|
+
mfaPreauthEnableStartHandler: AuthMfaHandlers.enableStartPreauth,
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
86
|
+
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
87
|
+
await unsafeCreateEntityTable(stack.db, userMfaEntity);
|
|
88
|
+
await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
afterAll(async () => {
|
|
92
|
+
await stack.cleanup();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
beforeEach(async () => {
|
|
96
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
|
|
97
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
async function loginBlockedByEnforcement(): Promise<{
|
|
101
|
+
preauthSetupToken: string;
|
|
102
|
+
userId: string;
|
|
103
|
+
}> {
|
|
104
|
+
const password = "correct-horse-battery-2026";
|
|
105
|
+
const hash = await hashPassword(password);
|
|
106
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
107
|
+
UserHandlers.create,
|
|
108
|
+
{ email: "unenrolled@example.com", passwordHash: hash, displayName: "Unenrolled" },
|
|
109
|
+
createTestUser({ id: 411, tenantId: TENANT_ID, roles: ["SystemAdmin"] }),
|
|
110
|
+
);
|
|
111
|
+
await seedTenantMembership(stack.db, {
|
|
112
|
+
userId: created.id,
|
|
113
|
+
tenantId: TENANT_ID,
|
|
114
|
+
roles: ["User"],
|
|
115
|
+
});
|
|
116
|
+
await stack.http.writeOk(
|
|
117
|
+
ConfigHandlers.set,
|
|
118
|
+
{ key: mfaRequiredConfigHandle.name, value: "all" },
|
|
119
|
+
createTestUser({ id: 412, tenantId: TENANT_ID, roles: ["Admin"] }),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const loginRes = await stack.http.raw("POST", "/api/auth/login", {
|
|
123
|
+
email: "unenrolled@example.com",
|
|
124
|
+
password,
|
|
125
|
+
});
|
|
126
|
+
const loginBody = await loginRes.json();
|
|
127
|
+
return { preauthSetupToken: loginBody.preauthSetupToken as string, userId: created.id };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
describe("POST /auth/mfa/preauth-enable-start", () => {
|
|
131
|
+
test("generates a TOTP secret + QR from a valid preauthSetupToken, no session minted", async () => {
|
|
132
|
+
const { preauthSetupToken } = await loginBlockedByEnforcement();
|
|
133
|
+
|
|
134
|
+
const res = await stack.http.raw("POST", "/api/auth/mfa/preauth-enable-start", {
|
|
135
|
+
preauthSetupToken,
|
|
136
|
+
accountLabel: "unenrolled@example.com",
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
expect(res.status).toBe(200);
|
|
140
|
+
const body = await res.json();
|
|
141
|
+
expect(body.isSuccess).toBe(true);
|
|
142
|
+
expect(body.token).toBeUndefined();
|
|
143
|
+
expect(typeof body.setupToken).toBe("string");
|
|
144
|
+
expect(typeof body.otpauthUri).toBe("string");
|
|
145
|
+
expect(body.otpauthUri).toContain("otpauth://totp/");
|
|
146
|
+
expect(Array.isArray(body.recoveryCodes)).toBe(true);
|
|
147
|
+
expect(body.recoveryCodes).toHaveLength(8);
|
|
148
|
+
|
|
149
|
+
// The returned setupToken must compose with the EXISTING enable-confirm
|
|
150
|
+
// verification path (verifyMfaSetupToken) — proves #1465 and #1456
|
|
151
|
+
// actually chain together, not just that each returns *a* string.
|
|
152
|
+
const verified = verifyMfaSetupToken(body.setupToken, SETUP_TOKEN_SECRET);
|
|
153
|
+
expect(verified.ok).toBe(true);
|
|
154
|
+
if (verified.ok) {
|
|
155
|
+
expect(typeof verified.payload.totpSecretBase32).toBe("string");
|
|
156
|
+
expect(verified.payload.recoveryCodeHashes).toHaveLength(8);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("expired/garbage preauthSetupToken → invalid_challenge_token, no secret leaked", async () => {
|
|
161
|
+
const res = await stack.http.raw("POST", "/api/auth/mfa/preauth-enable-start", {
|
|
162
|
+
preauthSetupToken: "not-a-real-token",
|
|
163
|
+
accountLabel: "unenrolled@example.com",
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
expect(res.status).not.toBe(200);
|
|
167
|
+
const body = await res.json();
|
|
168
|
+
expect(body.isSuccess).toBe(false);
|
|
169
|
+
expect(body.setupToken).toBeUndefined();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("already-enrolled user gets mfa_already_enabled, not a fresh secret", async () => {
|
|
173
|
+
const { userId } = await loginBlockedByEnforcement();
|
|
174
|
+
const sessionUser = createTestUser({ id: userId, tenantId: TENANT_ID, roles: ["User"] });
|
|
175
|
+
|
|
176
|
+
// Really enrolls — via the existing session-authed enable-start/confirm
|
|
177
|
+
// pair, not a synthetic DB insert — so this test breaks if enrollment
|
|
178
|
+
// detection ever drifts from what a real enroll actually persists.
|
|
179
|
+
const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
|
|
180
|
+
AuthMfaHandlers.enableStart,
|
|
181
|
+
{ accountLabel: "unenrolled@example.com" },
|
|
182
|
+
sessionUser,
|
|
183
|
+
);
|
|
184
|
+
const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
|
|
185
|
+
const secret = base32Decode(secretParam);
|
|
186
|
+
await stack.http.writeOk(
|
|
187
|
+
AuthMfaHandlers.enableConfirm,
|
|
188
|
+
{ setupToken: start.setupToken, code: currentTotpCode(secret) },
|
|
189
|
+
sessionUser,
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
// A fresh preauthSetupToken for the SAME already-enrolled user — the
|
|
193
|
+
// login flow itself would no longer issue one (mfaStatusChecker sees
|
|
194
|
+
// the row and returns `required: true`, not `setupRequired`), so this
|
|
195
|
+
// mints one directly to isolate the handler's own already-enrolled
|
|
196
|
+
// check from that unrelated login-side branch.
|
|
197
|
+
const { token: freshPreauthToken } = signMfaPreauthSetupToken(
|
|
198
|
+
{ userId, tenantId: TENANT_ID },
|
|
199
|
+
10,
|
|
200
|
+
CHALLENGE_TOKEN_SECRET,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const res = await stack.http.raw("POST", "/api/auth/mfa/preauth-enable-start", {
|
|
204
|
+
preauthSetupToken: freshPreauthToken,
|
|
205
|
+
accountLabel: "unenrolled@example.com",
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
expect(res.status).not.toBe(200);
|
|
209
|
+
const body = await res.json();
|
|
210
|
+
expect(body.isSuccess).toBe(false);
|
|
211
|
+
expect(body.error.details.reason).toBe("mfa_already_enabled");
|
|
212
|
+
expect(body.setupToken).toBeUndefined();
|
|
213
|
+
});
|
|
214
|
+
});
|
|
@@ -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
|
+
});
|