@cosmicdrift/kumiko-bundled-features 0.299.0 → 0.305.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 +9 -9
- package/src/auth-email-password/__tests__/auth-tenants-rate-limit.integration.test.ts +116 -0
- package/src/auth-email-password/__tests__/auth.integration.test.ts +2 -2
- package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +5 -5
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +2 -2
- package/src/auth-email-password/__tests__/login-anonymous-identity-rate-limit.integration.test.ts +124 -0
- package/src/auth-email-password/__tests__/login-mfa-setup-required.integration.test.ts +6 -6
- package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.test.ts +28 -0
- package/src/auth-email-password/changes.json +13 -0
- package/src/auth-email-password/handlers/confirm-account-unlock.write.ts +2 -1
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -1
- package/src/auth-email-password/handlers/invite-accept.write.ts +3 -3
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -1
- package/src/auth-email-password/handlers/login.write.ts +6 -4
- package/src/auth-email-password/handlers/reset-password.write.ts +2 -1
- package/src/auth-email-password/handlers/signup-confirm.write.ts +2 -1
- package/src/auth-email-password/handlers/signup-request.write.ts +2 -1
- package/src/auth-email-password/handlers/token-request-handler.ts +2 -1
- package/src/auth-email-password/handlers/verify-email.write.ts +2 -1
- package/src/auth-email-password/i18n.ts +7 -0
- package/src/auth-email-password/web/__tests__/auth-client.test.ts +24 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +124 -2
- package/src/auth-email-password/web/__tests__/session-bootstrap-error.test.ts +32 -0
- package/src/auth-email-password/web/__tests__/test-utils.tsx +1 -0
- package/src/auth-email-password/web/auth-client.ts +34 -2
- package/src/auth-email-password/web/auth-gate.tsx +5 -1
- package/src/auth-email-password/web/index.ts +4 -1
- package/src/auth-email-password/web/session-bootstrap-error.tsx +56 -0
- package/src/auth-email-password/web/session.tsx +32 -3
- package/src/auth-mfa/__tests__/no-redis.integration.test.ts +6 -9
- package/src/auth-mfa/__tests__/verify.integration.test.ts +6 -6
- package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +3 -2
- package/src/auth-mfa/handlers/enable-start-preauth.write.ts +4 -2
- package/src/auth-mfa/handlers/verify.write.ts +3 -2
- package/src/auth-mfa/mfa-status-checker.ts +2 -2
- package/src/billing-foundation/changes.json +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.305.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>",
|
|
@@ -133,12 +133,12 @@
|
|
|
133
133
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
134
134
|
},
|
|
135
135
|
"dependencies": {
|
|
136
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
137
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
138
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
139
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
140
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
141
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
136
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.305.0",
|
|
137
|
+
"@cosmicdrift/kumiko-framework": "0.305.0",
|
|
138
|
+
"@cosmicdrift/kumiko-headless": "0.305.0",
|
|
139
|
+
"@cosmicdrift/kumiko-renderer": "0.305.0",
|
|
140
|
+
"@cosmicdrift/kumiko-renderer-web": "0.305.0",
|
|
141
|
+
"@cosmicdrift/kumiko-types": "0.305.0",
|
|
142
142
|
"@mollie/api-client": "^4.5.0",
|
|
143
143
|
"@node-rs/argon2": "^2.0.2",
|
|
144
144
|
"@types/mailparser": "^3.4.6",
|
|
@@ -167,8 +167,8 @@
|
|
|
167
167
|
],
|
|
168
168
|
"devDependencies": {
|
|
169
169
|
"@testing-library/user-event": "^14.6.1",
|
|
170
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
171
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
170
|
+
"@cosmicdrift/kumiko-locale-de": "0.305.0",
|
|
171
|
+
"@cosmicdrift/kumiko-locale-es": "0.305.0",
|
|
172
172
|
"jsqr": "^1.4.0"
|
|
173
173
|
}
|
|
174
174
|
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// GET /api/auth/tenants is exempt from the L2 auth-endpoint rate limit;
|
|
2
|
+
// credential endpoints on the same /api/auth/* mount (e.g. login) keep the default limit.
|
|
3
|
+
|
|
4
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import {
|
|
8
|
+
setupTestStack,
|
|
9
|
+
type TestStack,
|
|
10
|
+
TestUsers,
|
|
11
|
+
unsafeCreateEntityTable,
|
|
12
|
+
unsafePushTables,
|
|
13
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
14
|
+
import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
|
|
15
|
+
import { createConfigFeature } from "../../config";
|
|
16
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
17
|
+
import { configValuesTable } from "../../config/table";
|
|
18
|
+
import { createTenantFeature } from "../../tenant";
|
|
19
|
+
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
20
|
+
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
21
|
+
import { seedTenantMembership } from "../../tenant/testing";
|
|
22
|
+
import { UserHandlers } from "../../user";
|
|
23
|
+
import { createUserFeature } from "../../user/feature";
|
|
24
|
+
import { userEntity } from "../../user/schema/user";
|
|
25
|
+
import { AuthHandlers } from "../constants";
|
|
26
|
+
import { createAuthEmailPasswordFeature } from "../feature";
|
|
27
|
+
|
|
28
|
+
let stack: TestStack;
|
|
29
|
+
const encryptionKey = randomBytes(32).toString("base64");
|
|
30
|
+
const membershipTenantId = "00000000-0000-4000-8000-000000000001";
|
|
31
|
+
|
|
32
|
+
beforeAll(async () => {
|
|
33
|
+
const encryption = createTestEnvelopeCipher(encryptionKey);
|
|
34
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
35
|
+
|
|
36
|
+
stack = await setupTestStack({
|
|
37
|
+
features: [
|
|
38
|
+
createConfigFeature(),
|
|
39
|
+
createUserFeature(),
|
|
40
|
+
createTenantFeature(),
|
|
41
|
+
createAuthEmailPasswordFeature(),
|
|
42
|
+
],
|
|
43
|
+
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
44
|
+
authConfig: {
|
|
45
|
+
membershipQuery: "tenant:query:memberships",
|
|
46
|
+
loginHandler: AuthHandlers.login,
|
|
47
|
+
},
|
|
48
|
+
// No `limit` override — this proves the real framework default (5/60s)
|
|
49
|
+
// exempts GET /api/auth/tenants and still throttles POST /api/auth/login.
|
|
50
|
+
rateLimit: {
|
|
51
|
+
auth: { onFailClosed: () => {} },
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
56
|
+
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
57
|
+
await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterAll(async () => {
|
|
61
|
+
await stack.cleanup();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
async function getTenants(token: string, ip: string): Promise<Response> {
|
|
65
|
+
return stack.app.request("/api/auth/tenants", {
|
|
66
|
+
method: "GET",
|
|
67
|
+
headers: { Authorization: `Bearer ${token}`, "x-forwarded-for": ip },
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function postLogin(ip: string, email: string): Promise<Response> {
|
|
72
|
+
return stack.app.request("/api/auth/login", {
|
|
73
|
+
method: "POST",
|
|
74
|
+
headers: { "Content-Type": "application/json", "x-forwarded-for": ip },
|
|
75
|
+
body: JSON.stringify({ email, password: "wrong-password" }),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe("GET /api/auth/tenants is exempt from the L2 auth rate limit", () => {
|
|
80
|
+
test("10 GETs from the same IP all succeed — the 6th would 429 without the exemption", async () => {
|
|
81
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
82
|
+
UserHandlers.create,
|
|
83
|
+
{ email: "tenants-exempt@example.com", passwordHash: "unused", displayName: "exempt-user" },
|
|
84
|
+
TestUsers.systemAdmin,
|
|
85
|
+
);
|
|
86
|
+
await seedTenantMembership(stack.db, {
|
|
87
|
+
userId: created.id,
|
|
88
|
+
tenantId: membershipTenantId,
|
|
89
|
+
roles: ["User"],
|
|
90
|
+
});
|
|
91
|
+
const user: SessionUser = { id: created.id, tenantId: membershipTenantId, roles: ["User"] };
|
|
92
|
+
const token = await stack.jwt.sign(user);
|
|
93
|
+
const ip = "10.70.0.1";
|
|
94
|
+
|
|
95
|
+
for (let i = 0; i < 10; i++) {
|
|
96
|
+
const res = await getTenants(token, ip);
|
|
97
|
+
expect(res.status).toBe(200);
|
|
98
|
+
}
|
|
99
|
+
const body = await (await getTenants(token, ip)).json();
|
|
100
|
+
expect(body.tenants.length).toBe(1);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("POST /api/auth/login keeps the default L2 limit (5/60s)", () => {
|
|
105
|
+
test("6th login attempt from the same IP is 429 via the L2 bucket, not just the route's own limiter", async () => {
|
|
106
|
+
const ip = "10.70.0.2";
|
|
107
|
+
for (let i = 0; i < 5; i++) {
|
|
108
|
+
await postLogin(ip, `nope-${i}@example.com`);
|
|
109
|
+
}
|
|
110
|
+
const blocked = await postLogin(ip, "nope-5@example.com");
|
|
111
|
+
expect(blocked.status).toBe(429);
|
|
112
|
+
const body = (await blocked.json()) as { error: { code: string; details: { bucket: string } } };
|
|
113
|
+
expect(body.error.code).toBe("rate_limited");
|
|
114
|
+
expect(body.error.details.bucket).toBe(`l2:${ip}:/api/auth/login`);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -393,12 +393,12 @@ describe("scenario 6: logout", () => {
|
|
|
393
393
|
});
|
|
394
394
|
|
|
395
395
|
test("unauthenticated call to logout is rejected by framework access", async () => {
|
|
396
|
-
// roles: ["
|
|
396
|
+
// roles: ["anonymous"] — no authenticated role. Handler's access is
|
|
397
397
|
// access.authenticated which requires User/Admin/SystemAdmin.
|
|
398
398
|
const guest = createTestUser({
|
|
399
399
|
id: 0,
|
|
400
400
|
tenantId: "00000000-0000-4000-8000-000000000000",
|
|
401
|
-
roles: ["
|
|
401
|
+
roles: ["anonymous"],
|
|
402
402
|
});
|
|
403
403
|
const error = await stack.http.writeErr(AuthHandlers.logout, {}, guest);
|
|
404
404
|
expectErrorIncludes(error, "access_denied");
|
package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts
CHANGED
|
@@ -166,14 +166,14 @@ function aliceSession(): SessionUser {
|
|
|
166
166
|
return { id: aliceId, tenantId: TENANT_A_ID, roles: ["Admin"] };
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
-
// auth-mfa:write:verify runs pre-session (access: { roles: ["
|
|
169
|
+
// auth-mfa:write:verify runs pre-session (access: { roles: ["anonymous"] }) —
|
|
170
170
|
// dispatched here the same way the framework's /api/auth/mfa/verify route
|
|
171
|
-
// would, with
|
|
172
|
-
// from the challenge token, not from this actor.
|
|
171
|
+
// would, with the anonymous identity. The handler derives everything it
|
|
172
|
+
// needs from the challenge token, not from this actor.
|
|
173
173
|
const GUEST: SessionUser = {
|
|
174
|
-
id: "
|
|
174
|
+
id: "anonymous",
|
|
175
175
|
tenantId: "00000000-0000-4000-8000-000000000000" as TenantId,
|
|
176
|
-
roles: ["
|
|
176
|
+
roles: ["anonymous"],
|
|
177
177
|
};
|
|
178
178
|
|
|
179
179
|
async function inviteEmail(email: string, role: string): Promise<string> {
|
|
@@ -82,9 +82,9 @@ function newTenantId(_suffix: string): TenantId {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
const GUEST: SessionUser = {
|
|
85
|
-
id: "
|
|
85
|
+
id: "anonymous",
|
|
86
86
|
tenantId: SYSTEM_TENANT_ID,
|
|
87
|
-
roles: ["
|
|
87
|
+
roles: ["anonymous"],
|
|
88
88
|
};
|
|
89
89
|
|
|
90
90
|
function extractTokenFromMail(html: string): string {
|
package/src/auth-email-password/__tests__/login-anonymous-identity-rate-limit.integration.test.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// login.write.ts's access.roles: ["anonymous"] carries a mandatory
|
|
2
|
+
// handler-level rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 }
|
|
3
|
+
// (boot-validator's validateAnonymousRateLimit). Proves that L3 handler
|
|
4
|
+
// rate-limit fires over real HTTP, and that a legitimate login still succeeds.
|
|
5
|
+
//
|
|
6
|
+
// loginRateLimit is disabled (null): the route's own in-memory limiter
|
|
7
|
+
// buckets on "ip|email" and would trip before the handler-level "ip+handler"
|
|
8
|
+
// bucket under the same IP — this isolates the handler-level rateLimit.
|
|
9
|
+
|
|
10
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
13
|
+
import {
|
|
14
|
+
setupTestStack,
|
|
15
|
+
type TestStack,
|
|
16
|
+
TestUsers,
|
|
17
|
+
unsafeCreateEntityTable,
|
|
18
|
+
unsafePushTables,
|
|
19
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
20
|
+
import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
|
|
21
|
+
import { createConfigFeature } from "../../config";
|
|
22
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
23
|
+
import { configValuesTable } from "../../config/table";
|
|
24
|
+
import { hashPassword } from "../../shared";
|
|
25
|
+
import { createTenantFeature } from "../../tenant";
|
|
26
|
+
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
27
|
+
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
28
|
+
import { seedTenantMembership } from "../../tenant/testing";
|
|
29
|
+
import { UserHandlers } from "../../user";
|
|
30
|
+
import { createUserFeature } from "../../user/feature";
|
|
31
|
+
import { userEntity, userTable } from "../../user/schema/user";
|
|
32
|
+
import { AuthErrors, AuthHandlers } from "../constants";
|
|
33
|
+
import { createAuthEmailPasswordFeature } from "../feature";
|
|
34
|
+
|
|
35
|
+
let stack: TestStack;
|
|
36
|
+
const encryptionKey = randomBytes(32).toString("base64");
|
|
37
|
+
|
|
38
|
+
beforeAll(async () => {
|
|
39
|
+
const encryption = createTestEnvelopeCipher(encryptionKey);
|
|
40
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
41
|
+
|
|
42
|
+
stack = await setupTestStack({
|
|
43
|
+
features: [
|
|
44
|
+
createConfigFeature(),
|
|
45
|
+
createUserFeature(),
|
|
46
|
+
createTenantFeature(),
|
|
47
|
+
createAuthEmailPasswordFeature(),
|
|
48
|
+
],
|
|
49
|
+
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
50
|
+
authConfig: {
|
|
51
|
+
membershipQuery: "tenant:query:memberships",
|
|
52
|
+
loginHandler: AuthHandlers.login,
|
|
53
|
+
loginRateLimit: null,
|
|
54
|
+
loginErrorStatusMap: {
|
|
55
|
+
[AuthErrors.invalidCredentials]: 401,
|
|
56
|
+
[AuthErrors.noMembership]: 403,
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
await stack.redis.flushNamespace();
|
|
62
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
63
|
+
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
64
|
+
await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterAll(async () => {
|
|
68
|
+
await stack.cleanup();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
beforeEach(async () => {
|
|
72
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
|
|
73
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
|
|
74
|
+
await stack.redis.flushNamespace();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const systemAdmin = TestUsers.systemAdmin;
|
|
78
|
+
|
|
79
|
+
async function seedLoginUser(email: string, password: string): Promise<void> {
|
|
80
|
+
const hash = await hashPassword(password);
|
|
81
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
82
|
+
UserHandlers.create,
|
|
83
|
+
{ email, passwordHash: hash, displayName: email.split("@")[0] ?? "user" },
|
|
84
|
+
systemAdmin,
|
|
85
|
+
);
|
|
86
|
+
await seedTenantMembership(stack.db, {
|
|
87
|
+
userId: created.id,
|
|
88
|
+
tenantId: "00000000-0000-4000-8000-000000000001",
|
|
89
|
+
roles: ["User"],
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function postLogin(ip: string, email: string, password: string): Promise<Response> {
|
|
94
|
+
return await stack.app.request("/api/auth/login", {
|
|
95
|
+
method: "POST",
|
|
96
|
+
headers: { "Content-Type": "application/json", "x-forwarded-for": ip },
|
|
97
|
+
body: JSON.stringify({ email, password }),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
describe("POST /auth/login — anonymous identity handler rateLimit (ip+handler)", () => {
|
|
102
|
+
test("21st failed login from the same IP is rejected with rate_limited (429)", async () => {
|
|
103
|
+
const ip = "10.60.0.1";
|
|
104
|
+
for (let i = 0; i < 20; i++) {
|
|
105
|
+
const res = await postLogin(ip, `nope-${i}@example.com`, "wrong-password");
|
|
106
|
+
expect(res.status).toBe(401);
|
|
107
|
+
}
|
|
108
|
+
const last = await postLogin(ip, "nope-20@example.com", "wrong-password");
|
|
109
|
+
expect(last.status).toBe(429);
|
|
110
|
+
const body = (await last.json()) as { error?: { code?: string } | string };
|
|
111
|
+
const code = typeof body.error === "string" ? body.error : body.error?.code;
|
|
112
|
+
expect(code).toBe("rate_limited");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("a legitimate login still succeeds (anonymous dispatch identity didn't break the happy path)", async () => {
|
|
116
|
+
const ip = "10.60.0.2";
|
|
117
|
+
await seedLoginUser("legit@example.com", "correct horse battery staple");
|
|
118
|
+
|
|
119
|
+
const res = await postLogin(ip, "legit@example.com", "correct horse battery staple");
|
|
120
|
+
expect(res.status).toBe(200);
|
|
121
|
+
const body = (await res.json()) as { isSuccess: boolean };
|
|
122
|
+
expect(body.isSuccess).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -47,14 +47,14 @@ const CHALLENGE_TOKEN_SECRET = "test-mfa-challenge-secret-at-least-32-bytes!!";
|
|
|
47
47
|
const TENANT_ID: TenantId = testTenantId(400);
|
|
48
48
|
|
|
49
49
|
// auth-mfa:write:enable-start-preauth / enable-confirm-preauth both run
|
|
50
|
-
// pre-session (access: { roles: ["
|
|
51
|
-
// the framework's /api/auth/mfa/preauth-enable-start and preauth-confirm
|
|
52
|
-
// routes would, with
|
|
53
|
-
// need from the verified token, not from this actor.
|
|
50
|
+
// pre-session (access: { roles: ["anonymous"] }) — dispatched here the same
|
|
51
|
+
// way the framework's /api/auth/mfa/preauth-enable-start and preauth-confirm
|
|
52
|
+
// routes would, with the anonymous identity. Both handlers derive everything
|
|
53
|
+
// they need from the verified token, not from this actor.
|
|
54
54
|
const GUEST: SessionUser = {
|
|
55
|
-
id: "
|
|
55
|
+
id: "anonymous",
|
|
56
56
|
tenantId: "00000000-0000-4000-8000-000000000000" as TenantId,
|
|
57
|
-
roles: ["
|
|
57
|
+
roles: ["anonymous"],
|
|
58
58
|
};
|
|
59
59
|
|
|
60
60
|
beforeAll(async () => {
|
|
@@ -10,6 +10,7 @@ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
|
10
10
|
import {
|
|
11
11
|
setupTestStack,
|
|
12
12
|
type TestStack,
|
|
13
|
+
TestUsers,
|
|
13
14
|
unsafeCreateEntityTable,
|
|
14
15
|
unsafePushTables,
|
|
15
16
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
@@ -22,10 +23,12 @@ import { createDeliveryFeature, createDeliveryTestContext } from "../../delivery
|
|
|
22
23
|
import { notificationPreferencesTable } from "../../delivery/tables";
|
|
23
24
|
import { createRendererFoundationFeature } from "../../renderer-foundation/feature";
|
|
24
25
|
import { createRendererSimpleFeature, simpleRenderer } from "../../renderer-simple";
|
|
26
|
+
import { hashPassword } from "../../shared";
|
|
25
27
|
import { createTemplateResolverFeature } from "../../template-resolver/feature";
|
|
26
28
|
import { createTenantFeature } from "../../tenant";
|
|
27
29
|
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
28
30
|
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
31
|
+
import { UserHandlers } from "../../user";
|
|
29
32
|
import { createUserFeature } from "../../user/feature";
|
|
30
33
|
import { userEntity, userTable } from "../../user/schema/user";
|
|
31
34
|
import { AuthHandlers } from "../constants";
|
|
@@ -174,3 +177,28 @@ describe("L2 rate-limit covers public token routes", () => {
|
|
|
174
177
|
expect(victimFirst.status).toBe(200);
|
|
175
178
|
});
|
|
176
179
|
});
|
|
180
|
+
|
|
181
|
+
// The 200-for-unknown-email tests above prove nothing about the handler
|
|
182
|
+
// actually running — registerTokenRequestRoute swallows every handler
|
|
183
|
+
// failure into an always-200 response (anti-enumeration). Only a seeded,
|
|
184
|
+
// real user distinguishes "mail sent" from "silently swallowed" — which is
|
|
185
|
+
// exactly what an anonymous-dispatch switch could silently break.
|
|
186
|
+
describe("request-password-reset actually dispatches mail for a real user", () => {
|
|
187
|
+
test("a seeded user gets the reset mail (anonymous dispatch didn't swallow it)", async () => {
|
|
188
|
+
const hash = await hashPassword("correct horse battery staple");
|
|
189
|
+
await stack.http.writeOk(
|
|
190
|
+
UserHandlers.create,
|
|
191
|
+
{ email: "reset-target@example.com", passwordHash: hash, displayName: "reset-target" },
|
|
192
|
+
TestUsers.systemAdmin,
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const before = emailTransport.sent.length;
|
|
196
|
+
const res = await postFrom("/api/auth/request-password-reset", "10.50.0.200", {
|
|
197
|
+
email: "reset-target@example.com",
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect(res.status).toBe(200);
|
|
201
|
+
expect(emailTransport.sent.length).toBe(before + 1);
|
|
202
|
+
expect(emailTransport.sent.at(-1)?.to).toBe("reset-target@example.com");
|
|
203
|
+
});
|
|
204
|
+
});
|
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.305.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "Session bootstrap failures now render a retryable error screen instead of hanging on \"loading\"",
|
|
6
|
+
"detail": "fetchTenants()/fetchCurrentUser() failures (rate limit, 5xx, network)\nused to reject out of the session bootstrap effect and leave the UI on\nthe loading placeholder forever. SessionStatus gained an \"error\" value\nand SessionState gained a bootstrapFailure field; the auth gate now\nrenders SessionBootstrapErrorScreen with a Retry-After-aware retry\nbutton for this case.",
|
|
7
|
+
"migration": "Code with an exhaustive switch over SessionStatus, or tests/stories that\nhand-build a SessionState literal, needs to add the \"error\" case and the\nbootstrapFailure field. refresh() no longer rejects on bootstrap\nfailures; it resolves and sets status to \"error\" instead."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.300.0",
|
|
11
|
+
"type": "breaking",
|
|
12
|
+
"title": "auth handlers switch from GUEST_USER (roles: [\"all\"]) to the anonymous identity with a per-IP rateLimit",
|
|
13
|
+
"migration": "The auth-email-password and auth-mfa handlers reachable from unauthenticated /auth/* routes (login, invite-accept-with-login, invite-signup-complete, reset-password, signup-confirm, verify-email, confirm-account-unlock, signup-request, the token-request handlers, mfa verify, mfa enable-start-preauth, mfa enable-confirm-preauth) now declare access: { roles: [\"anonymous\"] } instead of access: { roles: [\"all\"] }, each with its own rateLimit: { per: \"ip+handler\", limit, windowSeconds }. Enforcing that rateLimit on a request that carries a client IP requires a RateLimitResolver (Redis), same as the already rate-limited self-registration-status query; without one these handlers fail with InternalError (fail-closed, #1467). The password-reset, email-verification and account-unlock request routes keep answering 200 for anti-enumeration, so there the failure shows up as a \"[kumiko] token request handler ... failed\" error log line and no mail is sent. Wire Redis (or context.rateLimit) before relying on these routes. Test fixtures that hand-roll a guest SessionUser with roles: [\"all\"] must switch to roles: [\"anonymous\"] (or createAnonymousUser from @cosmicdrift/kumiko-framework/engine)."
|
|
14
|
+
},
|
|
2
15
|
{
|
|
3
16
|
"version": "0.291.0",
|
|
4
17
|
"type": "improvement",
|
|
@@ -29,7 +29,8 @@ export function createConfirmAccountUnlockHandler(opts: ConfirmAccountUnlockOpti
|
|
|
29
29
|
schema: z.object({
|
|
30
30
|
token: z.string().min(1),
|
|
31
31
|
}),
|
|
32
|
-
access: { roles: ["
|
|
32
|
+
access: { roles: ["anonymous"] },
|
|
33
|
+
rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
|
|
33
34
|
agent: { expose: false },
|
|
34
35
|
handler: async (event, ctx) => {
|
|
35
36
|
if (!opts.hmacSecret) {
|
|
@@ -115,7 +115,8 @@ export function createInviteAcceptWithLoginHandler(opts: InviteAcceptWithLoginOp
|
|
|
115
115
|
>({
|
|
116
116
|
name: "invite-accept-with-login",
|
|
117
117
|
schema: InviteAcceptWithLoginSchema,
|
|
118
|
-
access: { roles: ["
|
|
118
|
+
access: { roles: ["anonymous"] },
|
|
119
|
+
rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
|
|
119
120
|
escapeHatch: {
|
|
120
121
|
reason:
|
|
121
122
|
"Anonymous invite-accept has no session in the invited tenant yet — checks existing " +
|
|
@@ -78,9 +78,9 @@ export function createInviteAcceptHandler() {
|
|
|
78
78
|
return defineWriteHandler<"invite-accept", typeof InviteAcceptSchema, InviteAcceptData>({
|
|
79
79
|
name: "invite-accept",
|
|
80
80
|
schema: InviteAcceptSchema,
|
|
81
|
-
// openToAll: any authenticated user (Branch 1). Branch 2+3 (anon)
|
|
82
|
-
//
|
|
83
|
-
//
|
|
81
|
+
// openToAll: any authenticated user (Branch 1). Branch 2+3 (anon) live
|
|
82
|
+
// in the sibling invite-accept-with-login / invite-signup-complete
|
|
83
|
+
// handlers, which declare roles: ["anonymous"] instead.
|
|
84
84
|
access: {
|
|
85
85
|
openToAll: {
|
|
86
86
|
reason:
|
|
@@ -92,7 +92,8 @@ export function createInviteSignupCompleteHandler() {
|
|
|
92
92
|
>({
|
|
93
93
|
name: "invite-signup-complete",
|
|
94
94
|
schema: InviteSignupCompleteSchema,
|
|
95
|
-
access: { roles: ["
|
|
95
|
+
access: { roles: ["anonymous"] },
|
|
96
|
+
rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
|
|
96
97
|
agent: { expose: false },
|
|
97
98
|
escapeHatch: {
|
|
98
99
|
reason: INVITE_SIGNUP_COMPLETE_ESCAPE_HATCH_REASON,
|
|
@@ -257,9 +257,10 @@ export async function gateBuildSession(
|
|
|
257
257
|
}
|
|
258
258
|
|
|
259
259
|
// Login — unauthenticated entry point. The route is wired public (no JWT
|
|
260
|
-
// middleware),
|
|
261
|
-
// check. Everything inside the handler goes through
|
|
262
|
-
// so the user feature stays the single owner of its
|
|
260
|
+
// middleware), dispatching with the framework's anonymous identity for the
|
|
261
|
+
// handler's access check. Everything inside the handler goes through
|
|
262
|
+
// ctx.queryAs(system, ...) so the user feature stays the single owner of its
|
|
263
|
+
// table.
|
|
263
264
|
export function createLoginHandler(opts: LoginHandlerOptions = {}) {
|
|
264
265
|
const strictVerification = opts.strictEmailVerification === true;
|
|
265
266
|
const maxFailedAttempts =
|
|
@@ -273,7 +274,8 @@ export function createLoginHandler(opts: LoginHandlerOptions = {}) {
|
|
|
273
274
|
email: z.email(),
|
|
274
275
|
password: z.string().min(1),
|
|
275
276
|
}),
|
|
276
|
-
access: { roles: ["
|
|
277
|
+
access: { roles: ["anonymous"] },
|
|
278
|
+
rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
|
|
277
279
|
escapeHatch: {
|
|
278
280
|
reason:
|
|
279
281
|
"Unauthenticated login has no caller identity yet — it looks up the user row by " +
|
|
@@ -25,7 +25,8 @@ export function createResetPasswordHandler(opts: ResetPasswordOptions) {
|
|
|
25
25
|
token: z.string().min(1),
|
|
26
26
|
newPassword: passwordSchema,
|
|
27
27
|
}),
|
|
28
|
-
access: { roles: ["
|
|
28
|
+
access: { roles: ["anonymous"] },
|
|
29
|
+
rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
|
|
29
30
|
escapeHatch: {
|
|
30
31
|
reason:
|
|
31
32
|
"Anonymous token holder has no session — runConfirmTokenFlow loads and updates the " +
|
|
@@ -67,7 +67,8 @@ export function createSignupConfirmHandler() {
|
|
|
67
67
|
return defineWriteHandler<"signup-confirm", typeof SignupConfirmSchema, SignupConfirmData>({
|
|
68
68
|
name: "signup-confirm",
|
|
69
69
|
schema: SignupConfirmSchema,
|
|
70
|
-
access: { roles: ["
|
|
70
|
+
access: { roles: ["anonymous"] },
|
|
71
|
+
rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
|
|
71
72
|
agent: { expose: false },
|
|
72
73
|
escapeHatch: {
|
|
73
74
|
reason: SIGNUP_CONFIRM_PROVISION_REASON,
|
|
@@ -75,7 +75,8 @@ export function createSignupRequestHandler(opts: SignupRequestOptions) {
|
|
|
75
75
|
return defineWriteHandler<"signup-request", typeof SignupRequestSchema, SignupRequestData>({
|
|
76
76
|
name: "signup-request",
|
|
77
77
|
schema: SignupRequestSchema,
|
|
78
|
-
access: { roles: ["
|
|
78
|
+
access: { roles: ["anonymous"] },
|
|
79
|
+
rateLimit: { per: "ip+handler", limit: 10, windowSeconds: 60 },
|
|
79
80
|
description:
|
|
80
81
|
"Starts magic-link self-registration by mailing a fresh activation link to an address and invalidating any link still outstanding for it; the answer looks the same whether or not the address is already registered.",
|
|
81
82
|
handler: async (event, ctx) => {
|
|
@@ -94,7 +94,8 @@ export function createTokenRequestHandler<TName extends string, TSuccessKind ext
|
|
|
94
94
|
return defineWriteHandler<TName, typeof RequestTokenSchema, TokenRequestData<TSuccessKind>>({
|
|
95
95
|
name: spec.handlerName,
|
|
96
96
|
schema: RequestTokenSchema,
|
|
97
|
-
access: { roles: ["
|
|
97
|
+
access: { roles: ["anonymous"] },
|
|
98
|
+
rateLimit: { per: "ip+handler", limit: 10, windowSeconds: 60 },
|
|
98
99
|
escapeHatch: {
|
|
99
100
|
reason:
|
|
100
101
|
"Anonymous requester has no session — looks up the target user by email via " +
|
|
@@ -26,7 +26,8 @@ export function createVerifyEmailHandler(opts: VerifyEmailOptions) {
|
|
|
26
26
|
return defineWriteHandler<"verify-email", typeof VerifyEmailSchema, VerifyEmailData>({
|
|
27
27
|
name: "verify-email",
|
|
28
28
|
schema: VerifyEmailSchema,
|
|
29
|
-
access: { roles: ["
|
|
29
|
+
access: { roles: ["anonymous"] },
|
|
30
|
+
rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
|
|
30
31
|
escapeHatch: {
|
|
31
32
|
reason:
|
|
32
33
|
"Anonymous token holder has no session — runConfirmTokenFlow loads and updates the " +
|
|
@@ -131,6 +131,13 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
131
131
|
"auth.inviteAccept.toggleExisting": "I already have an account",
|
|
132
132
|
"auth.inviteAccept.missingToken": "The invitation link is missing or invalid.",
|
|
133
133
|
"auth.inviteAccept.goToLogin": "Go to sign in",
|
|
134
|
+
"auth.sessionBootstrap.errorTitle": "Couldn't load your session",
|
|
135
|
+
"auth.sessionBootstrap.errorBody":
|
|
136
|
+
"The server could not be reached or returned an error. Please try again.",
|
|
137
|
+
"auth.sessionBootstrap.rateLimitedBody":
|
|
138
|
+
"Too many requests in a short time. Please wait a moment and try again.",
|
|
139
|
+
"auth.sessionBootstrap.retry": "Try again",
|
|
140
|
+
"auth.sessionBootstrap.retrying": "Retrying…",
|
|
134
141
|
"auth.user.menu.label": "Account",
|
|
135
142
|
"auth.user.menu.logout": "Sign out",
|
|
136
143
|
"auth.tenant.switcher.label": "Tenant",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
2
|
import { CSRF_HEADER_NAME } from "@cosmicdrift/kumiko-dispatcher-live";
|
|
3
3
|
import {
|
|
4
|
+
AuthRequestError,
|
|
4
5
|
confirmAccountUnlock,
|
|
5
6
|
confirmSignup,
|
|
6
7
|
csrfHeader,
|
|
@@ -390,6 +391,29 @@ describe("fetchTenants", () => {
|
|
|
390
391
|
|
|
391
392
|
await expect(fetchTenants()).rejects.toThrow("auth/tenants failed: 500");
|
|
392
393
|
});
|
|
394
|
+
|
|
395
|
+
test("429 with Retry-After → AuthRequestError carries status + retryAfterSeconds", async () => {
|
|
396
|
+
globalThis.fetch = mock(
|
|
397
|
+
async () => new Response(null, { status: 429, headers: { "Retry-After": "7" } }),
|
|
398
|
+
) as unknown as typeof fetch;
|
|
399
|
+
|
|
400
|
+
const err = await fetchTenants().catch((e: unknown) => e);
|
|
401
|
+
|
|
402
|
+
expect(err).toBeInstanceOf(AuthRequestError);
|
|
403
|
+
expect((err as AuthRequestError).status).toBe(429);
|
|
404
|
+
expect((err as AuthRequestError).retryAfterSeconds).toBe(7);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("non-ok without Retry-After header → retryAfterSeconds is undefined", async () => {
|
|
408
|
+
globalThis.fetch = mock(
|
|
409
|
+
async () => new Response(null, { status: 500 }),
|
|
410
|
+
) as unknown as typeof fetch;
|
|
411
|
+
|
|
412
|
+
const err = await fetchTenants().catch((e: unknown) => e);
|
|
413
|
+
|
|
414
|
+
expect(err).toBeInstanceOf(AuthRequestError);
|
|
415
|
+
expect((err as AuthRequestError).retryAfterSeconds).toBeUndefined();
|
|
416
|
+
});
|
|
393
417
|
});
|
|
394
418
|
|
|
395
419
|
describe("switchTenant", () => {
|
|
@@ -1,7 +1,15 @@
|
|
|
1
|
-
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
-
import {
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
createStaticLocaleResolver,
|
|
4
|
+
LocaleProvider,
|
|
5
|
+
PrimitivesProvider,
|
|
6
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
7
|
+
import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
|
|
8
|
+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
3
9
|
import type { ReactNode } from "react";
|
|
10
|
+
import { defaultTranslations } from "../../i18n";
|
|
4
11
|
import { createLoginRoute, makeAuthGate } from "../auth-gate";
|
|
12
|
+
import { SessionProvider } from "../session";
|
|
5
13
|
import { makeSessionApi, renderWithProviders } from "./test-utils";
|
|
6
14
|
|
|
7
15
|
describe("makeAuthGate", () => {
|
|
@@ -236,3 +244,117 @@ describe("createLoginRoute", () => {
|
|
|
236
244
|
);
|
|
237
245
|
});
|
|
238
246
|
});
|
|
247
|
+
|
|
248
|
+
// Real SessionProvider (no mocked SessionContext) — proves the actual
|
|
249
|
+
// bootstrap-failure wiring end to end: hasLikelyAuthSession → fetchTenants
|
|
250
|
+
// throws → doRefresh catches → "error" status → SessionBootstrapErrorScreen.
|
|
251
|
+
// Uses makeAuthGate + SessionProvider directly instead of makeSessionAuthGate:
|
|
252
|
+
// client-plugin.test.tsx mocks makeSessionAuthGate process-wide via bun:test's
|
|
253
|
+
// shared module registry, which would silently no-op it here too.
|
|
254
|
+
describe("makeSessionAuthGate — session bootstrap failure", () => {
|
|
255
|
+
const resolver = createStaticLocaleResolver({ locale: "en" });
|
|
256
|
+
const originalFetch = globalThis.fetch;
|
|
257
|
+
|
|
258
|
+
function renderRealSession(ui: ReactNode) {
|
|
259
|
+
return render(
|
|
260
|
+
<PrimitivesProvider value={defaultPrimitives}>
|
|
261
|
+
<LocaleProvider resolver={resolver} fallbackBundles={[defaultTranslations]}>
|
|
262
|
+
<SessionProvider>{ui}</SessionProvider>
|
|
263
|
+
</LocaleProvider>
|
|
264
|
+
</PrimitivesProvider>,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
beforeEach(() => {
|
|
269
|
+
document.cookie = "kumiko_csrf=gate-test-token";
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
afterEach(() => {
|
|
273
|
+
document.cookie = "kumiko_csrf=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/";
|
|
274
|
+
globalThis.fetch = originalFetch;
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("429 on GET /auth/tenants → error screen with data-http-status=429, children not rendered; retry recovers", async () => {
|
|
278
|
+
let tenantsCalls = 0;
|
|
279
|
+
globalThis.fetch = mock(async (input: RequestInfo | URL) => {
|
|
280
|
+
const url = typeof input === "string" ? input : input.toString();
|
|
281
|
+
if (url === "/api/auth/tenants") {
|
|
282
|
+
tenantsCalls++;
|
|
283
|
+
if (tenantsCalls === 1) return new Response(null, { status: 429 });
|
|
284
|
+
return new Response(JSON.stringify({ tenants: [], activeTenantId: "t1" }), {
|
|
285
|
+
status: 200,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
if (url === "/api/query") {
|
|
289
|
+
return new Response(
|
|
290
|
+
JSON.stringify({
|
|
291
|
+
data: { id: "u1", email: "u@example.com", displayName: "U", roles: "[]" },
|
|
292
|
+
}),
|
|
293
|
+
{ status: 200 },
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
return new Response(null, { status: 404 });
|
|
297
|
+
}) as unknown as typeof fetch;
|
|
298
|
+
|
|
299
|
+
const Gate = makeAuthGate();
|
|
300
|
+
renderRealSession(
|
|
301
|
+
<Gate>
|
|
302
|
+
<div data-testid="protected">secret</div>
|
|
303
|
+
</Gate>,
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
const errorScreen = await waitFor(() => screen.getByTestId("session-bootstrap-error"));
|
|
307
|
+
expect(errorScreen.getAttribute("data-http-status")).toBe("429");
|
|
308
|
+
expect(screen.queryByTestId("protected")).toBeNull();
|
|
309
|
+
|
|
310
|
+
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
|
|
311
|
+
|
|
312
|
+
await waitFor(() => {
|
|
313
|
+
expect(screen.getByTestId("protected")).toBeTruthy();
|
|
314
|
+
});
|
|
315
|
+
expect(tenantsCalls).toBe(2);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("500 on POST /api/query (user:me) → error screen with data-http-status=500, children not rendered", async () => {
|
|
319
|
+
globalThis.fetch = mock(async (input: RequestInfo | URL) => {
|
|
320
|
+
const url = typeof input === "string" ? input : input.toString();
|
|
321
|
+
if (url === "/api/auth/tenants") {
|
|
322
|
+
return new Response(JSON.stringify({ tenants: [], activeTenantId: "t1" }), {
|
|
323
|
+
status: 200,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
if (url === "/api/query") {
|
|
327
|
+
return new Response(null, { status: 500 });
|
|
328
|
+
}
|
|
329
|
+
return new Response(null, { status: 404 });
|
|
330
|
+
}) as unknown as typeof fetch;
|
|
331
|
+
|
|
332
|
+
const Gate = makeAuthGate();
|
|
333
|
+
renderRealSession(
|
|
334
|
+
<Gate>
|
|
335
|
+
<div data-testid="protected">secret</div>
|
|
336
|
+
</Gate>,
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
const errorScreen = await waitFor(() => screen.getByTestId("session-bootstrap-error"));
|
|
340
|
+
expect(errorScreen.getAttribute("data-http-status")).toBe("500");
|
|
341
|
+
expect(screen.queryByTestId("protected")).toBeNull();
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("network failure on GET /auth/tenants → error screen with data-http-status=network", async () => {
|
|
345
|
+
globalThis.fetch = mock(async () => {
|
|
346
|
+
throw new TypeError("Failed to fetch");
|
|
347
|
+
}) as unknown as typeof fetch;
|
|
348
|
+
|
|
349
|
+
const Gate = makeAuthGate();
|
|
350
|
+
renderRealSession(
|
|
351
|
+
<Gate>
|
|
352
|
+
<div data-testid="protected">secret</div>
|
|
353
|
+
</Gate>,
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
const errorScreen = await waitFor(() => screen.getByTestId("session-bootstrap-error"));
|
|
357
|
+
expect(errorScreen.getAttribute("data-http-status")).toBe("network");
|
|
358
|
+
expect(screen.queryByTestId("protected")).toBeNull();
|
|
359
|
+
});
|
|
360
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { SessionBootstrapFailure } from "../session";
|
|
3
|
+
import { retryDelayMs } from "../session-bootstrap-error";
|
|
4
|
+
|
|
5
|
+
describe("retryDelayMs", () => {
|
|
6
|
+
test("Retry-After not yet elapsed → remaining delay in ms", () => {
|
|
7
|
+
const failure: SessionBootstrapFailure = {
|
|
8
|
+
httpStatus: 429,
|
|
9
|
+
retryAfterSeconds: 11,
|
|
10
|
+
failedAtEpochMs: 1000,
|
|
11
|
+
};
|
|
12
|
+
expect(retryDelayMs(failure, 3000)).toBe(9000);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("Retry-After already elapsed → 0, never negative", () => {
|
|
16
|
+
const failure: SessionBootstrapFailure = {
|
|
17
|
+
httpStatus: 429,
|
|
18
|
+
retryAfterSeconds: 11,
|
|
19
|
+
failedAtEpochMs: 1000,
|
|
20
|
+
};
|
|
21
|
+
expect(retryDelayMs(failure, 20_000)).toBe(0);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("no retryAfterSeconds → 0 (retry immediately)", () => {
|
|
25
|
+
const failure: SessionBootstrapFailure = {
|
|
26
|
+
httpStatus: 500,
|
|
27
|
+
retryAfterSeconds: null,
|
|
28
|
+
failedAtEpochMs: 1000,
|
|
29
|
+
};
|
|
30
|
+
expect(retryDelayMs(failure, 1000)).toBe(0);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -62,6 +62,26 @@ export function csrfHeader(): Record<string, string> {
|
|
|
62
62
|
return token !== undefined ? { [CSRF_HEADER_NAME]: token } : {};
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
// Thrown by fetchTenants/fetchCurrentUser on a non-401 error status, distinguishable from a plain network failure by its status field.
|
|
66
|
+
export class AuthRequestError extends Error {
|
|
67
|
+
readonly status: number;
|
|
68
|
+
readonly retryAfterSeconds: number | undefined;
|
|
69
|
+
|
|
70
|
+
constructor(message: string, status: number, retryAfterSeconds: number | undefined) {
|
|
71
|
+
super(message);
|
|
72
|
+
this.name = "AuthRequestError";
|
|
73
|
+
this.status = status;
|
|
74
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Only a non-negative integer second count is usable; anything else is treated as absent.
|
|
79
|
+
export function parseRetryAfterSeconds(header: string | null): number | undefined {
|
|
80
|
+
if (header === null) return undefined;
|
|
81
|
+
if (!/^\d+$/.test(header)) return undefined;
|
|
82
|
+
return Number(header);
|
|
83
|
+
}
|
|
84
|
+
|
|
65
85
|
// LoginResult mirrors the two-step login contract auth-routes.ts owns: a
|
|
66
86
|
// straight success, an MFA challenge (auth-mfa mints the token), an
|
|
67
87
|
// MFA-setup-required block (enforcement policy blocks an unenrolled user),
|
|
@@ -344,7 +364,13 @@ export async function fetchTenants(): Promise<{
|
|
|
344
364
|
credentials: "same-origin",
|
|
345
365
|
});
|
|
346
366
|
if (res.status === 401) return null;
|
|
347
|
-
if (!res.ok)
|
|
367
|
+
if (!res.ok) {
|
|
368
|
+
throw new AuthRequestError(
|
|
369
|
+
`auth/tenants failed: ${res.status}`,
|
|
370
|
+
res.status,
|
|
371
|
+
parseRetryAfterSeconds(res.headers.get("Retry-After")),
|
|
372
|
+
);
|
|
373
|
+
}
|
|
348
374
|
// @cast-boundary engine-payload — HTTP-API contract, server-side schema-validated
|
|
349
375
|
return (await res.json()) as {
|
|
350
376
|
tenants: readonly TenantSummary[];
|
|
@@ -392,7 +418,13 @@ export async function fetchCurrentUser(): Promise<CurrentUserProfile | null> {
|
|
|
392
418
|
body: JSON.stringify({ type: "user:query:user:me", payload: {} }),
|
|
393
419
|
});
|
|
394
420
|
if (res.status === 401) return null;
|
|
395
|
-
if (!res.ok)
|
|
421
|
+
if (!res.ok) {
|
|
422
|
+
throw new AuthRequestError(
|
|
423
|
+
`user:me failed: ${res.status}`,
|
|
424
|
+
res.status,
|
|
425
|
+
parseRetryAfterSeconds(res.headers.get("Retry-After")),
|
|
426
|
+
);
|
|
427
|
+
}
|
|
396
428
|
// @cast-boundary engine-payload — HTTP-API contract, server-side schema-validated
|
|
397
429
|
const body = (await res.json()) as {
|
|
398
430
|
data?: {
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { type ComponentType, type ReactNode, useEffect, useState } from "react";
|
|
13
13
|
import { LoginScreen, type LoginScreenProps } from "./login-screen";
|
|
14
14
|
import { SessionProvider, useSession } from "./session";
|
|
15
|
+
import { SessionBootstrapErrorScreen } from "./session-bootstrap-error";
|
|
15
16
|
|
|
16
17
|
// Generic — NOT auth-mfa's MfaVerifyScreenProps directly, so this feature
|
|
17
18
|
// stays unaware of auth-mfa's concrete shape (same coupling direction as
|
|
@@ -65,7 +66,7 @@ export function createLoginRoute(
|
|
|
65
66
|
const MfaSetupComponent = opts.mfaSetupScreen;
|
|
66
67
|
|
|
67
68
|
function LoginRoute(): ReactNode {
|
|
68
|
-
const { status, refresh } = useSession();
|
|
69
|
+
const { status, refresh, bootstrapFailure } = useSession();
|
|
69
70
|
const { onAuthenticated } = opts;
|
|
70
71
|
// Pending challenge-token from LoginScreen's onMfaChallenge. Lives here
|
|
71
72
|
// (not in SessionState) because it's a UI-only transition — the server
|
|
@@ -86,6 +87,9 @@ export function createLoginRoute(
|
|
|
86
87
|
<div className="min-h-screen flex items-center justify-center text-muted-foreground text-sm" />
|
|
87
88
|
);
|
|
88
89
|
}
|
|
90
|
+
if (status === "error" && bootstrapFailure !== null) {
|
|
91
|
+
return <SessionBootstrapErrorScreen failure={bootstrapFailure} onRetry={refresh} />;
|
|
92
|
+
}
|
|
89
93
|
// A standalone mount (no parent gate, no onAuthenticated wired — e.g. an
|
|
90
94
|
// apex/marketing surface that just places <LoginRoute /> directly) has no
|
|
91
95
|
// way to navigate away once authenticated. Blanking the page there is a
|
|
@@ -18,6 +18,7 @@ export type {
|
|
|
18
18
|
VerifyEmailFailure,
|
|
19
19
|
} from "./auth-client";
|
|
20
20
|
export {
|
|
21
|
+
AuthRequestError,
|
|
21
22
|
confirmAccountUnlock,
|
|
22
23
|
confirmSignup,
|
|
23
24
|
requestAccountUnlock,
|
|
@@ -54,8 +55,10 @@ export type { RequestAccountUnlockScreenProps } from "./request-account-unlock-s
|
|
|
54
55
|
export { RequestAccountUnlockScreen } from "./request-account-unlock-screen";
|
|
55
56
|
export type { ResetPasswordScreenProps } from "./reset-password-screen";
|
|
56
57
|
export { ResetPasswordScreen } from "./reset-password-screen";
|
|
57
|
-
export type { SessionApi, SessionState, SessionStatus } from "./session";
|
|
58
|
+
export type { SessionApi, SessionBootstrapFailure, SessionState, SessionStatus } from "./session";
|
|
58
59
|
export { hasLikelyAuthSession, SessionContext, SessionProvider, useSession } from "./session";
|
|
60
|
+
export type { SessionBootstrapErrorScreenProps } from "./session-bootstrap-error";
|
|
61
|
+
export { retryDelayMs, SessionBootstrapErrorScreen } from "./session-bootstrap-error";
|
|
59
62
|
export type { SignupCompleteScreenProps } from "./signup-complete-screen";
|
|
60
63
|
export { SignupCompleteScreen } from "./signup-complete-screen";
|
|
61
64
|
export type { SignupScreenProps } from "./signup-screen";
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Rendered by auth-gate's LoginRoute when the session bootstrap failed for a reason other than "not logged in".
|
|
3
|
+
|
|
4
|
+
import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
5
|
+
import { type ReactNode, useState } from "react";
|
|
6
|
+
import { AuthCard } from "./auth-form-primitives";
|
|
7
|
+
import type { SessionBootstrapFailure } from "./session";
|
|
8
|
+
|
|
9
|
+
export type SessionBootstrapErrorScreenProps = {
|
|
10
|
+
readonly failure: SessionBootstrapFailure;
|
|
11
|
+
readonly onRetry: () => Promise<void>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** Retry delay measured from failure time, not click time. */
|
|
15
|
+
export function retryDelayMs(failure: SessionBootstrapFailure, nowEpochMs: number): number {
|
|
16
|
+
const retryAfterMs = (failure.retryAfterSeconds ?? 0) * 1000;
|
|
17
|
+
return Math.max(0, failure.failedAtEpochMs + retryAfterMs - nowEpochMs);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function wait(ms: number): Promise<void> {
|
|
21
|
+
if (ms <= 0) return Promise.resolve();
|
|
22
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function SessionBootstrapErrorScreen({
|
|
26
|
+
failure,
|
|
27
|
+
onRetry,
|
|
28
|
+
}: SessionBootstrapErrorScreenProps): ReactNode {
|
|
29
|
+
const t = useTranslation();
|
|
30
|
+
const { Banner, Button } = usePrimitives();
|
|
31
|
+
const [retrying, setRetrying] = useState(false);
|
|
32
|
+
|
|
33
|
+
const handleRetry = (): void => {
|
|
34
|
+
setRetrying(true);
|
|
35
|
+
void wait(retryDelayMs(failure, Date.now()))
|
|
36
|
+
.then(onRetry)
|
|
37
|
+
.then(() => setRetrying(false));
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div data-testid="session-bootstrap-error" data-http-status={failure.httpStatus ?? "network"}>
|
|
42
|
+
<AuthCard title={t("auth.sessionBootstrap.errorTitle")}>
|
|
43
|
+
<div className="flex flex-col gap-4 px-6 pb-4">
|
|
44
|
+
<Banner variant="error">
|
|
45
|
+
{failure.httpStatus === 429
|
|
46
|
+
? t("auth.sessionBootstrap.rateLimitedBody")
|
|
47
|
+
: t("auth.sessionBootstrap.errorBody")}
|
|
48
|
+
</Banner>
|
|
49
|
+
<Button variant="primary" onClick={handleRetry} disabled={retrying}>
|
|
50
|
+
{retrying ? t("auth.sessionBootstrap.retrying") : t("auth.sessionBootstrap.retry")}
|
|
51
|
+
</Button>
|
|
52
|
+
</div>
|
|
53
|
+
</AuthCard>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { readCsrfToken } from "@cosmicdrift/kumiko-dispatcher-live";
|
|
13
13
|
import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react";
|
|
14
14
|
import {
|
|
15
|
+
AuthRequestError,
|
|
15
16
|
type CurrentUserProfile,
|
|
16
17
|
fetchCurrentUser,
|
|
17
18
|
fetchTenants,
|
|
@@ -23,7 +24,14 @@ import {
|
|
|
23
24
|
type TenantSummary,
|
|
24
25
|
} from "./auth-client";
|
|
25
26
|
|
|
26
|
-
export type SessionStatus = "loading" | "unauthenticated" | "authenticated";
|
|
27
|
+
export type SessionStatus = "loading" | "unauthenticated" | "authenticated" | "error";
|
|
28
|
+
|
|
29
|
+
// httpStatus null means the fetch itself threw (offline/DNS/CORS), not that the server answered.
|
|
30
|
+
export type SessionBootstrapFailure = {
|
|
31
|
+
readonly httpStatus: number | null;
|
|
32
|
+
readonly retryAfterSeconds: number | null;
|
|
33
|
+
readonly failedAtEpochMs: number;
|
|
34
|
+
};
|
|
27
35
|
|
|
28
36
|
export type SessionState = {
|
|
29
37
|
readonly status: SessionStatus;
|
|
@@ -36,6 +44,7 @@ export type SessionState = {
|
|
|
36
44
|
* Client computed dieselbe merge-Logik aus user.globalRoles +
|
|
37
45
|
* tenants[active].roles damit nav-filtering greift. Dedupliziert. */
|
|
38
46
|
readonly roles: readonly string[];
|
|
47
|
+
readonly bootstrapFailure: SessionBootstrapFailure | null;
|
|
39
48
|
};
|
|
40
49
|
|
|
41
50
|
export type SessionApi = SessionState & {
|
|
@@ -58,6 +67,7 @@ export const UNAUTHENTICATED: SessionState = {
|
|
|
58
67
|
activeTenantId: null,
|
|
59
68
|
tenants: [],
|
|
60
69
|
roles: [],
|
|
70
|
+
bootstrapFailure: null,
|
|
61
71
|
};
|
|
62
72
|
|
|
63
73
|
const INITIAL: SessionState = {
|
|
@@ -66,6 +76,7 @@ const INITIAL: SessionState = {
|
|
|
66
76
|
activeTenantId: null,
|
|
67
77
|
tenants: [],
|
|
68
78
|
roles: [],
|
|
79
|
+
bootstrapFailure: null,
|
|
69
80
|
};
|
|
70
81
|
|
|
71
82
|
// kumiko_auth ist HttpOnly — kumiko_csrf wird beim Login gemeinsam gesetzt.
|
|
@@ -117,15 +128,33 @@ async function refresh(): Promise<SessionState> {
|
|
|
117
128
|
activeTenantId: tenants.activeTenantId,
|
|
118
129
|
tenants: tenants.tenants,
|
|
119
130
|
roles: computeActiveRoles(user, tenants.activeTenantId, tenants.tenants),
|
|
131
|
+
bootstrapFailure: null,
|
|
120
132
|
};
|
|
121
133
|
}
|
|
122
134
|
|
|
135
|
+
// Non-AuthRequestError throws (offline, DNS, CORS) have no status, so httpStatus stays null.
|
|
136
|
+
function toBootstrapFailure(err: unknown): SessionBootstrapFailure {
|
|
137
|
+
if (err instanceof AuthRequestError) {
|
|
138
|
+
return {
|
|
139
|
+
httpStatus: err.status,
|
|
140
|
+
retryAfterSeconds: err.retryAfterSeconds ?? null,
|
|
141
|
+
failedAtEpochMs: Date.now(),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
return { httpStatus: null, retryAfterSeconds: null, failedAtEpochMs: Date.now() };
|
|
145
|
+
}
|
|
146
|
+
|
|
123
147
|
export function SessionProvider({ children }: { readonly children: ReactNode }): ReactNode {
|
|
124
148
|
const [state, setState] = useState<SessionState>(INITIAL);
|
|
125
149
|
|
|
150
|
+
// Never rejects, so bootstrap can't strand the UI on "loading" forever.
|
|
126
151
|
const doRefresh = useCallback(async () => {
|
|
127
|
-
|
|
128
|
-
|
|
152
|
+
try {
|
|
153
|
+
const next = await refresh();
|
|
154
|
+
setState(next);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
setState({ ...UNAUTHENTICATED, status: "error", bootstrapFailure: toBootstrapFailure(err) });
|
|
157
|
+
}
|
|
129
158
|
}, []);
|
|
130
159
|
|
|
131
160
|
// kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
createTenantDb,
|
|
15
15
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
16
16
|
import {
|
|
17
|
+
createAnonymousUser,
|
|
17
18
|
type SessionUser,
|
|
18
19
|
SYSTEM_TENANT_ID,
|
|
19
20
|
type TenantId,
|
|
@@ -45,14 +46,10 @@ const CHALLENGE_TOKEN_SECRET = "no-redis-test-challenge-token-secret-not-real-00
|
|
|
45
46
|
const SETUP_TOKEN_SECRET = "no-redis-test-setup-token-secret-not-real-0002";
|
|
46
47
|
const TENANT_ID: TenantId = testTenantId(421);
|
|
47
48
|
|
|
48
|
-
// Mirrors framework's auth-routes.ts
|
|
49
|
-
// are guest-callable (access: { roles: ["
|
|
49
|
+
// Mirrors framework's auth-routes.ts anonymous dispatch — verify/enable-confirm-preauth
|
|
50
|
+
// are guest-callable (access: { roles: ["anonymous"] }), the real identity comes
|
|
50
51
|
// entirely from the signed token, not from this dispatch-level user.
|
|
51
|
-
const
|
|
52
|
-
id: "00000000-0000-0000-0000-000000000000",
|
|
53
|
-
tenantId: SYSTEM_TENANT_ID,
|
|
54
|
-
roles: ["all"],
|
|
55
|
-
};
|
|
52
|
+
const ANONYMOUS_USER: SessionUser = createAnonymousUser(SYSTEM_TENANT_ID);
|
|
56
53
|
|
|
57
54
|
beforeAll(async () => {
|
|
58
55
|
const encryption = createTestEnvelopeCipher(randomBytes(32).toString("base64"));
|
|
@@ -125,7 +122,7 @@ describe("auth-mfa handlers — ctx.redis unset (#1467)", () => {
|
|
|
125
122
|
const err = await stack.http.writeErr(
|
|
126
123
|
AuthMfaHandlers.verify,
|
|
127
124
|
{ challengeToken: token, code: "123456" },
|
|
128
|
-
|
|
125
|
+
ANONYMOUS_USER,
|
|
129
126
|
);
|
|
130
127
|
expect(err.httpStatus).toBeGreaterThanOrEqual(500);
|
|
131
128
|
expect(err.code).toBe("internal_error");
|
|
@@ -148,7 +145,7 @@ describe("auth-mfa handlers — ctx.redis unset (#1467)", () => {
|
|
|
148
145
|
const err = await stack.http.writeErr(
|
|
149
146
|
AuthMfaHandlers.enableConfirmPreauth,
|
|
150
147
|
{ setupToken: token, code: "123456" },
|
|
151
|
-
|
|
148
|
+
ANONYMOUS_USER,
|
|
152
149
|
);
|
|
153
150
|
expect(err.httpStatus).toBeGreaterThanOrEqual(500);
|
|
154
151
|
expect(err.code).toBe("internal_error");
|
|
@@ -44,14 +44,14 @@ let stack: TestStack;
|
|
|
44
44
|
const SETUP_TOKEN_SECRET = "test-mfa-setup-secret-at-least-32-bytes-long!!";
|
|
45
45
|
const CHALLENGE_TOKEN_SECRET = "test-mfa-challenge-secret-at-least-32-bytes!!";
|
|
46
46
|
|
|
47
|
-
// Handler access is `{ roles: ["
|
|
48
|
-
// dispatches with
|
|
49
|
-
// handler derives everything from the challenge-token payload,
|
|
50
|
-
// event.user.
|
|
47
|
+
// Handler access is `{ roles: ["anonymous"] }`, matching how the framework
|
|
48
|
+
// route dispatches with the anonymous identity — a literal here is enough
|
|
49
|
+
// since the handler derives everything from the challenge-token payload,
|
|
50
|
+
// not from event.user.
|
|
51
51
|
const GUEST: SessionUser = {
|
|
52
|
-
id: "
|
|
52
|
+
id: "anonymous",
|
|
53
53
|
tenantId: "00000000-0000-4000-8000-000000000001" as TenantId,
|
|
54
|
-
roles: ["
|
|
54
|
+
roles: ["anonymous"],
|
|
55
55
|
};
|
|
56
56
|
|
|
57
57
|
beforeAll(async () => {
|
|
@@ -63,7 +63,8 @@ export function createEnableConfirmPreauthHandler(opts: EnableConfirmPreauthOpti
|
|
|
63
63
|
setupToken: z.string().min(1),
|
|
64
64
|
code: z.string().length(6),
|
|
65
65
|
}),
|
|
66
|
-
access: { roles: ["
|
|
66
|
+
access: { roles: ["anonymous"] },
|
|
67
|
+
rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
|
|
67
68
|
escapeHatch: {
|
|
68
69
|
reason:
|
|
69
70
|
"Pre-auth MFA enrollment step has no session yet — re-checks status via ctx.queryAs(SYSTEM, " +
|
|
@@ -86,7 +87,7 @@ export function createEnableConfirmPreauthHandler(opts: EnableConfirmPreauthOpti
|
|
|
86
87
|
if (tenantId === undefined) return invalidSetupToken();
|
|
87
88
|
|
|
88
89
|
// Fail closed: unlike verify.write.ts/enable-confirm.write.ts, this
|
|
89
|
-
// endpoint has NO gate before it (access: { roles: ["
|
|
90
|
+
// endpoint has NO gate before it (access: { roles: ["anonymous"] }, no JWT,
|
|
90
91
|
// no prior challenge-token from a successful login) — its only secret
|
|
91
92
|
// is a 6-digit TOTP code. Silently skipping the brute-force cap and
|
|
92
93
|
// single-use burn when ctx.redis is absent would let the setup token
|
|
@@ -29,7 +29,8 @@ export type EnableStartPreauthOptions = {
|
|
|
29
29
|
// preauthSetupToken (minted by login.write.ts when enforcement policy
|
|
30
30
|
// blocks an unenrolled user), not from event.user — there is no session at
|
|
31
31
|
// this point. Runs pre-session the same way verify.write.ts does (dispatched
|
|
32
|
-
// by the framework's /auth/mfa/preauth-enable-start route with
|
|
32
|
+
// by the framework's /auth/mfa/preauth-enable-start route with the
|
|
33
|
+
// anonymous identity).
|
|
33
34
|
export function createEnableStartPreauthHandler(opts: EnableStartPreauthOptions) {
|
|
34
35
|
return defineWriteHandler({
|
|
35
36
|
name: "enable-start-preauth",
|
|
@@ -37,7 +38,8 @@ export function createEnableStartPreauthHandler(opts: EnableStartPreauthOptions)
|
|
|
37
38
|
preauthSetupToken: z.string().min(1),
|
|
38
39
|
accountLabel: z.string().min(1).max(200),
|
|
39
40
|
}),
|
|
40
|
-
access: { roles: ["
|
|
41
|
+
access: { roles: ["anonymous"] },
|
|
42
|
+
rateLimit: { per: "ip+handler", limit: 10, windowSeconds: 60 },
|
|
41
43
|
description:
|
|
42
44
|
"Begins TOTP enrollment for a user whose sign-in was blocked because the tenant requires two-factor authentication, taking identity from the pre-auth token login issued instead of from a session.",
|
|
43
45
|
// Same secret-bearing result as enable-start.
|
|
@@ -35,7 +35,7 @@ const executor = createEventStoreExecutor(userMfaTable, userMfaEntity, {
|
|
|
35
35
|
});
|
|
36
36
|
|
|
37
37
|
// Completes the two-step login. Runs pre-session (dispatched by the
|
|
38
|
-
// framework's /auth/mfa/verify route with
|
|
38
|
+
// framework's /auth/mfa/verify route with the anonymous identity, same as
|
|
39
39
|
// login.write.ts) — everything it needs (which user, which tenant) comes
|
|
40
40
|
// from the challenge token, not from an authenticated caller.
|
|
41
41
|
export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
|
|
@@ -45,7 +45,8 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
|
|
|
45
45
|
return defineWriteHandler({
|
|
46
46
|
name: "verify",
|
|
47
47
|
schema: z.object({ challengeToken: z.string().min(1), code: z.string().min(6).max(9) }),
|
|
48
|
-
access: { roles: ["
|
|
48
|
+
access: { roles: ["anonymous"] },
|
|
49
|
+
rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
|
|
49
50
|
escapeHatch: {
|
|
50
51
|
reason:
|
|
51
52
|
"Pre-auth MFA step has no session yet — re-derives it via ctx.queryAs(SYSTEM, " +
|
|
@@ -65,8 +65,8 @@ export function createMfaStatusChecker(opts: {
|
|
|
65
65
|
return { required: true, challengeToken: token };
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
// ctx.config is bound to the CALLING user's tenant (
|
|
69
|
-
// login) — wrong tenant here. ctx.configResolver takes tenantId
|
|
68
|
+
// ctx.config is bound to the CALLING user's tenant (the anonymous
|
|
69
|
+
// identity's tenant for login) — wrong tenant here. ctx.configResolver takes tenantId
|
|
70
70
|
// explicitly, which is what a pre-session check against the LOGGING-IN
|
|
71
71
|
// user's tenant needs.
|
|
72
72
|
// @cast-boundary engine-payload — "select" config values are validated
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.301.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "billing-foundation no longer exports SystemWriteResult (removed in 0.298.0 without a changelog note)",
|
|
6
|
+
"migration": "SystemWriteResult was exported from @cosmicdrift/kumiko-bundled-features/billing-foundation up to 0.297.0 and was dropped in 0.298.0 together with the dispatchSystemWrite dep of createSubscriptionTierSync; there is no alias. Code that still imports it fails with \"has no exported member 'SystemWriteResult'\". If the type is only used for a dispatchSystemWrite dep passed to createSubscriptionTierSync, drop that dep and the type together (see the 0.298.0 webhook-wiring migration: extraRoutes: [createSubscriptionTierSync({ ... }).createWebhookRoute()]). Where a system-write result type is still needed, import type { WriteResult } from \"@cosmicdrift/kumiko-framework/engine\" and use WriteResult<unknown>; it is a discriminated union ({ isSuccess: true; data } | WriteFailure), so narrow on isSuccess before reading data or error."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.298.0",
|
|
4
10
|
"type": "breaking",
|