@davaux/session 0.8.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +69 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +129 -0
- package/dist/index.js.map +1 -0
- package/package.json +6 -3
- package/CLAUDE.md +0 -95
- package/src/index.ts +0 -186
- package/tsconfig.json +0 -17
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { MiddlewareFn } from 'davaux';
|
|
2
|
+
declare module 'davaux' {
|
|
3
|
+
interface State {
|
|
4
|
+
session: Session;
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Per-request session object. Stores arbitrary key-value data serialised into
|
|
9
|
+
* an HMAC-signed cookie. Mutations (`set`, `delete`, `destroy`) are persisted
|
|
10
|
+
* automatically before the response headers are sent.
|
|
11
|
+
*/
|
|
12
|
+
export declare class Session {
|
|
13
|
+
private readonly _data;
|
|
14
|
+
private _dirty;
|
|
15
|
+
private _destroyed;
|
|
16
|
+
constructor(data?: Record<string, unknown>);
|
|
17
|
+
/** Retrieve a session value by key. Returns `undefined` when absent. */
|
|
18
|
+
get<T = unknown>(key: string): T | undefined;
|
|
19
|
+
/** Store a value under `key`. Marks the session dirty so it is persisted. */
|
|
20
|
+
set(key: string, value: unknown): void;
|
|
21
|
+
/** Remove `key` from the session. Marks the session dirty so it is persisted. */
|
|
22
|
+
delete(key: string): void;
|
|
23
|
+
/** Clear all session data and delete the cookie on response. */
|
|
24
|
+
destroy(): void;
|
|
25
|
+
/** Snapshot of current session data. */
|
|
26
|
+
get data(): Record<string, unknown>;
|
|
27
|
+
/** @internal */
|
|
28
|
+
get dirty(): boolean;
|
|
29
|
+
/** @internal */
|
|
30
|
+
get destroyed(): boolean;
|
|
31
|
+
}
|
|
32
|
+
export interface SessionOptions {
|
|
33
|
+
/**
|
|
34
|
+
* Secret(s) used to sign the session cookie with HMAC-SHA256.
|
|
35
|
+
* Pass an array for rotation: the first secret signs new sessions,
|
|
36
|
+
* all are accepted for verification.
|
|
37
|
+
*/
|
|
38
|
+
secret: string | string[];
|
|
39
|
+
/** Cookie name. Default: `'session'` */
|
|
40
|
+
cookieName?: string;
|
|
41
|
+
/** Max-Age in seconds. Omit for a session cookie (clears on browser close). */
|
|
42
|
+
maxAge?: number;
|
|
43
|
+
/** Default: `true` */
|
|
44
|
+
httpOnly?: boolean;
|
|
45
|
+
/** Default: `false`. Enable in production to require HTTPS. */
|
|
46
|
+
secure?: boolean;
|
|
47
|
+
/** Default: `'Lax'` */
|
|
48
|
+
sameSite?: 'Strict' | 'Lax' | 'None';
|
|
49
|
+
/** Default: `'/'` */
|
|
50
|
+
path?: string;
|
|
51
|
+
domain?: string;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* HMAC-signed cookie session middleware. Parses the incoming session cookie,
|
|
55
|
+
* exposes the data as `ctx.state.session`, and automatically writes an updated
|
|
56
|
+
* cookie when the session is mutated or destroyed.
|
|
57
|
+
*
|
|
58
|
+
* Pass an array of secrets for key rotation: the first secret signs new
|
|
59
|
+
* sessions, all secrets are accepted for verification.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* import { sessionMiddleware } from '@davaux/session'
|
|
63
|
+
* export default sessionMiddleware({
|
|
64
|
+
* secret: process.env.SESSION_SECRET!,
|
|
65
|
+
* maxAge: 60 * 60 * 24 * 7, // 1 week
|
|
66
|
+
* })
|
|
67
|
+
*/
|
|
68
|
+
export declare function sessionMiddleware(options: SessionOptions): MiddlewareFn;
|
|
69
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAA;AAI1C,OAAO,QAAQ,QAAQ,CAAC;IACtB,UAAU,KAAK;QACb,OAAO,EAAE,OAAO,CAAA;KACjB;CACF;AAID;;;;GAIG;AACH,qBAAa,OAAO;IAClB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAyB;IAC/C,OAAO,CAAC,MAAM,CAAQ;IACtB,OAAO,CAAC,UAAU,CAAQ;gBAEd,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM;IAI9C,wEAAwE;IACxE,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,GAAG,CAAC,GAAG,SAAS;IAI5C,6EAA6E;IAC7E,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,IAAI;IAKtC,iFAAiF;IACjF,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IAKzB,gEAAgE;IAChE,OAAO,IAAI,IAAI;IAKf,wCAAwC;IACxC,IAAI,IAAI,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAElC;IAED,gBAAgB;IAChB,IAAI,KAAK,IAAI,OAAO,CAEnB;IAED,gBAAgB;IAChB,IAAI,SAAS,IAAI,OAAO,CAEvB;CACF;AAID,MAAM,WAAW,cAAc;IAC7B;;;;OAIG;IACH,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;IACzB,wCAAwC;IACxC,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,+EAA+E;IAC/E,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,sBAAsB;IACtB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,+DAA+D;IAC/D,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,uBAAuB;IACvB,QAAQ,CAAC,EAAE,QAAQ,GAAG,KAAK,GAAG,MAAM,CAAA;IACpC,qBAAqB;IACrB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAmCD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,cAAc,GAAG,YAAY,CA8CvE"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
// ─── Session ──────────────────────────────────────────────────────────────────
|
|
3
|
+
/**
|
|
4
|
+
* Per-request session object. Stores arbitrary key-value data serialised into
|
|
5
|
+
* an HMAC-signed cookie. Mutations (`set`, `delete`, `destroy`) are persisted
|
|
6
|
+
* automatically before the response headers are sent.
|
|
7
|
+
*/
|
|
8
|
+
export class Session {
|
|
9
|
+
_data;
|
|
10
|
+
_dirty = false;
|
|
11
|
+
_destroyed = false;
|
|
12
|
+
constructor(data = {}) {
|
|
13
|
+
this._data = data;
|
|
14
|
+
}
|
|
15
|
+
/** Retrieve a session value by key. Returns `undefined` when absent. */
|
|
16
|
+
get(key) {
|
|
17
|
+
return this._data[key];
|
|
18
|
+
}
|
|
19
|
+
/** Store a value under `key`. Marks the session dirty so it is persisted. */
|
|
20
|
+
set(key, value) {
|
|
21
|
+
this._data[key] = value;
|
|
22
|
+
this._dirty = true;
|
|
23
|
+
}
|
|
24
|
+
/** Remove `key` from the session. Marks the session dirty so it is persisted. */
|
|
25
|
+
delete(key) {
|
|
26
|
+
delete this._data[key];
|
|
27
|
+
this._dirty = true;
|
|
28
|
+
}
|
|
29
|
+
/** Clear all session data and delete the cookie on response. */
|
|
30
|
+
destroy() {
|
|
31
|
+
this._destroyed = true;
|
|
32
|
+
this._dirty = false;
|
|
33
|
+
}
|
|
34
|
+
/** Snapshot of current session data. */
|
|
35
|
+
get data() {
|
|
36
|
+
return { ...this._data };
|
|
37
|
+
}
|
|
38
|
+
/** @internal */
|
|
39
|
+
get dirty() {
|
|
40
|
+
return this._dirty;
|
|
41
|
+
}
|
|
42
|
+
/** @internal */
|
|
43
|
+
get destroyed() {
|
|
44
|
+
return this._destroyed;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// ─── Signing ──────────────────────────────────────────────────────────────────
|
|
48
|
+
function sign(payload, secret) {
|
|
49
|
+
const sig = createHmac('sha256', secret).update(payload).digest('base64url');
|
|
50
|
+
return `${payload}.${sig}`;
|
|
51
|
+
}
|
|
52
|
+
function verify(value, secrets) {
|
|
53
|
+
const dot = value.lastIndexOf('.');
|
|
54
|
+
if (dot === -1)
|
|
55
|
+
return null;
|
|
56
|
+
const payload = value.slice(0, dot);
|
|
57
|
+
const sigBuf = Buffer.from(value.slice(dot + 1), 'base64url');
|
|
58
|
+
for (const secret of secrets) {
|
|
59
|
+
const expectedBuf = createHmac('sha256', secret).update(payload).digest();
|
|
60
|
+
if (expectedBuf.length === sigBuf.length && timingSafeEqual(expectedBuf, sigBuf)) {
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf-8'));
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
// ─── Middleware ───────────────────────────────────────────────────────────────
|
|
72
|
+
/**
|
|
73
|
+
* HMAC-signed cookie session middleware. Parses the incoming session cookie,
|
|
74
|
+
* exposes the data as `ctx.state.session`, and automatically writes an updated
|
|
75
|
+
* cookie when the session is mutated or destroyed.
|
|
76
|
+
*
|
|
77
|
+
* Pass an array of secrets for key rotation: the first secret signs new
|
|
78
|
+
* sessions, all secrets are accepted for verification.
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* import { sessionMiddleware } from '@davaux/session'
|
|
82
|
+
* export default sessionMiddleware({
|
|
83
|
+
* secret: process.env.SESSION_SECRET!,
|
|
84
|
+
* maxAge: 60 * 60 * 24 * 7, // 1 week
|
|
85
|
+
* })
|
|
86
|
+
*/
|
|
87
|
+
export function sessionMiddleware(options) {
|
|
88
|
+
const secrets = Array.isArray(options.secret) ? options.secret : [options.secret];
|
|
89
|
+
if (secrets.length === 0 || secrets.some((s) => !s)) {
|
|
90
|
+
throw new Error('@davaux/session: at least one non-empty secret is required');
|
|
91
|
+
}
|
|
92
|
+
if (secrets.some((s) => s.length < 32)) {
|
|
93
|
+
console.warn('@davaux/session: secret should be at least 32 characters — short secrets are weak and easy to brute-force');
|
|
94
|
+
}
|
|
95
|
+
const name = options.cookieName ?? 'session';
|
|
96
|
+
return async (ctx, next) => {
|
|
97
|
+
const raw = ctx.cookies.get(name);
|
|
98
|
+
const existing = raw ? verify(raw, secrets) : null;
|
|
99
|
+
const session = new Session(existing ?? {});
|
|
100
|
+
ctx.state.session = session;
|
|
101
|
+
// Inject the session cookie right before headers are committed.
|
|
102
|
+
// This approach handles the common case where the route handler calls
|
|
103
|
+
// res.writeHead() inside next(), after which res.setHeader() would fail.
|
|
104
|
+
const originalWriteHead = ctx.res.writeHead.bind(ctx.res);
|
|
105
|
+
// biome-ignore lint/suspicious/noExplicitAny: writeHead has multiple overloads
|
|
106
|
+
ctx.res.writeHead = ((...args) => {
|
|
107
|
+
ctx.res.writeHead = originalWriteHead;
|
|
108
|
+
if (session.destroyed) {
|
|
109
|
+
ctx.cookies.delete(name, { path: options.path ?? '/' });
|
|
110
|
+
}
|
|
111
|
+
else if (session.dirty) {
|
|
112
|
+
const payload = Buffer.from(JSON.stringify(session.data)).toString('base64url');
|
|
113
|
+
const signed = sign(payload, secrets[0]);
|
|
114
|
+
ctx.cookies.set(name, signed, {
|
|
115
|
+
httpOnly: options.httpOnly ?? true,
|
|
116
|
+
secure: options.secure ?? false,
|
|
117
|
+
sameSite: options.sameSite ?? 'Lax',
|
|
118
|
+
maxAge: options.maxAge,
|
|
119
|
+
path: options.path ?? '/',
|
|
120
|
+
domain: options.domain,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
// biome-ignore lint/suspicious/noExplicitAny: forwarding overloaded args
|
|
124
|
+
return originalWriteHead(...args);
|
|
125
|
+
});
|
|
126
|
+
await next();
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davaux/session",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "HMAC-signed cookie sessions for Davaux",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "David L Dyess II",
|
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
"url": "https://codeberg.org/davaux/davaux/issues"
|
|
14
14
|
},
|
|
15
15
|
"homepage": "https://codeberg.org/davaux/davaux#readme",
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
16
19
|
"exports": {
|
|
17
20
|
".": {
|
|
18
21
|
"import": "./dist/index.js",
|
|
@@ -24,11 +27,11 @@
|
|
|
24
27
|
"typecheck": "tsc --noEmit"
|
|
25
28
|
},
|
|
26
29
|
"peerDependencies": {
|
|
27
|
-
"davaux": ">=0.8.
|
|
30
|
+
"davaux": ">=0.8.1"
|
|
28
31
|
},
|
|
29
32
|
"devDependencies": {
|
|
30
33
|
"@types/node": "^25.0.0",
|
|
31
34
|
"davaux": "*",
|
|
32
35
|
"typescript": "^6.0.3"
|
|
33
36
|
}
|
|
34
|
-
}
|
|
37
|
+
}
|
package/CLAUDE.md
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
<!-- pka-generated -->
|
|
2
|
-
# @davaux/session
|
|
3
|
-
|
|
4
|
-
> Generated by Project Knowledge Analyzer on 2026-06-06T21:53:10.444Z
|
|
5
|
-
|
|
6
|
-
## Overview
|
|
7
|
-
|
|
8
|
-
HMAC-signed cookie sessions for Davaux
|
|
9
|
-
|
|
10
|
-
**Version**: 0.8.0
|
|
11
|
-
**Author**: David L Dyess II
|
|
12
|
-
**License**: MIT
|
|
13
|
-
**Repository**: https://codeberg.org/davaux/davaux#readme
|
|
14
|
-
|
|
15
|
-
## Tech Stack
|
|
16
|
-
|
|
17
|
-
- **Language**: TypeScript
|
|
18
|
-
- **Module System**: ESM (`type: module`)
|
|
19
|
-
|
|
20
|
-
## Commands
|
|
21
|
-
|
|
22
|
-
- `npm run build` — tsc
|
|
23
|
-
- `npm run typecheck` — tsc --noEmit
|
|
24
|
-
|
|
25
|
-
## Project Structure
|
|
26
|
-
|
|
27
|
-
```
|
|
28
|
-
├── CLAUDE.md
|
|
29
|
-
├── README.md
|
|
30
|
-
├── package.json
|
|
31
|
-
├── tsconfig.json
|
|
32
|
-
└── src/
|
|
33
|
-
└── index.ts
|
|
34
|
-
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
## Entry Points
|
|
38
|
-
|
|
39
|
-
- `src/index.ts`
|
|
40
|
-
|
|
41
|
-
## Files by Type
|
|
42
|
-
|
|
43
|
-
### Documentation (2)
|
|
44
|
-
- `CLAUDE.md`
|
|
45
|
-
- `README.md`
|
|
46
|
-
|
|
47
|
-
### Config (2)
|
|
48
|
-
- `package.json`
|
|
49
|
-
- `tsconfig.json`
|
|
50
|
-
|
|
51
|
-
### Module (1)
|
|
52
|
-
- `src/index.ts`
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
## Git
|
|
56
|
-
- **Branch**: main
|
|
57
|
-
- **Last Commit**: chore: Add alpha status note to README
|
|
58
|
-
- **Author**: David Dyess II
|
|
59
|
-
- **Date**: 2026-06-06 15:52:27 -0600
|
|
60
|
-
- **Remote**: https://codeberg.org/davaux/davaux.git
|
|
61
|
-
|
|
62
|
-
### Recent Commits
|
|
63
|
-
```
|
|
64
|
-
f527031 chore: Add alpha status note to README
|
|
65
|
-
90c819e chore: Add repo info to package.json files
|
|
66
|
-
b200d9d feat(davaux)!: Add OmlCacheConfig - opt-in with includes option or opt-out with excludes option; Fix OML implementation to follow OML spec - use output instead of return
|
|
67
|
-
3bce0c2 chore: Update and add READMEs to packages; update ROADMAP
|
|
68
|
-
fc27c20 fix(davaux): Remove old dist folder on new builds; fix server port per DavauxConfig
|
|
69
|
-
c760659 feat(davaux): Add minify CSS in production builds
|
|
70
|
-
c899ab8 fix(davaux): Added method JS and JSX extenstion to scanner
|
|
71
|
-
7d99f04 feat(davaux): Add support for declarative partial updates
|
|
72
|
-
3b47f37 chore: Bump package versions to 0.8.0
|
|
73
|
-
aa0460f chore: Add CHANGELOG.md
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
## Dependencies
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
### Development
|
|
80
|
-
@types/node, davaux, typescript
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
## Exported Symbols
|
|
91
|
-
|
|
92
|
-
**`src/index.ts`**
|
|
93
|
-
`Session`, `sessionMiddleware`, `SessionOptions`
|
|
94
|
-
|
|
95
|
-
|
package/src/index.ts
DELETED
|
@@ -1,186 +0,0 @@
|
|
|
1
|
-
import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
2
|
-
import type { ServerResponse } from 'node:http'
|
|
3
|
-
import type { MiddlewareFn } from 'davaux'
|
|
4
|
-
|
|
5
|
-
// ─── State augmentation ───────────────────────────────────────────────────────
|
|
6
|
-
|
|
7
|
-
declare module 'davaux' {
|
|
8
|
-
interface State {
|
|
9
|
-
session: Session
|
|
10
|
-
}
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
// ─── Session ──────────────────────────────────────────────────────────────────
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Per-request session object. Stores arbitrary key-value data serialised into
|
|
17
|
-
* an HMAC-signed cookie. Mutations (`set`, `delete`, `destroy`) are persisted
|
|
18
|
-
* automatically before the response headers are sent.
|
|
19
|
-
*/
|
|
20
|
-
export class Session {
|
|
21
|
-
private readonly _data: Record<string, unknown>
|
|
22
|
-
private _dirty = false
|
|
23
|
-
private _destroyed = false
|
|
24
|
-
|
|
25
|
-
constructor(data: Record<string, unknown> = {}) {
|
|
26
|
-
this._data = data
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** Retrieve a session value by key. Returns `undefined` when absent. */
|
|
30
|
-
get<T = unknown>(key: string): T | undefined {
|
|
31
|
-
return this._data[key] as T | undefined
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** Store a value under `key`. Marks the session dirty so it is persisted. */
|
|
35
|
-
set(key: string, value: unknown): void {
|
|
36
|
-
this._data[key] = value
|
|
37
|
-
this._dirty = true
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** Remove `key` from the session. Marks the session dirty so it is persisted. */
|
|
41
|
-
delete(key: string): void {
|
|
42
|
-
delete this._data[key]
|
|
43
|
-
this._dirty = true
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Clear all session data and delete the cookie on response. */
|
|
47
|
-
destroy(): void {
|
|
48
|
-
this._destroyed = true
|
|
49
|
-
this._dirty = false
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Snapshot of current session data. */
|
|
53
|
-
get data(): Record<string, unknown> {
|
|
54
|
-
return { ...this._data }
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** @internal */
|
|
58
|
-
get dirty(): boolean {
|
|
59
|
-
return this._dirty
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** @internal */
|
|
63
|
-
get destroyed(): boolean {
|
|
64
|
-
return this._destroyed
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// ─── Options ──────────────────────────────────────────────────────────────────
|
|
69
|
-
|
|
70
|
-
export interface SessionOptions {
|
|
71
|
-
/**
|
|
72
|
-
* Secret(s) used to sign the session cookie with HMAC-SHA256.
|
|
73
|
-
* Pass an array for rotation: the first secret signs new sessions,
|
|
74
|
-
* all are accepted for verification.
|
|
75
|
-
*/
|
|
76
|
-
secret: string | string[]
|
|
77
|
-
/** Cookie name. Default: `'session'` */
|
|
78
|
-
cookieName?: string
|
|
79
|
-
/** Max-Age in seconds. Omit for a session cookie (clears on browser close). */
|
|
80
|
-
maxAge?: number
|
|
81
|
-
/** Default: `true` */
|
|
82
|
-
httpOnly?: boolean
|
|
83
|
-
/** Default: `false`. Enable in production to require HTTPS. */
|
|
84
|
-
secure?: boolean
|
|
85
|
-
/** Default: `'Lax'` */
|
|
86
|
-
sameSite?: 'Strict' | 'Lax' | 'None'
|
|
87
|
-
/** Default: `'/'` */
|
|
88
|
-
path?: string
|
|
89
|
-
domain?: string
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// ─── Signing ──────────────────────────────────────────────────────────────────
|
|
93
|
-
|
|
94
|
-
function sign(payload: string, secret: string): string {
|
|
95
|
-
const sig = createHmac('sha256', secret).update(payload).digest('base64url')
|
|
96
|
-
return `${payload}.${sig}`
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function verify(value: string, secrets: string[]): Record<string, unknown> | null {
|
|
100
|
-
const dot = value.lastIndexOf('.')
|
|
101
|
-
if (dot === -1) return null
|
|
102
|
-
|
|
103
|
-
const payload = value.slice(0, dot)
|
|
104
|
-
const sigBuf = Buffer.from(value.slice(dot + 1), 'base64url')
|
|
105
|
-
|
|
106
|
-
for (const secret of secrets) {
|
|
107
|
-
const expectedBuf = createHmac('sha256', secret).update(payload).digest()
|
|
108
|
-
if (expectedBuf.length === sigBuf.length && timingSafeEqual(expectedBuf, sigBuf)) {
|
|
109
|
-
try {
|
|
110
|
-
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf-8')) as Record<
|
|
111
|
-
string,
|
|
112
|
-
unknown
|
|
113
|
-
>
|
|
114
|
-
} catch {
|
|
115
|
-
return null
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
return null
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// ─── Middleware ───────────────────────────────────────────────────────────────
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* HMAC-signed cookie session middleware. Parses the incoming session cookie,
|
|
127
|
-
* exposes the data as `ctx.state.session`, and automatically writes an updated
|
|
128
|
-
* cookie when the session is mutated or destroyed.
|
|
129
|
-
*
|
|
130
|
-
* Pass an array of secrets for key rotation: the first secret signs new
|
|
131
|
-
* sessions, all secrets are accepted for verification.
|
|
132
|
-
*
|
|
133
|
-
* @example
|
|
134
|
-
* import { sessionMiddleware } from '@davaux/session'
|
|
135
|
-
* export default sessionMiddleware({
|
|
136
|
-
* secret: process.env.SESSION_SECRET!,
|
|
137
|
-
* maxAge: 60 * 60 * 24 * 7, // 1 week
|
|
138
|
-
* })
|
|
139
|
-
*/
|
|
140
|
-
export function sessionMiddleware(options: SessionOptions): MiddlewareFn {
|
|
141
|
-
const secrets = Array.isArray(options.secret) ? options.secret : [options.secret]
|
|
142
|
-
if (secrets.length === 0 || secrets.some((s) => !s)) {
|
|
143
|
-
throw new Error('@davaux/session: at least one non-empty secret is required')
|
|
144
|
-
}
|
|
145
|
-
if (secrets.some((s) => s.length < 32)) {
|
|
146
|
-
console.warn(
|
|
147
|
-
'@davaux/session: secret should be at least 32 characters — short secrets are weak and easy to brute-force',
|
|
148
|
-
)
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const name = options.cookieName ?? 'session'
|
|
152
|
-
|
|
153
|
-
return async (ctx, next) => {
|
|
154
|
-
const raw = ctx.cookies.get(name)
|
|
155
|
-
const existing = raw ? verify(raw, secrets) : null
|
|
156
|
-
const session = new Session(existing ?? {})
|
|
157
|
-
ctx.state.session = session
|
|
158
|
-
|
|
159
|
-
// Inject the session cookie right before headers are committed.
|
|
160
|
-
// This approach handles the common case where the route handler calls
|
|
161
|
-
// res.writeHead() inside next(), after which res.setHeader() would fail.
|
|
162
|
-
const originalWriteHead = ctx.res.writeHead.bind(ctx.res) as typeof ctx.res.writeHead
|
|
163
|
-
// biome-ignore lint/suspicious/noExplicitAny: writeHead has multiple overloads
|
|
164
|
-
ctx.res.writeHead = ((...args: any[]) => {
|
|
165
|
-
ctx.res.writeHead = originalWriteHead
|
|
166
|
-
if (session.destroyed) {
|
|
167
|
-
ctx.cookies.delete(name, { path: options.path ?? '/' })
|
|
168
|
-
} else if (session.dirty) {
|
|
169
|
-
const payload = Buffer.from(JSON.stringify(session.data)).toString('base64url')
|
|
170
|
-
const signed = sign(payload, secrets[0])
|
|
171
|
-
ctx.cookies.set(name, signed, {
|
|
172
|
-
httpOnly: options.httpOnly ?? true,
|
|
173
|
-
secure: options.secure ?? false,
|
|
174
|
-
sameSite: options.sameSite ?? 'Lax',
|
|
175
|
-
maxAge: options.maxAge,
|
|
176
|
-
path: options.path ?? '/',
|
|
177
|
-
domain: options.domain,
|
|
178
|
-
})
|
|
179
|
-
}
|
|
180
|
-
// biome-ignore lint/suspicious/noExplicitAny: forwarding overloaded args
|
|
181
|
-
return (originalWriteHead as (...a: any[]) => ServerResponse)(...args)
|
|
182
|
-
}) as typeof ctx.res.writeHead
|
|
183
|
-
|
|
184
|
-
await next()
|
|
185
|
-
}
|
|
186
|
-
}
|
package/tsconfig.json
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ESNext",
|
|
4
|
-
"module": "NodeNext",
|
|
5
|
-
"moduleResolution": "NodeNext",
|
|
6
|
-
"strict": true,
|
|
7
|
-
"declaration": true,
|
|
8
|
-
"declarationMap": true,
|
|
9
|
-
"sourceMap": true,
|
|
10
|
-
"outDir": "./dist",
|
|
11
|
-
"rootDir": "./src",
|
|
12
|
-
"skipLibCheck": true,
|
|
13
|
-
"lib": ["ESNext"],
|
|
14
|
-
"types": ["node"]
|
|
15
|
-
},
|
|
16
|
-
"include": ["src/**/*"]
|
|
17
|
-
}
|