@thi.ng/server 0.5.0 → 0.6.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/CHANGELOG.md +13 -1
- package/api.d.ts +19 -2
- package/package.json +8 -8
- package/server.js +12 -2
- package/session/session.d.ts +20 -10
- package/session/session.js +26 -22
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Change Log
|
|
2
2
|
|
|
3
|
-
- **Last updated**: 2025-02-
|
|
3
|
+
- **Last updated**: 2025-02-21T21:54:17Z
|
|
4
4
|
- **Generator**: [thi.ng/monopub](https://thi.ng/monopub)
|
|
5
5
|
|
|
6
6
|
All notable changes to this project will be documented in this file.
|
|
@@ -11,6 +11,18 @@ See [Conventional Commits](https://conventionalcommits.org/) for commit guidelin
|
|
|
11
11
|
**Note:** Unlisted _patch_ versions only involve non-code or otherwise excluded changes
|
|
12
12
|
and/or version bumps of transitive dependencies.
|
|
13
13
|
|
|
14
|
+
## [0.6.0](https://github.com/thi-ng/umbrella/tree/@thi.ng/server@0.6.0) (2025-02-21)
|
|
15
|
+
|
|
16
|
+
#### 🚀 Features
|
|
17
|
+
|
|
18
|
+
- update `sessionInterceptor()` cookie signing/handling ([bdd4d66](https://github.com/thi-ng/umbrella/commit/bdd4d66))
|
|
19
|
+
- update `SessionInterceptor.newSession()`
|
|
20
|
+
- pre-compute session metadata (HMAC & cookie values), store in WeakMap
|
|
21
|
+
- update `.withSession()`
|
|
22
|
+
- update `.validateSession()` to use cached signature
|
|
23
|
+
- update tests
|
|
24
|
+
- add/update `ServerOpts` ([23b5321](https://github.com/thi-ng/umbrella/commit/23b5321))
|
|
25
|
+
|
|
14
26
|
## [0.5.0](https://github.com/thi-ng/umbrella/tree/@thi.ng/server@0.5.0) (2025-02-19)
|
|
15
27
|
|
|
16
28
|
#### 🚀 Features
|
package/api.d.ts
CHANGED
|
@@ -2,7 +2,8 @@ import type { Fn, Maybe, MaybePromise } from "@thi.ng/api";
|
|
|
2
2
|
import type { ILogger } from "@thi.ng/logger";
|
|
3
3
|
import type { Route, RouteMatch } from "@thi.ng/router";
|
|
4
4
|
import type { IncomingMessage } from "node:http";
|
|
5
|
-
import type {
|
|
5
|
+
import type { BlockList } from "node:net";
|
|
6
|
+
import type { Server, ServerResponse } from "./server.js";
|
|
6
7
|
export type Method = "get" | "put" | "post" | "delete" | "head" | "options" | "patch";
|
|
7
8
|
export interface ServerOpts<CTX extends RequestCtx = RequestCtx> {
|
|
8
9
|
logger: ILogger;
|
|
@@ -50,6 +51,22 @@ export interface ServerOpts<CTX extends RequestCtx = RequestCtx> {
|
|
|
50
51
|
* request before processing its handler & interceptors.
|
|
51
52
|
*/
|
|
52
53
|
context: Fn<RequestCtx, CTX>;
|
|
54
|
+
/**
|
|
55
|
+
* Timeout in milliseconds for receiving the entire request from the client.
|
|
56
|
+
*/
|
|
57
|
+
requestTimeout: number;
|
|
58
|
+
/**
|
|
59
|
+
* List of response headers that should be sent only once.
|
|
60
|
+
*/
|
|
61
|
+
uniqueHeaders: string[];
|
|
62
|
+
/**
|
|
63
|
+
* Used for disabling inbound access to specific IP addresses, IP ranges, or
|
|
64
|
+
* IP subnets. This does not work if the server is behind a reverse proxy.
|
|
65
|
+
*
|
|
66
|
+
* @remarks
|
|
67
|
+
* Reference: https://nodejs.org/api/net.html#class-netblocklist
|
|
68
|
+
*/
|
|
69
|
+
blockList: BlockList;
|
|
53
70
|
}
|
|
54
71
|
export interface ServerRoute<CTX extends RequestCtx = RequestCtx> extends Route {
|
|
55
72
|
handlers: Partial<Record<Method, RequestHandler<CTX>>>;
|
|
@@ -143,7 +160,7 @@ export interface ISessionStore<T extends ServerSession = ServerSession> {
|
|
|
143
160
|
*/
|
|
144
161
|
get(id: string): MaybePromise<Maybe<T>>;
|
|
145
162
|
/**
|
|
146
|
-
* Adds given `session`
|
|
163
|
+
* Adds to or updates given `session` in underlying storage.
|
|
147
164
|
*
|
|
148
165
|
* @param session
|
|
149
166
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thi.ng/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Minimal HTTP server with declarative routing, static file serving and freely extensible via pre/post interceptors",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"module": "./index.js",
|
|
@@ -40,15 +40,15 @@
|
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
42
|
"@thi.ng/api": "^8.11.21",
|
|
43
|
-
"@thi.ng/arrays": "^2.10.
|
|
44
|
-
"@thi.ng/cache": "^2.3.
|
|
45
|
-
"@thi.ng/checks": "^3.
|
|
43
|
+
"@thi.ng/arrays": "^2.10.17",
|
|
44
|
+
"@thi.ng/cache": "^2.3.25",
|
|
45
|
+
"@thi.ng/checks": "^3.7.0",
|
|
46
46
|
"@thi.ng/errors": "^2.5.27",
|
|
47
|
-
"@thi.ng/file-io": "^2.1.
|
|
47
|
+
"@thi.ng/file-io": "^2.1.29",
|
|
48
48
|
"@thi.ng/logger": "^3.1.2",
|
|
49
49
|
"@thi.ng/mime": "^2.7.3",
|
|
50
|
-
"@thi.ng/paths": "^5.2.
|
|
51
|
-
"@thi.ng/router": "^4.1.
|
|
50
|
+
"@thi.ng/paths": "^5.2.3",
|
|
51
|
+
"@thi.ng/router": "^4.1.20",
|
|
52
52
|
"@thi.ng/strings": "^3.9.6",
|
|
53
53
|
"@thi.ng/timestamp": "^1.1.6",
|
|
54
54
|
"@thi.ng/uuid": "^1.1.18"
|
|
@@ -163,5 +163,5 @@
|
|
|
163
163
|
"status": "alpha",
|
|
164
164
|
"year": 2024
|
|
165
165
|
},
|
|
166
|
-
"gitHead": "
|
|
166
|
+
"gitHead": "2958e6a9881bd9e06de587a2141f6d23c64278db\n"
|
|
167
167
|
}
|
package/server.js
CHANGED
|
@@ -45,13 +45,23 @@ class Server {
|
|
|
45
45
|
host;
|
|
46
46
|
augmentCtx;
|
|
47
47
|
async start() {
|
|
48
|
-
const {
|
|
48
|
+
const {
|
|
49
|
+
ssl,
|
|
50
|
+
host = "localhost",
|
|
51
|
+
port = ssl ? 443 : 8080,
|
|
52
|
+
uniqueHeaders,
|
|
53
|
+
blockList,
|
|
54
|
+
requestTimeout
|
|
55
|
+
} = this.opts;
|
|
49
56
|
try {
|
|
50
57
|
this.server = ssl ? https.createServer(
|
|
51
58
|
{
|
|
52
59
|
key: readText(ssl.key, this.logger),
|
|
53
60
|
cert: readText(ssl.cert, this.logger),
|
|
54
|
-
ServerResponse
|
|
61
|
+
ServerResponse,
|
|
62
|
+
uniqueHeaders,
|
|
63
|
+
blockList,
|
|
64
|
+
requestTimeout
|
|
55
65
|
},
|
|
56
66
|
this.listener.bind(this)
|
|
57
67
|
) : http.createServer(
|
package/session/session.d.ts
CHANGED
|
@@ -32,19 +32,29 @@ export interface SessionOpts<CTX extends RequestCtx = RequestCtx, SESSION extend
|
|
|
32
32
|
*/
|
|
33
33
|
secret?: number | string | Buffer;
|
|
34
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Cached session metadata, stored in a WeakMap.
|
|
37
|
+
*
|
|
38
|
+
* @internal
|
|
39
|
+
*/
|
|
40
|
+
export interface SessionMeta {
|
|
41
|
+
hmac: Buffer;
|
|
42
|
+
cookie: string;
|
|
43
|
+
}
|
|
35
44
|
/**
|
|
36
45
|
* Pre-interceptor which parses & validates session cookie (if available) from
|
|
37
46
|
* current request and injects/updates session cookie in response. Only a signed
|
|
38
|
-
* session ID will be stored in the cookie. Thr actual session data is
|
|
39
|
-
* server side
|
|
40
|
-
*
|
|
47
|
+
* session ID will be stored in the cookie. Thr actual session data and HMAC is
|
|
48
|
+
* held server side (via configured session storage, see
|
|
49
|
+
* {@link SessionOpts.store}; by default uses {@link InMemorySessionStore}).
|
|
41
50
|
*/
|
|
42
51
|
export declare class SessionInterceptor<CTX extends RequestCtx = RequestCtx, SESSION extends ServerSession = ServerSession> implements Interceptor<CTX> {
|
|
43
|
-
factory:
|
|
52
|
+
factory: SessionOpts<CTX, SESSION>["factory"];
|
|
44
53
|
store: ISessionStore<SESSION>;
|
|
54
|
+
meta: WeakMap<SESSION, SessionMeta>;
|
|
55
|
+
secret: Buffer;
|
|
45
56
|
cookieName: string;
|
|
46
57
|
cookieOpts: string;
|
|
47
|
-
secret: Buffer;
|
|
48
58
|
constructor({ factory, store, cookieName, cookieOpts, secret, }: SessionOpts<CTX, SESSION>);
|
|
49
59
|
pre(ctx: CTX): Promise<boolean>;
|
|
50
60
|
/**
|
|
@@ -60,9 +70,9 @@ export declare class SessionInterceptor<CTX extends RequestCtx = RequestCtx, SES
|
|
|
60
70
|
*/
|
|
61
71
|
deleteSession(ctx: CTX, sessionID: string): Promise<void>;
|
|
62
72
|
/**
|
|
63
|
-
* Creates a new session object (via configured {@link SessionOpts.factory})
|
|
64
|
-
* and submits it to configured {@link SessionOpts.store}, Returns session
|
|
65
|
-
*
|
|
73
|
+
* Creates a new session object (via configured {@link SessionOpts.factory}), pre-computes HMAC
|
|
74
|
+
* and submits it to configured {@link SessionOpts.store}. If successful, Returns session
|
|
75
|
+
* , otherwise returns `undefined`.
|
|
66
76
|
*
|
|
67
77
|
* @param ctx
|
|
68
78
|
*/
|
|
@@ -75,8 +85,8 @@ export declare class SessionInterceptor<CTX extends RequestCtx = RequestCtx, SES
|
|
|
75
85
|
* @param ctx
|
|
76
86
|
*/
|
|
77
87
|
replaceSession(ctx: CTX): Promise<SESSION | undefined>;
|
|
78
|
-
withSession(res: ServerResponse,
|
|
79
|
-
validateSession(cookie: string):
|
|
88
|
+
withSession(res: ServerResponse, session: SESSION): ServerResponse<import("http").IncomingMessage>;
|
|
89
|
+
validateSession(cookie: string): Promise<SESSION | undefined>;
|
|
80
90
|
}
|
|
81
91
|
/**
|
|
82
92
|
* Factory function to create a new {@link SessionInterceptor} instance
|
package/session/session.js
CHANGED
|
@@ -6,9 +6,10 @@ import { inMemorySessionStore } from "./memory.js";
|
|
|
6
6
|
class SessionInterceptor {
|
|
7
7
|
factory;
|
|
8
8
|
store;
|
|
9
|
+
meta = /* @__PURE__ */ new WeakMap();
|
|
10
|
+
secret;
|
|
9
11
|
cookieName;
|
|
10
12
|
cookieOpts;
|
|
11
|
-
secret;
|
|
12
13
|
constructor({
|
|
13
14
|
factory,
|
|
14
15
|
store = inMemorySessionStore(),
|
|
@@ -24,21 +25,20 @@ class SessionInterceptor {
|
|
|
24
25
|
}
|
|
25
26
|
async pre(ctx) {
|
|
26
27
|
const cookie = ctx.cookies?.[this.cookieName];
|
|
27
|
-
let
|
|
28
|
+
let session;
|
|
28
29
|
if (cookie) {
|
|
29
|
-
|
|
30
|
-
if (!
|
|
30
|
+
session = await this.validateSession(cookie);
|
|
31
|
+
if (!session) {
|
|
31
32
|
ctx.res.forbidden();
|
|
32
33
|
return false;
|
|
33
34
|
}
|
|
34
35
|
}
|
|
35
|
-
let session = id ? await this.store.get(id) : void 0;
|
|
36
36
|
if (!session || session.ip !== ctx.req.socket.remoteAddress) {
|
|
37
37
|
session = await this.newSession(ctx);
|
|
38
38
|
if (!session) return false;
|
|
39
39
|
}
|
|
40
40
|
ctx.session = session;
|
|
41
|
-
this.withSession(ctx.res, session
|
|
41
|
+
this.withSession(ctx.res, session);
|
|
42
42
|
return true;
|
|
43
43
|
}
|
|
44
44
|
/**
|
|
@@ -63,9 +63,9 @@ class SessionInterceptor {
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
/**
|
|
66
|
-
* Creates a new session object (via configured {@link SessionOpts.factory})
|
|
67
|
-
* and submits it to configured {@link SessionOpts.store}, Returns session
|
|
68
|
-
*
|
|
66
|
+
* Creates a new session object (via configured {@link SessionOpts.factory}), pre-computes HMAC
|
|
67
|
+
* and submits it to configured {@link SessionOpts.store}. If successful, Returns session
|
|
68
|
+
* , otherwise returns `undefined`.
|
|
69
69
|
*
|
|
70
70
|
* @param ctx
|
|
71
71
|
*/
|
|
@@ -76,6 +76,11 @@ class SessionInterceptor {
|
|
|
76
76
|
ctx.logger.warn("could not store session...");
|
|
77
77
|
return;
|
|
78
78
|
}
|
|
79
|
+
const hmac = createHmac("sha256", this.secret).update(session.id, "ascii").update(randomBytes(8)).digest();
|
|
80
|
+
this.meta.set(session, {
|
|
81
|
+
hmac,
|
|
82
|
+
cookie: session.id + ":" + hmac.toString("base64url")
|
|
83
|
+
});
|
|
79
84
|
return session;
|
|
80
85
|
}
|
|
81
86
|
/**
|
|
@@ -90,28 +95,27 @@ class SessionInterceptor {
|
|
|
90
95
|
if (session) {
|
|
91
96
|
if (ctx.session?.id) this.store.delete(ctx.session.id);
|
|
92
97
|
ctx.session = session;
|
|
93
|
-
this.withSession(ctx.res, session
|
|
98
|
+
this.withSession(ctx.res, session);
|
|
94
99
|
return session;
|
|
95
100
|
}
|
|
96
101
|
}
|
|
97
|
-
withSession(res,
|
|
98
|
-
const cookie =
|
|
99
|
-
const signature = createHmac("sha256", this.secret).update(cookie, "ascii").digest().toString("base64url");
|
|
102
|
+
withSession(res, session) {
|
|
103
|
+
const cookie = this.meta.get(session)?.cookie;
|
|
100
104
|
return res.appendHeader(
|
|
101
105
|
"set-cookie",
|
|
102
|
-
`${this.cookieName}=${cookie}
|
|
106
|
+
`${this.cookieName}=${cookie};Max-Age=${this.store.ttl};${this.cookieOpts}`
|
|
103
107
|
);
|
|
104
108
|
}
|
|
105
|
-
validateSession(cookie) {
|
|
109
|
+
async validateSession(cookie) {
|
|
106
110
|
const parts = cookie.split(":");
|
|
107
|
-
if (parts.length
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
111
|
+
if (parts.length !== 2) return;
|
|
112
|
+
const session = await this.store.get(parts[0]);
|
|
113
|
+
if (!session) return;
|
|
114
|
+
const actual = Buffer.from(parts[1], "base64url");
|
|
115
|
+
const expected = this.meta.get(session)?.hmac;
|
|
116
|
+
if (!expected) return;
|
|
113
117
|
const sameLength = actual.length === expected.length;
|
|
114
|
-
return timingSafeEqual(sameLength ? actual : expected, expected) && sameLength ?
|
|
118
|
+
return timingSafeEqual(sameLength ? actual : expected, expected) && sameLength ? session : void 0;
|
|
115
119
|
}
|
|
116
120
|
}
|
|
117
121
|
const sessionInterceptor = (opts) => new SessionInterceptor(opts);
|