jskelet 0.5.3 → 0.5.4
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 +8 -0
- package/docs/07-yapilandirma.md +25 -1
- package/docs/12-panel-ve-oturum.md +88 -0
- package/docs/en/07-configuration.md +26 -1
- package/docs/en/12-dashboards-and-sessions.md +91 -0
- package/package.json +2 -2
- 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 +59 -1
- 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
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,14 @@ one is listed under a **Breaking** heading.
|
|
|
10
10
|
|
|
11
11
|
### Added
|
|
12
12
|
|
|
13
|
+
- Shared cross-subdomain cookies: `brand.sharedCookieRoots` plus
|
|
14
|
+
`writeSharedCookie` / `clearSharedCookie` on server (`jskelet/cookies`) and
|
|
15
|
+
client (`jskelet/client`). `Secure` follows https / `x-forwarded-proto` (not
|
|
16
|
+
`NODE_ENV`); the client read-back fails into handoff when the browser rejects
|
|
17
|
+
`Domain`. Optional `auth.crossSubdomainHandoff` mounts
|
|
18
|
+
`POST /_jskelet/auth/handoff` (one-time ticket → `?handoff=`) and documents a
|
|
19
|
+
`window.name` bridge. Large tokens are refused — put a short session id in the
|
|
20
|
+
cookie, not a JWT.
|
|
13
21
|
- HTML cache key vary (`cache().vary`): `host: true` adds the public Host
|
|
14
22
|
(`x-forwarded-host` or `Host`, lowercase, no port) as `h=…|` before the path;
|
|
15
23
|
optional `headers` and `fn(req)` add further segments. Required on host-based
|
package/docs/07-yapilandirma.md
CHANGED
|
@@ -186,14 +186,38 @@ birleştirilir.
|
|
|
186
186
|
| `prewarmUserAgent` | `string` | `"jskelet-prewarm"` | Isıtma isteklerinin UA'sı; dev paneli bunu filtreler |
|
|
187
187
|
| `devTokenCookie` | `string` | `"dev_token"` | Dev gate'in çerez ve query parametresi adı |
|
|
188
188
|
| `lang` | `string` | — | `<html lang>` varsayılanı. Verilmezse layout `"en"` kullanır. |
|
|
189
|
+
| `sharedCookieRoots` | `string[]` | `[]` | Paylaşımlı cookie Domain kökleri (örn. `.investvio.com`, `.localhost`). [12](./12-panel-ve-oturum.md) |
|
|
189
190
|
|
|
190
191
|
`lang` için öncelik sırası: `hooks.layoutContext()` → `lang` **>** `brand.lang`
|
|
191
192
|
**>** `"en"`.
|
|
192
193
|
|
|
193
194
|
```js
|
|
194
|
-
brand: {
|
|
195
|
+
brand: {
|
|
196
|
+
lang: "tr",
|
|
197
|
+
poweredBy: "Örnek",
|
|
198
|
+
sharedCookieRoots: [".investvio.com", ".localhost"],
|
|
199
|
+
}
|
|
195
200
|
```
|
|
196
201
|
|
|
202
|
+
## `auth`
|
|
203
|
+
|
|
204
|
+
**Tip:** `object` — **Varsayılan:** `{ crossSubdomainHandoff: false }`
|
|
205
|
+
|
|
206
|
+
Kimlik framework'te yok; bu bölüm yalnızca alt alan adları arasında kısa
|
|
207
|
+
session id taşımak için handoff köprüsünü açar.
|
|
208
|
+
|
|
209
|
+
| Alan | Tip | Varsayılan | Anlamı |
|
|
210
|
+
| --- | --- | --- | --- |
|
|
211
|
+
| `crossSubdomainHandoff` | `boolean \| object` | `false` | `true` veya `{ ttlSeconds?, path?, maxValueBytes? }` → `POST /_jskelet/auth/handoff` + `?handoff=` redeem |
|
|
212
|
+
|
|
213
|
+
```js
|
|
214
|
+
auth: {
|
|
215
|
+
crossSubdomainHandoff: { ttlSeconds: 60 },
|
|
216
|
+
},
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Ayrıntı ve `window.name` yedeği: [12-panel-ve-oturum.md](./12-panel-ve-oturum.md).
|
|
220
|
+
|
|
197
221
|
## `layout`
|
|
198
222
|
|
|
199
223
|
**Tip:** `string` — **Varsayılan:** yok (otomatik çözüm)
|
|
@@ -129,6 +129,94 @@ kapatıyor — cookie çapraz site POST'larında hiç gönderilmiyor.
|
|
|
129
129
|
Cookie **şifrelenmiyor**, imzalanıyor. Değer okunabilir; gizli kalması gereken
|
|
130
130
|
veriyi değil, onun kimliğini koyun.
|
|
131
131
|
|
|
132
|
+
## Alt alan adları: paylaşımlı cookie
|
|
133
|
+
|
|
134
|
+
Host tabanlı i18n (`tr.example.com` / `en.example.com`) oturumu alt alanlar
|
|
135
|
+
arasında paylaşmak ister. Çözüm **kısa session id** + isteğe bağlı
|
|
136
|
+
`Domain=.example.com` — JWT veya büyük access token paylaşımlı cookie'ye
|
|
137
|
+
konmaz (`large token ≠ shared cookie`).
|
|
138
|
+
|
|
139
|
+
```js
|
|
140
|
+
// jskelet.config.mjs
|
|
141
|
+
export default {
|
|
142
|
+
brand: {
|
|
143
|
+
sharedCookieRoots: [".investvio.com", ".localhost"],
|
|
144
|
+
},
|
|
145
|
+
auth: {
|
|
146
|
+
crossSubdomainHandoff: true, // POST /_jskelet/auth/handoff
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Sunucu
|
|
152
|
+
|
|
153
|
+
```js
|
|
154
|
+
import { writeSharedCookie } from "jskelet/cookies";
|
|
155
|
+
|
|
156
|
+
export function startSession(res, req, sessionId) {
|
|
157
|
+
const result = writeSharedCookie(res, "sid", sessionId, {
|
|
158
|
+
req,
|
|
159
|
+
maxAge: 60 * 60 * 8,
|
|
160
|
+
});
|
|
161
|
+
// result.ok === false → result.handoff; istemci handoff denemeli
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`Secure` **protokole** bakılır (`https` / `x-forwarded-proto`), `NODE_ENV`'e
|
|
167
|
+
değil. Domain, isteğin Host'u `sharedCookieRoots` ile eşleşince yazılır.
|
|
168
|
+
Değer ~512 baytı aşarsa yazım reddedilir ve `handoff: true` döner.
|
|
169
|
+
|
|
170
|
+
### İstemci
|
|
171
|
+
|
|
172
|
+
```js
|
|
173
|
+
import {
|
|
174
|
+
writeSharedCookie,
|
|
175
|
+
createHandoffUrl,
|
|
176
|
+
handoffViaWindowName,
|
|
177
|
+
consumeWindowNameHandoff,
|
|
178
|
+
} from "jskelet/client";
|
|
179
|
+
|
|
180
|
+
const result = writeSharedCookie("sid", sessionId, {
|
|
181
|
+
roots: [".investvio.com", ".localhost"],
|
|
182
|
+
maxAge: 60 * 60 * 8,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
if (!result.ok && result.handoff) {
|
|
186
|
+
const url = await createHandoffUrl({
|
|
187
|
+
name: "sid",
|
|
188
|
+
value: sessionId,
|
|
189
|
+
next: "https://tr.investvio.com/panel",
|
|
190
|
+
});
|
|
191
|
+
if (url) location.assign(url);
|
|
192
|
+
else handoffViaWindowName("https://tr.investvio.com/panel", {
|
|
193
|
+
name: "sid",
|
|
194
|
+
value: sessionId,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Hedef host'ta (layout / island bootstrap):
|
|
199
|
+
consumeWindowNameHandoff();
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
`roots` verilmezse `<html data-jskelet-cookie-roots=".investvio.com,.localhost">`
|
|
203
|
+
okunur. Yazımdan sonra **read-back** yapılır; tarayıcı Domain'i reddettiyse
|
|
204
|
+
`handoff: true`.
|
|
205
|
+
|
|
206
|
+
### Handoff bileti
|
|
207
|
+
|
|
208
|
+
`auth.crossSubdomainHandoff` açıkken:
|
|
209
|
+
|
|
210
|
+
1. `POST /_jskelet/auth/handoff` `{ name, value, next }` → `{ url }` (`?handoff=` ekli)
|
|
211
|
+
2. Hedef host'ta GET middleware bileti tek kullanımlık tüketir, cookie yazar
|
|
212
|
+
(önce shared Domain, olmazsa host-only), `handoff` query'siz 303
|
|
213
|
+
|
|
214
|
+
`next` yalnızca aynı `sharedCookieRoots` altındaki host'lara izinli. Bilet
|
|
215
|
+
~60 sn, süreç belleğinde. JWT URL'ye konmaz.
|
|
216
|
+
|
|
217
|
+
`window.name` köprüsü cookie'siz yedek: kaynakta `handoffViaWindowName`,
|
|
218
|
+
hedeefte `consumeWindowNameHandoff`.
|
|
219
|
+
|
|
132
220
|
## CSRF
|
|
133
221
|
|
|
134
222
|
Gövdeyi framework ayrıştırıyor (`express.urlencoded` + `express.json`), yani
|
|
@@ -191,14 +191,39 @@ shallow-merged with the defaults.
|
|
|
191
191
|
| `prewarmUserAgent` | `string` | `"jskelet-prewarm"` | UA of prewarm requests; the dev panel filters on it |
|
|
192
192
|
| `devTokenCookie` | `string` | `"dev_token"` | Name of the dev gate's cookie and query parameter |
|
|
193
193
|
| `lang` | `string` | — | Default for `<html lang>`. If not given, the layout uses `"en"`. |
|
|
194
|
+
| `sharedCookieRoots` | `string[]` | `[]` | Shared cookie Domain roots (e.g. `.investvio.com`, `.localhost`). [12](./12-dashboards-and-sessions.md) |
|
|
194
195
|
|
|
195
196
|
Precedence for `lang`: `hooks.layoutContext()` → `lang` **>** `brand.lang`
|
|
196
197
|
**>** `"en"`.
|
|
197
198
|
|
|
198
199
|
```js
|
|
199
|
-
brand: {
|
|
200
|
+
brand: {
|
|
201
|
+
lang: "tr",
|
|
202
|
+
poweredBy: "Example",
|
|
203
|
+
sharedCookieRoots: [".investvio.com", ".localhost"],
|
|
204
|
+
}
|
|
200
205
|
```
|
|
201
206
|
|
|
207
|
+
## `auth`
|
|
208
|
+
|
|
209
|
+
**Type:** `object` — **Default:** `{ crossSubdomainHandoff: false }`
|
|
210
|
+
|
|
211
|
+
The framework does not provide identity; this section only opens the
|
|
212
|
+
cross-subdomain handoff bridge for a short session id.
|
|
213
|
+
|
|
214
|
+
| Field | Type | Default | Meaning |
|
|
215
|
+
| --- | --- | --- | --- |
|
|
216
|
+
| `crossSubdomainHandoff` | `boolean \| object` | `false` | `true` or `{ ttlSeconds?, path?, maxValueBytes? }` → `POST /_jskelet/auth/handoff` + `?handoff=` redeem |
|
|
217
|
+
|
|
218
|
+
```js
|
|
219
|
+
auth: {
|
|
220
|
+
crossSubdomainHandoff: { ttlSeconds: 60 },
|
|
221
|
+
},
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Details and the `window.name` fallback:
|
|
225
|
+
[12-dashboards-and-sessions.md](./12-dashboards-and-sessions.md).
|
|
226
|
+
|
|
202
227
|
## `layout`
|
|
203
228
|
|
|
204
229
|
**Type:** `string` — **Default:** none (automatic resolution)
|
|
@@ -131,6 +131,97 @@ cookie is simply not sent on cross-site POSTs.
|
|
|
131
131
|
Cookies are **signed, not encrypted**. The value is readable, so store the
|
|
132
132
|
identifier of a secret rather than the secret itself.
|
|
133
133
|
|
|
134
|
+
## Cross-subdomain: shared cookies
|
|
135
|
+
|
|
136
|
+
Host-based i18n (`tr.example.com` / `en.example.com`) often needs a session on
|
|
137
|
+
every locale host. The supported pattern is a **short session id** plus an
|
|
138
|
+
optional `Domain=.example.com` — do not put a JWT or a large access token in a
|
|
139
|
+
shared cookie (`large token ≠ shared cookie`).
|
|
140
|
+
|
|
141
|
+
```js
|
|
142
|
+
// jskelet.config.mjs
|
|
143
|
+
export default {
|
|
144
|
+
brand: {
|
|
145
|
+
sharedCookieRoots: [".investvio.com", ".localhost"],
|
|
146
|
+
},
|
|
147
|
+
auth: {
|
|
148
|
+
crossSubdomainHandoff: true, // POST /_jskelet/auth/handoff
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Server
|
|
154
|
+
|
|
155
|
+
```js
|
|
156
|
+
import { writeSharedCookie } from "jskelet/cookies";
|
|
157
|
+
|
|
158
|
+
export function startSession(res, req, sessionId) {
|
|
159
|
+
const result = writeSharedCookie(res, "sid", sessionId, {
|
|
160
|
+
req,
|
|
161
|
+
maxAge: 60 * 60 * 8,
|
|
162
|
+
});
|
|
163
|
+
// result.ok === false → result.handoff; the client should use handoff
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`Secure` follows the **protocol** (`https` / `x-forwarded-proto`), not
|
|
169
|
+
`NODE_ENV`. The Domain is set when the request Host matches
|
|
170
|
+
`sharedCookieRoots`. Values larger than ~512 bytes are refused with
|
|
171
|
+
`handoff: true`.
|
|
172
|
+
|
|
173
|
+
### Client
|
|
174
|
+
|
|
175
|
+
```js
|
|
176
|
+
import {
|
|
177
|
+
writeSharedCookie,
|
|
178
|
+
createHandoffUrl,
|
|
179
|
+
handoffViaWindowName,
|
|
180
|
+
consumeWindowNameHandoff,
|
|
181
|
+
} from "jskelet/client";
|
|
182
|
+
|
|
183
|
+
const result = writeSharedCookie("sid", sessionId, {
|
|
184
|
+
roots: [".investvio.com", ".localhost"],
|
|
185
|
+
maxAge: 60 * 60 * 8,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
if (!result.ok && result.handoff) {
|
|
189
|
+
const url = await createHandoffUrl({
|
|
190
|
+
name: "sid",
|
|
191
|
+
value: sessionId,
|
|
192
|
+
next: "https://tr.investvio.com/panel",
|
|
193
|
+
});
|
|
194
|
+
if (url) location.assign(url);
|
|
195
|
+
else handoffViaWindowName("https://tr.investvio.com/panel", {
|
|
196
|
+
name: "sid",
|
|
197
|
+
value: sessionId,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// On the target host (layout / island bootstrap):
|
|
202
|
+
consumeWindowNameHandoff();
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
If `roots` is omitted, the client reads
|
|
206
|
+
`<html data-jskelet-cookie-roots=".investvio.com,.localhost">`. After writing,
|
|
207
|
+
a **read-back** runs; if the browser rejected the Domain, `handoff: true`.
|
|
208
|
+
|
|
209
|
+
### Handoff ticket
|
|
210
|
+
|
|
211
|
+
With `auth.crossSubdomainHandoff` on:
|
|
212
|
+
|
|
213
|
+
1. `POST /_jskelet/auth/handoff` `{ name, value, next }` → `{ url }` (with `?handoff=`)
|
|
214
|
+
2. On the target host a GET middleware redeems the one-time ticket, sets the
|
|
215
|
+
cookie (shared Domain first, else host-only), and 303-redirects without
|
|
216
|
+
`handoff`
|
|
217
|
+
|
|
218
|
+
`next` must be under the same `sharedCookieRoots`. Tickets live ~60s in process
|
|
219
|
+
memory. Do not put a JWT in the URL.
|
|
220
|
+
|
|
221
|
+
The `window.name` bridge is the cookie-less fallback:
|
|
222
|
+
`handoffViaWindowName` on the source page, `consumeWindowNameHandoff` on the
|
|
223
|
+
target.
|
|
224
|
+
|
|
134
225
|
## CSRF
|
|
135
226
|
|
|
136
227
|
The framework parses the request body (`express.urlencoded` + `express.json`),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jskelet",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
4
4
|
"description": "A framework that feels like no framework: Express 5 + build-time .jsk (or EJS) SSR, vanilla JS islands, Tailwind v4 and an in-process HTML TTL cache.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"./client": "./src/client/index.js",
|
|
34
34
|
"./html": "./src/views/helpers/html.js",
|
|
35
35
|
"./tags": "./src/views/helpers/tags.js",
|
|
36
|
-
"./cookies": "./src/http/cookies.js",
|
|
36
|
+
"./cookies": "./src/http/cookies-entry.js",
|
|
37
37
|
"./log": "./src/log.mjs",
|
|
38
38
|
"./register": "./src/runtime/register.mjs",
|
|
39
39
|
"./layout": "./src/templates/layout.ejs"
|
package/src/client/index.js
CHANGED
|
@@ -33,3 +33,13 @@ export {
|
|
|
33
33
|
getOverlayRoot,
|
|
34
34
|
} from "./dom.js";
|
|
35
35
|
export { startSafeImages } from "./safe-image.js";
|
|
36
|
+
export {
|
|
37
|
+
clearSharedCookie,
|
|
38
|
+
consumeWindowNameHandoff,
|
|
39
|
+
createHandoffUrl,
|
|
40
|
+
handoffViaWindowName,
|
|
41
|
+
readCookie,
|
|
42
|
+
resolveSharedCookieDomain,
|
|
43
|
+
SHARED_COOKIE_WARN_BYTES,
|
|
44
|
+
writeSharedCookie,
|
|
45
|
+
} from "./shared-cookie.js";
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tarayıcıda paylaşımlı cookie yazma + read-back.
|
|
3
|
+
*
|
|
4
|
+
* `document.cookie` ile Domain=.example.com yazmak bazı tarayıcılarda
|
|
5
|
+
* (Public Suffix, ITP) sessizce başarısız olur. Yazımdan sonra okunamazsa
|
|
6
|
+
* `{ handoff: true }` döner — çağıran `createHandoffUrl` veya
|
|
7
|
+
* `handoffViaWindowName` kullanır.
|
|
8
|
+
*
|
|
9
|
+
* `roots` verilmezse `document.documentElement.dataset.jskeletCookieRoots`
|
|
10
|
+
* (virgülle ayrılmış) okunur; layout'ta
|
|
11
|
+
* `data-jskelet-cookie-roots=".investvio.com,.localhost"` basılabilir.
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
SHARED_COOKIE_WARN_BYTES,
|
|
15
|
+
resolveSharedCookieDomain,
|
|
16
|
+
} from "../shared/cookie-domain.js";
|
|
17
|
+
|
|
18
|
+
export {
|
|
19
|
+
SHARED_COOKIE_WARN_BYTES,
|
|
20
|
+
resolveSharedCookieDomain,
|
|
21
|
+
stripHostPort,
|
|
22
|
+
} from "../shared/cookie-domain.js";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @returns {string[]}
|
|
26
|
+
*/
|
|
27
|
+
function rootsFromDom() {
|
|
28
|
+
if (typeof document === "undefined") return [];
|
|
29
|
+
const raw = document.documentElement?.dataset?.jskeletCookieRoots ?? "";
|
|
30
|
+
return raw
|
|
31
|
+
.split(",")
|
|
32
|
+
.map((entry) => entry.trim())
|
|
33
|
+
.filter(Boolean);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {string} name
|
|
38
|
+
* @returns {string | null}
|
|
39
|
+
*/
|
|
40
|
+
export function readCookie(name) {
|
|
41
|
+
if (typeof document === "undefined") return null;
|
|
42
|
+
const prefix = `${encodeURIComponent(name)}=`;
|
|
43
|
+
for (const part of document.cookie.split(";")) {
|
|
44
|
+
const trimmed = part.trim();
|
|
45
|
+
if (!trimmed.startsWith(prefix)) continue;
|
|
46
|
+
try {
|
|
47
|
+
return decodeURIComponent(trimmed.slice(prefix.length));
|
|
48
|
+
} catch {
|
|
49
|
+
return trimmed.slice(prefix.length);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @typedef {object} ClientSharedCookieResult
|
|
57
|
+
* @property {boolean} ok
|
|
58
|
+
* @property {string | null} domain
|
|
59
|
+
* @property {boolean} handoff
|
|
60
|
+
* @property {string} [reason]
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {string} name
|
|
65
|
+
* @param {string} value
|
|
66
|
+
* @param {{ roots?: string[], maxAge?: number, path?: string, sameSite?: string,
|
|
67
|
+
* secure?: boolean }} [options]
|
|
68
|
+
* @returns {ClientSharedCookieResult}
|
|
69
|
+
*/
|
|
70
|
+
export function writeSharedCookie(name, value, options = {}) {
|
|
71
|
+
if (typeof document === "undefined") {
|
|
72
|
+
return { ok: false, domain: null, handoff: true, reason: "no-document" };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const roots = options.roots ?? rootsFromDom();
|
|
76
|
+
const domain = resolveSharedCookieDomain(location.hostname, roots);
|
|
77
|
+
if (!domain) {
|
|
78
|
+
return { ok: false, domain: null, handoff: true, reason: "no-matching-root" };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const text = String(value ?? "");
|
|
82
|
+
if (new TextEncoder().encode(text).length > SHARED_COOKIE_WARN_BYTES) {
|
|
83
|
+
return { ok: false, domain, handoff: true, reason: "value-too-large" };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const secure = options.secure ?? location.protocol === "https:";
|
|
87
|
+
const parts = [
|
|
88
|
+
`${encodeURIComponent(name)}=${encodeURIComponent(text)}`,
|
|
89
|
+
`Path=${options.path ?? "/"}`,
|
|
90
|
+
`Domain=${domain}`,
|
|
91
|
+
`SameSite=${options.sameSite ?? "Lax"}`,
|
|
92
|
+
];
|
|
93
|
+
if (options.maxAge !== undefined) {
|
|
94
|
+
parts.push(`Max-Age=${Math.floor(Number(options.maxAge))}`);
|
|
95
|
+
}
|
|
96
|
+
if (secure) parts.push("Secure");
|
|
97
|
+
|
|
98
|
+
document.cookie = parts.join("; ");
|
|
99
|
+
|
|
100
|
+
// Read-back: tarayıcı Domain'i reddettiyse cookie görünmez.
|
|
101
|
+
if (readCookie(name) !== text) {
|
|
102
|
+
return { ok: false, domain, handoff: true, reason: "readback-failed" };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { ok: true, domain, handoff: false };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @param {string} name
|
|
110
|
+
* @param {{ roots?: string[], path?: string, secure?: boolean, sameSite?: string }} [options]
|
|
111
|
+
* @returns {ClientSharedCookieResult}
|
|
112
|
+
*/
|
|
113
|
+
export function clearSharedCookie(name, options = {}) {
|
|
114
|
+
if (typeof document === "undefined") {
|
|
115
|
+
return { ok: false, domain: null, handoff: false, reason: "no-document" };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const roots = options.roots ?? rootsFromDom();
|
|
119
|
+
const domain = resolveSharedCookieDomain(location.hostname, roots);
|
|
120
|
+
if (!domain) {
|
|
121
|
+
return { ok: false, domain: null, handoff: false, reason: "no-matching-root" };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const secure = options.secure ?? location.protocol === "https:";
|
|
125
|
+
const parts = [
|
|
126
|
+
`${encodeURIComponent(name)}=`,
|
|
127
|
+
`Path=${options.path ?? "/"}`,
|
|
128
|
+
`Domain=${domain}`,
|
|
129
|
+
"Max-Age=0",
|
|
130
|
+
`SameSite=${options.sameSite ?? "Lax"}`,
|
|
131
|
+
];
|
|
132
|
+
if (secure) parts.push("Secure");
|
|
133
|
+
document.cookie = parts.join("; ");
|
|
134
|
+
|
|
135
|
+
return { ok: true, domain, handoff: false };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Handoff bileti ister; dönen URL'ye gidilir (`?handoff=` taşır).
|
|
140
|
+
*
|
|
141
|
+
* @param {{ name: string, value: string, next: string }} body
|
|
142
|
+
* @param {{ path?: string }} [options]
|
|
143
|
+
* @returns {Promise<string | null>}
|
|
144
|
+
*/
|
|
145
|
+
export async function createHandoffUrl(body, options = {}) {
|
|
146
|
+
const path = options.path ?? "/_jskelet/auth/handoff";
|
|
147
|
+
const response = await fetch(path, {
|
|
148
|
+
method: "POST",
|
|
149
|
+
headers: {
|
|
150
|
+
"content-type": "application/json",
|
|
151
|
+
accept: "application/json",
|
|
152
|
+
},
|
|
153
|
+
credentials: "same-origin",
|
|
154
|
+
body: JSON.stringify(body),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
if (!response.ok) return null;
|
|
158
|
+
/** @type {{ ok?: boolean, url?: string }} */
|
|
159
|
+
const data = await response.json().catch(() => ({}));
|
|
160
|
+
return typeof data.url === "string" ? data.url : null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* first-party `window.name` köprüsü — cookie Domain reddedilince yedek.
|
|
165
|
+
* Kaynak sayfada hedefe gitmeden önce çağırın; hedefte
|
|
166
|
+
* `consumeWindowNameHandoff` okur.
|
|
167
|
+
*
|
|
168
|
+
* @param {string} targetUrl
|
|
169
|
+
* @param {{ name: string, value: string, maxAge?: number }} cookie
|
|
170
|
+
* @returns {void}
|
|
171
|
+
*/
|
|
172
|
+
export function handoffViaWindowName(targetUrl, cookie) {
|
|
173
|
+
if (typeof window === "undefined") return;
|
|
174
|
+
window.name = JSON.stringify({
|
|
175
|
+
jskeletHandoff: 1,
|
|
176
|
+
cookie: {
|
|
177
|
+
name: cookie.name,
|
|
178
|
+
value: cookie.value,
|
|
179
|
+
maxAge: cookie.maxAge,
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
location.assign(targetUrl);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Hedef host'ta `window.name` içindeki handoff'u host-only cookie olarak yazar.
|
|
187
|
+
*
|
|
188
|
+
* @param {{ write?: typeof writeSharedCookie }} [options]
|
|
189
|
+
* Varsayılan: Domain denemeden host-only `document.cookie`.
|
|
190
|
+
* @returns {boolean} Bir şey yazıldı mı.
|
|
191
|
+
*/
|
|
192
|
+
export function consumeWindowNameHandoff(options = {}) {
|
|
193
|
+
if (typeof window === "undefined") return false;
|
|
194
|
+
|
|
195
|
+
let payload;
|
|
196
|
+
try {
|
|
197
|
+
payload = JSON.parse(window.name || "");
|
|
198
|
+
} catch {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!payload || payload.jskeletHandoff !== 1 || !payload.cookie?.name) {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
window.name = "";
|
|
207
|
+
|
|
208
|
+
const { name, value, maxAge } = payload.cookie;
|
|
209
|
+
const text = String(value ?? "");
|
|
210
|
+
const secure = location.protocol === "https:";
|
|
211
|
+
const parts = [
|
|
212
|
+
`${encodeURIComponent(name)}=${encodeURIComponent(text)}`,
|
|
213
|
+
"Path=/",
|
|
214
|
+
"SameSite=Lax",
|
|
215
|
+
];
|
|
216
|
+
if (maxAge !== undefined) parts.push(`Max-Age=${Math.floor(Number(maxAge))}`);
|
|
217
|
+
if (secure) parts.push("Secure");
|
|
218
|
+
document.cookie = parts.join("; ");
|
|
219
|
+
|
|
220
|
+
if (typeof options.write === "function") {
|
|
221
|
+
options.write(name, text, { maxAge });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return readCookie(name) === text;
|
|
225
|
+
}
|
package/src/config/defaults.js
CHANGED
|
@@ -453,4 +453,23 @@ export const DEFAULT_BRAND = {
|
|
|
453
453
|
devBasePath: "/__jskelet/dev",
|
|
454
454
|
prewarmUserAgent: "jskelet-prewarm",
|
|
455
455
|
devTokenCookie: "dev_token",
|
|
456
|
+
/**
|
|
457
|
+
* Paylaşımlı cookie Domain kökleri (örn. `.investvio.com`, `.localhost`).
|
|
458
|
+
* `writeSharedCookie` host bunlardan birine uyuyorsa Domain olarak yazar.
|
|
459
|
+
* @type {string[]}
|
|
460
|
+
*/
|
|
461
|
+
sharedCookieRoots: [],
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Kimlik / alt alan handoff. Framework oturum vermez; yalnızca çapraz-subdomain
|
|
466
|
+
* cookie köprüsü opsiyoneldir.
|
|
467
|
+
*/
|
|
468
|
+
export const DEFAULT_AUTH = {
|
|
469
|
+
/**
|
|
470
|
+
* `true` veya `{ ttlSeconds?, path?, maxValueBytes? }`.
|
|
471
|
+
* Açıkken `POST /_jskelet/auth/handoff` ve `?handoff=` redeem middleware'i.
|
|
472
|
+
* @type {boolean | { enabled?: boolean, ttlSeconds?: number, path?: string, maxValueBytes?: number }}
|
|
473
|
+
*/
|
|
474
|
+
crossSubdomainHandoff: false,
|
|
456
475
|
};
|
package/src/config/index.js
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* maxEntries?: number,
|
|
22
22
|
* data?: {...}, redis?: {...}, prewarm?: {...} }
|
|
23
23
|
* admin() → { enabled?, basePath?, allowIps?, blockBots?, … }
|
|
24
|
+
* auth → { crossSubdomainHandoff?: boolean | object }
|
|
24
25
|
* logs → { console?, kinds?, file?, s3? }
|
|
25
26
|
*
|
|
26
27
|
* Fonksiyon olmayan bölümler (`brand`, `security`, `static`, `navigation`…)
|
|
@@ -33,6 +34,7 @@ import { pathToFileURL } from "node:url";
|
|
|
33
34
|
import { compilePattern, matchPattern } from "./pattern.js";
|
|
34
35
|
import {
|
|
35
36
|
DEFAULT_ADMIN,
|
|
37
|
+
DEFAULT_AUTH,
|
|
36
38
|
DEFAULT_BRAND,
|
|
37
39
|
DEFAULT_CLOUDFLARE,
|
|
38
40
|
DEFAULT_DATA_CACHE,
|
|
@@ -126,6 +128,7 @@ const CONFIG_FILE = "jskelet.config.mjs";
|
|
|
126
128
|
* @property {Record<string, unknown>} prewarm
|
|
127
129
|
* @property {{ source: string, test: (pathname: string) => boolean }[]} prewarmPriority
|
|
128
130
|
* @property {Record<string, unknown>} brand
|
|
131
|
+
* @property {{ crossSubdomainHandoff: boolean | Record<string, unknown> }} auth
|
|
129
132
|
* @property {Record<string, Function>} hooks
|
|
130
133
|
* @property {string} layout Layout `.ejs` dosyasının mutlak yolu.
|
|
131
134
|
* @property {string[] | null} routes Açık route modülü listesi.
|
|
@@ -896,6 +899,59 @@ function normalizeNavigation(raw, brand) {
|
|
|
896
899
|
* headerName: string }} csrf
|
|
897
900
|
*/
|
|
898
901
|
|
|
902
|
+
/**
|
|
903
|
+
* @param {unknown} raw
|
|
904
|
+
* @returns {Record<string, unknown>}
|
|
905
|
+
*/
|
|
906
|
+
function normalizeBrand(raw) {
|
|
907
|
+
const source = /** @type {Record<string, unknown>} */ (raw ?? {});
|
|
908
|
+
const roots = asArray(
|
|
909
|
+
source.sharedCookieRoots ?? DEFAULT_BRAND.sharedCookieRoots,
|
|
910
|
+
"brand.sharedCookieRoots",
|
|
911
|
+
)
|
|
912
|
+
.filter((entry) => typeof entry === "string")
|
|
913
|
+
.map((entry) => {
|
|
914
|
+
const trimmed = String(entry).trim().toLowerCase();
|
|
915
|
+
if (!trimmed) return null;
|
|
916
|
+
return trimmed.startsWith(".") ? trimmed : `.${trimmed}`;
|
|
917
|
+
})
|
|
918
|
+
.filter((entry) => entry !== null);
|
|
919
|
+
|
|
920
|
+
return {
|
|
921
|
+
...DEFAULT_BRAND,
|
|
922
|
+
...source,
|
|
923
|
+
sharedCookieRoots: roots,
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* @param {unknown} raw
|
|
929
|
+
* @returns {{ crossSubdomainHandoff: boolean | Record<string, unknown> }}
|
|
930
|
+
*/
|
|
931
|
+
function normalizeAuth(raw) {
|
|
932
|
+
if (raw == null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
933
|
+
return { ...DEFAULT_AUTH };
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
const source = /** @type {Record<string, unknown>} */ (raw);
|
|
937
|
+
const handoff = source.crossSubdomainHandoff;
|
|
938
|
+
|
|
939
|
+
if (handoff === true || handoff === false || handoff == null) {
|
|
940
|
+
return {
|
|
941
|
+
crossSubdomainHandoff: handoff === true,
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
if (typeof handoff === "object" && !Array.isArray(handoff)) {
|
|
946
|
+
return { crossSubdomainHandoff: { ...handoff } };
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
console.warn(
|
|
950
|
+
"[config] auth.crossSubdomainHandoff must be boolean or object, ignoring it",
|
|
951
|
+
);
|
|
952
|
+
return { ...DEFAULT_AUTH };
|
|
953
|
+
}
|
|
954
|
+
|
|
899
955
|
/**
|
|
900
956
|
* Güvenlik bölümü. `csrf.exclude` desenleri burada derlenir: her istekte
|
|
901
957
|
* yeniden derlemek gereksiz, ve bozuk bir desen sunucuyu düşürmemeli.
|
|
@@ -1132,7 +1188,8 @@ export async function loadConfig(options = {}) {
|
|
|
1132
1188
|
prewarmPriority,
|
|
1133
1189
|
} = normalizeCache(cache);
|
|
1134
1190
|
const dirs = resolveDirs(root, source.paths);
|
|
1135
|
-
const brand =
|
|
1191
|
+
const brand = normalizeBrand(source.brand);
|
|
1192
|
+
const auth = normalizeAuth(source.auth);
|
|
1136
1193
|
|
|
1137
1194
|
config = {
|
|
1138
1195
|
root,
|
|
@@ -1157,6 +1214,7 @@ export async function loadConfig(options = {}) {
|
|
|
1157
1214
|
prewarm,
|
|
1158
1215
|
prewarmPriority,
|
|
1159
1216
|
brand,
|
|
1217
|
+
auth,
|
|
1160
1218
|
hooks: source.hooks ?? {},
|
|
1161
1219
|
layout: resolveLayout(dirs, source.layout),
|
|
1162
1220
|
routes: Array.isArray(source.routes) ? source.routes : null,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `jskelet/cookies` paket girişi — imzalı cookie + paylaşımlı Domain yüzeyi.
|
|
3
|
+
*/
|
|
4
|
+
export {
|
|
5
|
+
clearCookie,
|
|
6
|
+
getSignedCookie,
|
|
7
|
+
parseCookies,
|
|
8
|
+
randomToken,
|
|
9
|
+
safeEqual,
|
|
10
|
+
serializeCookie,
|
|
11
|
+
setCookie,
|
|
12
|
+
setSignedCookie,
|
|
13
|
+
} from "./cookies.js";
|
|
14
|
+
export {
|
|
15
|
+
clearSharedCookie,
|
|
16
|
+
requestIsHttps,
|
|
17
|
+
resolveSharedCookieDomain,
|
|
18
|
+
SHARED_COOKIE_WARN_BYTES,
|
|
19
|
+
writeSharedCookie,
|
|
20
|
+
} from "./shared-cookie.js";
|
package/src/http/cookies.js
CHANGED
|
@@ -144,6 +144,7 @@ export function parseCookies(req) {
|
|
|
144
144
|
* @property {Date} [expires]
|
|
145
145
|
* @property {boolean} [httpOnly] Varsayılan `true`.
|
|
146
146
|
* @property {boolean} [secure] Varsayılan: development dışında `true`.
|
|
147
|
+
* `writeSharedCookie` protokole bakarak ezer (`https` → Secure).
|
|
147
148
|
* @property {"Strict" | "Lax" | "None"} [sameSite] Varsayılan `Lax`.
|
|
148
149
|
*/
|
|
149
150
|
|
|
@@ -255,3 +256,4 @@ export function getSignedCookie(req, name) {
|
|
|
255
256
|
export function randomToken(bytes = 32) {
|
|
256
257
|
return crypto.randomBytes(bytes).toString("base64url");
|
|
257
258
|
}
|
|
259
|
+
|
|
@@ -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;
|