Sessions
Cookie sessions: signed, encrypted, or a random id in front of a store.
Needs inkan 0.8
@inkanjs/session 0.2 needs inkan 0.8.0 or newer, below 1.0. It keeps sessions in any inkan
Store, with TTLs in milliseconds.
npm i @inkanjs/sessionimport { } from "@inkanjs/session";
type = { : number; : string[] };
.(<>({ : ..! }))
.("/login", { : .({ : .(), : .() }) }, async () => {
const = await checkPassword(.., ..);
.session.regenerate().set("userId", .id); // a new id at sign-in
return { : true };
})
.("/me", () => ({ : .session.get("userId") ?? null }))
.("/logout", () => {
.session.destroy();
return { : true };
});ctx.session is typed by the Data you give it, made only for a request that reads it, and
written back only when it changed. No dependencies: everything is node:crypto. Make a
secret with node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))".
Three ways to keep it
| the cookie holds | the client can read it | revocable | room | |
|---|---|---|---|---|
| signed (default) | the data and an HMAC-SHA256 | yes | no, valid until it expires | about 3 KB |
encrypt: true | the data, AES-256-GCM | no | no, valid until it expires | about 2.5 KB |
store | a random 256-bit id, signed | no | yes: destroy() deletes it | what the store takes |
A signed cookie cannot be changed, but anyone who has it can read it: keep nothing secret in
it. A cookie session lasts until it expires even after destroy(), if someone kept a copy.
When logout must end a session for certain, use a store.
import { memoryStore } from "@vxnsin/inkan";
import { session } from "@inkanjs/session";
app.register(session({ secret, store: memoryStore({ max: 50_000 }) }));The store is an inkan Store. memoryStore from @vxnsin/inkan lives in
the process: right for development and one process. For more, any Store works, such as
Redis, PostgreSQL,
MongoDB or MySQL:
import { redisStore } from "@inkanjs/redis";
app.register(session({ secret, store: redisStore(redis) }));The plugin uses the store's get, set and delete, keeps one record per session
({ d, c, f? }, plain JSON), and gives it a TTL of cookie.maxAge in milliseconds. A store
that is not a Store is refused when the plugin is made.
ctx.session
get(key), has(key) | read, typed by Data |
set(key, value), delete(key), clear() | change; the answer carries the new cookie |
regenerate() | a new id for the same data. Call it at sign-in: an id somebody planted or saw before is worth nothing afterwards (session fixation) |
destroy() | ends it: the cookie is cleared, the store forgets the id |
flash(type, message), flashes(type?) | messages for a later request, read once (below) |
id, isNew, changed, toJSON() |
Flash messages
A message for the next page, such as "Saved." after a form posts and redirects. It stays in the session until it is read, and is gone once it was.
.("/teas", async () => {
await saveTea(.);
.session.flash("success", "Tea saved.");
return .("/teas", 303);
})
.("/teas", () => {
const = .session.flashes("success"); // ["Tea saved."], then [] on the next request
return .(teaList());
});flash(type, message)adds a message of a type ("success","error", …); messages of a type are kept in order. Both are strings.flashes(type)hands back the messages of that type and removes them;flashes()all of them by type,{ success: ["Tea saved."] }.- They are part of the session: in the cookie, signed or encrypted, or in the store, so the
cookie's size limit counts them too.
clear()anddestroy()drop them. Reading when there are none sets no cookie. - Render them escaped, as the
htmltag does: a message may carry what a user typed.
Behaviour
- CSRF is not handled here. The cookie is
SameSite=Laxby default, which keeps it off cross-site POSTs from other sites in current browsers, and that is all. Lax still sends it on top-level GET navigations, and same-site subdomains are not other sites. If a form or afetchchanges something on the strength of this cookie, check the request's origin: use@inkanjs/auth, which does, a check ofSec-Fetch-Site/Originof your own, orsameSite: "strict". - Rolling sessions never end while used, unless you give
maxLifetime: then a session ends that long after it began (or after the lastregenerate()), however active it was. - A request that never reads
ctx.sessioncosts nothing and sets no cookie. One that reads but does not change it sets none either, unlessrolling. - An answer that sets the cookie gets
cache-control: private, no-storeunless the handler set its own, so no shared cache keeps one user's cookie for another. - A changed, forged, expired or foreign cookie is no session: the request gets a new, empty one. Every value is bound to the cookie's name.
- Secrets rotate: the first in the list signs and encrypts, every one is accepted. Put the
new one first; drop the old one once
maxAgehas passed. Each secret gives two keys through HKDF-SHA256, one to sign and one to encrypt. - A cookie over 4096 bytes is refused with an error (a 500) instead of being sent: browsers drop it without a word. Keep less in the session, or use a store.
- With a store, the session is read in an
onRequesthook, and only when the request brings a valid cookie. A forged id never reaches the store.
Options
| option | default | |
|---|---|---|
secret | required | at least 32 bytes (a string as UTF-8, or bytes); a list rotates them |
encrypt | false | AES-256-GCM instead of a signature; ignored with a store |
store | none | any inkan Store; the TTLs it gets are milliseconds, records plain JSON |
rolling | false | write the cookie on every answer, so a session lasts maxAge after the last request |
maxLifetime | none | seconds a session lasts at most from its start (or regenerate()), however active; with rolling you want one |
cookie.name | "sid" | |
cookie.maxAge | 86400 | seconds; checked on the server too, not only by the browser |
cookie.secure | true | only over HTTPS; browsers take it on http://localhost as well |
cookie.sameSite | "lax" | "strict", "lax" or "none" (which needs secure) |
cookie.httpOnly | true | |
cookie.path | "/" | |
cookie.domain | none |
OpenAPI
Unchanged. It is a shared plugin: registered on the app it serves every route, inside a plugin only that plugin's routes.