@thi.ng/server
Version:
Minimal HTTP server with declarative routing, static file serving and freely extensible via pre/post interceptors
146 lines (145 loc) • 4.43 kB
JavaScript
import { isNumber, isString } from "@thi.ng/checks";
import { uuid } from "@thi.ng/uuid";
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { ServerResponse } from "node:http";
import { inMemorySessionStore } from "./memory.js";
class SessionInterceptor {
factory;
onInvalid;
store;
meta = /* @__PURE__ */ new WeakMap();
secret;
cookieName;
cookieOpts;
constructor({
factory,
store = inMemorySessionStore(),
cookieName = "__sid",
cookieOpts = "Secure;HttpOnly;SameSite=Strict;Path=/",
secret = 32,
onInvalid = async (ctx, session) => {
session.expireCookie(ctx);
ctx.res.forbidden();
return false;
}
}) {
this.factory = factory;
this.onInvalid = onInvalid;
this.store = store;
this.secret = isNumber(secret) ? randomBytes(secret) : isString(secret) ? Buffer.from(secret) : secret;
this.cookieName = cookieName;
this.cookieOpts = cookieOpts;
}
async pre(ctx) {
const cookie = ctx.cookies?.[this.cookieName];
let session;
if (cookie) {
session = await this.validateSession(cookie);
if (!session) {
return await this.onInvalid(ctx, this);
}
}
if (!session || session.ip !== ctx.req.socket.remoteAddress) {
session = await this.newSession(ctx);
if (!session) return false;
}
ctx.session = session;
this.withSession(ctx.res, session);
return true;
}
/**
* Attempts to delete session for given ID and if successful also sets
* force-expired cookie in response.
*
* @remarks
* Intended for logout handlers and/or switching sessions when a user has
* successfully authenticated (to avoid session fixation).
*
* @param ctx
* @param sessionID
*/
async deleteSession(ctx, sessionID) {
if (await this.store.delete(sessionID)) {
ctx.logger.info("delete session:", sessionID);
ctx.session = void 0;
this.expireCookie(ctx);
}
}
/**
* Creates a new session object (via configured
* {@link SessionOpts.factory}), pre-computes HMAC and submits it to
* configured {@link SessionOpts.store}. If successful, Returns session ,
* otherwise returns `undefined`.
*
* @param ctx
*/
async newSession(ctx) {
const session = this.factory(ctx);
ctx.logger.info("new session:", session.id);
if (!await this.store.set(session)) {
ctx.logger.warn("could not store session...");
return;
}
const hmac = createHmac("sha256", this.secret).update(session.id, "ascii").update(randomBytes(8)).digest();
this.meta.set(session, {
hmac,
cookie: session.id + ":" + hmac.toString("base64url")
});
return session;
}
/**
* Calls {@link SessionInterceptor.newSession} to create a new session and,
* if successful, associates it with current context & response. Deletes
* existing session (if any). Returns new session object.
*
* @param ctx
*/
async replaceSession(ctx) {
const session = await this.newSession(ctx);
if (session) {
if (ctx.session?.id) this.store.delete(ctx.session.id);
ctx.session = session;
this.withSession(ctx.res, session);
return session;
}
}
withSession(res, session) {
const cookie = this.meta.get(session)?.cookie;
return res.appendHeader(
"set-cookie",
`${this.cookieName}=${cookie};Max-Age=${this.store.ttl};${this.cookieOpts}`
);
}
async validateSession(cookie) {
const parts = cookie.split(":");
if (parts.length !== 2) return;
const session = await this.store.get(parts[0]);
if (!session) return;
const actual = Buffer.from(parts[1], "base64url");
const expected = this.meta.get(session)?.hmac;
if (!expected) return;
const sameLength = actual.length === expected.length;
return timingSafeEqual(sameLength ? actual : expected, expected) && sameLength ? session : void 0;
}
/**
* Appends `set-cookie` header which forces immediate expiry of session cookie.
*
* @param ctx
*/
expireCookie(ctx) {
ctx.res.appendHeader(
"set-cookie",
`${this.cookieName}=;Expires=Thu, 01 Jan 1970 00:00:00 GMT;${this.cookieOpts}`
);
}
}
const sessionInterceptor = (opts) => new SessionInterceptor(opts);
const createSession = (ctx) => ({
id: uuid(),
ip: ctx.req.socket.remoteAddress
});
export {
SessionInterceptor,
createSession,
sessionInterceptor
};