@prairielearn/session 2.0.6 → 3.0.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/src/index.ts CHANGED
@@ -2,22 +2,18 @@ import type { Request, Response, NextFunction } from 'express';
2
2
  import onHeaders from 'on-headers';
3
3
  import signature from 'cookie-signature';
4
4
  import asyncHandler from 'express-async-handler';
5
+ import cookie from 'cookie';
5
6
 
6
- import { SessionStore } from './store';
7
- import { beforeEnd } from './before-end';
8
- import {
9
- type CookieSecure,
10
- shouldSecureCookie,
11
- getSessionCookie,
12
- getSessionIdFromCookie,
13
- } from './cookie';
7
+ import { type SessionStore } from './store.js';
8
+ import { beforeEnd } from './before-end.js';
9
+ import { type CookieSecure, shouldSecureCookie, getSessionIdFromCookie } from './cookie.js';
14
10
  import {
15
11
  type Session,
16
12
  generateSessionId,
17
13
  loadSession,
18
14
  hashSession,
19
15
  truncateExpirationDate,
20
- } from './session';
16
+ } from './session.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
- * If multiple names are provided, the first one is used as the primary
37
- * name for setting the cookie. The other names are used as fallbacks when
38
- * reading the cookie in requests. If a fallback name is found in a request,
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
- name?: string | string[];
42
- secure?: CookieSecure;
43
- httpOnly?: boolean;
44
- domain?: string;
45
- sameSite?: boolean | 'none' | 'lax' | 'strict';
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 cookieNames = getCookieNames(options.cookie?.name);
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 sessionCookie = getSessionCookie(req, cookieNames);
68
- const cookieSessionId = getSessionIdFromCookie(sessionCookie?.value, secrets);
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
- cookieNames.forEach((cookieName) => {
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
- const needsRotation = sessionCookie?.name && sessionCookie?.name !== primaryCookieName;
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 || needsRotation) {
135
+ if (isNewSession || didExpirationChange || !hasAllCookies) {
104
136
  const signedSessionId = signSessionId(req.session.id, secrets[0]);
105
- res.cookie(primaryCookieName, signedSessionId, {
106
- secure: secureCookie,
107
- httpOnly: options.cookie?.httpOnly ?? true,
108
- domain: options.cookie?.domain,
109
- sameSite: options.cookie?.sameSite ?? false,
110
- expires: req.session.getExpirationDate(),
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
  }
@@ -1,4 +1,4 @@
1
- import { SessionStore, SessionStoreData } from './store';
1
+ import { SessionStore, SessionStoreData } from './store.js';
2
2
 
3
3
  export class MemoryStore implements SessionStore {
4
4
  private sessions = new Map<string, { expiresAt: Date; data: string }>();
@@ -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
@@ -2,7 +2,7 @@ import type { Request } from 'express';
2
2
  import uid from 'uid-safe';
3
3
  import crypto from 'node:crypto';
4
4
 
5
- import type { SessionStore } from './store';
5
+ import { SessionStore } from './store.js';
6
6
 
7
7
  export interface Session {
8
8
  id: string;