@nifrajs/auth 2.1.0 → 2.3.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/README.md +3 -3
- package/dist/csrf.d.ts +2 -2
- package/dist/csrf.js +1 -1
- package/dist/guards.d.ts +2 -2
- package/dist/guards.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/session.d.ts +8 -8
- package/dist/session.js +7 -7
- package/dist/store.d.ts +8 -8
- package/dist/store.js +4 -4
- package/package.json +3 -3
- package/src/csrf.ts +3 -3
- package/src/guards.ts +3 -3
- package/src/index.ts +1 -1
- package/src/session.ts +13 -13
- package/src/store.ts +8 -8
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @nifrajs/auth
|
|
2
2
|
|
|
3
|
-
Session primitives for nifra
|
|
3
|
+
Session primitives for nifra - signed-cookie + server-store sessions, CSRF, and route guards. Bring your own identity (Better Auth / Lucia / OAuth); nifra owns the session.
|
|
4
4
|
|
|
5
|
-
Part of the **[nifra](https://nifra.dev)** full-stack TypeScript framework
|
|
5
|
+
Part of the **[nifra](https://nifra.dev)** full-stack TypeScript framework - one core, five UI libraries, every runtime. Scaffold a new app with `bun create nifra`.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -19,7 +19,7 @@ MIT
|
|
|
19
19
|
|
|
20
20
|
## For AI agents
|
|
21
21
|
|
|
22
|
-
Start with [`LLM.md`](./LLM.md)
|
|
22
|
+
Start with [`LLM.md`](./LLM.md) - this package's contract card (the exports you call + its footguns),
|
|
23
23
|
one cheap read instead of the whole corpus. For the wider framework: the repo's
|
|
24
24
|
[`AGENTS.md`](../../AGENTS.md) is the copy-paste quick reference, and
|
|
25
25
|
[`llms-full.txt`](../../llms-full.txt) is the full machine-readable corpus. Run `nifra check` as the
|
package/dist/csrf.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CSRF protection via an **Origin / Referer check** on state-changing requests
|
|
2
|
+
* CSRF protection via an **Origin / Referer check** on state-changing requests - OWASP's recommended
|
|
3
3
|
* defense for cookie-authenticated apps. A browser always attaches `Origin` (or at least `Referer`) to
|
|
4
4
|
* a cross-origin or same-origin *unsafe* request; it must match an allowed origin, else `403`. Safe
|
|
5
5
|
* methods (GET/HEAD/OPTIONS) pass. Apply with `app.use(csrf({ origins: ["https://example.com"] }))`.
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import type { Middleware } from "@nifrajs/core/server";
|
|
8
8
|
export interface CsrfOptions {
|
|
9
9
|
/**
|
|
10
|
-
* Allowed origins (e.g. `["https://example.com"]`). **Set this in production behind a proxy**
|
|
10
|
+
* Allowed origins (e.g. `["https://example.com"]`). **Set this in production behind a proxy** - when
|
|
11
11
|
* omitted, the check derives same-origin from the request URL, which is correct in dev / when the
|
|
12
12
|
* proxy preserves `Host` but not when the public origin differs from the worker's.
|
|
13
13
|
*/
|
package/dist/csrf.js
CHANGED
|
@@ -11,7 +11,7 @@ export function csrf(options = {}) {
|
|
|
11
11
|
const origin = req.headers.get("origin");
|
|
12
12
|
if (origin !== null)
|
|
13
13
|
return allowed.has(origin) ? undefined : forbidden();
|
|
14
|
-
// Some same-origin requests omit `Origin`
|
|
14
|
+
// Some same-origin requests omit `Origin` - fall back to the `Referer`'s origin.
|
|
15
15
|
const referer = req.headers.get("referer");
|
|
16
16
|
if (referer !== null) {
|
|
17
17
|
let refererOrigin;
|
package/dist/guards.d.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Route guards
|
|
2
|
+
* Route guards - run at the top of a protected loader/action/handler. On a missing session they
|
|
3
3
|
* **throw a `Response`** (a 302 to your login path, or a 401); nifra returns a thrown Response as-is, so
|
|
4
4
|
* the guard short-circuits the rest of the handler. Pairs with `@nifrajs/auth` sessions but only needs the
|
|
5
5
|
* `Session` shape, so it's framework-agnostic.
|
|
6
6
|
*/
|
|
7
7
|
import type { Session } from "./session.js";
|
|
8
|
-
/** What a guard does when the check fails: 302 to `redirectTo` (a same-origin path), or
|
|
8
|
+
/** What a guard does when the check fails: 302 to `redirectTo` (a same-origin path), or - omitted - a
|
|
9
9
|
* 401 JSON (`{ ok: false, error: "unauthorized" }`). */
|
|
10
10
|
export interface GuardOptions {
|
|
11
11
|
readonly redirectTo?: string;
|
package/dist/guards.js
CHANGED
|
@@ -4,7 +4,7 @@ const rejection = (options) => {
|
|
|
4
4
|
return Response.json({ ok: false, error: "unauthorized" }, { status: 401 });
|
|
5
5
|
}
|
|
6
6
|
// Same-origin guard (mirrors @nifrajs/web `redirect`): a single leading "/", never "//host" or an
|
|
7
|
-
// absolute URL. `redirectTo` is dev-authored, so a bad value is a config bug
|
|
7
|
+
// absolute URL. `redirectTo` is dev-authored, so a bad value is a config bug - fail loud here.
|
|
8
8
|
if (!to.startsWith("/") || to.startsWith("//")) {
|
|
9
9
|
throw new Error(`[nifra/auth] guard redirectTo must be a same-origin path beginning with "/" (got ${JSON.stringify(to)})`);
|
|
10
10
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@nifrajs/auth`
|
|
2
|
+
* `@nifrajs/auth` - session primitives for nifra. Signed-cookie + server-store sessions, with the same
|
|
3
3
|
* pluggable-store + prod-guard discipline as the ISR cache. **Bring your own identity** (Better Auth,
|
|
4
4
|
* Lucia, your own OAuth/password logic); nifra owns the session cookie, store, CSRF, and route guards.
|
|
5
5
|
*/
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@nifrajs/auth`
|
|
2
|
+
* `@nifrajs/auth` - session primitives for nifra. Signed-cookie + server-store sessions, with the same
|
|
3
3
|
* pluggable-store + prod-guard discipline as the ISR cache. **Bring your own identity** (Better Auth,
|
|
4
4
|
* Lucia, your own OAuth/password logic); nifra owns the session cookie, store, CSRF, and route guards.
|
|
5
5
|
*/
|
package/dist/session.d.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The session manager
|
|
2
|
+
* The session manager - `createSessions<Data>()` returns get/commit/destroy/regenerate bound to a
|
|
3
3
|
* signing secret + cookie config. Two modes:
|
|
4
4
|
* • **store mode** (a {@link SessionStore} is given): a random session id rides a signed cookie; the
|
|
5
5
|
* data lives in the store. Big/sensitive data, server-side revocation.
|
|
6
6
|
* • **cookie mode** (no store): the data itself is signed into the cookie (stateless, edge-friendly,
|
|
7
7
|
* ≤4 KB). No server state.
|
|
8
8
|
* The cookie value is **always HMAC-signed** (tamper-evident; verified constant-time) and the session
|
|
9
|
-
* cookie is always `HttpOnly`. Reads **fail closed**
|
|
9
|
+
* cookie is always `HttpOnly`. Reads **fail closed** - a tampered/missing/expired cookie yields a fresh
|
|
10
10
|
* anonymous session, never an error.
|
|
11
11
|
*/
|
|
12
12
|
import { type CookieOptions } from "@nifrajs/core/cookies";
|
|
13
13
|
import type { SessionStore } from "./store.js";
|
|
14
|
-
/** The cookie + response surface the manager needs
|
|
14
|
+
/** The cookie + response surface the manager needs - a structural subset of nifra's `Context`, so any
|
|
15
15
|
* `c` satisfies it and it's testable with a stub. */
|
|
16
16
|
export interface SessionContext {
|
|
17
17
|
readonly cookies: Readonly<Record<string, string>>;
|
|
@@ -23,7 +23,7 @@ export interface SessionContext {
|
|
|
23
23
|
}): void;
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
|
-
/** A typed session handle. Every key is optional
|
|
26
|
+
/** A typed session handle. Every key is optional - a fresh session is empty. */
|
|
27
27
|
export interface Session<Data extends Record<string, unknown> = Record<string, unknown>> {
|
|
28
28
|
get<K extends keyof Data>(key: K): Data[K] | undefined;
|
|
29
29
|
set<K extends keyof Data>(key: K, value: Data[K]): void;
|
|
@@ -35,7 +35,7 @@ export interface Session<Data extends Record<string, unknown> = Record<string, u
|
|
|
35
35
|
readonly data: Readonly<Partial<Data>>;
|
|
36
36
|
readonly isEmpty: boolean;
|
|
37
37
|
}
|
|
38
|
-
/** Cookie attributes a session may tune. `httpOnly` is **not** offered
|
|
38
|
+
/** Cookie attributes a session may tune. `httpOnly` is **not** offered - a session cookie is always
|
|
39
39
|
* HttpOnly; `maxAge`/`expires` are derived from the session's lifetime. */
|
|
40
40
|
export type SessionCookieOptions = Pick<CookieOptions, "secure" | "sameSite" | "path" | "domain">;
|
|
41
41
|
export interface SessionOptions {
|
|
@@ -52,20 +52,20 @@ export interface SessionOptions {
|
|
|
52
52
|
/** Extra cookie attributes (the session cookie is always HttpOnly; `Secure`/`SameSite=Lax`/`Path=/`
|
|
53
53
|
* default via `c.set.cookie`). Pass `{ secure: false }` for local http dev. */
|
|
54
54
|
readonly cookie?: SessionCookieOptions;
|
|
55
|
-
/** Clock (ms)
|
|
55
|
+
/** Clock (ms) - injected for testability; production passes `() => Date.now()` (the default). */
|
|
56
56
|
readonly now?: () => number;
|
|
57
57
|
}
|
|
58
58
|
export interface SessionManager<Data extends Record<string, unknown> = Record<string, unknown>> {
|
|
59
59
|
/** Load the session from the request cookie (verified + un-expired), or a fresh anonymous one. */
|
|
60
60
|
get(c: SessionContext): Promise<Session<Data>>;
|
|
61
|
-
/** Read-only load from a raw `Request`
|
|
61
|
+
/** Read-only load from a raw `Request` - for `@nifrajs/web` loaders (which can read the request but not
|
|
62
62
|
* write cookies). Identical verify/expiry to {@link get}; commit/destroy in a route or action. */
|
|
63
63
|
read(request: Request): Promise<Session<Data>>;
|
|
64
64
|
/** Persist the session: write the store (store mode) + the signed cookie. */
|
|
65
65
|
commit(c: SessionContext, session: Session<Data>): Promise<void>;
|
|
66
66
|
/** End the session: drop the store record (store mode) + clear the cookie. */
|
|
67
67
|
destroy(c: SessionContext, session?: Session<Data>): Promise<void>;
|
|
68
|
-
/** Rotate the session id on the next commit (call on privilege change
|
|
68
|
+
/** Rotate the session id on the next commit (call on privilege change - login - to defend against
|
|
69
69
|
* session fixation). Data is preserved; the old store record is dropped. */
|
|
70
70
|
regenerate(session: Session<Data>): void;
|
|
71
71
|
}
|
package/dist/session.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The session manager
|
|
2
|
+
* The session manager - `createSessions<Data>()` returns get/commit/destroy/regenerate bound to a
|
|
3
3
|
* signing secret + cookie config. Two modes:
|
|
4
4
|
* • **store mode** (a {@link SessionStore} is given): a random session id rides a signed cookie; the
|
|
5
5
|
* data lives in the store. Big/sensitive data, server-side revocation.
|
|
6
6
|
* • **cookie mode** (no store): the data itself is signed into the cookie (stateless, edge-friendly,
|
|
7
7
|
* ≤4 KB). No server state.
|
|
8
8
|
* The cookie value is **always HMAC-signed** (tamper-evident; verified constant-time) and the session
|
|
9
|
-
* cookie is always `HttpOnly`. Reads **fail closed**
|
|
9
|
+
* cookie is always `HttpOnly`. Reads **fail closed** - a tampered/missing/expired cookie yields a fresh
|
|
10
10
|
* anonymous session, never an error.
|
|
11
11
|
*/
|
|
12
12
|
import { parseCookies, signValue, unsignValue } from "@nifrajs/core/cookies";
|
|
@@ -22,9 +22,9 @@ const encodeBase64Url = (bytes) => {
|
|
|
22
22
|
bin += String.fromCharCode(b);
|
|
23
23
|
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
24
24
|
};
|
|
25
|
-
/** A 256-bit random session id (base64url). High-entropy + unguessable
|
|
25
|
+
/** A 256-bit random session id (base64url). High-entropy + unguessable - the cookie also signs it. */
|
|
26
26
|
const randomId = () => encodeBase64Url(crypto.getRandomValues(new Uint8Array(32)));
|
|
27
|
-
/** Validate a cookie-mode payload at the trust boundary
|
|
27
|
+
/** Validate a cookie-mode payload at the trust boundary - malformed JSON / shape → `null` (no session). */
|
|
28
28
|
const parsePayload = (json) => {
|
|
29
29
|
let value;
|
|
30
30
|
try {
|
|
@@ -51,7 +51,7 @@ export function createSessions(options) {
|
|
|
51
51
|
const maxAge = options.maxAge ?? 7 * DAY_SECONDS;
|
|
52
52
|
const rolling = options.rolling ?? true;
|
|
53
53
|
const now = options.now ?? (() => Date.now());
|
|
54
|
-
// Annotated (not just `?? {}`) so `cookieOpts.path`/`.domain` stay typed
|
|
54
|
+
// Annotated (not just `?? {}`) so `cookieOpts.path`/`.domain` stay typed - `?? {}` alone widens the
|
|
55
55
|
// type to include the empty-object literal, which the package's build tsconfig rejects on access.
|
|
56
56
|
const cookieOpts = options.cookie ?? {};
|
|
57
57
|
// Per-manager so the WeakMap is typed to this `Data` (no `any`); a session can only be committed by
|
|
@@ -105,7 +105,7 @@ export function createSessions(options) {
|
|
|
105
105
|
if (record === undefined)
|
|
106
106
|
return make({});
|
|
107
107
|
if (record.expiresAt <= now()) {
|
|
108
|
-
await store.delete(unsigned); // expired
|
|
108
|
+
await store.delete(unsigned); // expired - evict + treat as no session
|
|
109
109
|
return make({});
|
|
110
110
|
}
|
|
111
111
|
return make({ id: unsigned, data: record.data, expiresAt: record.expiresAt });
|
|
@@ -116,7 +116,7 @@ export function createSessions(options) {
|
|
|
116
116
|
return make({ data: payload.data, expiresAt: payload.expiresAt });
|
|
117
117
|
};
|
|
118
118
|
const get = (c) => loadFromRaw(c.cookies[cookieName]);
|
|
119
|
-
// Read-only load from a raw Request
|
|
119
|
+
// Read-only load from a raw Request - for @nifrajs/web loaders, which have the request but can't write
|
|
120
120
|
// cookies (commit/destroy belong in a route/action with the full Context). Same verify + expiry.
|
|
121
121
|
const read = (request) => loadFromRaw(parseCookies(request.headers.get("cookie"))[cookieName]);
|
|
122
122
|
const writeCookie = (c, value, expiresAt) => {
|
package/dist/store.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Session stores (the **store mode** backend)
|
|
2
|
+
* Session stores (the **store mode** backend) - a pluggable `SessionStore` plus an in-memory
|
|
3
3
|
* implementation (dev / single-instance, prod-guarded) and a Cloudflare Workers KV implementation
|
|
4
4
|
* (the durable, shared production path). The store is dumb persistence: the session *manager* owns the
|
|
5
5
|
* clock and authoritatively enforces expiry, so the store stays clock-free and trivially testable.
|
|
6
6
|
*/
|
|
7
|
-
/** A persisted session
|
|
7
|
+
/** A persisted session - its data plus an absolute expiry (ms epoch). */
|
|
8
8
|
export interface SessionRecord {
|
|
9
9
|
readonly data: Record<string, unknown>;
|
|
10
10
|
/** Absolute expiry (ms epoch). The manager treats `expiresAt <= now` as no session. */
|
|
@@ -12,7 +12,7 @@ export interface SessionRecord {
|
|
|
12
12
|
}
|
|
13
13
|
/**
|
|
14
14
|
* Pluggable session backend (store mode). Async so a network store (KV/Redis) fits the same shape.
|
|
15
|
-
* **Production needs a shared/durable store** so sessions hold across instances
|
|
15
|
+
* **Production needs a shared/durable store** so sessions hold across instances - {@link MemorySessionStore}
|
|
16
16
|
* prod-guards against the per-instance footgun.
|
|
17
17
|
*/
|
|
18
18
|
export interface SessionStore {
|
|
@@ -24,7 +24,7 @@ export interface SessionStore {
|
|
|
24
24
|
delete(id: string): Promise<void>;
|
|
25
25
|
}
|
|
26
26
|
export interface MemorySessionStoreOptions {
|
|
27
|
-
/** Allow the in-memory store in production. Off by default
|
|
27
|
+
/** Allow the in-memory store in production. Off by default - per-instance sessions don't hold across
|
|
28
28
|
* instances and are lost on restart. */
|
|
29
29
|
readonly allowInProduction?: boolean;
|
|
30
30
|
/** Hard cap on stored sessions; the oldest-inserted is evicted past it (default 10_000). */
|
|
@@ -32,7 +32,7 @@ export interface MemorySessionStoreOptions {
|
|
|
32
32
|
}
|
|
33
33
|
/**
|
|
34
34
|
* In-process session store. Refuses to run in production unless explicitly allowed (mirrors the ISR
|
|
35
|
-
* `MemoryCacheStore` + the rate-limit `MemoryStore`
|
|
35
|
+
* `MemoryCacheStore` + the rate-limit `MemoryStore` - a per-instance store is unsafe across instances).
|
|
36
36
|
* Bounded: oldest-inserted entries evict past `max`.
|
|
37
37
|
*/
|
|
38
38
|
export declare class MemorySessionStore implements SessionStore {
|
|
@@ -44,7 +44,7 @@ export declare class MemorySessionStore implements SessionStore {
|
|
|
44
44
|
delete(id: string): Promise<void>;
|
|
45
45
|
}
|
|
46
46
|
/**
|
|
47
|
-
* Minimal structural shape of a Cloudflare Workers **KV namespace** binding
|
|
47
|
+
* Minimal structural shape of a Cloudflare Workers **KV namespace** binding - just the three methods
|
|
48
48
|
* {@link KVSessionStore} uses. Structural (no `@cloudflare/workers-types` dependency) so any KV-like
|
|
49
49
|
* binding satisfies it and tests can pass an in-memory double.
|
|
50
50
|
*/
|
|
@@ -58,9 +58,9 @@ export interface KVNamespaceLike {
|
|
|
58
58
|
}
|
|
59
59
|
/**
|
|
60
60
|
* A {@link SessionStore} backed by a **Cloudflare Workers KV** namespace (or any {@link KVNamespaceLike})
|
|
61
|
-
*
|
|
61
|
+
* - the durable, shared production store: sessions hold across worker instances and survive restarts.
|
|
62
62
|
* Records serialize to JSON; the entry's KV `expiration` is set from the record's `expiresAt` so KV
|
|
63
|
-
* auto-evicts the session (a GC backstop
|
|
63
|
+
* auto-evicts the session (a GC backstop - the manager still authoritatively checks expiry on read).
|
|
64
64
|
* Every read is validated, so a corrupt/skewed entry reads as no session.
|
|
65
65
|
*/
|
|
66
66
|
export declare class KVSessionStore implements SessionStore {
|
package/dist/store.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Session stores (the **store mode** backend)
|
|
2
|
+
* Session stores (the **store mode** backend) - a pluggable `SessionStore` plus an in-memory
|
|
3
3
|
* implementation (dev / single-instance, prod-guarded) and a Cloudflare Workers KV implementation
|
|
4
4
|
* (the durable, shared production path). The store is dumb persistence: the session *manager* owns the
|
|
5
5
|
* clock and authoritatively enforces expiry, so the store stays clock-free and trivially testable.
|
|
6
6
|
*/
|
|
7
7
|
/**
|
|
8
8
|
* In-process session store. Refuses to run in production unless explicitly allowed (mirrors the ISR
|
|
9
|
-
* `MemoryCacheStore` + the rate-limit `MemoryStore`
|
|
9
|
+
* `MemoryCacheStore` + the rate-limit `MemoryStore` - a per-instance store is unsafe across instances).
|
|
10
10
|
* Bounded: oldest-inserted entries evict past `max`.
|
|
11
11
|
*/
|
|
12
12
|
export class MemorySessionStore {
|
|
@@ -54,9 +54,9 @@ const isSessionRecord = (value) => {
|
|
|
54
54
|
};
|
|
55
55
|
/**
|
|
56
56
|
* A {@link SessionStore} backed by a **Cloudflare Workers KV** namespace (or any {@link KVNamespaceLike})
|
|
57
|
-
*
|
|
57
|
+
* - the durable, shared production store: sessions hold across worker instances and survive restarts.
|
|
58
58
|
* Records serialize to JSON; the entry's KV `expiration` is set from the record's `expiresAt` so KV
|
|
59
|
-
* auto-evicts the session (a GC backstop
|
|
59
|
+
* auto-evicts the session (a GC backstop - the manager still authoritatively checks expiry on read).
|
|
60
60
|
* Every read is validated, so a corrupt/skewed entry reads as no session.
|
|
61
61
|
*/
|
|
62
62
|
export class KVSessionStore {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nifrajs/auth",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "Session primitives for nifra
|
|
3
|
+
"version": "2.3.0",
|
|
4
|
+
"description": "Session primitives for nifra - signed-cookie + server-store sessions, CSRF, and route guards. Bring your own identity (Better Auth / Lucia / OAuth); nifra owns the session.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bun",
|
|
7
7
|
"typescript",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"access": "public"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
|
-
"@nifrajs/core": "^2.
|
|
38
|
+
"@nifrajs/core": "^2.3.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@nifrajs/core": "workspace:*"
|
package/src/csrf.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CSRF protection via an **Origin / Referer check** on state-changing requests
|
|
2
|
+
* CSRF protection via an **Origin / Referer check** on state-changing requests - OWASP's recommended
|
|
3
3
|
* defense for cookie-authenticated apps. A browser always attaches `Origin` (or at least `Referer`) to
|
|
4
4
|
* a cross-origin or same-origin *unsafe* request; it must match an allowed origin, else `403`. Safe
|
|
5
5
|
* methods (GET/HEAD/OPTIONS) pass. Apply with `app.use(csrf({ origins: ["https://example.com"] }))`.
|
|
@@ -10,7 +10,7 @@ const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"])
|
|
|
10
10
|
|
|
11
11
|
export interface CsrfOptions {
|
|
12
12
|
/**
|
|
13
|
-
* Allowed origins (e.g. `["https://example.com"]`). **Set this in production behind a proxy**
|
|
13
|
+
* Allowed origins (e.g. `["https://example.com"]`). **Set this in production behind a proxy** - when
|
|
14
14
|
* omitted, the check derives same-origin from the request URL, which is correct in dev / when the
|
|
15
15
|
* proxy preserves `Host` but not when the public origin differs from the worker's.
|
|
16
16
|
*/
|
|
@@ -31,7 +31,7 @@ export function csrf(options: CsrfOptions = {}): Middleware {
|
|
|
31
31
|
const origin = req.headers.get("origin")
|
|
32
32
|
if (origin !== null) return allowed.has(origin) ? undefined : forbidden()
|
|
33
33
|
|
|
34
|
-
// Some same-origin requests omit `Origin`
|
|
34
|
+
// Some same-origin requests omit `Origin` - fall back to the `Referer`'s origin.
|
|
35
35
|
const referer = req.headers.get("referer")
|
|
36
36
|
if (referer !== null) {
|
|
37
37
|
let refererOrigin: string
|
package/src/guards.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Route guards
|
|
2
|
+
* Route guards - run at the top of a protected loader/action/handler. On a missing session they
|
|
3
3
|
* **throw a `Response`** (a 302 to your login path, or a 401); nifra returns a thrown Response as-is, so
|
|
4
4
|
* the guard short-circuits the rest of the handler. Pairs with `@nifrajs/auth` sessions but only needs the
|
|
5
5
|
* `Session` shape, so it's framework-agnostic.
|
|
6
6
|
*/
|
|
7
7
|
import type { Session } from "./session.ts"
|
|
8
8
|
|
|
9
|
-
/** What a guard does when the check fails: 302 to `redirectTo` (a same-origin path), or
|
|
9
|
+
/** What a guard does when the check fails: 302 to `redirectTo` (a same-origin path), or - omitted - a
|
|
10
10
|
* 401 JSON (`{ ok: false, error: "unauthorized" }`). */
|
|
11
11
|
export interface GuardOptions {
|
|
12
12
|
readonly redirectTo?: string
|
|
@@ -18,7 +18,7 @@ const rejection = (options: GuardOptions): Response => {
|
|
|
18
18
|
return Response.json({ ok: false, error: "unauthorized" }, { status: 401 })
|
|
19
19
|
}
|
|
20
20
|
// Same-origin guard (mirrors @nifrajs/web `redirect`): a single leading "/", never "//host" or an
|
|
21
|
-
// absolute URL. `redirectTo` is dev-authored, so a bad value is a config bug
|
|
21
|
+
// absolute URL. `redirectTo` is dev-authored, so a bad value is a config bug - fail loud here.
|
|
22
22
|
if (!to.startsWith("/") || to.startsWith("//")) {
|
|
23
23
|
throw new Error(
|
|
24
24
|
`[nifra/auth] guard redirectTo must be a same-origin path beginning with "/" (got ${JSON.stringify(to)})`,
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@nifrajs/auth`
|
|
2
|
+
* `@nifrajs/auth` - session primitives for nifra. Signed-cookie + server-store sessions, with the same
|
|
3
3
|
* pluggable-store + prod-guard discipline as the ISR cache. **Bring your own identity** (Better Auth,
|
|
4
4
|
* Lucia, your own OAuth/password logic); nifra owns the session cookie, store, CSRF, and route guards.
|
|
5
5
|
*/
|
package/src/session.ts
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The session manager
|
|
2
|
+
* The session manager - `createSessions<Data>()` returns get/commit/destroy/regenerate bound to a
|
|
3
3
|
* signing secret + cookie config. Two modes:
|
|
4
4
|
* • **store mode** (a {@link SessionStore} is given): a random session id rides a signed cookie; the
|
|
5
5
|
* data lives in the store. Big/sensitive data, server-side revocation.
|
|
6
6
|
* • **cookie mode** (no store): the data itself is signed into the cookie (stateless, edge-friendly,
|
|
7
7
|
* ≤4 KB). No server state.
|
|
8
8
|
* The cookie value is **always HMAC-signed** (tamper-evident; verified constant-time) and the session
|
|
9
|
-
* cookie is always `HttpOnly`. Reads **fail closed**
|
|
9
|
+
* cookie is always `HttpOnly`. Reads **fail closed** - a tampered/missing/expired cookie yields a fresh
|
|
10
10
|
* anonymous session, never an error.
|
|
11
11
|
*/
|
|
12
12
|
import { type CookieOptions, parseCookies, signValue, unsignValue } from "@nifrajs/core/cookies"
|
|
13
13
|
import type { SessionRecord, SessionStore } from "./store.ts"
|
|
14
14
|
|
|
15
|
-
/** The cookie + response surface the manager needs
|
|
15
|
+
/** The cookie + response surface the manager needs - a structural subset of nifra's `Context`, so any
|
|
16
16
|
* `c` satisfies it and it's testable with a stub. */
|
|
17
17
|
export interface SessionContext {
|
|
18
18
|
readonly cookies: Readonly<Record<string, string>>
|
|
@@ -22,7 +22,7 @@ export interface SessionContext {
|
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
/** A typed session handle. Every key is optional
|
|
25
|
+
/** A typed session handle. Every key is optional - a fresh session is empty. */
|
|
26
26
|
export interface Session<Data extends Record<string, unknown> = Record<string, unknown>> {
|
|
27
27
|
get<K extends keyof Data>(key: K): Data[K] | undefined
|
|
28
28
|
set<K extends keyof Data>(key: K, value: Data[K]): void
|
|
@@ -35,7 +35,7 @@ export interface Session<Data extends Record<string, unknown> = Record<string, u
|
|
|
35
35
|
readonly isEmpty: boolean
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
/** Cookie attributes a session may tune. `httpOnly` is **not** offered
|
|
38
|
+
/** Cookie attributes a session may tune. `httpOnly` is **not** offered - a session cookie is always
|
|
39
39
|
* HttpOnly; `maxAge`/`expires` are derived from the session's lifetime. */
|
|
40
40
|
export type SessionCookieOptions = Pick<CookieOptions, "secure" | "sameSite" | "path" | "domain">
|
|
41
41
|
|
|
@@ -53,21 +53,21 @@ export interface SessionOptions {
|
|
|
53
53
|
/** Extra cookie attributes (the session cookie is always HttpOnly; `Secure`/`SameSite=Lax`/`Path=/`
|
|
54
54
|
* default via `c.set.cookie`). Pass `{ secure: false }` for local http dev. */
|
|
55
55
|
readonly cookie?: SessionCookieOptions
|
|
56
|
-
/** Clock (ms)
|
|
56
|
+
/** Clock (ms) - injected for testability; production passes `() => Date.now()` (the default). */
|
|
57
57
|
readonly now?: () => number
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
export interface SessionManager<Data extends Record<string, unknown> = Record<string, unknown>> {
|
|
61
61
|
/** Load the session from the request cookie (verified + un-expired), or a fresh anonymous one. */
|
|
62
62
|
get(c: SessionContext): Promise<Session<Data>>
|
|
63
|
-
/** Read-only load from a raw `Request`
|
|
63
|
+
/** Read-only load from a raw `Request` - for `@nifrajs/web` loaders (which can read the request but not
|
|
64
64
|
* write cookies). Identical verify/expiry to {@link get}; commit/destroy in a route or action. */
|
|
65
65
|
read(request: Request): Promise<Session<Data>>
|
|
66
66
|
/** Persist the session: write the store (store mode) + the signed cookie. */
|
|
67
67
|
commit(c: SessionContext, session: Session<Data>): Promise<void>
|
|
68
68
|
/** End the session: drop the store record (store mode) + clear the cookie. */
|
|
69
69
|
destroy(c: SessionContext, session?: Session<Data>): Promise<void>
|
|
70
|
-
/** Rotate the session id on the next commit (call on privilege change
|
|
70
|
+
/** Rotate the session id on the next commit (call on privilege change - login - to defend against
|
|
71
71
|
* session fixation). Data is preserved; the old store record is dropped. */
|
|
72
72
|
regenerate(session: Session<Data>): void
|
|
73
73
|
}
|
|
@@ -85,7 +85,7 @@ const encodeBase64Url = (bytes: Uint8Array): string => {
|
|
|
85
85
|
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
/** A 256-bit random session id (base64url). High-entropy + unguessable
|
|
88
|
+
/** A 256-bit random session id (base64url). High-entropy + unguessable - the cookie also signs it. */
|
|
89
89
|
const randomId = (): string => encodeBase64Url(crypto.getRandomValues(new Uint8Array(32)))
|
|
90
90
|
|
|
91
91
|
interface Payload {
|
|
@@ -93,7 +93,7 @@ interface Payload {
|
|
|
93
93
|
expiresAt: number
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
/** Validate a cookie-mode payload at the trust boundary
|
|
96
|
+
/** Validate a cookie-mode payload at the trust boundary - malformed JSON / shape → `null` (no session). */
|
|
97
97
|
const parsePayload = (json: string): Payload | null => {
|
|
98
98
|
let value: unknown
|
|
99
99
|
try {
|
|
@@ -121,7 +121,7 @@ export function createSessions<Data extends Record<string, unknown> = Record<str
|
|
|
121
121
|
const maxAge = options.maxAge ?? 7 * DAY_SECONDS
|
|
122
122
|
const rolling = options.rolling ?? true
|
|
123
123
|
const now = options.now ?? (() => Date.now())
|
|
124
|
-
// Annotated (not just `?? {}`) so `cookieOpts.path`/`.domain` stay typed
|
|
124
|
+
// Annotated (not just `?? {}`) so `cookieOpts.path`/`.domain` stay typed - `?? {}` alone widens the
|
|
125
125
|
// type to include the empty-object literal, which the package's build tsconfig rejects on access.
|
|
126
126
|
const cookieOpts: SessionCookieOptions = options.cookie ?? {}
|
|
127
127
|
|
|
@@ -187,7 +187,7 @@ export function createSessions<Data extends Record<string, unknown> = Record<str
|
|
|
187
187
|
const record = await store.get(unsigned)
|
|
188
188
|
if (record === undefined) return make({})
|
|
189
189
|
if (record.expiresAt <= now()) {
|
|
190
|
-
await store.delete(unsigned) // expired
|
|
190
|
+
await store.delete(unsigned) // expired - evict + treat as no session
|
|
191
191
|
return make({})
|
|
192
192
|
}
|
|
193
193
|
return make({ id: unsigned, data: record.data, expiresAt: record.expiresAt })
|
|
@@ -200,7 +200,7 @@ export function createSessions<Data extends Record<string, unknown> = Record<str
|
|
|
200
200
|
|
|
201
201
|
const get = (c: SessionContext): Promise<Session<Data>> => loadFromRaw(c.cookies[cookieName])
|
|
202
202
|
|
|
203
|
-
// Read-only load from a raw Request
|
|
203
|
+
// Read-only load from a raw Request - for @nifrajs/web loaders, which have the request but can't write
|
|
204
204
|
// cookies (commit/destroy belong in a route/action with the full Context). Same verify + expiry.
|
|
205
205
|
const read = (request: Request): Promise<Session<Data>> =>
|
|
206
206
|
loadFromRaw(parseCookies(request.headers.get("cookie"))[cookieName])
|
package/src/store.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Session stores (the **store mode** backend)
|
|
2
|
+
* Session stores (the **store mode** backend) - a pluggable `SessionStore` plus an in-memory
|
|
3
3
|
* implementation (dev / single-instance, prod-guarded) and a Cloudflare Workers KV implementation
|
|
4
4
|
* (the durable, shared production path). The store is dumb persistence: the session *manager* owns the
|
|
5
5
|
* clock and authoritatively enforces expiry, so the store stays clock-free and trivially testable.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
/** A persisted session
|
|
8
|
+
/** A persisted session - its data plus an absolute expiry (ms epoch). */
|
|
9
9
|
export interface SessionRecord {
|
|
10
10
|
readonly data: Record<string, unknown>
|
|
11
11
|
/** Absolute expiry (ms epoch). The manager treats `expiresAt <= now` as no session. */
|
|
@@ -14,7 +14,7 @@ export interface SessionRecord {
|
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* Pluggable session backend (store mode). Async so a network store (KV/Redis) fits the same shape.
|
|
17
|
-
* **Production needs a shared/durable store** so sessions hold across instances
|
|
17
|
+
* **Production needs a shared/durable store** so sessions hold across instances - {@link MemorySessionStore}
|
|
18
18
|
* prod-guards against the per-instance footgun.
|
|
19
19
|
*/
|
|
20
20
|
export interface SessionStore {
|
|
@@ -27,7 +27,7 @@ export interface SessionStore {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export interface MemorySessionStoreOptions {
|
|
30
|
-
/** Allow the in-memory store in production. Off by default
|
|
30
|
+
/** Allow the in-memory store in production. Off by default - per-instance sessions don't hold across
|
|
31
31
|
* instances and are lost on restart. */
|
|
32
32
|
readonly allowInProduction?: boolean
|
|
33
33
|
/** Hard cap on stored sessions; the oldest-inserted is evicted past it (default 10_000). */
|
|
@@ -36,7 +36,7 @@ export interface MemorySessionStoreOptions {
|
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
38
|
* In-process session store. Refuses to run in production unless explicitly allowed (mirrors the ISR
|
|
39
|
-
* `MemoryCacheStore` + the rate-limit `MemoryStore`
|
|
39
|
+
* `MemoryCacheStore` + the rate-limit `MemoryStore` - a per-instance store is unsafe across instances).
|
|
40
40
|
* Bounded: oldest-inserted entries evict past `max`.
|
|
41
41
|
*/
|
|
42
42
|
export class MemorySessionStore implements SessionStore {
|
|
@@ -77,7 +77,7 @@ export class MemorySessionStore implements SessionStore {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
/**
|
|
80
|
-
* Minimal structural shape of a Cloudflare Workers **KV namespace** binding
|
|
80
|
+
* Minimal structural shape of a Cloudflare Workers **KV namespace** binding - just the three methods
|
|
81
81
|
* {@link KVSessionStore} uses. Structural (no `@cloudflare/workers-types` dependency) so any KV-like
|
|
82
82
|
* binding satisfies it and tests can pass an in-memory double.
|
|
83
83
|
*/
|
|
@@ -104,9 +104,9 @@ const isSessionRecord = (value: unknown): value is SessionRecord => {
|
|
|
104
104
|
|
|
105
105
|
/**
|
|
106
106
|
* A {@link SessionStore} backed by a **Cloudflare Workers KV** namespace (or any {@link KVNamespaceLike})
|
|
107
|
-
*
|
|
107
|
+
* - the durable, shared production store: sessions hold across worker instances and survive restarts.
|
|
108
108
|
* Records serialize to JSON; the entry's KV `expiration` is set from the record's `expiresAt` so KV
|
|
109
|
-
* auto-evicts the session (a GC backstop
|
|
109
|
+
* auto-evicts the session (a GC backstop - the manager still authoritatively checks expiry on read).
|
|
110
110
|
* Every read is validated, so a corrupt/skewed entry reads as no session.
|
|
111
111
|
*/
|
|
112
112
|
export class KVSessionStore implements SessionStore {
|