@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.162.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -117,11 +117,11 @@
|
|
|
117
117
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
118
118
|
},
|
|
119
119
|
"dependencies": {
|
|
120
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
121
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
122
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
123
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
124
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
120
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.162.0",
|
|
121
|
+
"@cosmicdrift/kumiko-framework": "0.162.0",
|
|
122
|
+
"@cosmicdrift/kumiko-headless": "0.162.0",
|
|
123
|
+
"@cosmicdrift/kumiko-renderer": "0.162.0",
|
|
124
|
+
"@cosmicdrift/kumiko-renderer-web": "0.162.0",
|
|
125
125
|
"@mollie/api-client": "^4.5.0",
|
|
126
126
|
"imapflow": "^1.3.3",
|
|
127
127
|
"mailparser": "^3.9.8",
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// #1455: enforcement policy blocks an unenrolled user at login
|
|
2
|
+
// (mfa-setup-required) — the response must carry a preauthSetupToken so a
|
|
3
|
+
// later pre-auth enroll step (#1231) can look the user back up without a
|
|
4
|
+
// session. Real HTTP through setupTestStack, both auth-mfa and
|
|
5
|
+
// auth-email-password mounted together (mirrors auth.integration.test.ts).
|
|
6
|
+
|
|
7
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
9
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
10
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
11
|
+
import {
|
|
12
|
+
createTestUser,
|
|
13
|
+
setupTestStack,
|
|
14
|
+
type TestStack,
|
|
15
|
+
testTenantId,
|
|
16
|
+
unsafeCreateEntityTable,
|
|
17
|
+
unsafePushTables,
|
|
18
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
19
|
+
import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
|
|
20
|
+
import {
|
|
21
|
+
createAuthMfaFeature,
|
|
22
|
+
mfaRequiredConfigHandle,
|
|
23
|
+
mfaStatusCheckerFromFeature,
|
|
24
|
+
} from "../../auth-mfa";
|
|
25
|
+
import { userMfaEntity } from "../../auth-mfa/schema/user-mfa";
|
|
26
|
+
import { ConfigHandlers, createConfigFeature } from "../../config";
|
|
27
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
28
|
+
import { configValuesTable } from "../../config/table";
|
|
29
|
+
import { hashPassword } from "../../shared";
|
|
30
|
+
import { createTenantFeature } from "../../tenant";
|
|
31
|
+
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
32
|
+
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
33
|
+
import { seedTenantMembership } from "../../tenant/testing";
|
|
34
|
+
import { UserHandlers } from "../../user";
|
|
35
|
+
import { createUserFeature } from "../../user/feature";
|
|
36
|
+
import { userEntity, userTable } from "../../user/schema/user";
|
|
37
|
+
import { AuthErrors, AuthHandlers } from "../constants";
|
|
38
|
+
import { createAuthEmailPasswordFeature } from "../feature";
|
|
39
|
+
|
|
40
|
+
let stack: TestStack;
|
|
41
|
+
|
|
42
|
+
const CHALLENGE_TOKEN_SECRET = "test-mfa-challenge-secret-at-least-32-bytes!!";
|
|
43
|
+
const TENANT_ID: TenantId = testTenantId(400);
|
|
44
|
+
|
|
45
|
+
beforeAll(async () => {
|
|
46
|
+
const encryption = createTestEnvelopeCipher(randomBytes(32).toString("base64"));
|
|
47
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
48
|
+
const authMfaFeature = createAuthMfaFeature({
|
|
49
|
+
setupTokenSecret: "test-mfa-setup-token-secret-at-least-32-bytes!!",
|
|
50
|
+
issuer: "Kumiko Test",
|
|
51
|
+
challengeTokenSecret: CHALLENGE_TOKEN_SECRET,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
stack = await setupTestStack({
|
|
55
|
+
features: [
|
|
56
|
+
createConfigFeature(),
|
|
57
|
+
createUserFeature(),
|
|
58
|
+
createTenantFeature(),
|
|
59
|
+
authMfaFeature,
|
|
60
|
+
createAuthEmailPasswordFeature({
|
|
61
|
+
mfaStatusChecker: mfaStatusCheckerFromFeature(authMfaFeature),
|
|
62
|
+
}),
|
|
63
|
+
],
|
|
64
|
+
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
65
|
+
authConfig: {
|
|
66
|
+
membershipQuery: "tenant:query:memberships",
|
|
67
|
+
loginHandler: AuthHandlers.login,
|
|
68
|
+
loginErrorStatusMap: {
|
|
69
|
+
[AuthErrors.invalidCredentials]: 401,
|
|
70
|
+
[AuthErrors.noMembership]: 403,
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
76
|
+
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
77
|
+
await unsafeCreateEntityTable(stack.db, userMfaEntity);
|
|
78
|
+
await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
afterAll(async () => {
|
|
82
|
+
await stack.cleanup();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
beforeEach(async () => {
|
|
86
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
|
|
87
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("login: mfa-setup-required carries a verifiable preauthSetupToken", () => {
|
|
91
|
+
test("unenrolled user blocked by 'all' policy gets a token instead of a session", async () => {
|
|
92
|
+
const password = "correct-horse-battery-2026";
|
|
93
|
+
const hash = await hashPassword(password);
|
|
94
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
95
|
+
UserHandlers.create,
|
|
96
|
+
{ email: "unenrolled@example.com", passwordHash: hash, displayName: "Unenrolled" },
|
|
97
|
+
createTestUser({ id: 401, tenantId: TENANT_ID, roles: ["SystemAdmin"] }),
|
|
98
|
+
);
|
|
99
|
+
await seedTenantMembership(stack.db, {
|
|
100
|
+
userId: created.id,
|
|
101
|
+
tenantId: TENANT_ID,
|
|
102
|
+
roles: ["User"],
|
|
103
|
+
});
|
|
104
|
+
await stack.http.writeOk(
|
|
105
|
+
ConfigHandlers.set,
|
|
106
|
+
{ key: mfaRequiredConfigHandle.name, value: "all" },
|
|
107
|
+
createTestUser({ id: 402, tenantId: TENANT_ID, roles: ["Admin"] }),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const res = await stack.http.raw("POST", "/api/auth/login", {
|
|
111
|
+
email: "unenrolled@example.com",
|
|
112
|
+
password,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
expect(res.status).toBe(200);
|
|
116
|
+
const body = await res.json();
|
|
117
|
+
expect(body.isSuccess).toBe(true);
|
|
118
|
+
expect(body.mfaSetupRequired).toBe(true);
|
|
119
|
+
expect(body.token).toBeUndefined();
|
|
120
|
+
expect(typeof body.preauthSetupToken).toBe("string");
|
|
121
|
+
|
|
122
|
+
const { verifyMfaPreauthSetupToken } = await import("../../auth-mfa/mfa-preauth-setup-token");
|
|
123
|
+
const verified = verifyMfaPreauthSetupToken(body.preauthSetupToken, CHALLENGE_TOKEN_SECRET);
|
|
124
|
+
expect(verified.ok).toBe(true);
|
|
125
|
+
if (verified.ok) {
|
|
126
|
+
expect(verified.payload).toEqual({ userId: created.id, tenantId: TENANT_ID });
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// Runtime on/off switch for self-signup (#self-registration-toggle). Pins:
|
|
2
|
+
// 1. Default (no override row) → signup-request works.
|
|
3
|
+
// 2. runtime.apply(off) → signup-request 403 feature_disabled, no mail sent.
|
|
4
|
+
// 3. runtime.apply(on) → works again.
|
|
5
|
+
// Mirrors samples/recipes/feature-toggles' runtime.apply() pattern — flips the
|
|
6
|
+
// in-memory snapshot directly instead of going through the set-handler/HTTP,
|
|
7
|
+
// since that wiring is already covered by feature-toggles' own tests.
|
|
8
|
+
|
|
9
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
10
|
+
import {
|
|
11
|
+
createFeatureTogglesFeature,
|
|
12
|
+
GlobalFeatureToggleRuntime,
|
|
13
|
+
globalFeatureStateTable,
|
|
14
|
+
} from "@cosmicdrift/kumiko-bundled-features/feature-toggles";
|
|
15
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
16
|
+
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
17
|
+
import {
|
|
18
|
+
setupTestStack,
|
|
19
|
+
type TestStack,
|
|
20
|
+
unsafeCreateEntityTable,
|
|
21
|
+
unsafePushTables,
|
|
22
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
23
|
+
import { createLateBoundHolder } from "@cosmicdrift/kumiko-framework/testing";
|
|
24
|
+
import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
|
|
25
|
+
import { createConfigFeature } from "../../config";
|
|
26
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
27
|
+
import { configValuesTable } from "../../config/table";
|
|
28
|
+
import { createDeliveryFeature, createDeliveryTestContext } from "../../delivery";
|
|
29
|
+
import { notificationPreferencesTable } from "../../delivery/tables";
|
|
30
|
+
import { createRendererFoundationFeature } from "../../renderer-foundation/feature";
|
|
31
|
+
import { createRendererSimpleFeature, simpleRenderer } from "../../renderer-simple";
|
|
32
|
+
import { createTemplateResolverFeature } from "../../template-resolver/feature";
|
|
33
|
+
import { createTenantFeature } from "../../tenant";
|
|
34
|
+
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
35
|
+
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
36
|
+
import { createUserFeature } from "../../user/feature";
|
|
37
|
+
import { userEntity, userTable } from "../../user/schema/user";
|
|
38
|
+
import { AuthHandlers } from "../constants";
|
|
39
|
+
import { createAuthEmailPasswordFeature } from "../feature";
|
|
40
|
+
import {
|
|
41
|
+
AUTH_SELF_REGISTRATION_FEATURE,
|
|
42
|
+
createAuthSelfRegistrationToggleFeature,
|
|
43
|
+
} from "../self-registration-toggle";
|
|
44
|
+
|
|
45
|
+
const APP_ACTIVATION_URL = "https://app.example.com/signup/complete";
|
|
46
|
+
const emailTransport = createInMemoryTransport();
|
|
47
|
+
|
|
48
|
+
let stack: TestStack;
|
|
49
|
+
let runtime: GlobalFeatureToggleRuntime;
|
|
50
|
+
|
|
51
|
+
beforeAll(async () => {
|
|
52
|
+
let effective: () => ReadonlySet<string> = () => new Set();
|
|
53
|
+
const runtimeHolder = createLateBoundHolder<GlobalFeatureToggleRuntime>("runtime");
|
|
54
|
+
|
|
55
|
+
stack = await setupTestStack({
|
|
56
|
+
features: [
|
|
57
|
+
createConfigFeature(),
|
|
58
|
+
createUserFeature(),
|
|
59
|
+
createTenantFeature(),
|
|
60
|
+
createTemplateResolverFeature(),
|
|
61
|
+
createRendererFoundationFeature(),
|
|
62
|
+
createDeliveryFeature(),
|
|
63
|
+
createRendererSimpleFeature(),
|
|
64
|
+
createChannelEmailFeature({
|
|
65
|
+
transport: emailTransport,
|
|
66
|
+
renderer: simpleRenderer,
|
|
67
|
+
resolveEmail: async () => "unused@test.local",
|
|
68
|
+
}),
|
|
69
|
+
createAuthEmailPasswordFeature({
|
|
70
|
+
signup: { tokenTtlMinutes: 60, appUrl: APP_ACTIVATION_URL },
|
|
71
|
+
}),
|
|
72
|
+
createAuthSelfRegistrationToggleFeature(),
|
|
73
|
+
createFeatureTogglesFeature({ getRuntime: () => runtimeHolder.get() }),
|
|
74
|
+
],
|
|
75
|
+
effectiveFeatures: () => effective(),
|
|
76
|
+
extraContext: (deps) => ({
|
|
77
|
+
...createDeliveryTestContext(deps),
|
|
78
|
+
configResolver: createConfigResolver(),
|
|
79
|
+
}),
|
|
80
|
+
systemHooks: [],
|
|
81
|
+
anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID },
|
|
82
|
+
authConfig: {
|
|
83
|
+
membershipQuery: "tenant:query:memberships",
|
|
84
|
+
loginHandler: AuthHandlers.login,
|
|
85
|
+
signup: {
|
|
86
|
+
requestHandler: AuthHandlers.signupRequest,
|
|
87
|
+
confirmHandler: AuthHandlers.signupConfirm,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
93
|
+
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
94
|
+
await unsafePushTables(stack.db, {
|
|
95
|
+
configValuesTable,
|
|
96
|
+
tenantMembershipsTable,
|
|
97
|
+
notificationPreferencesTable,
|
|
98
|
+
globalFeatureStateTable,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
runtime = new GlobalFeatureToggleRuntime(stack.db, stack.registry);
|
|
102
|
+
await runtime.initialize();
|
|
103
|
+
effective = runtime.effectiveFeatures;
|
|
104
|
+
runtimeHolder.set(runtime);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
afterAll(async () => {
|
|
108
|
+
await stack.cleanup();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
beforeEach(async () => {
|
|
112
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
|
|
113
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${globalFeatureStateTable.tableName}"`);
|
|
114
|
+
await runtime.refresh();
|
|
115
|
+
emailTransport.sent.length = 0;
|
|
116
|
+
const allKeys = await stack.redis.redis.keys("signup:*");
|
|
117
|
+
if (allKeys.length > 0) await stack.redis.redis.del(...allKeys);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
async function postSignupRequest(email: string): Promise<Response> {
|
|
121
|
+
return stack.http.raw("POST", "/api/auth/signup-request", { email });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
describe("auth-self-registration toggle", () => {
|
|
125
|
+
test("default (no override) → signup-request works", async () => {
|
|
126
|
+
const res = await postSignupRequest("alice@example.com");
|
|
127
|
+
expect(res.status).toBe(200);
|
|
128
|
+
expect(emailTransport.sent).toHaveLength(1);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("runtime off → signup-request still 200 (anti-enumeration silent-success) but no mail sent", async () => {
|
|
132
|
+
runtime.apply(AUTH_SELF_REGISTRATION_FEATURE, false);
|
|
133
|
+
|
|
134
|
+
const res = await postSignupRequest("blocked@example.com");
|
|
135
|
+
expect(res.status).toBe(200);
|
|
136
|
+
expect(emailTransport.sent).toHaveLength(0);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("runtime back on → mail sends again", async () => {
|
|
140
|
+
runtime.apply(AUTH_SELF_REGISTRATION_FEATURE, false);
|
|
141
|
+
await postSignupRequest("still-blocked@example.com");
|
|
142
|
+
expect(emailTransport.sent).toHaveLength(0);
|
|
143
|
+
|
|
144
|
+
runtime.apply(AUTH_SELF_REGISTRATION_FEATURE, true);
|
|
145
|
+
const res = await postSignupRequest("reenabled@example.com");
|
|
146
|
+
expect(res.status).toBe(200);
|
|
147
|
+
expect(emailTransport.sent).toHaveLength(1);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("status query reflects the runtime flip", async () => {
|
|
151
|
+
const onRes = await stack.http.raw("POST", "/api/query", {
|
|
152
|
+
type: "auth-email-password:query:signup-registration-status",
|
|
153
|
+
payload: {},
|
|
154
|
+
});
|
|
155
|
+
expect((await onRes.json()) as { data?: { enabled: boolean } }).toMatchObject({
|
|
156
|
+
data: { enabled: true },
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
runtime.apply(AUTH_SELF_REGISTRATION_FEATURE, false);
|
|
160
|
+
const offRes = await stack.http.raw("POST", "/api/query", {
|
|
161
|
+
type: "auth-email-password:query:signup-registration-status",
|
|
162
|
+
payload: {},
|
|
163
|
+
});
|
|
164
|
+
expect((await offRes.json()) as { data?: { enabled: boolean } }).toMatchObject({
|
|
165
|
+
data: { enabled: false },
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -158,6 +158,11 @@ async function seedUser(opts: {
|
|
|
158
158
|
roles: t.roles,
|
|
159
159
|
});
|
|
160
160
|
}
|
|
161
|
+
// request-helper mints a sid for systemAdmin when sessionCreator is wired
|
|
162
|
+
// (#1372) — that is seed plumbing, not the auth-route behavior under test.
|
|
163
|
+
store.live.clear();
|
|
164
|
+
store.created.length = 0;
|
|
165
|
+
store.revoked.length = 0;
|
|
161
166
|
return { userId: created.id };
|
|
162
167
|
}
|
|
163
168
|
|
|
@@ -46,6 +46,12 @@ export const AuthHandlers = {
|
|
|
46
46
|
inviteCancel: "auth-email-password:write:invite-cancel",
|
|
47
47
|
} as const;
|
|
48
48
|
|
|
49
|
+
// Qualified query names. Anonymous-readable status so the (unauthenticated)
|
|
50
|
+
// signup page can decide whether to show its own link/form.
|
|
51
|
+
export const AuthQueries = {
|
|
52
|
+
signupRegistrationStatus: "auth-email-password:query:signup-registration-status",
|
|
53
|
+
} as const;
|
|
54
|
+
|
|
49
55
|
// Error codes — kept intentionally generic so clients can't distinguish
|
|
50
56
|
// "email doesn't exist" from "password wrong". Both surface as invalid_credentials.
|
|
51
57
|
// Soft-deleted users also collapse into invalid_credentials to avoid enumeration.
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
import { createRequestEmailVerificationHandler } from "./handlers/request-email-verification.write";
|
|
21
21
|
import { createRequestPasswordResetHandler } from "./handlers/request-password-reset.write";
|
|
22
22
|
import { createResetPasswordHandler } from "./handlers/reset-password.write";
|
|
23
|
+
import { selfRegistrationStatusQuery } from "./handlers/self-registration-status.query";
|
|
23
24
|
import { createSignupConfirmHandler } from "./handlers/signup-confirm.write";
|
|
24
25
|
import {
|
|
25
26
|
createSignupRequestHandler,
|
|
@@ -223,6 +224,12 @@ export function createAuthEmailPasswordFeature(
|
|
|
223
224
|
r.writeHandler(createVerifyEmailHandler(opts.emailVerification));
|
|
224
225
|
}
|
|
225
226
|
|
|
227
|
+
const queries = {
|
|
228
|
+
...(opts.signup && {
|
|
229
|
+
signupRegistrationStatus: r.queryHandler(selfRegistrationStatusQuery),
|
|
230
|
+
}),
|
|
231
|
+
};
|
|
232
|
+
|
|
226
233
|
if (opts.signup) {
|
|
227
234
|
r.writeHandler(createSignupRequestHandler(opts.signup));
|
|
228
235
|
r.writeHandler(createSignupConfirmHandler());
|
|
@@ -240,6 +247,6 @@ export function createAuthEmailPasswordFeature(
|
|
|
240
247
|
r.writeHandler(createConfirmAccountUnlockHandler(opts.accountUnlock));
|
|
241
248
|
}
|
|
242
249
|
|
|
243
|
-
return { handlers };
|
|
250
|
+
return { handlers, queries };
|
|
244
251
|
});
|
|
245
252
|
}
|
|
@@ -58,7 +58,7 @@ export type LoginHandlerOptions = {
|
|
|
58
58
|
) => Promise<
|
|
59
59
|
| { readonly required: false }
|
|
60
60
|
| { readonly required: true; readonly challengeToken: string }
|
|
61
|
-
| { readonly setupRequired: true }
|
|
61
|
+
| { readonly setupRequired: true; readonly preauthSetupToken: string }
|
|
62
62
|
>;
|
|
63
63
|
};
|
|
64
64
|
|
|
@@ -70,7 +70,7 @@ const SYSTEM_USER_ID = "00000000-0000-4000-8000-000000000000";
|
|
|
70
70
|
type LoginResult =
|
|
71
71
|
| { readonly kind: "auth-session"; readonly session: SessionUser }
|
|
72
72
|
| { readonly kind: "mfa-challenge"; readonly challengeToken: string }
|
|
73
|
-
| { readonly kind: "mfa-setup-required" };
|
|
73
|
+
| { readonly kind: "mfa-setup-required"; readonly preauthSetupToken: string };
|
|
74
74
|
|
|
75
75
|
// Login — unauthenticated entry point. The route is wired public (no JWT
|
|
76
76
|
// middleware), synthesising a guest SessionUser for the handler's access
|
|
@@ -226,7 +226,10 @@ export function createLoginHandler(opts: LoginHandlerOptions = {}) {
|
|
|
226
226
|
};
|
|
227
227
|
}
|
|
228
228
|
if ("setupRequired" in mfaStatus) {
|
|
229
|
-
return {
|
|
229
|
+
return {
|
|
230
|
+
isSuccess: true,
|
|
231
|
+
data: { kind: "mfa-setup-required", preauthSetupToken: mfaStatus.preauthSetupToken },
|
|
232
|
+
};
|
|
230
233
|
}
|
|
231
234
|
}
|
|
232
235
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { AUTH_SELF_REGISTRATION_FEATURE } from "../self-registration-toggle";
|
|
4
|
+
|
|
5
|
+
// Anonymous-readable status for the (unauthenticated) signup page: lets it
|
|
6
|
+
// hide its own link/form when an operator has flipped self-registration
|
|
7
|
+
// off, instead of collecting an email that signup-request will silently
|
|
8
|
+
// no-op on. Lives in auth-email-password itself (never toggleable) rather
|
|
9
|
+
// than on the auth-self-registration companion feature — the dispatcher's
|
|
10
|
+
// per-feature gate would otherwise make this query unreachable exactly when
|
|
11
|
+
// the toggle is off (same split as managed-pages' branding query vs.
|
|
12
|
+
// css-gate.ts).
|
|
13
|
+
export const selfRegistrationStatusQuery = defineQueryHandler({
|
|
14
|
+
name: "signup-registration-status",
|
|
15
|
+
schema: z.object({}),
|
|
16
|
+
access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
|
|
17
|
+
handler: async (_query, ctx) => ({
|
|
18
|
+
enabled: await ctx.hasFeature(AUTH_SELF_REGISTRATION_FEATURE),
|
|
19
|
+
}),
|
|
20
|
+
});
|
|
@@ -31,6 +31,7 @@ import { AUTH_SIGNUP_DEFAULT_TTL_MINUTES } from "../constants";
|
|
|
31
31
|
import type { AuthMailLocale } from "../email-templates";
|
|
32
32
|
import { renderActivationEmail } from "../email-templates";
|
|
33
33
|
import { dispatchMagicLinkMail } from "../magic-link-mail";
|
|
34
|
+
import { AUTH_SELF_REGISTRATION_FEATURE } from "../self-registration-toggle";
|
|
34
35
|
import { getTokenForSignupEmail, normalizeEmail, storeSignupToken } from "../signup-token-store";
|
|
35
36
|
|
|
36
37
|
const SIGNUP_NOTIFICATION_TYPE = "auth-email-password:signup-activation";
|
|
@@ -68,6 +69,16 @@ export function createSignupRequestHandler(opts: SignupRequestOptions) {
|
|
|
68
69
|
schema: SignupRequestSchema,
|
|
69
70
|
access: { roles: ["all"] },
|
|
70
71
|
handler: async (event, ctx) => {
|
|
72
|
+
// Silent no-op when off, matching the route's own always-200
|
|
73
|
+
// anti-enumeration contract (registerTokenRequestRoute swallows every
|
|
74
|
+
// handler failure into `{isSuccess:true}` regardless) — no mail goes
|
|
75
|
+
// out, but the caller can't distinguish "disabled" from "unknown
|
|
76
|
+
// email" either way. The client-visible signal is the `status` query
|
|
77
|
+
// on auth-self-registration, which the signup page uses to hide its
|
|
78
|
+
// own link/form instead of collecting input that silently no-ops.
|
|
79
|
+
if (!(await ctx.hasFeature(AUTH_SELF_REGISTRATION_FEATURE))) {
|
|
80
|
+
return { isSuccess: true, data: { kind: "no-op" } };
|
|
81
|
+
}
|
|
71
82
|
if (!ctx.redis) {
|
|
72
83
|
return writeFailure(
|
|
73
84
|
new InternalError({
|
|
@@ -35,6 +35,8 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
35
35
|
"auth.errors.loginFailed": "Login fehlgeschlagen.",
|
|
36
36
|
"auth.errors.mfaNotSupported":
|
|
37
37
|
"Diese App unterstützt keine Zwei-Faktor-Bestätigung. Bitte an den Support wenden.",
|
|
38
|
+
"auth.errors.mfaSetupRequired":
|
|
39
|
+
"Zwei-Faktor-Authentifizierung erforderlich. Bitte an den Administrator wenden.",
|
|
38
40
|
"auth.errors.invalidResetToken":
|
|
39
41
|
"Der Link ist ungültig oder abgelaufen. Bitte fordere einen neuen an.",
|
|
40
42
|
"auth.errors.invalidVerificationToken": "Der Bestätigungs-Link ist ungültig oder abgelaufen.",
|
|
@@ -161,6 +163,8 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
161
163
|
"auth.errors.loginFailed": "Login failed.",
|
|
162
164
|
"auth.errors.mfaNotSupported":
|
|
163
165
|
"This app doesn't support two-factor verification. Please contact support.",
|
|
166
|
+
"auth.errors.mfaSetupRequired":
|
|
167
|
+
"Two-factor authentication required. Please contact your administrator.",
|
|
164
168
|
"auth.errors.invalidResetToken": "Link is invalid or expired. Please request a new one.",
|
|
165
169
|
"auth.errors.invalidVerificationToken": "Verification link is invalid or expired.",
|
|
166
170
|
"auth.errors.invalidUnlockToken":
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// its own changeset + deliberate deprecation window, not a silent drop.
|
|
8
8
|
export { hashPassword, verifyPassword } from "../shared/password-hashing";
|
|
9
9
|
export { type AuthPaths, DEFAULT_AUTH_PATHS, makeAuthPaths } from "./auth-paths";
|
|
10
|
-
export { AUTH_EMAIL_PASSWORD_FEATURE, AuthErrors, AuthHandlers } from "./constants";
|
|
10
|
+
export { AUTH_EMAIL_PASSWORD_FEATURE, AuthErrors, AuthHandlers, AuthQueries } from "./constants";
|
|
11
11
|
// Renderers for the auth mails. All four magic-link flows (reset, verify,
|
|
12
12
|
// signup-activation, invite) emit structured AuthMailContent through delivery
|
|
13
13
|
// (ctx.notify).
|
|
@@ -35,6 +35,10 @@ export type {
|
|
|
35
35
|
SignupOptions,
|
|
36
36
|
} from "./feature";
|
|
37
37
|
export { authEmailPasswordEnvSchema, createAuthEmailPasswordFeature } from "./feature";
|
|
38
|
+
export {
|
|
39
|
+
AUTH_SELF_REGISTRATION_FEATURE,
|
|
40
|
+
createAuthSelfRegistrationToggleFeature,
|
|
41
|
+
} from "./self-registration-toggle";
|
|
38
42
|
// Generic HMAC-signed single-purpose token helpers. Re-exported damit
|
|
39
43
|
// app-spezifische out-of-band-Flows (subscriber-confirm, magic-links,
|
|
40
44
|
// invite-tokens) denselben battle-tested signer/verifier nutzen können
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
|
|
3
|
+
// Companion toggle for the auth-email-password self-signup flow. Handler-less
|
|
4
|
+
// (like managed-pages-css/css-gate.ts): composing it just registers
|
|
5
|
+
// "auth-self-registration" as toggleable (default ON) so an operator can
|
|
6
|
+
// flip it at runtime via feature-toggles, without redeploying and without
|
|
7
|
+
// making the rest of auth-email-password (login/reset/verify) toggleable.
|
|
8
|
+
//
|
|
9
|
+
// Deliberately owns NO handlers — the dispatcher's per-feature gate blocks
|
|
10
|
+
// every handler belonging to a disabled feature, and a `status` query living
|
|
11
|
+
// here would become unreachable exactly when it matters most (disabled).
|
|
12
|
+
// The signup-request handler and the `signupRegistrationStatus` query both
|
|
13
|
+
// live in auth-email-password itself and read `ctx.hasFeature(...)` against
|
|
14
|
+
// this feature name instead — same split as managed-pages/css-gate.ts vs.
|
|
15
|
+
// managed-pages' branding query.
|
|
16
|
+
export const AUTH_SELF_REGISTRATION_FEATURE = "auth-self-registration";
|
|
17
|
+
|
|
18
|
+
export function createAuthSelfRegistrationToggleFeature(): FeatureDefinition {
|
|
19
|
+
return defineFeature(AUTH_SELF_REGISTRATION_FEATURE, (r) => {
|
|
20
|
+
r.describe(
|
|
21
|
+
'Runtime on/off switch for the auth-email-password self-signup flow. Handler-less: composing it registers "auth-self-registration" as a toggleable feature (default ON). auth-email-password\'s signup-request handler and its `signupRegistrationStatus` query both read `ctx.hasFeature("auth-self-registration")` — the query stays reachable when the toggle is off (deliberately not gated itself) so the public signup page can hide its own link/form. Only meaningful when `signup` is configured on `createAuthEmailPasswordFeature` — compose alongside it, then flip via the feature-toggles admin screen.',
|
|
22
|
+
);
|
|
23
|
+
r.toggleable({ default: true });
|
|
24
|
+
});
|
|
25
|
+
}
|
|
@@ -95,12 +95,16 @@ describe("login", () => {
|
|
|
95
95
|
|
|
96
96
|
test("MFA enforcement blocks unenrolled user → kind:mfa-setup-required", async () => {
|
|
97
97
|
globalThis.fetch = mock(async () =>
|
|
98
|
-
jsonResponse({
|
|
98
|
+
jsonResponse({
|
|
99
|
+
isSuccess: true,
|
|
100
|
+
mfaSetupRequired: true,
|
|
101
|
+
preauthSetupToken: "setup-token-value",
|
|
102
|
+
}),
|
|
99
103
|
) as unknown as typeof fetch;
|
|
100
104
|
|
|
101
105
|
const res = await login({ email: "a@b.c", password: "secret" });
|
|
102
106
|
|
|
103
|
-
expect(res).toEqual({ kind: "mfa-setup-required" });
|
|
107
|
+
expect(res).toEqual({ kind: "mfa-setup-required", preauthSetupToken: "setup-token-value" });
|
|
104
108
|
});
|
|
105
109
|
|
|
106
110
|
test("string error → kind:failure with reason from string", async () => {
|
|
@@ -70,6 +70,43 @@ describe("createLoginRoute", () => {
|
|
|
70
70
|
return <div data-testid="mfa-verify">{challengeToken}</div>;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
function LoginWithMfaSetupTrigger({
|
|
74
|
+
onMfaSetupRequired,
|
|
75
|
+
}: {
|
|
76
|
+
readonly onMfaSetupRequired?: (preauthSetupToken: string, accountLabel: string) => void;
|
|
77
|
+
}): ReactNode {
|
|
78
|
+
return (
|
|
79
|
+
<button
|
|
80
|
+
type="button"
|
|
81
|
+
data-testid="trigger-mfa-setup"
|
|
82
|
+
onClick={() => onMfaSetupRequired?.("setup-token-123", "user@example.com")}
|
|
83
|
+
>
|
|
84
|
+
trigger
|
|
85
|
+
</button>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function CustomMfaSetup({
|
|
90
|
+
preauthSetupToken,
|
|
91
|
+
accountLabel,
|
|
92
|
+
onSuccess,
|
|
93
|
+
}: {
|
|
94
|
+
readonly preauthSetupToken: string;
|
|
95
|
+
readonly accountLabel: string;
|
|
96
|
+
readonly onSuccess?: () => void;
|
|
97
|
+
}): ReactNode {
|
|
98
|
+
return (
|
|
99
|
+
<div data-testid="mfa-setup">
|
|
100
|
+
<span data-testid="mfa-setup-info">
|
|
101
|
+
{preauthSetupToken}:{accountLabel}
|
|
102
|
+
</span>
|
|
103
|
+
<button type="button" data-testid="complete-mfa-setup" onClick={() => onSuccess?.()}>
|
|
104
|
+
complete
|
|
105
|
+
</button>
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
73
110
|
test("authenticated + onAuthenticated → renders nothing, fires onAuthenticated exactly once", () => {
|
|
74
111
|
const onAuthenticated = mock(() => {});
|
|
75
112
|
const LoginRoute = createLoginRoute({ loginScreen: CustomLogin, onAuthenticated });
|
|
@@ -109,4 +146,46 @@ describe("createLoginRoute", () => {
|
|
|
109
146
|
fireEvent.click(screen.getByTestId("trigger-mfa"));
|
|
110
147
|
expect(screen.getByTestId("mfa-verify").textContent).toBe("token-123");
|
|
111
148
|
});
|
|
149
|
+
|
|
150
|
+
test("onMfaSetupRequired → renders MfaSetupComponent with token and accountLabel", () => {
|
|
151
|
+
const LoginRoute = createLoginRoute({
|
|
152
|
+
loginScreen: LoginWithMfaSetupTrigger,
|
|
153
|
+
mfaSetupScreen: CustomMfaSetup,
|
|
154
|
+
});
|
|
155
|
+
const session = makeSessionApi({ status: "unauthenticated" });
|
|
156
|
+
renderWithProviders(<LoginRoute />, { session });
|
|
157
|
+
fireEvent.click(screen.getByTestId("trigger-mfa-setup"));
|
|
158
|
+
expect(screen.getByTestId("mfa-setup-info").textContent).toBe(
|
|
159
|
+
"setup-token-123:user@example.com",
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("MfaSetupComponent onSuccess → gate clears the request and refreshes the session", () => {
|
|
164
|
+
const LoginRoute = createLoginRoute({
|
|
165
|
+
loginScreen: LoginWithMfaSetupTrigger,
|
|
166
|
+
mfaSetupScreen: CustomMfaSetup,
|
|
167
|
+
});
|
|
168
|
+
const session = makeSessionApi({ status: "unauthenticated" });
|
|
169
|
+
renderWithProviders(<LoginRoute />, { session });
|
|
170
|
+
fireEvent.click(screen.getByTestId("trigger-mfa-setup"));
|
|
171
|
+
expect(screen.getByTestId("mfa-setup")).toBeTruthy();
|
|
172
|
+
fireEvent.click(screen.getByTestId("complete-mfa-setup"));
|
|
173
|
+
expect(session.refresh).toHaveBeenCalledTimes(1);
|
|
174
|
+
expect(screen.queryByTestId("mfa-setup")).toBeNull();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("makeAuthGate delegates mfaSetupScreen wiring to createLoginRoute", () => {
|
|
178
|
+
const Gate = makeAuthGate(LoginWithMfaSetupTrigger, undefined, undefined, CustomMfaSetup);
|
|
179
|
+
const session = makeSessionApi({ status: "unauthenticated" });
|
|
180
|
+
renderWithProviders(
|
|
181
|
+
<Gate>
|
|
182
|
+
<div data-testid="protected">secret</div>
|
|
183
|
+
</Gate>,
|
|
184
|
+
{ session },
|
|
185
|
+
);
|
|
186
|
+
fireEvent.click(screen.getByTestId("trigger-mfa-setup"));
|
|
187
|
+
expect(screen.getByTestId("mfa-setup-info").textContent).toBe(
|
|
188
|
+
"setup-token-123:user@example.com",
|
|
189
|
+
);
|
|
190
|
+
});
|
|
112
191
|
});
|