@prairielearn/session 1.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/.turbo/turbo-build.log +0 -0
- package/README.md +39 -0
- package/dist/before-end.d.ts +25 -0
- package/dist/before-end.js +81 -0
- package/dist/before-end.js.map +1 -0
- package/dist/before-end.test.d.ts +1 -0
- package/dist/before-end.test.js +33 -0
- package/dist/before-end.test.js.map +1 -0
- package/dist/cookie.d.ts +4 -0
- package/dist/cookie.js +34 -0
- package/dist/cookie.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +89 -0
- package/dist/index.js.map +1 -0
- package/dist/index.test.d.ts +1 -0
- package/dist/index.test.js +506 -0
- package/dist/index.test.js.map +1 -0
- package/dist/memory-store.d.ts +7 -0
- package/dist/memory-store.js +28 -0
- package/dist/memory-store.js.map +1 -0
- package/dist/session.d.ts +14 -0
- package/dist/session.js +78 -0
- package/dist/session.js.map +1 -0
- package/dist/session.test.d.ts +1 -0
- package/dist/session.test.js +92 -0
- package/dist/session.test.js.map +1 -0
- package/dist/store.d.ts +9 -0
- package/dist/store.js +3 -0
- package/dist/store.js.map +1 -0
- package/dist/test-utils.d.ts +10 -0
- package/dist/test-utils.js +32 -0
- package/dist/test-utils.js.map +1 -0
- package/package.json +44 -0
- package/src/before-end.test.ts +34 -0
- package/src/before-end.ts +96 -0
- package/src/cookie.ts +38 -0
- package/src/index.test.ts +628 -0
- package/src/index.ts +132 -0
- package/src/memory-store.ts +25 -0
- package/src/session.test.ts +122 -0
- package/src/session.ts +106 -0
- package/src/store.ts +10 -0
- package/src/test-utils.ts +42 -0
- package/tsconfig.json +11 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { Request, Response, NextFunction } from 'express';
|
|
2
|
+
import onHeaders from 'on-headers';
|
|
3
|
+
import signature from 'cookie-signature';
|
|
4
|
+
import asyncHandler from 'express-async-handler';
|
|
5
|
+
|
|
6
|
+
import { SessionStore } from './store';
|
|
7
|
+
import { beforeEnd } from './before-end';
|
|
8
|
+
import { getSessionIdFromCookie, type CookieSecure, shouldSecureCookie } from './cookie';
|
|
9
|
+
import { type Session, generateSessionId, loadSession, hashSession } from './session';
|
|
10
|
+
|
|
11
|
+
declare global {
|
|
12
|
+
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
13
|
+
namespace Express {
|
|
14
|
+
interface Request {
|
|
15
|
+
session: Session;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SessionOptions {
|
|
21
|
+
secret: string | string[];
|
|
22
|
+
store: SessionStore;
|
|
23
|
+
canSetCookie?: (req: Request) => boolean;
|
|
24
|
+
cookie?: {
|
|
25
|
+
name?: string;
|
|
26
|
+
secure?: CookieSecure;
|
|
27
|
+
httpOnly?: boolean;
|
|
28
|
+
sameSite?: boolean | 'none' | 'lax' | 'strict';
|
|
29
|
+
maxAge?: number;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const DEFAULT_COOKIE_NAME = 'session';
|
|
34
|
+
const DEFAULT_COOKIE_MAX_AGE = 86400000; // 1 day
|
|
35
|
+
|
|
36
|
+
export function createSessionMiddleware(options: SessionOptions) {
|
|
37
|
+
const secrets = Array.isArray(options.secret) ? options.secret : [options.secret];
|
|
38
|
+
const cookieName = options.cookie?.name ?? DEFAULT_COOKIE_NAME;
|
|
39
|
+
const cookieMaxAge = options.cookie?.maxAge ?? DEFAULT_COOKIE_MAX_AGE;
|
|
40
|
+
const store = options.store;
|
|
41
|
+
|
|
42
|
+
return asyncHandler(async function sessionMiddleware(
|
|
43
|
+
req: Request,
|
|
44
|
+
res: Response,
|
|
45
|
+
next: NextFunction,
|
|
46
|
+
) {
|
|
47
|
+
const cookieSessionId = getSessionIdFromCookie(req, cookieName, secrets);
|
|
48
|
+
const sessionId = cookieSessionId ?? (await generateSessionId());
|
|
49
|
+
req.session = await loadSession(sessionId, req, store, cookieMaxAge);
|
|
50
|
+
|
|
51
|
+
const originalHash = hashSession(req.session);
|
|
52
|
+
const originalExpirationDate = req.session.getExpirationDate();
|
|
53
|
+
|
|
54
|
+
const canSetCookie = options.canSetCookie?.(req) ?? true;
|
|
55
|
+
|
|
56
|
+
onHeaders(res, () => {
|
|
57
|
+
if (!req.session) {
|
|
58
|
+
if (cookieSessionId) {
|
|
59
|
+
// If the request arrived with a session cookie but the session was
|
|
60
|
+
// destroyed, clear the cookie.
|
|
61
|
+
res.clearCookie(cookieName);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// There is no session to do anything with.
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const secureCookie = shouldSecureCookie(req, options.cookie?.secure ?? 'auto');
|
|
70
|
+
if (secureCookie && req.protocol !== 'https') {
|
|
71
|
+
// Avoid sending cookie over insecure connection.
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const isNewSession = !cookieSessionId || cookieSessionId !== req.session.id;
|
|
76
|
+
const didExpirationChange =
|
|
77
|
+
originalExpirationDate.getTime() !== req.session.getExpirationDate().getTime();
|
|
78
|
+
if (canSetCookie && (isNewSession || didExpirationChange)) {
|
|
79
|
+
const signedSessionId = signSessionId(req.session.id, secrets[0]);
|
|
80
|
+
res.cookie(cookieName, signedSessionId, {
|
|
81
|
+
secure: secureCookie,
|
|
82
|
+
httpOnly: options.cookie?.httpOnly ?? true,
|
|
83
|
+
sameSite: options.cookie?.sameSite ?? false,
|
|
84
|
+
expires: req.session.getExpirationDate(),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
beforeEnd(res, next, async () => {
|
|
90
|
+
if (!req.session) {
|
|
91
|
+
// There is no session to do anything with.
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const isExistingSession = cookieSessionId && cookieSessionId === req.session.id;
|
|
96
|
+
const hashChanged = hashSession(req.session) !== originalHash;
|
|
97
|
+
const didExpirationChange =
|
|
98
|
+
originalExpirationDate.getTime() !== req.session.getExpirationDate().getTime();
|
|
99
|
+
if (
|
|
100
|
+
(hashChanged && isExistingSession) ||
|
|
101
|
+
(canSetCookie && (!isExistingSession || didExpirationChange))
|
|
102
|
+
) {
|
|
103
|
+
// Only update the expiration date in the store if we were actually
|
|
104
|
+
// able to update the cookie too.
|
|
105
|
+
const expirationDate = canSetCookie
|
|
106
|
+
? req.session.getExpirationDate()
|
|
107
|
+
: originalExpirationDate;
|
|
108
|
+
|
|
109
|
+
await store.set(
|
|
110
|
+
req.session.id,
|
|
111
|
+
req.session,
|
|
112
|
+
// Cookies only support second-level resolution. To ensure consistency
|
|
113
|
+
// between the cookie and the store, truncate the expiration date to
|
|
114
|
+
// the nearest second.
|
|
115
|
+
truncateExpirationDate(expirationDate),
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
next();
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function signSessionId(sessionId: string, secret: string): string {
|
|
125
|
+
return signature.sign(sessionId, secret);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function truncateExpirationDate(date: Date) {
|
|
129
|
+
const time = date.getTime();
|
|
130
|
+
const truncatedTime = Math.floor(time / 1000) * 1000;
|
|
131
|
+
return new Date(truncatedTime);
|
|
132
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { SessionStore, SessionStoreData } from './store';
|
|
2
|
+
|
|
3
|
+
export class MemoryStore implements SessionStore {
|
|
4
|
+
private sessions = new Map<string, { expiresAt: Date; data: string }>();
|
|
5
|
+
|
|
6
|
+
async set(id: string, session: any, expiresAt: Date): Promise<void> {
|
|
7
|
+
this.sessions.set(id, {
|
|
8
|
+
expiresAt,
|
|
9
|
+
data: JSON.stringify(session),
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async get(id: string): Promise<SessionStoreData | null> {
|
|
14
|
+
const value = this.sessions.get(id);
|
|
15
|
+
if (!value) return null;
|
|
16
|
+
return {
|
|
17
|
+
expiresAt: value.expiresAt,
|
|
18
|
+
data: JSON.parse(value.data),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async destroy(id: string): Promise<void> {
|
|
23
|
+
this.sessions.delete(id);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { assert } from 'chai';
|
|
2
|
+
|
|
3
|
+
import { MemoryStore } from './memory-store';
|
|
4
|
+
import { hashSession, loadSession, makeSession } from './session';
|
|
5
|
+
|
|
6
|
+
const SESSION_MAX_AGE = 10000;
|
|
7
|
+
const SESSION_EXPIRATION_DATE = new Date(Date.now() + SESSION_MAX_AGE);
|
|
8
|
+
|
|
9
|
+
describe('session', () => {
|
|
10
|
+
describe('loadSession', () => {
|
|
11
|
+
it('loads session that does not exist', async () => {
|
|
12
|
+
const store = new MemoryStore();
|
|
13
|
+
|
|
14
|
+
const req = {} as any;
|
|
15
|
+
const session = await loadSession('123', req, store, SESSION_MAX_AGE);
|
|
16
|
+
|
|
17
|
+
assert.equal(session.id, '123');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('loads session from store', async () => {
|
|
21
|
+
const store = new MemoryStore();
|
|
22
|
+
await store.set('123', { foo: 'bar' }, SESSION_EXPIRATION_DATE);
|
|
23
|
+
|
|
24
|
+
const req = {} as any;
|
|
25
|
+
const session = await loadSession('123', req, store, SESSION_MAX_AGE);
|
|
26
|
+
|
|
27
|
+
assert.equal(session.id, '123');
|
|
28
|
+
assert.equal(session.foo, 'bar');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('does not try to overwrite existing session properties', async () => {
|
|
32
|
+
const store = new MemoryStore();
|
|
33
|
+
await store.set('123', { foo: 'bar', id: '456' }, SESSION_EXPIRATION_DATE);
|
|
34
|
+
|
|
35
|
+
const req = {} as any;
|
|
36
|
+
const session = await loadSession('123', req, store, SESSION_MAX_AGE);
|
|
37
|
+
|
|
38
|
+
assert.equal(session.id, '123');
|
|
39
|
+
assert.equal(session.foo, 'bar');
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('makeSession', () => {
|
|
44
|
+
it('has immutable properties', () => {
|
|
45
|
+
const store = new MemoryStore();
|
|
46
|
+
|
|
47
|
+
const req = {} as any;
|
|
48
|
+
const session = makeSession('123', req, store, SESSION_EXPIRATION_DATE, SESSION_MAX_AGE);
|
|
49
|
+
|
|
50
|
+
assert.equal(session.id, '123');
|
|
51
|
+
|
|
52
|
+
const originalId = session.id;
|
|
53
|
+
const originalDestroy = session.destroy;
|
|
54
|
+
const originalRegenerate = session.regenerate;
|
|
55
|
+
|
|
56
|
+
assert.throw(() => {
|
|
57
|
+
session.id = '456';
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
assert.throw(() => {
|
|
61
|
+
session.destroy = async () => {};
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
assert.throw(() => {
|
|
65
|
+
session.regenerate = async () => {};
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
assert.equal(session.id, originalId);
|
|
69
|
+
assert.equal(session.destroy, originalDestroy);
|
|
70
|
+
assert.equal(session.regenerate, originalRegenerate);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('has immutable destroy property', async () => {
|
|
74
|
+
const store = new MemoryStore();
|
|
75
|
+
|
|
76
|
+
const req = {} as any;
|
|
77
|
+
const session = makeSession('123', req, store, SESSION_EXPIRATION_DATE, SESSION_MAX_AGE);
|
|
78
|
+
|
|
79
|
+
assert.throw(() => {
|
|
80
|
+
session.destroy = async () => {};
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('can destroy itself', async () => {
|
|
85
|
+
const store = new MemoryStore();
|
|
86
|
+
|
|
87
|
+
const req = {} as any;
|
|
88
|
+
const session = makeSession('123', req, store, SESSION_EXPIRATION_DATE, SESSION_MAX_AGE);
|
|
89
|
+
req.session = session;
|
|
90
|
+
|
|
91
|
+
await session.destroy();
|
|
92
|
+
|
|
93
|
+
assert.isUndefined(req.session);
|
|
94
|
+
assert.isNull(await store.get('123'));
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('can regenerate itself', async () => {
|
|
98
|
+
const store = new MemoryStore();
|
|
99
|
+
|
|
100
|
+
const req = {} as any;
|
|
101
|
+
const session = makeSession('123', req, store, SESSION_EXPIRATION_DATE, SESSION_MAX_AGE);
|
|
102
|
+
req.session = session;
|
|
103
|
+
|
|
104
|
+
await session.regenerate();
|
|
105
|
+
|
|
106
|
+
assert.notEqual(req.session, session);
|
|
107
|
+
assert.notEqual(req.session.id, '123');
|
|
108
|
+
assert.isNull(await store.get('123'));
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('hashSession', () => {
|
|
113
|
+
it('ignores the cookie property', () => {
|
|
114
|
+
const hash1 = hashSession({ id: '123' } as any);
|
|
115
|
+
const hash2 = hashSession({ id: '123', cookie: { foo: 'bar' } } as any);
|
|
116
|
+
|
|
117
|
+
assert.equal(hash1, hash2);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('has cookie property', () => {});
|
|
122
|
+
});
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { Request } from 'express';
|
|
2
|
+
import uid from 'uid-safe';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
import type { SessionStore } from './store';
|
|
6
|
+
|
|
7
|
+
export interface Session {
|
|
8
|
+
id: string;
|
|
9
|
+
destroy(): Promise<void>;
|
|
10
|
+
regenerate(): Promise<void>;
|
|
11
|
+
setExpiration(expiry: Date | number): void;
|
|
12
|
+
getExpirationDate(): Date;
|
|
13
|
+
[key: string]: any;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function generateSessionId(): Promise<string> {
|
|
17
|
+
return await uid(24);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function loadSession(
|
|
21
|
+
sessionId: string,
|
|
22
|
+
req: Request,
|
|
23
|
+
store: SessionStore,
|
|
24
|
+
maxAge: number,
|
|
25
|
+
): Promise<Session> {
|
|
26
|
+
const sessionStoreData = await store.get(sessionId);
|
|
27
|
+
const expiresAt = sessionStoreData?.expiresAt ?? null;
|
|
28
|
+
|
|
29
|
+
const session = makeSession(sessionId, req, store, expiresAt, maxAge);
|
|
30
|
+
|
|
31
|
+
// Copy session data into the session object.
|
|
32
|
+
if (sessionStoreData != null) {
|
|
33
|
+
const { data } = sessionStoreData;
|
|
34
|
+
for (const prop in data) {
|
|
35
|
+
if (!(prop in session)) {
|
|
36
|
+
session[prop] = data[prop];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return session;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function makeSession(
|
|
45
|
+
sessionId: string,
|
|
46
|
+
req: Request,
|
|
47
|
+
store: SessionStore,
|
|
48
|
+
expirationDate: Date | null,
|
|
49
|
+
maxAge: number,
|
|
50
|
+
): Session {
|
|
51
|
+
const session = {};
|
|
52
|
+
|
|
53
|
+
let expiresAt = expirationDate;
|
|
54
|
+
|
|
55
|
+
defineStaticProperty<Session['id']>(session, 'id', sessionId);
|
|
56
|
+
|
|
57
|
+
defineStaticProperty<Session['destroy']>(session, 'destroy', async () => {
|
|
58
|
+
delete (req as any).session;
|
|
59
|
+
await store.destroy(sessionId);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
defineStaticProperty<Session['regenerate']>(session, 'regenerate', async () => {
|
|
63
|
+
await store.destroy(sessionId);
|
|
64
|
+
req.session = makeSession(await generateSessionId(), req, store, null, maxAge);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
defineStaticProperty<Session['getExpirationDate']>(session, 'getExpirationDate', () => {
|
|
68
|
+
if (expiresAt == null) {
|
|
69
|
+
expiresAt = new Date(Date.now() + maxAge);
|
|
70
|
+
}
|
|
71
|
+
return expiresAt;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
defineStaticProperty<Session['setExpiration']>(session, 'setExpiration', (expiration) => {
|
|
75
|
+
if (typeof expiration === 'number') {
|
|
76
|
+
expiresAt = new Date(Date.now() + expiration);
|
|
77
|
+
} else {
|
|
78
|
+
expiresAt = expiration;
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return session as Session;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function hashSession(session: Session): string {
|
|
86
|
+
const str = JSON.stringify(session, function (key, val) {
|
|
87
|
+
// ignore cookie property on the root object
|
|
88
|
+
if (this === session && key === 'cookie') {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return val;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// hash
|
|
96
|
+
return crypto.createHash('sha1').update(str, 'utf8').digest('hex');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function defineStaticProperty<T>(obj: object, name: string, fn: T) {
|
|
100
|
+
Object.defineProperty(obj, name, {
|
|
101
|
+
configurable: false,
|
|
102
|
+
enumerable: false,
|
|
103
|
+
writable: false,
|
|
104
|
+
value: fn,
|
|
105
|
+
});
|
|
106
|
+
}
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface SessionStoreData {
|
|
2
|
+
data: any;
|
|
3
|
+
expiresAt: Date;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface SessionStore {
|
|
7
|
+
set(id: string, session: any, expiresAt: Date): Promise<void>;
|
|
8
|
+
get(id: string): Promise<SessionStoreData | null>;
|
|
9
|
+
destroy(id: string): Promise<void>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { Server } from 'node:http';
|
|
3
|
+
|
|
4
|
+
interface WithServerContext {
|
|
5
|
+
server: Server;
|
|
6
|
+
port: number;
|
|
7
|
+
url: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function withServer(
|
|
11
|
+
app: express.Express,
|
|
12
|
+
fn: (ctx: WithServerContext) => Promise<void>,
|
|
13
|
+
) {
|
|
14
|
+
const server = app.listen();
|
|
15
|
+
|
|
16
|
+
await new Promise<void>((resolve, reject) => {
|
|
17
|
+
server.on('listening', () => resolve());
|
|
18
|
+
server.on('error', (err) => reject(err));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
await fn({
|
|
23
|
+
server,
|
|
24
|
+
port: getServerPort(server),
|
|
25
|
+
url: `http://localhost:${getServerPort(server)}`,
|
|
26
|
+
});
|
|
27
|
+
} finally {
|
|
28
|
+
server.close();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getServerPort(server: Server): number {
|
|
33
|
+
const address = server.address();
|
|
34
|
+
|
|
35
|
+
// istanbul ignore next
|
|
36
|
+
if (!address) throw new Error('Server is not listening');
|
|
37
|
+
|
|
38
|
+
// istanbul ignore next
|
|
39
|
+
if (typeof address === 'string') throw new Error('Server is listening on a pipe');
|
|
40
|
+
|
|
41
|
+
return address.port;
|
|
42
|
+
}
|