@prairielearn/session 2.0.6 → 3.0.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/.mocharc.cjs +3 -0
- package/CHANGELOG.md +12 -0
- package/README.md +18 -3
- package/dist/before-end.js +1 -5
- package/dist/before-end.js.map +1 -1
- package/dist/before-end.test.js +11 -16
- package/dist/before-end.test.js.map +1 -1
- package/dist/cookie.d.ts +0 -4
- package/dist/cookie.js +4 -33
- package/dist/cookie.js.map +1 -1
- package/dist/index.d.ts +25 -14
- package/dist/index.js +55 -47
- package/dist/index.js.map +1 -1
- package/dist/index.test.js +213 -210
- package/dist/index.test.js.map +1 -1
- package/dist/memory-store.d.ts +1 -1
- package/dist/memory-store.js +1 -5
- package/dist/memory-store.js.map +1 -1
- package/dist/session.d.ts +1 -1
- package/dist/session.js +9 -20
- package/dist/session.js.map +1 -1
- package/dist/session.test.js +35 -37
- package/dist/session.test.js.map +1 -1
- package/dist/store.js +1 -2
- package/dist/store.js.map +1 -1
- package/dist/test-utils.d.ts +2 -2
- package/dist/test-utils.js +1 -5
- package/dist/test-utils.js.map +1 -1
- package/package.json +10 -9
- package/src/before-end.test.ts +3 -2
- package/src/cookie.ts +0 -23
- package/src/index.test.ts +20 -9
- package/src/index.ts +71 -48
- package/src/memory-store.ts +1 -1
- package/src/session.test.ts +2 -2
- package/src/session.ts +3 -2
- package/src/test-utils.ts +2 -1
package/src/index.test.ts
CHANGED
|
@@ -1,16 +1,22 @@
|
|
|
1
|
-
import express from 'express';
|
|
2
1
|
import { assert } from 'chai';
|
|
3
|
-
import
|
|
4
|
-
import fetchCookie from 'fetch-cookie';
|
|
5
|
-
import { parse as parseSetCookie } from 'set-cookie-parser';
|
|
2
|
+
import express from 'express';
|
|
6
3
|
import asyncHandler from 'express-async-handler';
|
|
4
|
+
import fetchCookie from 'fetch-cookie';
|
|
5
|
+
import fetch from 'node-fetch';
|
|
6
|
+
import setCookie from 'set-cookie-parser';
|
|
7
|
+
|
|
7
8
|
import { withServer } from '@prairielearn/express-test-utils';
|
|
8
9
|
|
|
9
|
-
import {
|
|
10
|
-
|
|
10
|
+
import { MemoryStore } from './memory-store.js';
|
|
11
|
+
|
|
12
|
+
import { createSessionMiddleware } from './index.js';
|
|
11
13
|
|
|
12
14
|
const TEST_SECRET = 'test-secret';
|
|
13
15
|
|
|
16
|
+
function parseSetCookie(header: string) {
|
|
17
|
+
return setCookie.parse(setCookie.splitCookiesString(header));
|
|
18
|
+
}
|
|
19
|
+
|
|
14
20
|
describe('session middleware', () => {
|
|
15
21
|
it('sets a session cookie', async () => {
|
|
16
22
|
const app = express();
|
|
@@ -578,7 +584,9 @@ describe('session middleware', () => {
|
|
|
578
584
|
store,
|
|
579
585
|
secret: TEST_SECRET,
|
|
580
586
|
cookie: {
|
|
581
|
-
name:
|
|
587
|
+
name: 'legacy_session',
|
|
588
|
+
writeNames: ['legacy_session', 'session'],
|
|
589
|
+
writeOverrides: [{ domain: undefined }, { domain: '.example.com' }],
|
|
582
590
|
},
|
|
583
591
|
}),
|
|
584
592
|
);
|
|
@@ -604,8 +612,11 @@ describe('session middleware', () => {
|
|
|
604
612
|
const header = res.headers.get('set-cookie');
|
|
605
613
|
assert.isNotNull(header);
|
|
606
614
|
const cookies = parseSetCookie(header ?? '');
|
|
607
|
-
assert.equal(cookies.length,
|
|
608
|
-
assert.equal(cookies[0].name, '
|
|
615
|
+
assert.equal(cookies.length, 2);
|
|
616
|
+
assert.equal(cookies[0].name, 'legacy_session');
|
|
617
|
+
assert.isUndefined(cookies[0].domain);
|
|
618
|
+
assert.equal(cookies[1].name, 'session');
|
|
619
|
+
assert.equal(cookies[1].domain, '.example.com');
|
|
609
620
|
|
|
610
621
|
// Ensure that the legacy session is migrated to a new session.
|
|
611
622
|
assert.equal(newSessionId, legacySessionId);
|
package/src/index.ts
CHANGED
|
@@ -1,23 +1,19 @@
|
|
|
1
|
-
import
|
|
2
|
-
import onHeaders from 'on-headers';
|
|
1
|
+
import cookie from 'cookie';
|
|
3
2
|
import signature from 'cookie-signature';
|
|
3
|
+
import type { Request, Response, NextFunction } from 'express';
|
|
4
4
|
import asyncHandler from 'express-async-handler';
|
|
5
|
+
import onHeaders from 'on-headers';
|
|
5
6
|
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
type CookieSecure,
|
|
10
|
-
shouldSecureCookie,
|
|
11
|
-
getSessionCookie,
|
|
12
|
-
getSessionIdFromCookie,
|
|
13
|
-
} from './cookie';
|
|
7
|
+
import { beforeEnd } from './before-end.js';
|
|
8
|
+
import { type CookieSecure, shouldSecureCookie, getSessionIdFromCookie } from './cookie.js';
|
|
14
9
|
import {
|
|
15
10
|
type Session,
|
|
16
11
|
generateSessionId,
|
|
17
12
|
loadSession,
|
|
18
13
|
hashSession,
|
|
19
14
|
truncateExpirationDate,
|
|
20
|
-
} from './session';
|
|
15
|
+
} from './session.js';
|
|
16
|
+
import { type SessionStore } from './store.js';
|
|
21
17
|
|
|
22
18
|
declare global {
|
|
23
19
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
@@ -28,22 +24,34 @@ declare global {
|
|
|
28
24
|
}
|
|
29
25
|
}
|
|
30
26
|
|
|
27
|
+
interface CookieOptions {
|
|
28
|
+
secure?: CookieSecure;
|
|
29
|
+
httpOnly?: boolean;
|
|
30
|
+
domain?: string;
|
|
31
|
+
sameSite?: boolean | 'none' | 'lax' | 'strict';
|
|
32
|
+
maxAge?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
31
35
|
export interface SessionOptions {
|
|
32
36
|
secret: string | string[];
|
|
33
37
|
store: SessionStore;
|
|
34
|
-
cookie?: {
|
|
38
|
+
cookie?: CookieOptions & {
|
|
39
|
+
/**
|
|
40
|
+
* The name of the session cookie. The session is always read from this
|
|
41
|
+
* named cookie, but it may be written to multiple cookies if `writeNames`
|
|
42
|
+
* is provided.
|
|
43
|
+
*/
|
|
44
|
+
name?: string;
|
|
35
45
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* the cookie value will be transparently re-written to the primary name.
|
|
46
|
+
* Multiple write names can be provided to allow for a session cookie to be
|
|
47
|
+
* written to multiple names. This can be useful for a migration of a cookie
|
|
48
|
+
* to an explicit subdomain, for example.
|
|
40
49
|
*/
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
maxAge?: number;
|
|
50
|
+
writeNames?: string[];
|
|
51
|
+
/**
|
|
52
|
+
* Used with `writeNames` to provide additional options for each written cookie.
|
|
53
|
+
*/
|
|
54
|
+
writeOverrides?: Omit<CookieOptions, 'secure'>[];
|
|
47
55
|
};
|
|
48
56
|
}
|
|
49
57
|
|
|
@@ -54,18 +62,35 @@ const DEFAULT_COOKIE_MAX_AGE = 86400000; // 1 day
|
|
|
54
62
|
|
|
55
63
|
export function createSessionMiddleware(options: SessionOptions) {
|
|
56
64
|
const secrets = Array.isArray(options.secret) ? options.secret : [options.secret];
|
|
57
|
-
const
|
|
58
|
-
const primaryCookieName = cookieNames[0];
|
|
65
|
+
const cookieName = options.cookie?.name ?? DEFAULT_COOKIE_NAME;
|
|
59
66
|
const cookieMaxAge = options.cookie?.maxAge ?? DEFAULT_COOKIE_MAX_AGE;
|
|
60
67
|
const store = options.store;
|
|
61
68
|
|
|
69
|
+
// Ensure that the session cookie that we're reading from will be written to.
|
|
70
|
+
const writeCookieNames = options.cookie?.writeNames ?? [cookieName];
|
|
71
|
+
if (!writeCookieNames.includes(cookieName)) {
|
|
72
|
+
throw new Error('cookie.name must be included in cookie.writeNames');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Validate write overrides.
|
|
76
|
+
if (options.cookie?.writeOverrides && !options.cookie.writeNames) {
|
|
77
|
+
throw new Error('cookie.writeOverrides must be used with cookie.writeNames');
|
|
78
|
+
}
|
|
79
|
+
if (
|
|
80
|
+
options.cookie?.writeOverrides &&
|
|
81
|
+
options.cookie.writeOverrides.length !== writeCookieNames.length
|
|
82
|
+
) {
|
|
83
|
+
throw new Error('cookie.writeOverrides must have the same length as cookie.writeNames');
|
|
84
|
+
}
|
|
85
|
+
|
|
62
86
|
return asyncHandler(async function sessionMiddleware(
|
|
63
87
|
req: Request,
|
|
64
88
|
res: Response,
|
|
65
89
|
next: NextFunction,
|
|
66
90
|
) {
|
|
67
|
-
const
|
|
68
|
-
const
|
|
91
|
+
const cookies = cookie.parse(req.headers.cookie ?? '');
|
|
92
|
+
const sessionCookie = cookies[cookieName];
|
|
93
|
+
const cookieSessionId = getSessionIdFromCookie(sessionCookie, secrets);
|
|
69
94
|
const sessionId = cookieSessionId ?? (await generateSessionId());
|
|
70
95
|
req.session = await loadSession(sessionId, req, store, cookieMaxAge);
|
|
71
96
|
|
|
@@ -79,9 +104,15 @@ export function createSessionMiddleware(options: SessionOptions) {
|
|
|
79
104
|
// destroyed, clear the cookie.
|
|
80
105
|
//
|
|
81
106
|
// To cover all our bases, we'll clear *all* known session cookies to
|
|
82
|
-
// ensure that state sessions aren't left behind.
|
|
83
|
-
|
|
107
|
+
// ensure that state sessions aren't left behind. We'll also send commands
|
|
108
|
+
// to clear the cookies both on and off the explicit domain, to handle
|
|
109
|
+
// the case where the application has moved from one domain to another.
|
|
110
|
+
writeCookieNames.forEach((cookieName, i) => {
|
|
84
111
|
res.clearCookie(cookieName);
|
|
112
|
+
const domain = options.cookie?.writeOverrides?.[i]?.domain ?? options.cookie?.domain;
|
|
113
|
+
if (domain) {
|
|
114
|
+
res.clearCookie(cookieName, { domain: options.cookie?.domain });
|
|
115
|
+
}
|
|
85
116
|
});
|
|
86
117
|
return;
|
|
87
118
|
}
|
|
@@ -96,18 +127,22 @@ export function createSessionMiddleware(options: SessionOptions) {
|
|
|
96
127
|
return;
|
|
97
128
|
}
|
|
98
129
|
|
|
99
|
-
|
|
130
|
+
// Ensure that all known session cookies are set to the same value.
|
|
131
|
+
const hasAllCookies = writeCookieNames.every((cookieName) => !!cookies[cookieName]);
|
|
100
132
|
const isNewSession = !cookieSessionId || cookieSessionId !== req.session.id;
|
|
101
133
|
const didExpirationChange =
|
|
102
134
|
originalExpirationDate.getTime() !== req.session.getExpirationDate().getTime();
|
|
103
|
-
if (isNewSession || didExpirationChange ||
|
|
135
|
+
if (isNewSession || didExpirationChange || !hasAllCookies) {
|
|
104
136
|
const signedSessionId = signSessionId(req.session.id, secrets[0]);
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
137
|
+
writeCookieNames.forEach((cookieName, i) => {
|
|
138
|
+
res.cookie(cookieName, signedSessionId, {
|
|
139
|
+
secure: secureCookie,
|
|
140
|
+
httpOnly: options.cookie?.httpOnly ?? true,
|
|
141
|
+
domain: options.cookie?.domain,
|
|
142
|
+
sameSite: options.cookie?.sameSite ?? false,
|
|
143
|
+
expires: req.session.getExpirationDate(),
|
|
144
|
+
...(options.cookie?.writeOverrides?.[i] ?? {}),
|
|
145
|
+
});
|
|
111
146
|
});
|
|
112
147
|
}
|
|
113
148
|
});
|
|
@@ -168,18 +203,6 @@ export function createSessionMiddleware(options: SessionOptions) {
|
|
|
168
203
|
});
|
|
169
204
|
}
|
|
170
205
|
|
|
171
|
-
function getCookieNames(cookieName: string | string[] | undefined): string[] {
|
|
172
|
-
if (!cookieName) {
|
|
173
|
-
return [DEFAULT_COOKIE_NAME];
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
if (Array.isArray(cookieName) && cookieName.length === 0) {
|
|
177
|
-
throw new Error('cookie.name must not be an empty array');
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
return Array.isArray(cookieName) ? cookieName : [cookieName];
|
|
181
|
-
}
|
|
182
|
-
|
|
183
206
|
function signSessionId(sessionId: string, secret: string): string {
|
|
184
207
|
return signature.sign(sessionId, secret);
|
|
185
208
|
}
|
package/src/memory-store.ts
CHANGED
package/src/session.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { assert } from 'chai';
|
|
2
2
|
|
|
3
|
-
import { MemoryStore } from './memory-store';
|
|
4
|
-
import { loadSession, makeSession } from './session';
|
|
3
|
+
import { MemoryStore } from './memory-store.js';
|
|
4
|
+
import { loadSession, makeSession } from './session.js';
|
|
5
5
|
|
|
6
6
|
const SESSION_MAX_AGE = 10000;
|
|
7
7
|
const SESSION_EXPIRATION_DATE = new Date(Date.now() + SESSION_MAX_AGE);
|
package/src/session.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
|
|
1
3
|
import type { Request } from 'express';
|
|
2
4
|
import uid from 'uid-safe';
|
|
3
|
-
import crypto from 'node:crypto';
|
|
4
5
|
|
|
5
|
-
import
|
|
6
|
+
import { SessionStore } from './store.js';
|
|
6
7
|
|
|
7
8
|
export interface Session {
|
|
8
9
|
id: string;
|