@cosmicdrift/kumiko-framework 0.359.0 → 0.360.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/dist/api/anonymous-cookie.js +9 -12
- package/dist/api/api-constants.d.ts +0 -1
- package/dist/api/api-constants.js +0 -1
- package/dist/api/auth-middleware.d.ts +0 -2
- package/dist/api/auth-middleware.js +16 -9
- package/dist/api/auth-routes.d.ts +2 -0
- package/dist/api/auth-routes.js +28 -40
- package/dist/api/csrf-middleware.js +3 -2
- package/dist/api/index.d.ts +4 -1
- package/dist/api/index.js +3 -1
- package/dist/api/origin-middleware.d.ts +11 -1
- package/dist/api/origin-middleware.js +40 -7
- package/dist/api/server.js +20 -4
- package/dist/api/session-cookies.d.ts +23 -0
- package/dist/api/session-cookies.js +85 -0
- package/dist/engine/build-app-schema.js +1 -1
- package/dist/engine/feature-config-events-jobs.js +8 -0
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +3 -0
- package/dist/files/bind-pending-file-refs.d.ts +8 -0
- package/dist/files/bind-pending-file-refs.js +54 -0
- package/dist/files/create-file-ref-from-bytes.d.ts +22 -0
- package/dist/files/create-file-ref-from-bytes.js +39 -0
- package/dist/files/create-file-ref.d.ts +50 -0
- package/dist/files/create-file-ref.js +135 -0
- package/dist/files/feature.js +2 -2
- package/dist/files/file-ref-entity.d.ts +1 -0
- package/dist/files/file-ref-entity.js +1 -0
- package/dist/files/file-ref-table.js +2 -2
- package/dist/files/file-routes.d.ts +2 -0
- package/dist/files/file-routes.js +36 -126
- package/dist/files/index.d.ts +2 -0
- package/dist/files/index.js +1 -0
- package/dist/files/record-read-check.d.ts +9 -0
- package/dist/files/record-read-check.js +49 -0
- package/dist/jobs/cron-repeat-options.d.ts +9 -0
- package/dist/jobs/cron-repeat-options.js +5 -0
- package/dist/jobs/job-runner.js +2 -1
- package/dist/pipeline/dispatch-write.js +7 -0
- package/package.json +4 -4
- package/src/changes.json +45 -0
|
@@ -8,24 +8,18 @@
|
|
|
8
8
|
// middleware on the next request — no client-side JS needs to read it,
|
|
9
9
|
// and not exposing it to JS keeps it out of XSS reach.
|
|
10
10
|
import { deleteCookie, setCookie } from "hono/cookie";
|
|
11
|
-
import {
|
|
11
|
+
import { getSessionCookies, hostOnlyDeleteOptions, isHostPrefixedCookieName, TENANT_COOKIE_BASE, } from "./session-cookies.js";
|
|
12
12
|
// 30 days. The tenant assignment is stable for the lifetime of the
|
|
13
13
|
// visitor's relationship with the deployment — re-running the resolver
|
|
14
14
|
// on every visit would just hit the DB for no new information.
|
|
15
15
|
const DEFAULT_MAX_AGE_SECONDS = 30 * 24 * 60 * 60;
|
|
16
|
-
// Cookies marked Secure are dropped by browsers on http:// — locked off in
|
|
17
|
-
// dev/test so localhost works without TLS, on in prod where http would be
|
|
18
|
-
// a deployment bug we want to fail loud about (no cookie ⇒ resolver runs
|
|
19
|
-
// every request and the misconfiguration is visible in latency dashboards).
|
|
20
|
-
function cookieSecure() {
|
|
21
|
-
return process.env["NODE_ENV"] === "production";
|
|
22
|
-
}
|
|
23
16
|
// Stamps the kumiko_tenant cookie on the response. Idempotent — calling it
|
|
24
17
|
// twice in the same request just overwrites with the latest value.
|
|
25
18
|
export function setTenantCookie(c, tenantId, options = {}) {
|
|
26
|
-
|
|
19
|
+
const { secure, names } = getSessionCookies(c);
|
|
20
|
+
setCookie(c, names.tenant, tenantId, {
|
|
27
21
|
httpOnly: true,
|
|
28
|
-
secure
|
|
22
|
+
secure,
|
|
29
23
|
sameSite: options.sameSite ?? "Lax",
|
|
30
24
|
path: "/",
|
|
31
25
|
maxAge: options.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS,
|
|
@@ -34,7 +28,10 @@ export function setTenantCookie(c, tenantId, options = {}) {
|
|
|
34
28
|
// Removes the kumiko_tenant cookie. Use on switch-tenant flows or when
|
|
35
29
|
// the resolver no longer recognises the visitor's tenant — leaving a
|
|
36
30
|
// stale cookie behind would keep them pointed at a deleted tenant.
|
|
37
|
-
// @wrapper-known semantic-alias
|
|
38
31
|
export function deleteTenantCookie(c) {
|
|
39
|
-
|
|
32
|
+
const { names } = getSessionCookies(c);
|
|
33
|
+
deleteCookie(c, names.tenant, hostOnlyDeleteOptions(names.tenant));
|
|
34
|
+
if (isHostPrefixedCookieName(names.tenant)) {
|
|
35
|
+
deleteCookie(c, TENANT_COOKIE_BASE, { path: "/" });
|
|
36
|
+
}
|
|
40
37
|
}
|
|
@@ -42,7 +42,6 @@ export declare const AUTH_RATE_LIMIT_EXEMPT_ROUTES: ReadonlyArray<{
|
|
|
42
42
|
export declare function isAuthRateLimitExempt(method: string, path: string): boolean;
|
|
43
43
|
export declare const STATE_CHANGING_METHODS: ReadonlySet<string>;
|
|
44
44
|
export declare const TENANT_HEADER_NAME = "X-Tenant";
|
|
45
|
-
export declare const TENANT_COOKIE_NAME = "kumiko_tenant";
|
|
46
45
|
export declare const LOCALE_HEADER_NAME = "X-Locale";
|
|
47
46
|
export declare const NO_ROUTE_MATCH_HEADER_NAME = "X-Kumiko-No-Route-Match";
|
|
48
47
|
export type Route = (typeof Routes)[keyof typeof Routes];
|
|
@@ -127,7 +127,6 @@ export const STATE_CHANGING_METHODS = new Set([
|
|
|
127
127
|
// access. The middleware reads header first, then cookie, then falls back to
|
|
128
128
|
// `anonymousAccess.tenantResolver` and finally `anonymousAccess.defaultTenantId`.
|
|
129
129
|
export const TENANT_HEADER_NAME = "X-Tenant";
|
|
130
|
-
export const TENANT_COOKIE_NAME = "kumiko_tenant";
|
|
131
130
|
// Client-declared active UI locale (BCP-47). Read once per request in
|
|
132
131
|
// request-id-middleware.ts, before auth, so it reaches public routes
|
|
133
132
|
// (e.g. signup-request) too. Falls back to Accept-Language, then the app's
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import type { Context, Next } from "hono";
|
|
2
2
|
import type { SessionUser, TenantId } from "../engine/types/index.js";
|
|
3
3
|
import type { JwtHelper } from "./jwt.js";
|
|
4
|
-
export declare const AUTH_COOKIE_NAME = "kumiko_auth";
|
|
5
|
-
export declare const CSRF_COOKIE_NAME = "kumiko_csrf";
|
|
6
4
|
export declare const CSRF_HEADER_NAME = "X-CSRF-Token";
|
|
7
5
|
export declare const PAT_TOKEN_PREFIX = "kpat_";
|
|
8
6
|
export type AuthTransport = "cookie" | "bearer";
|
|
@@ -2,16 +2,12 @@ import { getCookie } from "hono/cookie";
|
|
|
2
2
|
import { TENANT_TEARDOWN_STATUSES } from "../engine/active-membership.js";
|
|
3
3
|
import { createAnonymousUser } from "../engine/system-user.js";
|
|
4
4
|
import { parseTenantId, SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
|
|
5
|
-
import { isSessionRequiredApiPath,
|
|
5
|
+
import { isSessionRequiredApiPath, TENANT_HEADER_NAME } from "./api-constants.js";
|
|
6
6
|
import { isForeignCookieOrigin } from "./origin-middleware.js";
|
|
7
|
+
import { clearSessionCookies, getSessionCookies, hasDuplicateSessionCookie, } from "./session-cookies.js";
|
|
7
8
|
const USER_KEY = "pipelineUser";
|
|
8
9
|
const AUTH_TRANSPORT_KEY = "authTransport";
|
|
9
10
|
const AUTH_TOKEN_EXPIRY_KEY = "authTokenExpiry";
|
|
10
|
-
// Names used across middleware and auth-routes. Kept here so csrf-middleware
|
|
11
|
-
// and auth-routes import them from a single source of truth — renaming a
|
|
12
|
-
// cookie is a coordinated change across issuer, reader and deleter.
|
|
13
|
-
export const AUTH_COOKIE_NAME = "kumiko_auth";
|
|
14
|
-
export const CSRF_COOKIE_NAME = "kumiko_csrf";
|
|
15
11
|
export const CSRF_HEADER_NAME = "X-CSRF-Token";
|
|
16
12
|
// Prefix that marks a bearer token as a long-lived Personal Access Token
|
|
17
13
|
// rather than a JWT session. The personal-access-tokens feature mints tokens
|
|
@@ -57,7 +53,7 @@ const INVALID_TOKEN_REJECTION = {
|
|
|
57
53
|
function isStaleCookieDowngradable(c, transport, anonymousAccess) {
|
|
58
54
|
return (transport === "cookie" && anonymousAccess !== undefined && !c.req.path.startsWith("/api/auth/"));
|
|
59
55
|
}
|
|
60
|
-
// Extract the JWT from either the
|
|
56
|
+
// Extract the JWT from either the auth cookie (web) or the
|
|
61
57
|
// Authorization Bearer header (native / server-to-server). The two paths
|
|
62
58
|
// are mutually exclusive: if both are present the request is rejected with
|
|
63
59
|
// `ambiguous_auth` to prevent a confused-deputy bug where a server-bug
|
|
@@ -65,8 +61,19 @@ function isStaleCookieDowngradable(c, transport, anonymousAccess) {
|
|
|
65
61
|
// Note: this is NOT a CSRF control — Bearer-only clients are already safe
|
|
66
62
|
// because browsers can't set Authorization headers cross-origin. The reject
|
|
67
63
|
// exists so future middleware authors can't accidentally mix transports.
|
|
64
|
+
//
|
|
65
|
+
// Duplicate auth/csrf cookie names make the cookie transport untrustworthy:
|
|
66
|
+
// the request is treated as carrying no cookie and every variant is expired,
|
|
67
|
+
// so the browser recovers with a fresh login instead of staying stuck.
|
|
68
68
|
function extractToken(c) {
|
|
69
|
-
const
|
|
69
|
+
const sessionCookies = getSessionCookies(c);
|
|
70
|
+
let cookieToken;
|
|
71
|
+
if (hasDuplicateSessionCookie(c, sessionCookies.names)) {
|
|
72
|
+
clearSessionCookies(c, sessionCookies);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
cookieToken = getCookie(c, sessionCookies.names.auth);
|
|
76
|
+
}
|
|
70
77
|
const header = c.req.header("Authorization");
|
|
71
78
|
const bearerToken = header?.startsWith("Bearer ") ? header.slice(7) : undefined;
|
|
72
79
|
if (cookieToken && bearerToken)
|
|
@@ -281,7 +288,7 @@ async function handleAnonymous(c, config, next, resolveTenantLifecycleStatus, ex
|
|
|
281
288
|
// Step 1+2: parse client-supplied tenant. Reject malformed values before
|
|
282
289
|
// they touch any downstream consumer (DB, cache, audit row).
|
|
283
290
|
const headerRaw = c.req.header(TENANT_HEADER_NAME);
|
|
284
|
-
const cookieRaw = getCookie(c,
|
|
291
|
+
const cookieRaw = getCookie(c, getSessionCookies(c).names.tenant);
|
|
285
292
|
const headerCheck = parseClientTenant(headerRaw, "X-Tenant header");
|
|
286
293
|
if (headerCheck.error)
|
|
287
294
|
return rejectTenant(headerCheck.error);
|
|
@@ -50,8 +50,10 @@ export type AuthRoutesConfig = {
|
|
|
50
50
|
invite?: InviteConfig;
|
|
51
51
|
cookieSameSite?: "lax" | "strict";
|
|
52
52
|
cookieDomain?: string;
|
|
53
|
+
secureCookies?: boolean;
|
|
53
54
|
retiredCookieDomains?: readonly string[];
|
|
54
55
|
allowedOrigins?: readonly string[];
|
|
56
|
+
publicOrigin?: string;
|
|
55
57
|
unsafeSkipOriginCheck?: boolean;
|
|
56
58
|
trustedProxyHops?: number;
|
|
57
59
|
postAuthLanding?: PostAuthLandingResolver;
|
package/dist/api/auth-routes.js
CHANGED
|
@@ -9,67 +9,49 @@ import { NotFoundError, toKumikoError, toWriteErrorInfo } from "../errors/index.
|
|
|
9
9
|
import { assertUnreachable } from "../utils/index.js";
|
|
10
10
|
import { parseRoles } from "../utils/serialization.js";
|
|
11
11
|
import { Routes } from "./api-constants.js";
|
|
12
|
-
import {
|
|
12
|
+
import { getUser, } from "./auth-middleware.js";
|
|
13
13
|
import { clientIpSourceFromHonoContext, createClientIpResolver, } from "./client-ip.js";
|
|
14
14
|
import { resolvePostAuthLandingPath } from "./post-auth-landing.js";
|
|
15
|
+
import { AUTH_COOKIE_BASE, CSRF_COOKIE_BASE, clearSessionCookies, getSessionCookies, isHostPrefixedCookieName, resolveSessionCookies, } from "./session-cookies.js";
|
|
15
16
|
import { generateToken } from "./tokens.js";
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
// flips it on — browsers drop Secure cookies on http, so a misconfigured
|
|
19
|
-
// prod deploy would silently break login rather than fail loud.
|
|
20
|
-
function cookieSecure() {
|
|
21
|
-
return process.env["NODE_ENV"] === "production";
|
|
22
|
-
}
|
|
23
|
-
// Double-entry cookie write used at login and switch-tenant. kumiko_auth is
|
|
24
|
-
// the HttpOnly carrier of the JWT; kumiko_csrf is the JS-readable token the
|
|
17
|
+
// Double-entry cookie write used at login and switch-tenant. The auth cookie is
|
|
18
|
+
// the HttpOnly carrier of the JWT; the csrf cookie is the JS-readable token the
|
|
25
19
|
// web client echoes in X-CSRF-Token on every state-changing request. Both
|
|
26
20
|
// cookies share lifetime and SameSite so a stale auth-cookie can't outlive
|
|
27
21
|
// its csrf partner (or vice versa) and leave the client in a half-logged-in
|
|
28
22
|
// state that would trip the csrf-middleware on every retry.
|
|
29
23
|
function setAuthCookies(c, opts) {
|
|
24
|
+
const { secure, names } = opts.cookies;
|
|
30
25
|
const sameSite = opts.sameSite === "strict" ? "Strict" : "Lax";
|
|
31
26
|
const common = {
|
|
32
|
-
secure
|
|
27
|
+
secure,
|
|
33
28
|
sameSite,
|
|
34
29
|
path: "/",
|
|
35
30
|
maxAge: opts.ttlSeconds,
|
|
36
31
|
...(opts.domain !== undefined && { domain: opts.domain }),
|
|
37
32
|
};
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
deleteCookie(c,
|
|
33
|
+
// With a Domain, expire the host-only variant first (as clearSessionCookies
|
|
34
|
+
// does): after a deploy that sets cookieDomain, the old host-only cookie
|
|
35
|
+
// would coexist with the new Domain cookie (RFC 6265 treats name+domain as
|
|
36
|
+
// distinct) and the server could bind to the stale host-only token. With
|
|
37
|
+
// __Host- names the unprefixed host-only cookie is a leftover from before
|
|
38
|
+
// the prefix and is expired too.
|
|
39
|
+
if (opts.domain !== undefined || isHostPrefixedCookieName(names.auth)) {
|
|
40
|
+
deleteCookie(c, AUTH_COOKIE_BASE, { path: "/" });
|
|
41
|
+
deleteCookie(c, CSRF_COOKIE_BASE, { path: "/" });
|
|
46
42
|
}
|
|
47
43
|
// Must precede the setCookie calls: browsers apply Set-Cookie in order and
|
|
48
44
|
// a retired domain equal to the new one is rejected at config time.
|
|
45
|
+
// Retired domains only ever carried unprefixed names (a __Host- cookie has no
|
|
46
|
+
// Domain), and a __Host- delete with a Domain would be ignored.
|
|
49
47
|
for (const retiredDomain of opts.retiredDomains) {
|
|
50
|
-
deleteCookie(c,
|
|
51
|
-
deleteCookie(c,
|
|
48
|
+
deleteCookie(c, AUTH_COOKIE_BASE, { path: "/", domain: retiredDomain });
|
|
49
|
+
deleteCookie(c, CSRF_COOKIE_BASE, { path: "/", domain: retiredDomain });
|
|
52
50
|
}
|
|
53
|
-
setCookie(c,
|
|
51
|
+
setCookie(c, names.auth, opts.token, { ...common, httpOnly: true });
|
|
54
52
|
// Intentionally NOT HttpOnly — the web client has to read this from
|
|
55
53
|
// document.cookie to include it in the X-CSRF-Token request header.
|
|
56
|
-
setCookie(c,
|
|
57
|
-
}
|
|
58
|
-
function clearAuthCookies(c, domain, retiredDomains) {
|
|
59
|
-
// Beide Varianten löschen: mit Domain (aktuelle Cookies) UND host-only
|
|
60
|
-
// (Bestand aus der Zeit vor cookieDomain) — sonst bleibt nach einem
|
|
61
|
-
// Deploy mit neu gesetztem cookieDomain der alte Cookie liegen und der
|
|
62
|
-
// Logout wirkt nur scheinbar.
|
|
63
|
-
deleteCookie(c, AUTH_COOKIE_NAME, { path: "/" });
|
|
64
|
-
deleteCookie(c, CSRF_COOKIE_NAME, { path: "/" });
|
|
65
|
-
if (domain !== undefined) {
|
|
66
|
-
deleteCookie(c, AUTH_COOKIE_NAME, { path: "/", domain });
|
|
67
|
-
deleteCookie(c, CSRF_COOKIE_NAME, { path: "/", domain });
|
|
68
|
-
}
|
|
69
|
-
for (const retiredDomain of retiredDomains) {
|
|
70
|
-
deleteCookie(c, AUTH_COOKIE_NAME, { path: "/", domain: retiredDomain });
|
|
71
|
-
deleteCookie(c, CSRF_COOKIE_NAME, { path: "/", domain: retiredDomain });
|
|
72
|
-
}
|
|
54
|
+
setCookie(c, names.csrf, opts.csrfToken, { ...common, httpOnly: false });
|
|
73
55
|
}
|
|
74
56
|
const INVALID_COOKIE_DOMAIN_CHARS = /[;,\s]/;
|
|
75
57
|
function normalizeCookieDomain(domain) {
|
|
@@ -332,6 +314,11 @@ export function createAuthRoutes(dispatcher, jwt, config) {
|
|
|
332
314
|
// working. High-security apps can opt into "strict" — see AuthRoutesConfig.
|
|
333
315
|
const cookieSameSite = config.cookieSameSite ?? "lax";
|
|
334
316
|
const cookieDomain = config.cookieDomain;
|
|
317
|
+
const sessionCookies = resolveSessionCookies({
|
|
318
|
+
secureCookies: config.secureCookies,
|
|
319
|
+
cookieDomain,
|
|
320
|
+
retiredCookieDomains: config.retiredCookieDomains,
|
|
321
|
+
});
|
|
335
322
|
assertRetiredCookieDomains(config.retiredCookieDomains, cookieDomain);
|
|
336
323
|
const retiredCookieDomains = config.retiredCookieDomains ?? [];
|
|
337
324
|
// Single hop-count-aware IP getter for every rate-limit call site below —
|
|
@@ -369,6 +356,7 @@ export function createAuthRoutes(dispatcher, jwt, config) {
|
|
|
369
356
|
const token = await jwt.sign(sessionForJwt);
|
|
370
357
|
const csrfToken = generateToken();
|
|
371
358
|
setAuthCookies(c, {
|
|
359
|
+
cookies: getSessionCookies(c, sessionCookies),
|
|
372
360
|
token,
|
|
373
361
|
csrfToken,
|
|
374
362
|
sameSite: cookieSameSite,
|
|
@@ -908,7 +896,7 @@ export function createAuthRoutes(dispatcher, jwt, config) {
|
|
|
908
896
|
}
|
|
909
897
|
// Clear cookies on the cookie-transport path. Idempotent — clearing a
|
|
910
898
|
// missing cookie is a no-op, so bearer-only clients aren't affected.
|
|
911
|
-
|
|
899
|
+
clearSessionCookies(c, getSessionCookies(c, sessionCookies));
|
|
912
900
|
return c.json({ isSuccess: true });
|
|
913
901
|
});
|
|
914
902
|
// GET /auth/tenants — list tenants the current user belongs to
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { getCookie } from "hono/cookie";
|
|
3
3
|
import { STATE_CHANGING_METHODS } from "./api-constants.js";
|
|
4
|
-
import {
|
|
4
|
+
import { CSRF_HEADER_NAME, getAuthTransport } from "./auth-middleware.js";
|
|
5
|
+
import { getSessionCookies } from "./session-cookies.js";
|
|
5
6
|
// Constant-time byte compare. `a !== b` short-circuits at the first
|
|
6
7
|
// differing byte and leaks the common prefix length to anyone who can
|
|
7
8
|
// time requests — in principle exploitable against sufficiently small
|
|
@@ -46,7 +47,7 @@ export function csrfMiddleware() {
|
|
|
46
47
|
// CSRF-via-GET vector.
|
|
47
48
|
if (!STATE_CHANGING_METHODS.has(c.req.method))
|
|
48
49
|
return next();
|
|
49
|
-
const cookieToken = getCookie(c,
|
|
50
|
+
const cookieToken = getCookie(c, getSessionCookies(c).names.csrf);
|
|
50
51
|
const headerToken = c.req.header(CSRF_HEADER_NAME);
|
|
51
52
|
// Both must exist and match byte-for-byte. A missing cookie means the
|
|
52
53
|
// token was never issued (stale session or cross-origin attempt);
|
package/dist/api/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export { deleteTenantCookie, setTenantCookie } from "./anonymous-cookie.js";
|
|
|
3
3
|
export { collectAnonymousLiveEntities } from "./anonymous-live-entities.js";
|
|
4
4
|
export { LOCALE_HEADER_NAME, NO_ROUTE_MATCH_HEADER_NAME } from "./api-constants.js";
|
|
5
5
|
export type { AnonymousAccessConfig, AnonymousAccessResolved, AuthMiddlewareOptions, AuthSessionChecker, AuthSessionCheckResult, AuthSessionStatus, TenantExists, TenantLifecycleStatusResolver, TenantResolver, TokenVerifier, } from "./auth-middleware.js";
|
|
6
|
-
export {
|
|
6
|
+
export { authMiddleware, getUser, PAT_TOKEN_PREFIX, sessionCheckStatus, } from "./auth-middleware.js";
|
|
7
7
|
export type { AuthRoutesConfig, LoginRateLimiter, SessionChecker, SessionCreator, SessionMassRevoker, SessionMetadata, SessionRevoker, } from "./auth-routes.js";
|
|
8
8
|
export { createAuthRoutes, createInMemoryLoginRateLimiter, createRedisLoginRateLimiter, } from "./auth-routes.js";
|
|
9
9
|
export type { ClientIpHeaderSource, ClientIpResolver } from "./client-ip.js";
|
|
@@ -14,6 +14,7 @@ export type { CachedResponseInit, CachePolicy } from "./http-cache.js";
|
|
|
14
14
|
export { cacheControlHeader, cachedResponse, computeRevisionEtag, computeStrongEtag, computeWeakEtag, etagMatches, parseIfNoneMatch, } from "./http-cache.js";
|
|
15
15
|
export type { JwtHelper, JwtKeyring, JwtPayload } from "./jwt.js";
|
|
16
16
|
export { createJwtHelper, loadJwtSecretOrKeyring } from "./jwt.js";
|
|
17
|
+
export { publicOriginOf } from "./origin-middleware.js";
|
|
17
18
|
export { patAllows, qnMatches } from "./pat-scope.js";
|
|
18
19
|
export type { PostAuthLandingArgs, PostAuthLandingFlow, PostAuthLandingResolver, } from "./post-auth-landing.js";
|
|
19
20
|
export { isSafeLandingPath } from "./post-auth-landing.js";
|
|
@@ -26,6 +27,8 @@ export { createMetricsApp, DEFAULT_MAX_REQUEST_BYTES, isPrometheusMeter, } from
|
|
|
26
27
|
export { createApiRoutes } from "./routes.js";
|
|
27
28
|
export type { KumikoServer, ServerOptions } from "./server.js";
|
|
28
29
|
export { buildServer, makeDispatchSystemQuery, makeDispatchSystemWrite } from "./server.js";
|
|
30
|
+
export type { ResolvedSessionCookies, SessionCookieBase, SessionCookieNames, } from "./session-cookies.js";
|
|
31
|
+
export { AUTH_COOKIE_BASE, CSRF_COOKIE_BASE, getSessionCookies, HOST_COOKIE_PREFIX, isHostPrefixedCookieName, resolveSessionCookieNames, resolveSessionCookies, sessionCookieName, sessionCookiesMiddleware, TENANT_COOKIE_BASE, } from "./session-cookies.js";
|
|
29
32
|
export type { SseBroker, SseClient, SseEvent } from "./sse-broker.js";
|
|
30
33
|
export { createSseBroker } from "./sse-broker.js";
|
|
31
34
|
export { createSseRoute, SSE_HEARTBEAT_INTERVAL_MS, type SseRouteOptions } from "./sse-route.js";
|
package/dist/api/index.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
export { deleteTenantCookie, setTenantCookie } from "./anonymous-cookie.js";
|
|
2
2
|
export { collectAnonymousLiveEntities } from "./anonymous-live-entities.js";
|
|
3
3
|
export { LOCALE_HEADER_NAME, NO_ROUTE_MATCH_HEADER_NAME } from "./api-constants.js";
|
|
4
|
-
export {
|
|
4
|
+
export { authMiddleware, getUser, PAT_TOKEN_PREFIX, sessionCheckStatus, } from "./auth-middleware.js";
|
|
5
5
|
export { createAuthRoutes, createInMemoryLoginRateLimiter, createRedisLoginRateLimiter, } from "./auth-routes.js";
|
|
6
6
|
export { assertValidTrustedProxyHops, clientIpSourceFromHonoContext, createClientIpResolver, extractSocketAddress, parseTrustedProxyHopsEnv, TRUSTED_PROXY_HOPS_ENV, UNKNOWN_CLIENT_IP, } from "./client-ip.js";
|
|
7
7
|
export { ExtraRouteEntries, ExtraRouteRejection, signatureRoute } from "./extra-route.js";
|
|
8
8
|
export { cacheControlHeader, cachedResponse, computeRevisionEtag, computeStrongEtag, computeWeakEtag, etagMatches, parseIfNoneMatch, } from "./http-cache.js";
|
|
9
9
|
export { createJwtHelper, loadJwtSecretOrKeyring } from "./jwt.js";
|
|
10
|
+
export { publicOriginOf } from "./origin-middleware.js";
|
|
10
11
|
export { patAllows, qnMatches } from "./pat-scope.js";
|
|
11
12
|
export { isSafeLandingPath } from "./post-auth-landing.js";
|
|
12
13
|
export { createDefaultSseBroker, createRedisSseBroker, isRedisSseBroker, } from "./redis-sse-broker.js";
|
|
@@ -15,6 +16,7 @@ export { buildRequestContextDataFromRequest, requestIdMiddleware, } from "./requ
|
|
|
15
16
|
export { createMetricsApp, DEFAULT_MAX_REQUEST_BYTES, isPrometheusMeter, } from "./route-registrars.js";
|
|
16
17
|
export { createApiRoutes } from "./routes.js";
|
|
17
18
|
export { buildServer, makeDispatchSystemQuery, makeDispatchSystemWrite } from "./server.js";
|
|
19
|
+
export { AUTH_COOKIE_BASE, CSRF_COOKIE_BASE, getSessionCookies, HOST_COOKIE_PREFIX, isHostPrefixedCookieName, resolveSessionCookieNames, resolveSessionCookies, sessionCookieName, sessionCookiesMiddleware, TENANT_COOKIE_BASE, } from "./session-cookies.js";
|
|
18
20
|
export { createSseBroker } from "./sse-broker.js";
|
|
19
21
|
export { createSseRoute, SSE_HEARTBEAT_INTERVAL_MS } from "./sse-route.js";
|
|
20
22
|
export { generateToken } from "./tokens.js";
|
|
@@ -4,9 +4,19 @@ export declare function isOriginAllowed(origin: string, normalizedAllowlist: Rea
|
|
|
4
4
|
export declare function isForeignCookieOrigin(c: Context, allowlist: ReadonlySet<string>): boolean;
|
|
5
5
|
export declare function rejectOrigin(c: Context): Response;
|
|
6
6
|
export declare function isWebSocketOriginAllowed(c: Context, normalizedAllowlist: ReadonlySet<string> | undefined): boolean;
|
|
7
|
-
export declare function
|
|
7
|
+
export declare function isSameHostOrigin(c: Context): boolean;
|
|
8
|
+
export declare function publicOriginOf(baseUrl: string): string | undefined;
|
|
9
|
+
export declare function originMiddleware(allowedOrigins: readonly string[], options?: {
|
|
10
|
+
readonly allowRequestHost?: boolean;
|
|
11
|
+
}): (c: Context, next: Next) => Promise<void | Response>;
|
|
8
12
|
export declare function assertOriginGuardConfig(auth: {
|
|
9
13
|
cookieDomain?: string;
|
|
10
14
|
allowedOrigins?: readonly string[];
|
|
11
15
|
unsafeSkipOriginCheck?: boolean;
|
|
12
16
|
} | undefined): void;
|
|
17
|
+
export declare function resolveOriginGuard(auth: {
|
|
18
|
+
cookieDomain?: string;
|
|
19
|
+
allowedOrigins?: readonly string[];
|
|
20
|
+
publicOrigin?: string;
|
|
21
|
+
unsafeSkipOriginCheck?: boolean;
|
|
22
|
+
} | undefined): ReturnType<typeof originMiddleware> | undefined;
|
|
@@ -44,6 +44,19 @@ export function isWebSocketOriginAllowed(c, normalizedAllowlist) {
|
|
|
44
44
|
if (getAuthTransport(c) !== "cookie")
|
|
45
45
|
return true;
|
|
46
46
|
const origin = c.req.header("origin");
|
|
47
|
+
if (origin === undefined || origin === "null")
|
|
48
|
+
return false;
|
|
49
|
+
// With an allowlist there is deliberately no same-host fallback: wide
|
|
50
|
+
// cookieDomain setups are exactly where a sibling subdomain is the attacker.
|
|
51
|
+
if (normalizedAllowlist !== undefined && normalizedAllowlist.size > 0) {
|
|
52
|
+
return isOriginAllowed(origin, normalizedAllowlist);
|
|
53
|
+
}
|
|
54
|
+
return isSameHostOrigin(c);
|
|
55
|
+
}
|
|
56
|
+
// The request's own host is a safe extra origin only with host-only session
|
|
57
|
+
// cookies: no sibling host ever receives them.
|
|
58
|
+
export function isSameHostOrigin(c) {
|
|
59
|
+
const origin = c.req.header("origin");
|
|
47
60
|
if (origin === undefined || origin === "null")
|
|
48
61
|
return false;
|
|
49
62
|
let originHost;
|
|
@@ -53,14 +66,18 @@ export function isWebSocketOriginAllowed(c, normalizedAllowlist) {
|
|
|
53
66
|
catch {
|
|
54
67
|
return false;
|
|
55
68
|
}
|
|
56
|
-
// With an allowlist there is deliberately no same-host fallback: wide
|
|
57
|
-
// cookieDomain setups are exactly where a sibling subdomain is the attacker.
|
|
58
|
-
if (normalizedAllowlist !== undefined && normalizedAllowlist.size > 0) {
|
|
59
|
-
return isOriginAllowed(origin, normalizedAllowlist);
|
|
60
|
-
}
|
|
61
69
|
const requestHost = c.req.header("host")?.toLowerCase();
|
|
62
70
|
return requestHost !== undefined && originHost === requestHost;
|
|
63
71
|
}
|
|
72
|
+
export function publicOriginOf(baseUrl) {
|
|
73
|
+
try {
|
|
74
|
+
const origin = new URL(baseUrl).origin;
|
|
75
|
+
return origin === "null" ? undefined : normalizeOrigin(origin);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
64
81
|
// Server-side Origin-allowlist guard — an additional CSRF-hardening layer on
|
|
65
82
|
// top of the double-submit token (csrf-middleware) for deployments that widen
|
|
66
83
|
// the auth cookie across subdomains via AuthRoutesConfig.cookieDomain. A wide
|
|
@@ -75,7 +92,7 @@ export function isWebSocketOriginAllowed(c, normalizedAllowlist) {
|
|
|
75
92
|
// Bearer-auth requests skip — browsers cannot set Authorization cross-origin,
|
|
76
93
|
// and native clients send no/foreign Origin, so guarding them would be a
|
|
77
94
|
// false-positive with no CSRF vector to defend.
|
|
78
|
-
export function originMiddleware(allowedOrigins) {
|
|
95
|
+
export function originMiddleware(allowedOrigins, options = {}) {
|
|
79
96
|
const allowlist = new Set(allowedOrigins.map(normalizeOrigin));
|
|
80
97
|
return async (c, next) => {
|
|
81
98
|
const transport = getAuthTransport(c);
|
|
@@ -88,8 +105,11 @@ export function originMiddleware(allowedOrigins) {
|
|
|
88
105
|
// by the allowlist branch inside isForeignCookieOrigin. The residual is
|
|
89
106
|
// only the no-Origin-yet-Sec-Fetch-Site combo, which no current browser
|
|
90
107
|
// emits for state-changing requests.
|
|
91
|
-
if (isForeignCookieOrigin(c, allowlist))
|
|
108
|
+
if (isForeignCookieOrigin(c, allowlist)) {
|
|
109
|
+
if (options.allowRequestHost === true && isSameHostOrigin(c))
|
|
110
|
+
return next();
|
|
92
111
|
return rejectOrigin(c);
|
|
112
|
+
}
|
|
93
113
|
return next();
|
|
94
114
|
};
|
|
95
115
|
}
|
|
@@ -118,3 +138,16 @@ export function assertOriginGuardConfig(auth) {
|
|
|
118
138
|
"auth.unsafeSkipOriginCheck: true to accept the risk explicitly.");
|
|
119
139
|
}
|
|
120
140
|
}
|
|
141
|
+
// An explicit allowlist always wins and is strict. Without one, a known public
|
|
142
|
+
// origin still enables the guard, but only for host-only cookies (no
|
|
143
|
+
// cookieDomain) and never against an explicit opt-out.
|
|
144
|
+
export function resolveOriginGuard(auth) {
|
|
145
|
+
if (auth?.allowedOrigins && auth.allowedOrigins.length > 0) {
|
|
146
|
+
return originMiddleware(auth.allowedOrigins);
|
|
147
|
+
}
|
|
148
|
+
if (auth?.publicOrigin === undefined || auth.unsafeSkipOriginCheck === true)
|
|
149
|
+
return undefined;
|
|
150
|
+
if (auth.cookieDomain)
|
|
151
|
+
return undefined;
|
|
152
|
+
return originMiddleware([auth.publicOrigin], { allowRequestHost: true });
|
|
153
|
+
}
|
package/dist/api/server.js
CHANGED
|
@@ -11,6 +11,7 @@ import { assertSeedModeNotInProduction } from "../event-store/seed-clock.js";
|
|
|
11
11
|
import { createFileContext } from "../files/file-handle.js";
|
|
12
12
|
import { createFileRoutes, readFilesRouteOptions } from "../files/file-routes.js";
|
|
13
13
|
import { makeFileProviderResolver } from "../files/provider-resolver.js";
|
|
14
|
+
import { createRecordReadCheck } from "../files/record-read-check.js";
|
|
14
15
|
import { createFallbackLogger } from "../logging/index.js";
|
|
15
16
|
import { createNoopProvider, DEFAULT_SENSITIVE_CONFIG, mergeSensitiveConfig, registerStandardMetrics, wrapRedisClient, } from "../observability/index.js";
|
|
16
17
|
import { resolveTenantLifecyclePlugin } from "../pipeline/active-membership.js";
|
|
@@ -34,7 +35,7 @@ import { ExtraRouteEntries, ExtraRouteRejection, } from "./extra-route.js";
|
|
|
34
35
|
import { computeStrongEtag, etagMatches } from "./http-cache.js";
|
|
35
36
|
import { createJwtHelper } from "./jwt.js";
|
|
36
37
|
import { observabilityMiddleware } from "./observability-middleware.js";
|
|
37
|
-
import { assertOriginGuardConfig, isWebSocketOriginAllowed, normalizeOrigin,
|
|
38
|
+
import { assertOriginGuardConfig, isWebSocketOriginAllowed, normalizeOrigin, rejectOrigin, resolveOriginGuard, } from "./origin-middleware.js";
|
|
38
39
|
import { patRouteGuard } from "./pat-route-guard.js";
|
|
39
40
|
import { piiCiphertextResponseGuard } from "./pii-leak-guard.js";
|
|
40
41
|
import { createDefaultSseBroker } from "./redis-sse-broker.js";
|
|
@@ -42,6 +43,7 @@ import { requestContext } from "./request-context.js";
|
|
|
42
43
|
import { buildRequestContextData, requestIdMiddleware } from "./request-id-middleware.js";
|
|
43
44
|
import { bodyLimitForRoutePath, DEFAULT_MAX_REQUEST_BYTES, registerBodyLimit, registerHealthRoutes, registerMetricsRoute, registerVersionRoute, } from "./route-registrars.js";
|
|
44
45
|
import { createApiRoutes, handleUncaughtRouteError } from "./routes.js";
|
|
46
|
+
import { resolveSessionCookies, sessionCookiesMiddleware } from "./session-cookies.js";
|
|
45
47
|
import { createSseRoute } from "./sse-route.js";
|
|
46
48
|
import { buildWebSocketSessionRevalidator, createWebSocketConnectionLimiter, hasWebSocketUpgradeServer, isWebSocketUpgradeRequest, WEBSOCKET_DEFAULT_MAX_CONNECTIONS_PER_USER, WEBSOCKET_DEFAULT_MAX_MESSAGE_BYTES, } from "./websocket-route.js";
|
|
47
49
|
// The per-tenant file-provider resolver, built once for a registry+context so
|
|
@@ -424,6 +426,14 @@ export function buildServer(options) {
|
|
|
424
426
|
const app = new Hono();
|
|
425
427
|
// Sub-apps mounted via app.route() without their own handler inherit this one.
|
|
426
428
|
app.onError(handleUncaughtRouteError);
|
|
429
|
+
// Resolved once; auth routes, auth/csrf middleware and the tenant-cookie
|
|
430
|
+
// helpers read the cookie names from the request context.
|
|
431
|
+
const sessionCookies = resolveSessionCookies({
|
|
432
|
+
secureCookies: options.auth?.secureCookies,
|
|
433
|
+
cookieDomain: options.auth?.cookieDomain,
|
|
434
|
+
retiredCookieDomains: options.auth?.retiredCookieDomains,
|
|
435
|
+
});
|
|
436
|
+
app.use("*", sessionCookiesMiddleware(sessionCookies));
|
|
427
437
|
// Only entry:"signature" bypasses jwtGuard (verify() authenticates
|
|
428
438
|
// itself); entry:"anonymous" still needs the anonymousAccess fallthrough
|
|
429
439
|
// (tenant-by-host), entry:"user" needs c.get("user") populated.
|
|
@@ -553,7 +563,8 @@ export function buildServer(options) {
|
|
|
553
563
|
}
|
|
554
564
|
// Origin-allowlist guard — additional CSRF-hardening for deployments that
|
|
555
565
|
// widen the auth cookie across subdomains (auth.cookieDomain). Registered
|
|
556
|
-
//
|
|
566
|
+
// when allowedOrigins is non-empty, or with host-only cookies when
|
|
567
|
+
// auth.publicOrigin is known (see resolveOriginGuard), with the same /api/* + public-skip
|
|
557
568
|
// scope as the CSRF guard and BEFORE it, so a disallowed cross-site POST
|
|
558
569
|
// surfaces as `origin_not_allowed` rather than `csrf_token_mismatch`. Fails
|
|
559
570
|
// closed (assertOriginGuardConfig throws) when a wide cookieDomain is set
|
|
@@ -562,7 +573,7 @@ export function buildServer(options) {
|
|
|
562
573
|
assertOriginGuardConfig(options.auth);
|
|
563
574
|
assertSessionCallbacksPaired(options.auth);
|
|
564
575
|
const allowedOrigins = options.auth?.allowedOrigins;
|
|
565
|
-
const originGuard =
|
|
576
|
+
const originGuard = resolveOriginGuard(options.auth);
|
|
566
577
|
if (originGuard) {
|
|
567
578
|
app.use("/api/*", async (c, next) => {
|
|
568
579
|
if (PUBLIC_API_PATHS.has(c.req.path) || isExtraRoutePublicPath(c))
|
|
@@ -613,7 +624,11 @@ export function buildServer(options) {
|
|
|
613
624
|
"is expected (the bundled user feature already provides it).");
|
|
614
625
|
}
|
|
615
626
|
}
|
|
616
|
-
app.route("/api", createAuthRoutes(dispatcher, jwt, {
|
|
627
|
+
app.route("/api", createAuthRoutes(dispatcher, jwt, {
|
|
628
|
+
...options.auth,
|
|
629
|
+
secureCookies: sessionCookies.secure,
|
|
630
|
+
resolver: clientIpResolver,
|
|
631
|
+
}));
|
|
617
632
|
}
|
|
618
633
|
app.route("/api", createApiRoutes(dispatcher, {
|
|
619
634
|
...(options.sseHeartbeatMs !== undefined ? { sseHeartbeatMs: options.sseHeartbeatMs } : {}),
|
|
@@ -676,6 +691,7 @@ export function buildServer(options) {
|
|
|
676
691
|
db: fileDb,
|
|
677
692
|
registry: options.registry,
|
|
678
693
|
resolveProvider: fileProviderResolver,
|
|
694
|
+
canReadRecord: createRecordReadCheck(options.registry, dispatcher.query),
|
|
679
695
|
...(routeOptions.accessGuard ? { accessGuard: routeOptions.accessGuard } : {}),
|
|
680
696
|
...(routeOptions.privilegedRoles ? { privilegedRoles: routeOptions.privilegedRoles } : {}),
|
|
681
697
|
...(routeOptions.maxUploadSize ? { maxUploadSize: routeOptions.maxUploadSize } : {}),
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type SessionCookieNames } from "@cosmicdrift/kumiko-types/session-cookies";
|
|
2
|
+
import type { Context, MiddlewareHandler } from "hono";
|
|
3
|
+
export { AUTH_COOKIE_BASE, CSRF_COOKIE_BASE, HOST_COOKIE_PREFIX, isHostPrefixedCookieName, resolveSessionCookieNames, type SessionCookieBase, type SessionCookieNames, sessionCookieName, TENANT_COOKIE_BASE, } from "@cosmicdrift/kumiko-types/session-cookies";
|
|
4
|
+
export type ResolvedSessionCookies = {
|
|
5
|
+
readonly secure: boolean;
|
|
6
|
+
readonly names: SessionCookieNames;
|
|
7
|
+
readonly domain?: string | undefined;
|
|
8
|
+
readonly retiredDomains?: readonly string[] | undefined;
|
|
9
|
+
};
|
|
10
|
+
export declare function resolveSecureCookies(configured?: boolean): boolean;
|
|
11
|
+
export declare function resolveSessionCookies(options: {
|
|
12
|
+
readonly secureCookies?: boolean | undefined;
|
|
13
|
+
readonly cookieDomain?: string | undefined;
|
|
14
|
+
readonly retiredCookieDomains?: readonly string[] | undefined;
|
|
15
|
+
}): ResolvedSessionCookies;
|
|
16
|
+
export declare function sessionCookiesMiddleware(resolved: ResolvedSessionCookies): MiddlewareHandler;
|
|
17
|
+
export declare function getSessionCookies(c: Context, fallback?: ResolvedSessionCookies): ResolvedSessionCookies;
|
|
18
|
+
export declare function hostOnlyDeleteOptions(name: string): {
|
|
19
|
+
path: "/";
|
|
20
|
+
secure?: true;
|
|
21
|
+
};
|
|
22
|
+
export declare function clearSessionCookies(c: Context, cookies: ResolvedSessionCookies): void;
|
|
23
|
+
export declare function hasDuplicateSessionCookie(c: Context, names: SessionCookieNames): boolean;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { AUTH_COOKIE_BASE, CSRF_COOKIE_BASE, isHostPrefixedCookieName, resolveSessionCookieNames, } from "@cosmicdrift/kumiko-types/session-cookies";
|
|
2
|
+
import { deleteCookie } from "hono/cookie";
|
|
3
|
+
export { AUTH_COOKIE_BASE, CSRF_COOKIE_BASE, HOST_COOKIE_PREFIX, isHostPrefixedCookieName, resolveSessionCookieNames, sessionCookieName, TENANT_COOKIE_BASE, } from "@cosmicdrift/kumiko-types/session-cookies";
|
|
4
|
+
const SESSION_COOKIES_KEY = "sessionCookies";
|
|
5
|
+
// Browsers drop Secure cookies on http, so Secure is off outside production
|
|
6
|
+
// (Playwright against http://localhost) and on in production, where a
|
|
7
|
+
// misconfigured http deploy breaks login loudly instead of silently.
|
|
8
|
+
export function resolveSecureCookies(configured) {
|
|
9
|
+
return configured ?? process.env["NODE_ENV"] === "production";
|
|
10
|
+
}
|
|
11
|
+
export function resolveSessionCookies(options) {
|
|
12
|
+
const secure = resolveSecureCookies(options.secureCookies);
|
|
13
|
+
return {
|
|
14
|
+
secure,
|
|
15
|
+
names: resolveSessionCookieNames({ secure, domain: options.cookieDomain }),
|
|
16
|
+
domain: options.cookieDomain,
|
|
17
|
+
retiredDomains: options.retiredCookieDomains,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export function sessionCookiesMiddleware(resolved) {
|
|
21
|
+
return async (c, next) => {
|
|
22
|
+
c.set(SESSION_COOKIES_KEY, resolved);
|
|
23
|
+
await next();
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function isResolvedSessionCookies(value) {
|
|
27
|
+
return typeof value === "object" && value !== null && "names" in value && "secure" in value;
|
|
28
|
+
}
|
|
29
|
+
// buildServer resolves the names once and stores them per request, so writer
|
|
30
|
+
// (auth routes) and readers (auth/csrf/tenant) cannot diverge. The fallback
|
|
31
|
+
// only applies to contexts that never ran through buildServer, e.g. a bare
|
|
32
|
+
// Hono app in a unit test or a standalone createAuthRoutes mount.
|
|
33
|
+
export function getSessionCookies(c, fallback) {
|
|
34
|
+
const stored = c.get(SESSION_COOKIES_KEY);
|
|
35
|
+
if (isResolvedSessionCookies(stored))
|
|
36
|
+
return stored;
|
|
37
|
+
return fallback ?? resolveSessionCookies({});
|
|
38
|
+
}
|
|
39
|
+
// A deletion of a `__Host-` cookie is ignored by the browser unless it is
|
|
40
|
+
// itself Secure, Path=/ and Domain-less.
|
|
41
|
+
export function hostOnlyDeleteOptions(name) {
|
|
42
|
+
return isHostPrefixedCookieName(name) ? { path: "/", secure: true } : { path: "/" };
|
|
43
|
+
}
|
|
44
|
+
// Expire every variant: the Domain cookie (current) and the host-only one
|
|
45
|
+
// (from before cookieDomain), otherwise a cookie left over from an earlier
|
|
46
|
+
// config keeps the session alive after logout. With __Host- names the
|
|
47
|
+
// unprefixed leftovers from before the prefix are expired as well. Retired
|
|
48
|
+
// domains only ever carried unprefixed names.
|
|
49
|
+
export function clearSessionCookies(c, cookies) {
|
|
50
|
+
const { names, domain } = cookies;
|
|
51
|
+
deleteCookie(c, names.auth, hostOnlyDeleteOptions(names.auth));
|
|
52
|
+
deleteCookie(c, names.csrf, hostOnlyDeleteOptions(names.csrf));
|
|
53
|
+
if (isHostPrefixedCookieName(names.auth)) {
|
|
54
|
+
deleteCookie(c, AUTH_COOKIE_BASE, { path: "/" });
|
|
55
|
+
deleteCookie(c, CSRF_COOKIE_BASE, { path: "/" });
|
|
56
|
+
}
|
|
57
|
+
if (domain !== undefined) {
|
|
58
|
+
deleteCookie(c, AUTH_COOKIE_BASE, { path: "/", domain });
|
|
59
|
+
deleteCookie(c, CSRF_COOKIE_BASE, { path: "/", domain });
|
|
60
|
+
}
|
|
61
|
+
for (const retiredDomain of cookies.retiredDomains ?? []) {
|
|
62
|
+
deleteCookie(c, AUTH_COOKIE_BASE, { path: "/", domain: retiredDomain });
|
|
63
|
+
deleteCookie(c, CSRF_COOKIE_BASE, { path: "/", domain: retiredDomain });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function cookieNameOccurrences(cookieHeader, name) {
|
|
67
|
+
let count = 0;
|
|
68
|
+
for (const pair of cookieHeader.split(";")) {
|
|
69
|
+
const separator = pair.indexOf("=");
|
|
70
|
+
const pairName = (separator === -1 ? pair : pair.slice(0, separator)).trim();
|
|
71
|
+
if (pairName === name)
|
|
72
|
+
count++;
|
|
73
|
+
}
|
|
74
|
+
return count;
|
|
75
|
+
}
|
|
76
|
+
// Two cookies with the same name (e.g. a host-only and a Domain variant, or one
|
|
77
|
+
// planted by a sibling subdomain) are indistinguishable server-side; Hono's
|
|
78
|
+
// getCookie would silently bind to whichever the browser sent first.
|
|
79
|
+
export function hasDuplicateSessionCookie(c, names) {
|
|
80
|
+
const cookieHeader = c.req.header("cookie");
|
|
81
|
+
if (cookieHeader === undefined)
|
|
82
|
+
return false;
|
|
83
|
+
return (cookieNameOccurrences(cookieHeader, names.auth) > 1 ||
|
|
84
|
+
cookieNameOccurrences(cookieHeader, names.csrf) > 1);
|
|
85
|
+
}
|
|
@@ -523,7 +523,7 @@ function projectField(fieldDef) {
|
|
|
523
523
|
// date/timestamp/locatedTimestamp: display/parsing locale override (fw#2497).
|
|
524
524
|
if (typeof def["locale"] === "string")
|
|
525
525
|
out["locale"] = def["locale"];
|
|
526
|
-
// image: which camera a mobile capture opens (fw#2497).
|
|
526
|
+
// image/images: which camera a mobile capture opens (fw#2497).
|
|
527
527
|
if (typeof def["capture"] === "string")
|
|
528
528
|
out["capture"] = def["capture"];
|
|
529
529
|
// number: display-only suffix, static or read from a sibling field of the row.
|