@cosmicdrift/kumiko-framework 0.319.0 → 0.320.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 +8 -4
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +6 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +11 -0
- package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +8 -4
- package/src/api/__tests__/client-ip.test.ts +139 -0
- package/src/api/__tests__/extra-routes.integration.test.ts +8 -1
- package/src/api/__tests__/http-route-rate-limit.integration.test.ts +73 -2
- package/src/api/__tests__/pat-route-guard.integration.test.ts +126 -0
- package/src/api/__tests__/post-auth-landing.test.ts +100 -0
- package/src/api/__tests__/request-id-middleware.test.ts +35 -2
- package/src/api/__tests__/schema-route.integration.test.ts +157 -0
- package/src/api/api-constants.ts +2 -0
- package/src/api/auth-routes.ts +92 -95
- package/src/api/client-ip.ts +91 -0
- package/src/api/index.ts +15 -0
- package/src/api/pat-route-guard.ts +51 -0
- package/src/api/post-auth-landing.ts +105 -0
- package/src/api/request-context.ts +4 -2
- package/src/api/request-id-middleware.ts +44 -14
- package/src/api/server.ts +128 -40
- package/src/changes.json +73 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +47 -0
- package/src/engine/__tests__/metric-navigate-tab.test.ts +167 -0
- package/src/engine/__tests__/project-app-schema-for-roles.test.ts +500 -0
- package/src/engine/__tests__/toolbar-navigate-tab.test.ts +233 -0
- package/src/engine/boot-validator/pii-retention.ts +18 -2
- package/src/engine/boot-validator/screens.ts +75 -18
- package/src/engine/index.ts +1 -0
- package/src/engine/project-app-schema-for-roles.ts +950 -0
- package/src/engine/types/handlers.ts +1 -1
- package/src/engine/types/index.ts +5 -1
- package/src/entrypoint/index.ts +14 -1
- package/src/files/__tests__/files.integration.test.ts +105 -1
- package/src/files/file-routes.ts +54 -2
- package/src/files/index.ts +1 -1
- package/src/http/index.ts +10 -2
- package/src/jobs/__tests__/jobs.integration.test.ts +105 -3
- package/src/jobs/job-runner.ts +6 -1
- package/src/rate-limit/__tests__/middleware.integration.test.ts +24 -7
- package/src/rate-limit/middleware.ts +32 -8
- package/src/stack/__tests__/push-entity-projection-tables-column-sync.integration.test.ts +107 -0
- package/src/stack/push-entity-projection-tables.ts +52 -7
- package/src/stack/table-helpers.ts +17 -9
- package/src/stack/test-stack.ts +28 -1
- package/src/testing/real-providers.ts +2 -0
- package/src/ui-types/index.ts +1 -1
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// kumiko-framework#3314: GET /api/schema is the only way a client gets the
|
|
2
|
+
// AppSchema now — HTML never carries it. anonymousAccess is deliberately
|
|
3
|
+
// wired here (like http-route-entry.integration.test.ts): without it, a
|
|
4
|
+
// wrongly-mounted route (jwtGuard skipping instead of rejecting) would
|
|
5
|
+
// still 401 on a missing token and mask the "anonymous user reaches the
|
|
6
|
+
// schema" bug the anonymous-401 test exists to catch.
|
|
7
|
+
|
|
8
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
9
|
+
import { createEntity, defineFeature } from "../../engine";
|
|
10
|
+
import type { TenantId } from "../../engine/types/identifiers";
|
|
11
|
+
import { setupTestStack, type TestStack, TestUsers } from "../../stack";
|
|
12
|
+
import { Routes } from "../api-constants";
|
|
13
|
+
import { AUTH_COOKIE_NAME } from "../auth-middleware";
|
|
14
|
+
|
|
15
|
+
const TENANT_ID = "00000000-0000-4000-8000-000000000001" as TenantId;
|
|
16
|
+
const SCHEMA_PATH = `/api${Routes.schema}`;
|
|
17
|
+
|
|
18
|
+
const schemaRouteFeature = defineFeature("schema-route-fixture", (r) => {
|
|
19
|
+
r.entity("widget", createEntity({ fields: {} }));
|
|
20
|
+
r.screen({
|
|
21
|
+
id: "widget-list",
|
|
22
|
+
type: "entityList",
|
|
23
|
+
entity: "widget",
|
|
24
|
+
columns: ["id"],
|
|
25
|
+
});
|
|
26
|
+
r.screen({
|
|
27
|
+
id: "admin-only",
|
|
28
|
+
type: "entityList",
|
|
29
|
+
entity: "widget",
|
|
30
|
+
columns: ["id"],
|
|
31
|
+
access: { roles: ["Admin"] },
|
|
32
|
+
});
|
|
33
|
+
r.nav({ id: "widget-list", label: "Widgets", screen: "widget-list" });
|
|
34
|
+
r.nav({ id: "admin-only", label: "Admin Only", screen: "admin-only" });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
let stack: TestStack;
|
|
38
|
+
|
|
39
|
+
beforeAll(async () => {
|
|
40
|
+
stack = await setupTestStack({
|
|
41
|
+
features: [schemaRouteFeature],
|
|
42
|
+
anonymousAccess: { defaultTenantId: TENANT_ID },
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterAll(async () => {
|
|
47
|
+
await stack.cleanup();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("GET /api/schema", () => {
|
|
51
|
+
test("anonymous → 401, no schema leaked", async () => {
|
|
52
|
+
const res = await stack.app.request(SCHEMA_PATH);
|
|
53
|
+
expect(res.status).toBe(401);
|
|
54
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
55
|
+
expect(body.error.code).toBe("unauthenticated");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("signed-in user via Bearer token → 200 with the registry's schema, strong ETag, private/no-cache", async () => {
|
|
59
|
+
const token = await stack.jwt.sign(TestUsers.user);
|
|
60
|
+
const res = await stack.app.request(SCHEMA_PATH, {
|
|
61
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
62
|
+
});
|
|
63
|
+
expect(res.status).toBe(200);
|
|
64
|
+
expect(res.headers.get("content-type")).toContain("application/json");
|
|
65
|
+
const cacheControl = res.headers.get("cache-control") ?? "";
|
|
66
|
+
expect(cacheControl).toContain("private");
|
|
67
|
+
expect(cacheControl).toContain("no-cache");
|
|
68
|
+
expect(cacheControl).not.toContain("public");
|
|
69
|
+
expect(cacheControl).not.toContain("no-store");
|
|
70
|
+
const etag = res.headers.get("etag");
|
|
71
|
+
expect(etag).toMatch(/^"/);
|
|
72
|
+
expect(etag).not.toMatch(/^W\//);
|
|
73
|
+
const body = (await res.json()) as { features: Array<{ featureName: string }> };
|
|
74
|
+
expect(body.features.length).toBeGreaterThan(0);
|
|
75
|
+
expect(body.features.some((f) => f.featureName === "schema-route-fixture")).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("signed-in user via kumiko_auth cookie → 200", async () => {
|
|
79
|
+
const token = await stack.jwt.sign(TestUsers.user);
|
|
80
|
+
const res = await stack.app.request(SCHEMA_PATH, {
|
|
81
|
+
headers: { Cookie: `${AUTH_COOKIE_NAME}=${token}` },
|
|
82
|
+
});
|
|
83
|
+
expect(res.status).toBe(200);
|
|
84
|
+
const body = (await res.json()) as { features: Array<{ featureName: string }> };
|
|
85
|
+
expect(body.features.length).toBeGreaterThan(0);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("matching If-None-Match → 304 with no body, ETag kept", async () => {
|
|
89
|
+
const token = await stack.jwt.sign(TestUsers.user);
|
|
90
|
+
const first = await stack.app.request(SCHEMA_PATH, {
|
|
91
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
92
|
+
});
|
|
93
|
+
const etag = first.headers.get("etag");
|
|
94
|
+
expect(etag).toBeTruthy();
|
|
95
|
+
|
|
96
|
+
const second = await stack.app.request(SCHEMA_PATH, {
|
|
97
|
+
headers: { Authorization: `Bearer ${token}`, "If-None-Match": etag ?? "" },
|
|
98
|
+
});
|
|
99
|
+
expect(second.status).toBe(304);
|
|
100
|
+
expect(await second.text()).toBe("");
|
|
101
|
+
expect(second.headers.get("etag")).toBe(etag);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("anonymous with a valid If-None-Match → still 401, not 304", async () => {
|
|
105
|
+
const token = await stack.jwt.sign(TestUsers.user);
|
|
106
|
+
const authed = await stack.app.request(SCHEMA_PATH, {
|
|
107
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
108
|
+
});
|
|
109
|
+
const etag = authed.headers.get("etag");
|
|
110
|
+
expect(etag).toBeTruthy();
|
|
111
|
+
|
|
112
|
+
const res = await stack.app.request(SCHEMA_PATH, {
|
|
113
|
+
headers: { "If-None-Match": etag ?? "" },
|
|
114
|
+
});
|
|
115
|
+
expect(res.status).toBe(401);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("role projection: Admin sees the role-gated screen, User does not", async () => {
|
|
119
|
+
const adminToken = await stack.jwt.sign(TestUsers.admin);
|
|
120
|
+
const adminRes = await stack.app.request(SCHEMA_PATH, {
|
|
121
|
+
headers: { Authorization: `Bearer ${adminToken}` },
|
|
122
|
+
});
|
|
123
|
+
const adminBody = (await adminRes.json()) as {
|
|
124
|
+
features: Array<{ featureName: string; screens: Array<{ id: string }> }>;
|
|
125
|
+
};
|
|
126
|
+
const adminFixture = adminBody.features.find((f) => f.featureName === "schema-route-fixture");
|
|
127
|
+
expect(adminFixture?.screens.map((s) => s.id).sort()).toEqual(["admin-only", "widget-list"]);
|
|
128
|
+
|
|
129
|
+
const userToken = await stack.jwt.sign(TestUsers.user);
|
|
130
|
+
const userRes = await stack.app.request(SCHEMA_PATH, {
|
|
131
|
+
headers: { Authorization: `Bearer ${userToken}` },
|
|
132
|
+
});
|
|
133
|
+
const userBody = (await userRes.json()) as {
|
|
134
|
+
features: Array<{ featureName: string; screens: Array<{ id: string }> }>;
|
|
135
|
+
};
|
|
136
|
+
const userFixture = userBody.features.find((f) => f.featureName === "schema-route-fixture");
|
|
137
|
+
expect(userFixture?.screens.map((s) => s.id)).toEqual(["widget-list"]);
|
|
138
|
+
|
|
139
|
+
// Different bodies must carry different (strong) ETags.
|
|
140
|
+
expect(adminRes.headers.get("etag")).not.toBe(userRes.headers.get("etag"));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("role projection: one role's ETag sent by a different role never 304s", async () => {
|
|
144
|
+
const adminToken = await stack.jwt.sign(TestUsers.admin);
|
|
145
|
+
const adminRes = await stack.app.request(SCHEMA_PATH, {
|
|
146
|
+
headers: { Authorization: `Bearer ${adminToken}` },
|
|
147
|
+
});
|
|
148
|
+
const adminEtag = adminRes.headers.get("etag");
|
|
149
|
+
expect(adminEtag).toBeTruthy();
|
|
150
|
+
|
|
151
|
+
const userToken = await stack.jwt.sign(TestUsers.user);
|
|
152
|
+
const userRes = await stack.app.request(SCHEMA_PATH, {
|
|
153
|
+
headers: { Authorization: `Bearer ${userToken}`, "If-None-Match": adminEtag ?? "" },
|
|
154
|
+
});
|
|
155
|
+
expect(userRes.status).toBe(200);
|
|
156
|
+
});
|
|
157
|
+
});
|
package/src/api/api-constants.ts
CHANGED
|
@@ -36,6 +36,7 @@ export const Routes = {
|
|
|
36
36
|
authInviteSignupComplete: "/auth/invite-signup-complete",
|
|
37
37
|
authInviteInfo: "/auth/invite-info",
|
|
38
38
|
files: "/files",
|
|
39
|
+
schema: "/schema",
|
|
39
40
|
} as const;
|
|
40
41
|
|
|
41
42
|
// Routes that must be reachable WITHOUT a valid JWT.
|
|
@@ -87,6 +88,7 @@ export const NON_PUBLIC_API_PATHS: ReadonlySet<string> = new Set([
|
|
|
87
88
|
// invite-accept requires a JWT (Branch 1, see PUBLIC_API_PATHS above).
|
|
88
89
|
`/api${Routes.authInviteAccept}`,
|
|
89
90
|
`/api${Routes.files}`,
|
|
91
|
+
`/api${Routes.schema}`,
|
|
90
92
|
]);
|
|
91
93
|
|
|
92
94
|
// Opt-out from the default request-body-size cap (registerBodyLimit applies
|
package/src/api/auth-routes.ts
CHANGED
|
@@ -26,7 +26,14 @@ import {
|
|
|
26
26
|
getUser,
|
|
27
27
|
type TokenVerifier,
|
|
28
28
|
} from "./auth-middleware";
|
|
29
|
+
import {
|
|
30
|
+
type ClientIpResolver,
|
|
31
|
+
clientIpSourceFromHonoContext,
|
|
32
|
+
createClientIpResolver,
|
|
33
|
+
} from "./client-ip";
|
|
29
34
|
import type { JwtHelper } from "./jwt";
|
|
35
|
+
import type { PostAuthLandingArgs, PostAuthLandingResolver } from "./post-auth-landing";
|
|
36
|
+
import { resolvePostAuthLandingPath } from "./post-auth-landing";
|
|
30
37
|
import { generateToken } from "./tokens";
|
|
31
38
|
|
|
32
39
|
// Resolves the Secure cookie flag. Locked off in dev/test so Playwright
|
|
@@ -441,15 +448,26 @@ export type AuthRoutesConfig = {
|
|
|
441
448
|
// `x-forwarded-for` (e.g. nginx `$proxy_add_x_forwarded_for`). Used to
|
|
442
449
|
// derive the client IP for every auth rate-limiter (login, mfa-verify,
|
|
443
450
|
// preauth-enable-start, preauth-confirm) and for requestMeta's session
|
|
444
|
-
// IP
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
//
|
|
451
|
+
// IP via the shared resolveClientIp (see client-ip.ts for the extraction
|
|
452
|
+
// algorithm). Deprecated in favor of the
|
|
453
|
+
// top-level `trustedProxyHops` on ServerOptions/RunProdAppOptions/
|
|
454
|
+
// RunDevAppOptions, which wins when both are set — kept as a fallback so
|
|
455
|
+
// existing consumers that only set this don't regress. Default 0 = trust
|
|
456
|
+
// no proxy header, only the socket address (or "unknown") counts. Set
|
|
457
|
+
// this to your real proxy hop count (1 for a single ingress/reverse-
|
|
458
|
+
// proxy, 2 for edge-LB + ingress, etc.) when deployed behind a proxy.
|
|
452
459
|
trustedProxyHops?: number;
|
|
460
|
+
// Server-computed post-auth redirect, consulted at login, mfa-verify,
|
|
461
|
+
// mfa-preauth-confirm, signup-confirm, and all three invite-accept branches.
|
|
462
|
+
// Result lands in the response as `landingPath`; an invalid path or a
|
|
463
|
+
// throwing resolver just omits the field (see post-auth-landing.ts).
|
|
464
|
+
postAuthLanding?: PostAuthLandingResolver;
|
|
465
|
+
// Shared resolver instance from buildServer — takes precedence over
|
|
466
|
+
// `trustedProxyHops` so the whole server shares exactly one resolver
|
|
467
|
+
// instance (and warn-once flag) instead of building a private one here.
|
|
468
|
+
// Only standalone callers of createAuthRoutes
|
|
469
|
+
// (no buildServer) should rely on `trustedProxyHops` instead.
|
|
470
|
+
resolver?: ClientIpResolver;
|
|
453
471
|
};
|
|
454
472
|
|
|
455
473
|
export type PasswordResetConfig = {
|
|
@@ -504,69 +522,6 @@ export type SignupConfig = {
|
|
|
504
522
|
confirmHandler: string;
|
|
505
523
|
};
|
|
506
524
|
|
|
507
|
-
// Derives the caller IP from proxy headers, single source for the
|
|
508
|
-
// rate-limiter keys below and requestMeta. kumiko-framework#1523/#1522/#1539:
|
|
509
|
-
// `x-forwarded-for` is attacker-controlled unless we know how many trusted
|
|
510
|
-
// proxy hops sit between the client and this process — `trustedProxyHops`
|
|
511
|
-
// (AuthRoutesConfig) is that count.
|
|
512
|
-
//
|
|
513
|
-
// hops === 0 (default): legacy behavior — trust the first XFF entry (or
|
|
514
|
-
// x-real-ip) at face value. Kept as the default so unconfigured
|
|
515
|
-
// deployments don't regress: collapsing everyone into a single "unknown"
|
|
516
|
-
// bucket would turn mfa-verify/preauth-confirm's pure-IP-keyed limiter
|
|
517
|
-
// into a one-request-locks-out-everyone DoS, which is worse than the
|
|
518
|
-
// spoofing hole this issue is about. Apps behind a proxy MUST set this to
|
|
519
|
-
// close the hole — see AuthRoutesConfig.trustedProxyHops.
|
|
520
|
-
//
|
|
521
|
-
// hops >= 1: each trusted proxy appends (not overwrites) its peer address
|
|
522
|
-
// to XFF (e.g. nginx `$proxy_add_x_forwarded_for`), so the last `hops`
|
|
523
|
-
// entries are proxy-supplied and everything before them — including the
|
|
524
|
-
// real client — sits at `entries[length - hops]`. Anything the client
|
|
525
|
-
// itself prepended lands further left and is ignored. If the header has
|
|
526
|
-
// fewer entries than `hops`, the proxy chain is shorter than configured
|
|
527
|
-
// (misconfiguration or a bypassed hop) — fall back to x-real-ip when present,
|
|
528
|
-
// else "unknown". x-real-ip has no hop-count semantics (so it isn't used for
|
|
529
|
-
// hop math), but a shared "unknown" bucket is worse than trusting the
|
|
530
|
-
// proxy-set header when XFF is absent (common nginx X-Real-IP-only setups).
|
|
531
|
-
//
|
|
532
|
-
// Callers that need a hard boundary regardless of this config
|
|
533
|
-
// (preauth-enable-start) additionally key on something the caller can't
|
|
534
|
-
// freely choose (see preauthTokenKeyOf below).
|
|
535
|
-
let warnedUnknownClientIp = false;
|
|
536
|
-
function clientIpOf(
|
|
537
|
-
c: { req: { header(name: string): string | undefined } },
|
|
538
|
-
trustedProxyHops = 0,
|
|
539
|
-
): string {
|
|
540
|
-
if (trustedProxyHops <= 0) {
|
|
541
|
-
return (
|
|
542
|
-
c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ??
|
|
543
|
-
c.req.header("x-real-ip") ??
|
|
544
|
-
"unknown"
|
|
545
|
-
);
|
|
546
|
-
}
|
|
547
|
-
const entries = (c.req.header("x-forwarded-for") ?? "")
|
|
548
|
-
.split(",")
|
|
549
|
-
.map((entry) => entry.trim())
|
|
550
|
-
.filter((entry) => entry.length > 0);
|
|
551
|
-
if (entries.length < trustedProxyHops) {
|
|
552
|
-
// nginx often sets only X-Real-IP (no XFF). Prefer that over a shared
|
|
553
|
-
// "unknown" rate-limit bucket (fw#1555) — hop-count math still doesn't
|
|
554
|
-
// apply to X-Real-IP, but a shared bucket is worse than trusting the
|
|
555
|
-
// proxy-set header when the XFF chain is shorter than configured.
|
|
556
|
-
const realIp = c.req.header("x-real-ip")?.trim();
|
|
557
|
-
if (realIp) return realIp;
|
|
558
|
-
if (!warnedUnknownClientIp) {
|
|
559
|
-
warnedUnknownClientIp = true;
|
|
560
|
-
console.warn(
|
|
561
|
-
"[kumiko] trustedProxyHops>=1 but XFF chain too short and no x-real-ip — " +
|
|
562
|
-
'all such clients share the "unknown" rate-limit bucket. Check proxy headers.',
|
|
563
|
-
);
|
|
564
|
-
}
|
|
565
|
-
return "unknown";
|
|
566
|
-
}
|
|
567
|
-
return entries[entries.length - trustedProxyHops] ?? "unknown";
|
|
568
|
-
}
|
|
569
|
-
|
|
570
525
|
// Second rate-limit axis for preauth-enable-start: unlike the IP, a
|
|
571
526
|
// preauthSetupToken isn't freely choosable by the attacker (it's minted by
|
|
572
527
|
// login.write.ts), so hashing it into a bucket key still caps the replay
|
|
@@ -575,15 +530,12 @@ function preauthTokenKeyOf(token: string): string {
|
|
|
575
530
|
return `preauth-token:${createHash("sha256").update(token).digest("hex")}`;
|
|
576
531
|
}
|
|
577
532
|
|
|
578
|
-
// Extract `ip` and `user-agent` for the sessionCreator.
|
|
579
|
-
//
|
|
580
|
-
//
|
|
581
|
-
//
|
|
582
|
-
function requestMeta(
|
|
583
|
-
|
|
584
|
-
trustedProxyHops = 0,
|
|
585
|
-
): SessionMetadata {
|
|
586
|
-
const ip = clientIpOf(c, trustedProxyHops);
|
|
533
|
+
// Extract `ip` and `user-agent` for the sessionCreator. Client-IP resolution
|
|
534
|
+
// (trustedProxyHops-aware) is the shared resolveClientIp (see client-ip.ts)
|
|
535
|
+
// — createAuthRoutes builds one resolver instance below and
|
|
536
|
+
// closes over it here and in getClientIp.
|
|
537
|
+
function requestMeta(c: Context, resolver: ClientIpResolver): SessionMetadata {
|
|
538
|
+
const ip = resolver.resolve(clientIpSourceFromHonoContext(c));
|
|
587
539
|
const userAgent = c.req.header("user-agent") ?? "unknown";
|
|
588
540
|
return { ip, userAgent };
|
|
589
541
|
}
|
|
@@ -714,20 +666,14 @@ export function createAuthRoutes(
|
|
|
714
666
|
const cookieSameSite = config.cookieSameSite ?? "lax";
|
|
715
667
|
const cookieDomain = config.cookieDomain;
|
|
716
668
|
// Single hop-count-aware IP getter for every rate-limit call site below —
|
|
717
|
-
// see AuthRoutesConfig.trustedProxyHops
|
|
718
|
-
// loud on a non-finite/negative
|
|
719
|
-
//
|
|
720
|
-
//
|
|
721
|
-
|
|
722
|
-
config.trustedProxyHops
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
throw new Error(
|
|
726
|
-
`createAuthRoutes: trustedProxyHops must be a non-negative integer, got ${config.trustedProxyHops}.`,
|
|
727
|
-
);
|
|
728
|
-
}
|
|
729
|
-
const trustedProxyHops = config.trustedProxyHops ?? 0;
|
|
730
|
-
const getClientIp = (c: Context): string => clientIpOf(c, trustedProxyHops);
|
|
669
|
+
// see AuthRoutesConfig.trustedProxyHops and client-ip.ts's resolveClientIp
|
|
670
|
+
// doc comment. createClientIpResolver fails loud on a non-finite/negative
|
|
671
|
+
// value rather than letting it silently collapse every request into the
|
|
672
|
+
// shared "unknown" bucket.
|
|
673
|
+
const clientIpResolver =
|
|
674
|
+
config.resolver ?? createClientIpResolver(config.trustedProxyHops ?? 0, "createAuthRoutes");
|
|
675
|
+
const getClientIp = (c: Context): string =>
|
|
676
|
+
clientIpResolver.resolve(clientIpSourceFromHonoContext(c));
|
|
731
677
|
|
|
732
678
|
// Shared tail of every route that ends a request logged-in: create the
|
|
733
679
|
// session record (if wired), sign the JWT, set the auth+csrf cookies. Was
|
|
@@ -737,7 +683,7 @@ export function createAuthRoutes(
|
|
|
737
683
|
async function mintSessionAndRespond(c: Context, session: SessionUser): Promise<string> {
|
|
738
684
|
let sessionForJwt = session;
|
|
739
685
|
if (config.sessionCreator) {
|
|
740
|
-
const sid = await config.sessionCreator(session, requestMeta(c,
|
|
686
|
+
const sid = await config.sessionCreator(session, requestMeta(c, clientIpResolver));
|
|
741
687
|
sessionForJwt = { ...session, sid };
|
|
742
688
|
}
|
|
743
689
|
const token = await jwt.sign(sessionForJwt);
|
|
@@ -752,6 +698,13 @@ export function createAuthRoutes(
|
|
|
752
698
|
return token;
|
|
753
699
|
}
|
|
754
700
|
|
|
701
|
+
// Spreadable landingPath fragment for the 7 auth-response call sites below
|
|
702
|
+
// — absent (not `landingPath: undefined`) when unwired/invalid/throwing.
|
|
703
|
+
function landingPathFragment(args: PostAuthLandingArgs): { readonly landingPath?: string } {
|
|
704
|
+
const landingPath = resolvePostAuthLandingPath(config.postAuthLanding, args);
|
|
705
|
+
return landingPath === undefined ? {} : { landingPath };
|
|
706
|
+
}
|
|
707
|
+
|
|
755
708
|
// POST /auth/login — public endpoint (bypasses auth middleware via PUBLIC_API_PATHS).
|
|
756
709
|
// The configured login handler authenticates and returns a SessionUser;
|
|
757
710
|
// the route signs the JWT and hands it back to the client.
|
|
@@ -836,6 +789,11 @@ export function createAuthRoutes(
|
|
|
836
789
|
// that references it can be handed out, otherwise a fast client could
|
|
837
790
|
// arrive at an auth-middleware check before the insert commits.
|
|
838
791
|
const token = await mintSessionAndRespond(c, data.session);
|
|
792
|
+
const landingPath = landingPathFragment({
|
|
793
|
+
flow: "login",
|
|
794
|
+
roles: data.session.roles,
|
|
795
|
+
tenantId: data.session.tenantId,
|
|
796
|
+
});
|
|
839
797
|
|
|
840
798
|
if (rateLimiter) {
|
|
841
799
|
await rateLimiter.reset(rateLimitKey);
|
|
@@ -845,6 +803,7 @@ export function createAuthRoutes(
|
|
|
845
803
|
isSuccess: true,
|
|
846
804
|
token,
|
|
847
805
|
user: { id: data.session.id, tenantId: data.session.tenantId, roles: data.session.roles },
|
|
806
|
+
...landingPath,
|
|
848
807
|
});
|
|
849
808
|
});
|
|
850
809
|
}
|
|
@@ -907,6 +866,11 @@ export function createAuthRoutes(
|
|
|
907
866
|
const data = result.data as { kind: "mfa-verify-success"; session: SessionUser };
|
|
908
867
|
|
|
909
868
|
const token = await mintSessionAndRespond(c, data.session);
|
|
869
|
+
const landingPath = landingPathFragment({
|
|
870
|
+
flow: "login",
|
|
871
|
+
roles: data.session.roles,
|
|
872
|
+
tenantId: data.session.tenantId,
|
|
873
|
+
});
|
|
910
874
|
|
|
911
875
|
if (rateLimiter) {
|
|
912
876
|
await rateLimiter.reset(clientIp);
|
|
@@ -916,6 +880,7 @@ export function createAuthRoutes(
|
|
|
916
880
|
isSuccess: true,
|
|
917
881
|
token,
|
|
918
882
|
user: { id: data.session.id, tenantId: data.session.tenantId, roles: data.session.roles },
|
|
883
|
+
...landingPath,
|
|
919
884
|
});
|
|
920
885
|
});
|
|
921
886
|
}
|
|
@@ -1054,6 +1019,11 @@ export function createAuthRoutes(
|
|
|
1054
1019
|
const data = result.data as { kind: "mfa-preauth-confirm-success"; session: SessionUser };
|
|
1055
1020
|
|
|
1056
1021
|
const token = await mintSessionAndRespond(c, data.session);
|
|
1022
|
+
const landingPath = landingPathFragment({
|
|
1023
|
+
flow: "login",
|
|
1024
|
+
roles: data.session.roles,
|
|
1025
|
+
tenantId: data.session.tenantId,
|
|
1026
|
+
});
|
|
1057
1027
|
|
|
1058
1028
|
if (rateLimiter) {
|
|
1059
1029
|
await rateLimiter.reset(clientIp);
|
|
@@ -1063,6 +1033,7 @@ export function createAuthRoutes(
|
|
|
1063
1033
|
isSuccess: true,
|
|
1064
1034
|
token,
|
|
1065
1035
|
user: { id: data.session.id, tenantId: data.session.tenantId, roles: data.session.roles },
|
|
1036
|
+
...landingPath,
|
|
1066
1037
|
});
|
|
1067
1038
|
});
|
|
1068
1039
|
}
|
|
@@ -1169,6 +1140,13 @@ export function createAuthRoutes(
|
|
|
1169
1140
|
|
|
1170
1141
|
// Session creation + JWT sign + cookies — see mintSessionAndRespond.
|
|
1171
1142
|
const token = await mintSessionAndRespond(c, data.session);
|
|
1143
|
+
const landingPath = landingPathFragment({
|
|
1144
|
+
flow: "signup",
|
|
1145
|
+
roles: data.session.roles,
|
|
1146
|
+
tenantId: data.session.tenantId,
|
|
1147
|
+
tenantKey: data.tenantKey,
|
|
1148
|
+
...(data.handover !== undefined && { handover: data.handover }),
|
|
1149
|
+
});
|
|
1172
1150
|
|
|
1173
1151
|
return c.json({
|
|
1174
1152
|
isSuccess: true,
|
|
@@ -1189,6 +1167,7 @@ export function createAuthRoutes(
|
|
|
1189
1167
|
// absent, not null, so a client that doesn't know the field sees
|
|
1190
1168
|
// nothing unusual.
|
|
1191
1169
|
...(data.handover !== undefined && { handover: data.handover }),
|
|
1170
|
+
...landingPath,
|
|
1192
1171
|
});
|
|
1193
1172
|
});
|
|
1194
1173
|
}
|
|
@@ -1222,11 +1201,17 @@ export function createAuthRoutes(
|
|
|
1222
1201
|
role: string;
|
|
1223
1202
|
alreadyMember: boolean;
|
|
1224
1203
|
};
|
|
1204
|
+
const landingPath = landingPathFragment({
|
|
1205
|
+
flow: "invite",
|
|
1206
|
+
roles: [data.role],
|
|
1207
|
+
tenantId: data.tenantId,
|
|
1208
|
+
});
|
|
1225
1209
|
return c.json({
|
|
1226
1210
|
isSuccess: true,
|
|
1227
1211
|
tenantId: data.tenantId,
|
|
1228
1212
|
role: data.role,
|
|
1229
1213
|
alreadyMember: data.alreadyMember,
|
|
1214
|
+
...landingPath,
|
|
1230
1215
|
});
|
|
1231
1216
|
});
|
|
1232
1217
|
|
|
@@ -1269,6 +1254,11 @@ export function createAuthRoutes(
|
|
|
1269
1254
|
}
|
|
1270
1255
|
|
|
1271
1256
|
const token = await mintSessionAndRespond(c, data.session);
|
|
1257
|
+
const landingPath = landingPathFragment({
|
|
1258
|
+
flow: "invite",
|
|
1259
|
+
roles: data.session.roles,
|
|
1260
|
+
tenantId: data.tenantId,
|
|
1261
|
+
});
|
|
1272
1262
|
return c.json({
|
|
1273
1263
|
isSuccess: true,
|
|
1274
1264
|
token,
|
|
@@ -1279,6 +1269,7 @@ export function createAuthRoutes(
|
|
|
1279
1269
|
},
|
|
1280
1270
|
tenantId: data.tenantId,
|
|
1281
1271
|
role: data.role,
|
|
1272
|
+
...landingPath,
|
|
1282
1273
|
});
|
|
1283
1274
|
});
|
|
1284
1275
|
|
|
@@ -1306,6 +1297,11 @@ export function createAuthRoutes(
|
|
|
1306
1297
|
role: string;
|
|
1307
1298
|
}; // @cast-boundary engine-payload
|
|
1308
1299
|
const token = await mintSessionAndRespond(c, data.session);
|
|
1300
|
+
const landingPath = landingPathFragment({
|
|
1301
|
+
flow: "invite",
|
|
1302
|
+
roles: data.session.roles,
|
|
1303
|
+
tenantId: data.tenantId,
|
|
1304
|
+
});
|
|
1309
1305
|
return c.json({
|
|
1310
1306
|
isSuccess: true,
|
|
1311
1307
|
token,
|
|
@@ -1316,6 +1312,7 @@ export function createAuthRoutes(
|
|
|
1316
1312
|
},
|
|
1317
1313
|
tenantId: data.tenantId,
|
|
1318
1314
|
role: data.role,
|
|
1315
|
+
...landingPath,
|
|
1319
1316
|
});
|
|
1320
1317
|
});
|
|
1321
1318
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
2
|
+
|
|
3
|
+
// Single source for client-IP extraction, shared by every IP-keyed rate
|
|
4
|
+
// limit and audit field. Deriving the caller IP independently per call site
|
|
5
|
+
// makes it easy for one to silently trust the first XFF entry, which is
|
|
6
|
+
// attacker-controlled unless the exact number of trusted reverse proxies is
|
|
7
|
+
// known.
|
|
8
|
+
//
|
|
9
|
+
// Algorithm for `trustedProxyHops`:
|
|
10
|
+
// 0 (default): don't trust any proxy header — only the socket address
|
|
11
|
+
// counts. A spoofed XFF is ignored outright.
|
|
12
|
+
// >=1: each trusted proxy hop APPENDS (not overwrites) its peer address to
|
|
13
|
+
// XFF (e.g. nginx `$proxy_add_x_forwarded_for`), so the real client sits
|
|
14
|
+
// at `entries[length - hops]`. Anything a client itself prepended lands
|
|
15
|
+
// further left and is ignored. A chain shorter than `hops` falls back to
|
|
16
|
+
// `x-real-ip` (no hop-count semantics of its own), then the socket address.
|
|
17
|
+
// A bucket is never skipped: without any usable value the fallback is a
|
|
18
|
+
// fixed "unknown" string, which collapses affected callers into one shared
|
|
19
|
+
// bucket rather than letting them bypass the limit entirely.
|
|
20
|
+
export const UNKNOWN_CLIENT_IP = "unknown";
|
|
21
|
+
|
|
22
|
+
export type ClientIpHeaderSource = {
|
|
23
|
+
readonly header: (name: string) => string | undefined;
|
|
24
|
+
readonly socketAddress?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type ClientIpResolver = {
|
|
28
|
+
readonly resolve: (source: ClientIpHeaderSource) => string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// The socket address rides in as Hono's `Env` (2nd `app.fetch` arg, read
|
|
32
|
+
// back via `c.env`) as a bare string, never the raw Bun `Server` — a
|
|
33
|
+
// `Server.requestIP()` only resolves for the exact Request instance Bun
|
|
34
|
+
// created, and a cloned/rebuilt Request (tryHonoFirst's req.clone(), etc.)
|
|
35
|
+
// would silently return null. The address is extracted once at the
|
|
36
|
+
// outermost Bun.serve fetch handler and threaded down as a plain value.
|
|
37
|
+
export function extractSocketAddress(env: unknown): string | undefined {
|
|
38
|
+
return typeof env === "string" && env.length > 0 ? env : undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function assertValidTrustedProxyHops(value: number | undefined, context: string): void {
|
|
42
|
+
if (value !== undefined && (!Number.isInteger(value) || value < 0)) {
|
|
43
|
+
throw new Error(`${context}: trustedProxyHops must be a non-negative integer, got ${value}.`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Instance-scoped, NOT a module singleton: the warn-once flag must reset
|
|
48
|
+
// per server boot (and per test), otherwise the first test to trigger it
|
|
49
|
+
// consumes the warning for the whole process and later tests can't observe
|
|
50
|
+
// it.
|
|
51
|
+
export function createClientIpResolver(
|
|
52
|
+
trustedProxyHops: number,
|
|
53
|
+
context = "resolveClientIp",
|
|
54
|
+
): ClientIpResolver {
|
|
55
|
+
assertValidTrustedProxyHops(trustedProxyHops, context);
|
|
56
|
+
let warnedUnexpectedForwardedFor = false;
|
|
57
|
+
|
|
58
|
+
function resolve(source: ClientIpHeaderSource): string {
|
|
59
|
+
const xff = source.header("x-forwarded-for");
|
|
60
|
+
if (trustedProxyHops === 0) {
|
|
61
|
+
if (xff !== undefined && xff.trim().length > 0 && !warnedUnexpectedForwardedFor) {
|
|
62
|
+
warnedUnexpectedForwardedFor = true;
|
|
63
|
+
console.warn(
|
|
64
|
+
"[kumiko] received X-Forwarded-For but trustedProxyHops is 0 (no proxy trusted) — " +
|
|
65
|
+
"ignoring it. Running behind a proxy? Set trustedProxyHops, otherwise all clients " +
|
|
66
|
+
"share one rate-limit bucket.",
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return source.socketAddress ?? UNKNOWN_CLIENT_IP;
|
|
70
|
+
}
|
|
71
|
+
const entries = (xff ?? "")
|
|
72
|
+
.split(",")
|
|
73
|
+
.map((entry) => entry.trim())
|
|
74
|
+
.filter((entry) => entry.length > 0);
|
|
75
|
+
if (entries.length >= trustedProxyHops) {
|
|
76
|
+
return entries[entries.length - trustedProxyHops] ?? UNKNOWN_CLIENT_IP;
|
|
77
|
+
}
|
|
78
|
+
const realIp = source.header("x-real-ip")?.trim();
|
|
79
|
+
if (realIp) return realIp;
|
|
80
|
+
return source.socketAddress ?? UNKNOWN_CLIENT_IP;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { resolve };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function clientIpSourceFromHonoContext(c: Context): ClientIpHeaderSource {
|
|
87
|
+
return {
|
|
88
|
+
header: (name) => c.req.header(name),
|
|
89
|
+
socketAddress: extractSocketAddress(c.env),
|
|
90
|
+
};
|
|
91
|
+
}
|
package/src/api/index.ts
CHANGED
|
@@ -28,6 +28,14 @@ export {
|
|
|
28
28
|
createInMemoryLoginRateLimiter,
|
|
29
29
|
createRedisLoginRateLimiter,
|
|
30
30
|
} from "./auth-routes";
|
|
31
|
+
export type { ClientIpHeaderSource, ClientIpResolver } from "./client-ip";
|
|
32
|
+
export {
|
|
33
|
+
assertValidTrustedProxyHops,
|
|
34
|
+
clientIpSourceFromHonoContext,
|
|
35
|
+
createClientIpResolver,
|
|
36
|
+
extractSocketAddress,
|
|
37
|
+
UNKNOWN_CLIENT_IP,
|
|
38
|
+
} from "./client-ip";
|
|
31
39
|
export type {
|
|
32
40
|
AnonymousExtraRoute,
|
|
33
41
|
AnonymousExtraRouteDeps,
|
|
@@ -57,6 +65,12 @@ export {
|
|
|
57
65
|
export type { JwtHelper, JwtKeyring, JwtPayload } from "./jwt";
|
|
58
66
|
export { createJwtHelper, loadJwtSecretOrKeyring } from "./jwt";
|
|
59
67
|
export { patAllows, qnMatches } from "./pat-scope";
|
|
68
|
+
export type {
|
|
69
|
+
PostAuthLandingArgs,
|
|
70
|
+
PostAuthLandingFlow,
|
|
71
|
+
PostAuthLandingResolver,
|
|
72
|
+
} from "./post-auth-landing";
|
|
73
|
+
export { isSafeLandingPath } from "./post-auth-landing";
|
|
60
74
|
export type { RedisSseBroker, RedisSseBrokerOptions } from "./redis-sse-broker";
|
|
61
75
|
export { createDefaultSseBroker, createRedisSseBroker, isRedisSseBroker } from "./redis-sse-broker";
|
|
62
76
|
export { type RequestContextData, requestContext } from "./request-context";
|
|
@@ -64,6 +78,7 @@ export {
|
|
|
64
78
|
buildRequestContextDataFromRequest,
|
|
65
79
|
requestIdMiddleware,
|
|
66
80
|
} from "./request-id-middleware";
|
|
81
|
+
export { DEFAULT_MAX_REQUEST_BYTES } from "./route-registrars";
|
|
67
82
|
export { createApiRoutes } from "./routes";
|
|
68
83
|
export type { KumikoServer, ServerOptions } from "./server";
|
|
69
84
|
export { buildServer, makeDispatchSystemQuery, makeDispatchSystemWrite } from "./server";
|