UNPKG

@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
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 };