@thi.ng/server 0.4.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # Change Log
2
2
 
3
- - **Last updated**: 2025-02-13T16:03:11Z
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,32 @@ 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
+
26
+ ## [0.5.0](https://github.com/thi-ng/umbrella/tree/@thi.ng/server@0.5.0) (2025-02-19)
27
+
28
+ #### 🚀 Features
29
+
30
+ - update interceptor handling ([9cdb8b8](https://github.com/thi-ng/umbrella/commit/9cdb8b8))
31
+ - update post-interceptor execution logic & return values
32
+ - update `Server.runHandler()`, `Server.compileRoute()`
33
+ - update `logResponse()`, `measure()` interceptors
34
+
35
+ #### ♻️ Refactoring
36
+
37
+ - rename `serverSession()` => `sessionInterceptor()` ([2ada168](https://github.com/thi-ng/umbrella/commit/2ada168))
38
+ - add docs
39
+
14
40
  ## [0.4.0](https://github.com/thi-ng/umbrella/tree/@thi.ng/server@0.4.0) (2025-02-10)
15
41
 
16
42
  #### 🚀 Features
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  [![Mastodon Follow](https://img.shields.io/mastodon/follow/109331703950160316?domain=https%3A%2F%2Fmastodon.thi.ng&style=social)](https://mastodon.thi.ng/@toxi)
8
8
 
9
9
  > [!NOTE]
10
- > This is one of 201 standalone projects, maintained as part
10
+ > This is one of 202 standalone projects, maintained as part
11
11
  > of the [@thi.ng/umbrella](https://github.com/thi-ng/umbrella/) monorepo
12
12
  > and anti-framework.
13
13
  >
@@ -80,7 +80,7 @@ for more details.
80
80
  - [`logResponse()`](https://docs.thi.ng/umbrella/server/functions/logResponse.html): Response logging
81
81
  - [`rateLimiter()`](https://docs.thi.ng/umbrella/server/functions/rateLimiter-1.html): Configurable rate limiting
82
82
  - [`referrerPolicy()`](https://docs.thi.ng/umbrella/server/functions/referrerPolicy-1.html): Policy header injection
83
- - [`serverSession()`](https://docs.thi.ng/umbrella/server/functions/serverSession-1.html): User defined in-memory sessions with TTL
83
+ - [`sessionInterceptor()`](https://docs.thi.ng/umbrella/server/functions/sessionInterceptor-1.html): User defined in-memory sessions with TTL
84
84
  - [`strictTransportSecurity()`](https://docs.thi.ng/umbrella/server/functions/strictTransportSecurity.html): Policy header injection
85
85
 
86
86
  #### Custom interceptors
@@ -149,7 +149,7 @@ For Node.js REPL:
149
149
  const ser = await import("@thi.ng/server");
150
150
  ```
151
151
 
152
- Package sizes (brotli'd, pre-treeshake): ESM: 5.24 KB
152
+ Package sizes (brotli'd, pre-treeshake): ESM: 5.28 KB
153
153
 
154
154
  ## Dependencies
155
155
 
@@ -192,7 +192,9 @@ interface AppSession extends srv.ServerSession {
192
192
 
193
193
  // interceptor for injecting/managing sessions
194
194
  // by default uses in-memory storage/cache
195
- const session = srv.serverSession<AppCtx, AppSession>();
195
+ const session = srv.sessionInterceptor<AppCtx, AppSession>({
196
+ factory: srv.createSession
197
+ });
196
198
 
197
199
  // create server with given config
198
200
  const app = srv.server<AppCtx>({
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 { ServerResponse, Server } from "./server.js";
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>>>;
@@ -74,7 +91,8 @@ export interface RequestCtx {
74
91
  session?: ServerSession;
75
92
  }
76
93
  export type HandlerResult = MaybePromise<void>;
77
- export type InterceptorResult = MaybePromise<boolean>;
94
+ export type PreInterceptorResult = MaybePromise<boolean>;
95
+ export type PostInterceptorResult = MaybePromise<void>;
78
96
  export type RequestHandler<CTX extends RequestCtx = RequestCtx> = Fn<CTX, HandlerResult> | InterceptedRequestHandler<CTX>;
79
97
  export interface InterceptedRequestHandler<CTX extends RequestCtx = RequestCtx> {
80
98
  fn: Fn<CTX, HandlerResult>;
@@ -98,25 +116,27 @@ export interface InterceptedRequestHandler<CTX extends RequestCtx = RequestCtx>
98
116
  }
99
117
  export interface CompiledHandler<CTX extends RequestCtx = RequestCtx> {
100
118
  fn: Fn<CTX, HandlerResult>;
101
- pre?: Fn<CTX, InterceptorResult>[];
102
- post?: Fn<CTX, InterceptorResult>[];
119
+ pre?: Maybe<Fn<CTX, PreInterceptorResult>>[];
120
+ post?: Maybe<Fn<CTX, PostInterceptorResult>>[];
103
121
  }
104
122
  export interface Interceptor<CTX extends RequestCtx = RequestCtx> {
105
123
  /**
106
124
  * Interceptor function which will be run BEFORE the main route handler (aka
107
125
  * {@link InterceptedRequestHandler.fn}). If an interceptor needs to cancel
108
126
  * the request processing it must return `false`. In this case any further
109
- * pre-interceptors, the main handler and all post-interceptors will be
110
- * skipped.
127
+ * pre-interceptors and the main handler will be skipped. In the post-phase,
128
+ * only the interceptors preceding the failed one will be run (though in
129
+ * reverse order). E.g. If the 3rd pre-interceptor failed, only the post
130
+ * phases of the first two will still be run (if available)...
111
131
  */
112
- pre?: Fn<CTX, InterceptorResult>;
132
+ pre?: Fn<CTX, PreInterceptorResult>;
113
133
  /**
114
134
  * Interceptor function which will be run AFTER the main route handler (aka
115
- * {@link InterceptedRequestHandler.fn}). If an interceptor needs to cancel
116
- * the request processing it must return `false`. In this case any further
117
- * post-interceptors will be skipped.
135
+ * {@link InterceptedRequestHandler.fn}). Post-interceptors cannot cancel
136
+ * request processing and are mainly intended for logging or clean-up
137
+ * purposes. Post interceptors
118
138
  */
119
- post?: Fn<CTX, InterceptorResult>;
139
+ post?: Fn<CTX, PostInterceptorResult>;
120
140
  }
121
141
  export interface ServerSession {
122
142
  /**
@@ -140,7 +160,7 @@ export interface ISessionStore<T extends ServerSession = ServerSession> {
140
160
  */
141
161
  get(id: string): MaybePromise<Maybe<T>>;
142
162
  /**
143
- * Adds given `session` to underlying storage.
163
+ * Adds to or updates given `session` in underlying storage.
144
164
  *
145
165
  * @param session
146
166
  */
@@ -2,14 +2,15 @@ import type { Predicate } from "@thi.ng/api";
2
2
  import type { Interceptor, RequestCtx } from "../api.js";
3
3
  /**
4
4
  * Pre-interceptor. Checks if current route has `auth` flag enabled and if so
5
- * applies given predicate function to {@link RequestCtx}. If the predicate
6
- * returns false, the server responds with {@link Server.unauthorized} and any
7
- * following pre-interceptors and the main route handler will be skipped. Only
8
- * post-interceptors (if any) will still be executed.
5
+ * applies given predicate function to current {@link RequestCtx}. If the
6
+ * predicate returns false, the server responds with
7
+ * {@link ServerResponse.unauthorized} and any following pre-interceptors and
8
+ * the main route handler will be skipped. Only post-interceptors (if any) will
9
+ * still be executed.
9
10
  *
10
11
  * @remarks
11
- * If this interceptor is used with the {@link serverSession} interceptor, it
12
- * MUST come after it, otherwise the session information will not yet be
12
+ * If this interceptor is used with the {@link sessionInterceptor} interceptor,
13
+ * it MUST come after it, otherwise the session information will not yet be
13
14
  * available in the context object given to the predicate.
14
15
  *
15
16
  * @param pred
@@ -9,7 +9,7 @@ import type { Interceptor } from "../api.js";
9
9
  */
10
10
  export declare const logRequest: (level?: LogLevel | LogLevelName) => Interceptor;
11
11
  /**
12
- * Pre-interceptor to log response details (status, route, headers) using the
12
+ * Post-interceptor to log response details (status, route, headers) using the
13
13
  * server's {@link ServerOpts.logger}. The `level` arg can be used to customize
14
14
  * which log level to use.
15
15
  *
@@ -17,7 +17,6 @@ const logResponse = (level = "INFO") => {
17
17
  post: ({ logger, match, res }) => {
18
18
  logger[method]("response status", res.statusCode, match);
19
19
  logger[method]("response headers", res.getHeaders());
20
- return true;
21
20
  }
22
21
  };
23
22
  };
@@ -18,7 +18,6 @@ const measure = (level = "DEBUG") => {
18
18
  `request processed in: ${timeDiff(t0).toFixed(3)}ms`
19
19
  );
20
20
  }
21
- return true;
22
21
  }
23
22
  };
24
23
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thi.ng/server",
3
- "version": "0.4.1",
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",
@@ -39,19 +39,19 @@
39
39
  "tool:tangle": "../../node_modules/.bin/tangle src/**/*.ts"
40
40
  },
41
41
  "dependencies": {
42
- "@thi.ng/api": "^8.11.20",
43
- "@thi.ng/arrays": "^2.10.15",
44
- "@thi.ng/cache": "^2.3.23",
45
- "@thi.ng/checks": "^3.6.23",
46
- "@thi.ng/errors": "^2.5.26",
47
- "@thi.ng/file-io": "^2.1.27",
48
- "@thi.ng/logger": "^3.1.1",
49
- "@thi.ng/mime": "^2.7.2",
50
- "@thi.ng/paths": "^5.2.1",
51
- "@thi.ng/router": "^4.1.18",
52
- "@thi.ng/strings": "^3.9.5",
53
- "@thi.ng/timestamp": "^1.1.5",
54
- "@thi.ng/uuid": "^1.1.17"
42
+ "@thi.ng/api": "^8.11.21",
43
+ "@thi.ng/arrays": "^2.10.17",
44
+ "@thi.ng/cache": "^2.3.25",
45
+ "@thi.ng/checks": "^3.7.0",
46
+ "@thi.ng/errors": "^2.5.27",
47
+ "@thi.ng/file-io": "^2.1.29",
48
+ "@thi.ng/logger": "^3.1.2",
49
+ "@thi.ng/mime": "^2.7.3",
50
+ "@thi.ng/paths": "^5.2.3",
51
+ "@thi.ng/router": "^4.1.20",
52
+ "@thi.ng/strings": "^3.9.6",
53
+ "@thi.ng/timestamp": "^1.1.6",
54
+ "@thi.ng/uuid": "^1.1.18"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/node": "^22.13.1",
@@ -163,5 +163,5 @@
163
163
  "status": "alpha",
164
164
  "year": 2024
165
165
  },
166
- "gitHead": "9a0b33253fef092aaf301decf6ecd54317874d4c\n"
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 { ssl, host = "localhost", port = ssl ? 443 : 8080 } = this.opts;
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(
@@ -127,19 +137,25 @@ class Server {
127
137
  }
128
138
  }
129
139
  async runHandler({ fn, pre, post }, ctx) {
130
- const runPhase = async (fns) => {
131
- for (let f of fns) {
132
- if (!await f(ctx)) {
133
- ctx.res.end();
134
- return false;
140
+ try {
141
+ let failed;
142
+ if (pre) {
143
+ for (let i = 0, n = pre.length; i < n; i++) {
144
+ const fn2 = pre[i];
145
+ if (fn2 && !await fn2(ctx)) {
146
+ ctx.res.end();
147
+ failed = i;
148
+ break;
149
+ }
150
+ }
151
+ }
152
+ if (failed === void 0) await fn(ctx);
153
+ if (post) {
154
+ for (let i = failed ?? post.length; --i >= 0; ) {
155
+ const fn2 = post[i];
156
+ if (fn2) await fn2(ctx);
135
157
  }
136
158
  }
137
- return true;
138
- };
139
- try {
140
- if (pre && !await runPhase(pre)) return;
141
- await fn(ctx);
142
- if (post && !await runPhase(post)) return;
143
159
  ctx.res.end();
144
160
  } catch (e) {
145
161
  this.logger.warn(`handler error:`, e);
@@ -150,16 +166,18 @@ class Server {
150
166
  }
151
167
  compileRoute(route) {
152
168
  const compilePhase = (handler, phase) => {
153
- const fns = [];
154
- for (let x of this.opts.intercept ?? []) {
155
- if (x[phase]) fns.push(x[phase].bind(x));
156
- }
157
- if (!isFunction(handler)) {
158
- for (let x of handler.intercept ?? []) {
159
- if (x[phase]) fns.push(x[phase].bind(x));
169
+ let isPhaseUsed = false;
170
+ const $bind = (iceps) => (iceps ?? []).map((x) => {
171
+ if (x[phase]) {
172
+ isPhaseUsed = true;
173
+ return x[phase].bind(x);
160
174
  }
175
+ });
176
+ const fns = [...$bind(this.opts.intercept)];
177
+ if (!isFunction(handler)) {
178
+ fns.push(...$bind(handler.intercept));
161
179
  }
162
- return fns.length ? phase === "post" ? fns.reverse() : fns : void 0;
180
+ return isPhaseUsed ? fns : void 0;
163
181
  };
164
182
  const result = { ...route, handlers: {} };
165
183
  for (let method in route.handlers) {
@@ -21,8 +21,8 @@ export interface InMemorySessionOpts<T extends ServerSession = ServerSession> {
21
21
  initial: Record<string, T>;
22
22
  }
23
23
  /**
24
- * Session storage implementation for use with {@link serverSession}, using an
25
- * in-memory TLRU Cache with configurable TTL.
24
+ * Session storage implementation for use with {@link sessionInterceptor}, using
25
+ * an in-memory TLRU Cache with configurable TTL.
26
26
  */
27
27
  export declare class InMemorySessionStore<T extends ServerSession = ServerSession> implements ISessionStore<T> {
28
28
  readonly ttl: number;
@@ -1,9 +1,13 @@
1
1
  import type { Fn } from "@thi.ng/api";
2
2
  import { ServerResponse } from "node:http";
3
3
  import type { Interceptor, ISessionStore, RequestCtx, ServerSession } from "../api.js";
4
+ /**
5
+ * Configuration options for {@link SessionInterceptor}.
6
+ */
4
7
  export interface SessionOpts<CTX extends RequestCtx = RequestCtx, SESSION extends ServerSession = ServerSession> {
5
8
  /**
6
- * Factory function to create a new session object. See {@link createSession}.
9
+ * Factory function to create a new session object. See
10
+ * {@link createSession} for a base implementation.
7
11
  */
8
12
  factory: Fn<CTX, SESSION>;
9
13
  /**
@@ -28,15 +32,50 @@ export interface SessionOpts<CTX extends RequestCtx = RequestCtx, SESSION extend
28
32
  */
29
33
  secret?: number | string | Buffer;
30
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
+ }
44
+ /**
45
+ * Pre-interceptor which parses & validates session cookie (if available) from
46
+ * current request and injects/updates session cookie in response. Only a signed
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}).
50
+ */
31
51
  export declare class SessionInterceptor<CTX extends RequestCtx = RequestCtx, SESSION extends ServerSession = ServerSession> implements Interceptor<CTX> {
32
- factory: Fn<CTX, SESSION>;
52
+ factory: SessionOpts<CTX, SESSION>["factory"];
33
53
  store: ISessionStore<SESSION>;
54
+ meta: WeakMap<SESSION, SessionMeta>;
55
+ secret: Buffer;
34
56
  cookieName: string;
35
57
  cookieOpts: string;
36
- secret: Buffer;
37
58
  constructor({ factory, store, cookieName, cookieOpts, secret, }: SessionOpts<CTX, SESSION>);
38
59
  pre(ctx: CTX): Promise<boolean>;
60
+ /**
61
+ * Attempts to delete session for given ID and if successful also sets
62
+ * force-expired cookie in response.
63
+ *
64
+ * @remarks
65
+ * Intended for logout handlers and/or switching sessions when a user has
66
+ * successfully authenticated (to avoid session fixation).
67
+ *
68
+ * @param ctx
69
+ * @param sessionID
70
+ */
39
71
  deleteSession(ctx: CTX, sessionID: string): Promise<void>;
72
+ /**
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`.
76
+ *
77
+ * @param ctx
78
+ */
40
79
  newSession(ctx: CTX): Promise<SESSION | undefined>;
41
80
  /**
42
81
  * Calls {@link SessionInterceptor.newSession} to create a new session and,
@@ -46,15 +85,16 @@ export declare class SessionInterceptor<CTX extends RequestCtx = RequestCtx, SES
46
85
  * @param ctx
47
86
  */
48
87
  replaceSession(ctx: CTX): Promise<SESSION | undefined>;
49
- withSession(res: ServerResponse, sessionID: string): ServerResponse<import("http").IncomingMessage>;
50
- validateSession(cookie: string): string | undefined;
88
+ withSession(res: ServerResponse, session: SESSION): ServerResponse<import("http").IncomingMessage>;
89
+ validateSession(cookie: string): Promise<SESSION | undefined>;
51
90
  }
52
91
  /**
53
- * Factory function to create a new {@link SessionInterceptor} instance.
92
+ * Factory function to create a new {@link SessionInterceptor} instance
93
+ * configured with given options.
54
94
  *
55
95
  * @param opts
56
96
  */
57
- export declare const serverSession: <CTX extends RequestCtx = RequestCtx, SESSION extends ServerSession = ServerSession>(opts: SessionOpts<CTX, SESSION>) => SessionInterceptor<CTX, SESSION>;
97
+ export declare const sessionInterceptor: <CTX extends RequestCtx = RequestCtx, SESSION extends ServerSession = ServerSession>(opts: SessionOpts<CTX, SESSION>) => SessionInterceptor<CTX, SESSION>;
58
98
  /**
59
99
  * Creates a new basic {@link ServerSession}, using a UUID v4 for
60
100
  * {@link ServerSession.id}.
@@ -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,23 +25,33 @@ class SessionInterceptor {
24
25
  }
25
26
  async pre(ctx) {
26
27
  const cookie = ctx.cookies?.[this.cookieName];
27
- let id;
28
+ let session;
28
29
  if (cookie) {
29
- id = this.validateSession(cookie);
30
- if (!id) {
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.id);
41
+ this.withSession(ctx.res, session);
42
42
  return true;
43
43
  }
44
+ /**
45
+ * Attempts to delete session for given ID and if successful also sets
46
+ * force-expired cookie in response.
47
+ *
48
+ * @remarks
49
+ * Intended for logout handlers and/or switching sessions when a user has
50
+ * successfully authenticated (to avoid session fixation).
51
+ *
52
+ * @param ctx
53
+ * @param sessionID
54
+ */
44
55
  async deleteSession(ctx, sessionID) {
45
56
  if (await this.store.delete(sessionID)) {
46
57
  ctx.logger.info("delete session:", sessionID);
@@ -51,6 +62,13 @@ class SessionInterceptor {
51
62
  );
52
63
  }
53
64
  }
65
+ /**
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
+ *
70
+ * @param ctx
71
+ */
54
72
  async newSession(ctx) {
55
73
  const session = this.factory(ctx);
56
74
  ctx.logger.info("new session:", session.id);
@@ -58,6 +76,11 @@ class SessionInterceptor {
58
76
  ctx.logger.warn("could not store session...");
59
77
  return;
60
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
+ });
61
84
  return session;
62
85
  }
63
86
  /**
@@ -72,31 +95,30 @@ class SessionInterceptor {
72
95
  if (session) {
73
96
  if (ctx.session?.id) this.store.delete(ctx.session.id);
74
97
  ctx.session = session;
75
- this.withSession(ctx.res, session.id);
98
+ this.withSession(ctx.res, session);
76
99
  return session;
77
100
  }
78
101
  }
79
- withSession(res, sessionID) {
80
- const cookie = sessionID + ":" + randomBytes(8).toString("base64url");
81
- const signature = createHmac("sha256", this.secret).update(cookie, "ascii").digest().toString("base64url");
102
+ withSession(res, session) {
103
+ const cookie = this.meta.get(session)?.cookie;
82
104
  return res.appendHeader(
83
105
  "set-cookie",
84
- `${this.cookieName}=${cookie}:${signature};Max-Age=${this.store.ttl};${this.cookieOpts}`
106
+ `${this.cookieName}=${cookie};Max-Age=${this.store.ttl};${this.cookieOpts}`
85
107
  );
86
108
  }
87
- validateSession(cookie) {
109
+ async validateSession(cookie) {
88
110
  const parts = cookie.split(":");
89
- if (parts.length < 3) return;
90
- const actual = Buffer.from(parts[2], "base64url");
91
- const expected = createHmac("sha256", this.secret).update(
92
- cookie.substring(0, cookie.length - parts[2].length - 1),
93
- "ascii"
94
- ).digest();
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;
95
117
  const sameLength = actual.length === expected.length;
96
- return timingSafeEqual(sameLength ? actual : expected, expected) && sameLength ? parts[0] : void 0;
118
+ return timingSafeEqual(sameLength ? actual : expected, expected) && sameLength ? session : void 0;
97
119
  }
98
120
  }
99
- const serverSession = (opts) => new SessionInterceptor(opts);
121
+ const sessionInterceptor = (opts) => new SessionInterceptor(opts);
100
122
  const createSession = (ctx) => ({
101
123
  id: uuid(),
102
124
  ip: ctx.req.socket.remoteAddress
@@ -104,5 +126,5 @@ const createSession = (ctx) => ({
104
126
  export {
105
127
  SessionInterceptor,
106
128
  createSession,
107
- serverSession
129
+ sessionInterceptor
108
130
  };