jskelet 0.5.3 → 0.5.5
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/CHANGELOG.md +292 -377
- package/docs/07-yapilandirma.md +38 -8
- package/docs/08-build.md +26 -10
- package/docs/12-panel-ve-oturum.md +88 -0
- package/docs/en/07-configuration.md +41 -8
- package/docs/en/08-build.md +28 -12
- package/docs/en/12-dashboards-and-sessions.md +91 -0
- package/package.json +2 -2
- package/src/build/tasks/icons.mjs +141 -17
- package/src/client/index.js +10 -0
- package/src/client/shared-cookie.js +225 -0
- package/src/config/defaults.js +19 -0
- package/src/config/index.js +89 -3
- package/src/http/cookies-entry.js +20 -0
- package/src/http/cookies.js +2 -0
- package/src/http/shared-cookie.js +178 -0
- package/src/index.js +7 -0
- package/src/server/auth/handoff.js +226 -0
- package/src/server/create-app.js +14 -0
- package/src/shared/cookie-domain.js +66 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alt alan adları arasında paylaşılan cookie yazma.
|
|
3
|
+
*
|
|
4
|
+
* `Secure` protokole bakılır (`https` / `x-forwarded-proto`), `NODE_ENV`'e değil —
|
|
5
|
+
* yerel https veya prod http yanlış bayrak üretmesin.
|
|
6
|
+
*
|
|
7
|
+
* Domain `brand.sharedCookieRoots` ile seçilir. Eşleşme yoksa ya da değer çok
|
|
8
|
+
* büyükse `{ ok: false, handoff: true }` döner; çağıran handoff yoluna düşer.
|
|
9
|
+
*
|
|
10
|
+
* JWT'yi paylaşımlı cookie'ye koymayın: kısa session id koyun.
|
|
11
|
+
*/
|
|
12
|
+
import { getConfig } from "../config/index.js";
|
|
13
|
+
import {
|
|
14
|
+
SHARED_COOKIE_WARN_BYTES,
|
|
15
|
+
resolveSharedCookieDomain,
|
|
16
|
+
stripHostPort,
|
|
17
|
+
} from "../shared/cookie-domain.js";
|
|
18
|
+
import { clearCookie, setCookie } from "./cookies.js";
|
|
19
|
+
|
|
20
|
+
export {
|
|
21
|
+
SHARED_COOKIE_WARN_BYTES,
|
|
22
|
+
resolveSharedCookieDomain,
|
|
23
|
+
stripHostPort,
|
|
24
|
+
} from "../shared/cookie-domain.js";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {{ secure?: boolean, protocol?: string,
|
|
28
|
+
* headers?: Record<string, unknown>, get?: (name: string) => string | undefined }} [req]
|
|
29
|
+
* @returns {boolean}
|
|
30
|
+
*/
|
|
31
|
+
export function requestIsHttps(req) {
|
|
32
|
+
if (!req) return false;
|
|
33
|
+
if (req.secure === true) return true;
|
|
34
|
+
if (typeof req.protocol === "string" && req.protocol === "https") return true;
|
|
35
|
+
|
|
36
|
+
const forwarded =
|
|
37
|
+
req.get?.("x-forwarded-proto") ??
|
|
38
|
+
(typeof req.headers?.["x-forwarded-proto"] === "string"
|
|
39
|
+
? req.headers["x-forwarded-proto"]
|
|
40
|
+
: Array.isArray(req.headers?.["x-forwarded-proto"])
|
|
41
|
+
? req.headers["x-forwarded-proto"][0]
|
|
42
|
+
: "");
|
|
43
|
+
const first = String(forwarded ?? "")
|
|
44
|
+
.split(",")[0]
|
|
45
|
+
.trim()
|
|
46
|
+
.toLowerCase();
|
|
47
|
+
return first === "https";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {{ headers?: Record<string, unknown>, get?: (name: string) => string | undefined,
|
|
52
|
+
* hostname?: string }} [req]
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
function hostFromReq(req) {
|
|
56
|
+
if (!req) return "";
|
|
57
|
+
const forwarded =
|
|
58
|
+
req.get?.("x-forwarded-host") ??
|
|
59
|
+
(typeof req.headers?.["x-forwarded-host"] === "string"
|
|
60
|
+
? req.headers["x-forwarded-host"]
|
|
61
|
+
: "");
|
|
62
|
+
const raw =
|
|
63
|
+
forwarded ||
|
|
64
|
+
req.get?.("host") ||
|
|
65
|
+
(typeof req.headers?.host === "string" ? req.headers.host : "") ||
|
|
66
|
+
(typeof req.hostname === "string" ? req.hostname : "");
|
|
67
|
+
return stripHostPort(String(raw).split(",")[0]);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @returns {string[]}
|
|
72
|
+
*/
|
|
73
|
+
function configuredRoots() {
|
|
74
|
+
try {
|
|
75
|
+
const roots = getConfig().brand?.sharedCookieRoots;
|
|
76
|
+
return Array.isArray(roots) ? roots.map(String) : [];
|
|
77
|
+
} catch {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @typedef {import('./cookies.js').CookieOptions} CookieOptions
|
|
84
|
+
*
|
|
85
|
+
* @typedef {object} SharedCookieResult
|
|
86
|
+
* @property {boolean} ok Domain seçildi ve Set-Cookie yazıldı.
|
|
87
|
+
* @property {string | null} domain Yazılan Domain ya da null.
|
|
88
|
+
* @property {boolean} handoff ok değilse çağıran handoff denemeli.
|
|
89
|
+
* @property {string} [reason] Teşhis.
|
|
90
|
+
*/
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Paylaşımlı Domain ile cookie yazar.
|
|
94
|
+
*
|
|
95
|
+
* @param {import('http').ServerResponse & { req?: import('http').IncomingMessage }} res
|
|
96
|
+
* @param {string} name
|
|
97
|
+
* @param {string} value Kısa session id — JWT değil.
|
|
98
|
+
* @param {CookieOptions & { req?: import('http').IncomingMessage, roots?: string[],
|
|
99
|
+
* host?: string }} [options]
|
|
100
|
+
* @returns {SharedCookieResult}
|
|
101
|
+
*/
|
|
102
|
+
export function writeSharedCookie(res, name, value, options = {}) {
|
|
103
|
+
const { req: optReq, roots: optRoots, host: optHost, ...cookieOpts } = options;
|
|
104
|
+
const req = optReq ?? /** @type {{ req?: import('http').IncomingMessage }} */ (res).req;
|
|
105
|
+
const roots = optRoots ?? configuredRoots();
|
|
106
|
+
const host = optHost ? stripHostPort(optHost) : hostFromReq(req);
|
|
107
|
+
const domain = resolveSharedCookieDomain(host, roots);
|
|
108
|
+
|
|
109
|
+
if (!domain) {
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
domain: null,
|
|
113
|
+
handoff: true,
|
|
114
|
+
reason: "no-matching-root",
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const text = String(value ?? "");
|
|
119
|
+
if (Buffer.byteLength(text, "utf8") > SHARED_COOKIE_WARN_BYTES) {
|
|
120
|
+
console.warn(
|
|
121
|
+
`[cookies] shared cookie "${name}" is ${Buffer.byteLength(text, "utf8")} bytes; ` +
|
|
122
|
+
`prefer a short session id (≤${SHARED_COOKIE_WARN_BYTES}). Large tokens do not belong in shared cookies.`,
|
|
123
|
+
);
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
domain,
|
|
127
|
+
handoff: true,
|
|
128
|
+
reason: "value-too-large",
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const secure = cookieOpts.secure ?? requestIsHttps(req);
|
|
133
|
+
|
|
134
|
+
setCookie(res, name, text, {
|
|
135
|
+
...cookieOpts,
|
|
136
|
+
domain,
|
|
137
|
+
secure,
|
|
138
|
+
// Paylaşımlı oturum kimliği JS'ye gerekmez; HttpOnly kalsın.
|
|
139
|
+
httpOnly: cookieOpts.httpOnly !== false,
|
|
140
|
+
sameSite: cookieOpts.sameSite ?? "Lax",
|
|
141
|
+
path: cookieOpts.path ?? "/",
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Sunucuda document.cookie read-back yok; Domain seçildiyse yazım başarılı sayılır.
|
|
145
|
+
// İstemci `writeSharedCookie` read-back yapar; başarısızsa handoff'a düşer.
|
|
146
|
+
return { ok: true, domain, handoff: false };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Aynı Domain ile cookie siler.
|
|
151
|
+
*
|
|
152
|
+
* @param {import('http').ServerResponse & { req?: import('http').IncomingMessage }} res
|
|
153
|
+
* @param {string} name
|
|
154
|
+
* @param {CookieOptions & { req?: import('http').IncomingMessage, roots?: string[],
|
|
155
|
+
* host?: string }} [options]
|
|
156
|
+
* @returns {SharedCookieResult}
|
|
157
|
+
*/
|
|
158
|
+
export function clearSharedCookie(res, name, options = {}) {
|
|
159
|
+
const { req: optReq, roots: optRoots, host: optHost, ...cookieOpts } = options;
|
|
160
|
+
const req = optReq ?? /** @type {{ req?: import('http').IncomingMessage }} */ (res).req;
|
|
161
|
+
const roots = optRoots ?? configuredRoots();
|
|
162
|
+
const host = optHost ? stripHostPort(optHost) : hostFromReq(req);
|
|
163
|
+
const domain = resolveSharedCookieDomain(host, roots);
|
|
164
|
+
|
|
165
|
+
if (!domain) {
|
|
166
|
+
return { ok: false, domain: null, handoff: false, reason: "no-matching-root" };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const secure = cookieOpts.secure ?? requestIsHttps(req);
|
|
170
|
+
clearCookie(res, name, {
|
|
171
|
+
...cookieOpts,
|
|
172
|
+
domain,
|
|
173
|
+
secure,
|
|
174
|
+
path: cookieOpts.path ?? "/",
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return { ok: true, domain, handoff: false };
|
|
178
|
+
}
|
package/src/index.js
CHANGED
|
@@ -35,6 +35,13 @@ export {
|
|
|
35
35
|
setCookie,
|
|
36
36
|
setSignedCookie,
|
|
37
37
|
} from "./http/cookies.js";
|
|
38
|
+
export {
|
|
39
|
+
clearSharedCookie,
|
|
40
|
+
requestIsHttps,
|
|
41
|
+
resolveSharedCookieDomain,
|
|
42
|
+
SHARED_COOKIE_WARN_BYTES,
|
|
43
|
+
writeSharedCookie,
|
|
44
|
+
} from "./http/shared-cookie.js";
|
|
38
45
|
export { reportUpstreamFailure } from "./server/upstream-tracking.js";
|
|
39
46
|
export { asset, hasAsset, optimizedImage, getSpriteIds } from "./server/assets.js";
|
|
40
47
|
export { remoteImageUrl, parseAllowedRemoteUrl } from "./server/image-optimizer.js";
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alt alan adı handoff: kısa ömürlü tek kullanımlık bilet.
|
|
3
|
+
*
|
|
4
|
+
* Akış:
|
|
5
|
+
* 1. Paylaşımlı cookie yazımı başarısız (istemci read-back fail)
|
|
6
|
+
* 2. POST /_jskelet/auth/handoff { name, value, next }
|
|
7
|
+
* 3. Yanıt { url: "https://tr.…/path?handoff=TICKET" }
|
|
8
|
+
* 4. Hedef host'ta GET middleware bileti tüketir → Set-Cookie + 303
|
|
9
|
+
*
|
|
10
|
+
* Bilet süreç belleğinde; tek süreçte tüm locale host'ları servis eden
|
|
11
|
+
* kurulumlar için yeterli. Değer kısa session id olmalı.
|
|
12
|
+
*/
|
|
13
|
+
import { getConfig } from "../../config/index.js";
|
|
14
|
+
import { randomToken, setCookie } from "../../http/cookies.js";
|
|
15
|
+
import {
|
|
16
|
+
SHARED_COOKIE_WARN_BYTES,
|
|
17
|
+
requestIsHttps,
|
|
18
|
+
writeSharedCookie,
|
|
19
|
+
} from "../../http/shared-cookie.js";
|
|
20
|
+
import { resolveSharedCookieDomain } from "../../shared/cookie-domain.js";
|
|
21
|
+
|
|
22
|
+
/** @type {Map<string, { name: string, value: string, maxAge?: number, exp: number }>} */
|
|
23
|
+
const tickets = new Map();
|
|
24
|
+
|
|
25
|
+
const DEFAULT_TTL_SECONDS = 60;
|
|
26
|
+
const HANDOFF_PATH = "/_jskelet/auth/handoff";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @returns {{ enabled: boolean, ttlSeconds: number, path: string, maxValueBytes: number }}
|
|
30
|
+
*/
|
|
31
|
+
function settings() {
|
|
32
|
+
const auth = getConfig().auth ?? {};
|
|
33
|
+
const raw = auth.crossSubdomainHandoff;
|
|
34
|
+
if (raw === false || raw == null) {
|
|
35
|
+
return {
|
|
36
|
+
enabled: false,
|
|
37
|
+
ttlSeconds: DEFAULT_TTL_SECONDS,
|
|
38
|
+
path: HANDOFF_PATH,
|
|
39
|
+
maxValueBytes: SHARED_COOKIE_WARN_BYTES,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (raw === true) {
|
|
44
|
+
return {
|
|
45
|
+
enabled: true,
|
|
46
|
+
ttlSeconds: DEFAULT_TTL_SECONDS,
|
|
47
|
+
path: HANDOFF_PATH,
|
|
48
|
+
maxValueBytes: SHARED_COOKIE_WARN_BYTES,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const source = /** @type {Record<string, unknown>} */ (raw);
|
|
53
|
+
const ttl = Number(source.ttlSeconds);
|
|
54
|
+
const maxBytes = Number(source.maxValueBytes);
|
|
55
|
+
return {
|
|
56
|
+
enabled: source.enabled !== false,
|
|
57
|
+
ttlSeconds:
|
|
58
|
+
Number.isFinite(ttl) && ttl > 0 ? Math.min(300, Math.floor(ttl)) : DEFAULT_TTL_SECONDS,
|
|
59
|
+
path:
|
|
60
|
+
typeof source.path === "string" && source.path.startsWith("/")
|
|
61
|
+
? source.path
|
|
62
|
+
: HANDOFF_PATH,
|
|
63
|
+
maxValueBytes:
|
|
64
|
+
Number.isFinite(maxBytes) && maxBytes > 0
|
|
65
|
+
? Math.floor(maxBytes)
|
|
66
|
+
: SHARED_COOKIE_WARN_BYTES,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function sweep() {
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
for (const [id, entry] of tickets) {
|
|
73
|
+
if (entry.exp <= now) tickets.delete(id);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {string} next
|
|
79
|
+
* @param {import('express').Request} req
|
|
80
|
+
* @returns {URL | null}
|
|
81
|
+
*/
|
|
82
|
+
function parseNext(next, req) {
|
|
83
|
+
try {
|
|
84
|
+
return new URL(next, `${req.protocol}://${req.get("host")}`);
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param {URL} url
|
|
92
|
+
* @param {import('express').Request} req
|
|
93
|
+
* @returns {boolean}
|
|
94
|
+
*/
|
|
95
|
+
function nextAllowed(url, req) {
|
|
96
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
|
97
|
+
|
|
98
|
+
const roots = getConfig().brand?.sharedCookieRoots ?? [];
|
|
99
|
+
const targetHost = url.hostname.toLowerCase();
|
|
100
|
+
const sourceHost = String(req.hostname || "")
|
|
101
|
+
.toLowerCase()
|
|
102
|
+
.replace(/:\d+$/, "");
|
|
103
|
+
|
|
104
|
+
if (targetHost === sourceHost) return true;
|
|
105
|
+
|
|
106
|
+
const sourceRoot = resolveSharedCookieDomain(sourceHost, roots);
|
|
107
|
+
const targetRoot = resolveSharedCookieDomain(targetHost, roots);
|
|
108
|
+
return Boolean(sourceRoot && targetRoot && sourceRoot === targetRoot);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @param {import('express').Request} req
|
|
113
|
+
* @param {import('express').Response} res
|
|
114
|
+
* @param {{ name: string, value: string, maxAge?: number }} payload
|
|
115
|
+
* @returns {void}
|
|
116
|
+
*/
|
|
117
|
+
function issueCookie(req, res, payload) {
|
|
118
|
+
const shared = writeSharedCookie(res, payload.name, payload.value, {
|
|
119
|
+
req,
|
|
120
|
+
maxAge: payload.maxAge,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
if (shared.ok) return;
|
|
124
|
+
|
|
125
|
+
setCookie(res, payload.name, payload.value, {
|
|
126
|
+
path: "/",
|
|
127
|
+
httpOnly: true,
|
|
128
|
+
sameSite: "Lax",
|
|
129
|
+
secure: requestIsHttps(req),
|
|
130
|
+
maxAge: payload.maxAge,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @param {string} ticketId
|
|
136
|
+
* @returns {{ name: string, value: string, maxAge?: number } | null}
|
|
137
|
+
*/
|
|
138
|
+
function redeem(ticketId) {
|
|
139
|
+
sweep();
|
|
140
|
+
const entry = tickets.get(ticketId);
|
|
141
|
+
if (!entry) return null;
|
|
142
|
+
tickets.delete(ticketId);
|
|
143
|
+
if (entry.exp <= Date.now()) return null;
|
|
144
|
+
return { name: entry.name, value: entry.value, maxAge: entry.maxAge };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Handoff uçlarını mount eder. `auth.crossSubdomainHandoff` kapalıysa no-op.
|
|
149
|
+
*
|
|
150
|
+
* @param {import('express').Express} app
|
|
151
|
+
* @returns {void}
|
|
152
|
+
*/
|
|
153
|
+
export function mountAuthHandoff(app) {
|
|
154
|
+
const cfg = settings();
|
|
155
|
+
if (!cfg.enabled) return;
|
|
156
|
+
|
|
157
|
+
app.post(cfg.path, (req, res) => {
|
|
158
|
+
sweep();
|
|
159
|
+
|
|
160
|
+
const name = typeof req.body?.name === "string" ? req.body.name.trim() : "";
|
|
161
|
+
const value = typeof req.body?.value === "string" ? req.body.value : "";
|
|
162
|
+
const next = typeof req.body?.next === "string" ? req.body.next.trim() : "";
|
|
163
|
+
const maxAge =
|
|
164
|
+
req.body?.maxAge != null && Number.isFinite(Number(req.body.maxAge))
|
|
165
|
+
? Math.floor(Number(req.body.maxAge))
|
|
166
|
+
: undefined;
|
|
167
|
+
|
|
168
|
+
if (!name || !next) {
|
|
169
|
+
res.status(400).json({ ok: false, error: "name and next are required" });
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (Buffer.byteLength(value, "utf8") > cfg.maxValueBytes) {
|
|
174
|
+
res.status(400).json({
|
|
175
|
+
ok: false,
|
|
176
|
+
error: `value exceeds ${cfg.maxValueBytes} bytes; use a short session id`,
|
|
177
|
+
});
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const nextUrl = parseNext(next, req);
|
|
182
|
+
if (!nextUrl || !nextAllowed(nextUrl, req)) {
|
|
183
|
+
res.status(400).json({ ok: false, error: "next is not an allowed URL" });
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const id = randomToken(24);
|
|
188
|
+
tickets.set(id, {
|
|
189
|
+
name,
|
|
190
|
+
value,
|
|
191
|
+
maxAge,
|
|
192
|
+
exp: Date.now() + cfg.ttlSeconds * 1000,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
nextUrl.searchParams.set("handoff", id);
|
|
196
|
+
res.setHeader("Cache-Control", "no-store");
|
|
197
|
+
res.json({ ok: true, url: nextUrl.href });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Hedef host'ta `?handoff=` yakala — route render'ından önce.
|
|
201
|
+
app.use((req, res, next) => {
|
|
202
|
+
if (req.method !== "GET" && req.method !== "HEAD") return next();
|
|
203
|
+
const ticket = typeof req.query?.handoff === "string" ? req.query.handoff : "";
|
|
204
|
+
if (!ticket) return next();
|
|
205
|
+
|
|
206
|
+
const payload = redeem(ticket);
|
|
207
|
+
if (!payload) return next();
|
|
208
|
+
|
|
209
|
+
issueCookie(req, res, payload);
|
|
210
|
+
|
|
211
|
+
const url = new URL(req.originalUrl || req.url, `${req.protocol}://${req.get("host")}`);
|
|
212
|
+
url.searchParams.delete("handoff");
|
|
213
|
+
res.setHeader("Cache-Control", "no-store");
|
|
214
|
+
res.redirect(303, url.pathname + url.search + url.hash);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** @returns {void} */
|
|
219
|
+
export function _resetHandoffTickets() {
|
|
220
|
+
tickets.clear();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** @returns {number} */
|
|
224
|
+
export function _handoffTicketCount() {
|
|
225
|
+
return tickets.size;
|
|
226
|
+
}
|
package/src/server/create-app.js
CHANGED
|
@@ -21,6 +21,9 @@
|
|
|
21
21
|
* body parser'dan önce, admin ile aynı katmanda.
|
|
22
22
|
* 5. body parser'lar — statikten sonra: görsel isteklerinde gövde ayrıştırma
|
|
23
23
|
* maliyeti ödenmesin.
|
|
24
|
+
* 5b. auth handoff (açıksa) — POST bilet + `?handoff=` redeem; csrf'den
|
|
25
|
+
* önce değil sonra: JSON gövde parser'ı hazır olsun. Redeem GET route
|
|
26
|
+
* render'ından önce olmalı.
|
|
24
27
|
* 6. csrf — body parser'lardan sonra olmalı: token form alanından okunuyor.
|
|
25
28
|
* Rewrite'lardan önce, çünkü kontrol istemcinin gördüğü yola bakar.
|
|
26
29
|
* 7. rewrites(afterFiles) — statik denendikten sonra, sayfalardan önce.
|
|
@@ -149,6 +152,17 @@ export async function createApp(options = {}) {
|
|
|
149
152
|
app.use(express.urlencoded({ extended: false, limit: "64kb" }));
|
|
150
153
|
app.use(express.json({ limit: "256kb" }));
|
|
151
154
|
|
|
155
|
+
const handoff = config.auth?.crossSubdomainHandoff;
|
|
156
|
+
if (
|
|
157
|
+
handoff === true ||
|
|
158
|
+
(handoff &&
|
|
159
|
+
typeof handoff === "object" &&
|
|
160
|
+
/** @type {{ enabled?: boolean }} */ (handoff).enabled !== false)
|
|
161
|
+
) {
|
|
162
|
+
const { mountAuthHandoff } = await import("./auth/handoff.js");
|
|
163
|
+
mountAuthHandoff(app);
|
|
164
|
+
}
|
|
165
|
+
|
|
152
166
|
app.use(csrf());
|
|
153
167
|
|
|
154
168
|
app.use(configRewrites("afterFiles"));
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Paylaşımlı cookie Domain seçimi — sunucu ve istemci aynı kuralları kullanır.
|
|
3
|
+
*
|
|
4
|
+
* `brand.sharedCookieRoots` örn. `[".investvio.com", ".localhost"]`.
|
|
5
|
+
* Host bu köklerden birine uyuyorsa Domain olarak o kök yazılır.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {string} host Host veya Host:port.
|
|
10
|
+
* @returns {string} Lowercase, portsuz.
|
|
11
|
+
*/
|
|
12
|
+
export function stripHostPort(host) {
|
|
13
|
+
const raw = String(host ?? "")
|
|
14
|
+
.split(",")[0]
|
|
15
|
+
.trim()
|
|
16
|
+
.toLowerCase();
|
|
17
|
+
if (!raw) return "";
|
|
18
|
+
if (raw.startsWith("[")) {
|
|
19
|
+
const end = raw.indexOf("]");
|
|
20
|
+
return end === -1 ? raw : raw.slice(0, end + 1);
|
|
21
|
+
}
|
|
22
|
+
const colon = raw.lastIndexOf(":");
|
|
23
|
+
if (colon === -1) return raw;
|
|
24
|
+
if (raw.indexOf(":") !== colon) return raw;
|
|
25
|
+
return raw.slice(0, colon);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Kökü `Domain=` biçimine getirir (başında nokta).
|
|
30
|
+
*
|
|
31
|
+
* @param {unknown} root
|
|
32
|
+
* @returns {string | null}
|
|
33
|
+
*/
|
|
34
|
+
export function normalizeCookieRoot(root) {
|
|
35
|
+
if (typeof root !== "string") return null;
|
|
36
|
+
const trimmed = root.trim().toLowerCase();
|
|
37
|
+
if (!trimmed || trimmed === ".") return null;
|
|
38
|
+
const bare = trimmed.replace(/^\./, "");
|
|
39
|
+
if (!bare || bare.includes("/") || bare.includes(":")) return null;
|
|
40
|
+
return `.${bare}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {string} hostname Portsuz hostname.
|
|
45
|
+
* @param {Iterable<string>} roots `brand.sharedCookieRoots`.
|
|
46
|
+
* @returns {string | null} `Domain` değeri (başında nokta) ya da eşleşme yoksa null.
|
|
47
|
+
*/
|
|
48
|
+
export function resolveSharedCookieDomain(hostname, roots) {
|
|
49
|
+
const host = stripHostPort(hostname);
|
|
50
|
+
if (!host) return null;
|
|
51
|
+
|
|
52
|
+
for (const entry of roots ?? []) {
|
|
53
|
+
const root = normalizeCookieRoot(entry);
|
|
54
|
+
if (!root) continue;
|
|
55
|
+
const bare = root.slice(1);
|
|
56
|
+
if (host === bare || host.endsWith(`.${bare}`)) return root;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Paylaşımlı cookie için önerilen üst sınır. JWT / büyük token buraya
|
|
64
|
+
* sığmaz — kısa session id koyun; aksi halde handoff veya host-only cookie.
|
|
65
|
+
*/
|
|
66
|
+
export const SHARED_COOKIE_WARN_BYTES = 512;
|