@nifrajs/auth 0.1.0-alpha.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nifra Authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,18 @@
1
+ # @nifrajs/auth
2
+
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
+
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
+
7
+ ## Install
8
+
9
+ ```sh
10
+ bun add @nifrajs/auth
11
+ ```
12
+
13
+ ## Docs
14
+
15
+ - Reference: <https://nifra.dev/docs>
16
+ - AI-readable: <https://nifra.dev/llms.txt>
17
+
18
+ MIT
package/dist/csrf.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * CSRF protection via an **Origin / Referer check** on state-changing requests — OWASP's recommended
3
+ * defense for cookie-authenticated apps. A browser always attaches `Origin` (or at least `Referer`) to
4
+ * a cross-origin or same-origin *unsafe* request; it must match an allowed origin, else `403`. Safe
5
+ * methods (GET/HEAD/OPTIONS) pass. Apply with `app.use(csrf({ origins: ["https://example.com"] }))`.
6
+ */
7
+ import type { Middleware } from "@nifrajs/core";
8
+ export interface CsrfOptions {
9
+ /**
10
+ * Allowed origins (e.g. `["https://example.com"]`). **Set this in production behind a proxy** — when
11
+ * omitted, the check derives same-origin from the request URL, which is correct in dev / when the
12
+ * proxy preserves `Host` but not when the public origin differs from the worker's.
13
+ */
14
+ readonly origins?: readonly string[];
15
+ }
16
+ export declare function csrf(options?: CsrfOptions): Middleware;
17
+ //# sourceMappingURL=csrf.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"csrf.d.ts","sourceRoot":"","sources":["../src/csrf.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAA;AAI/C,MAAM,WAAW,WAAW;IAC1B;;;;OAIG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CACrC;AAKD,wBAAgB,IAAI,CAAC,OAAO,GAAE,WAAgB,GAAG,UAAU,CA2B1D"}
package/dist/csrf.js ADDED
@@ -0,0 +1,31 @@
1
+ const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
2
+ const forbidden = () => Response.json({ ok: false, error: "csrf_failed" }, { status: 403 });
3
+ export function csrf(options = {}) {
4
+ const configured = options.origins !== undefined ? new Set(options.origins) : undefined;
5
+ return {
6
+ name: "csrf",
7
+ onRequest(req) {
8
+ if (SAFE_METHODS.has(req.method))
9
+ return undefined;
10
+ const allowed = configured ?? new Set([new URL(req.url).origin]);
11
+ const origin = req.headers.get("origin");
12
+ if (origin !== null)
13
+ return allowed.has(origin) ? undefined : forbidden();
14
+ // Some same-origin requests omit `Origin` — fall back to the `Referer`'s origin.
15
+ const referer = req.headers.get("referer");
16
+ if (referer !== null) {
17
+ let refererOrigin;
18
+ try {
19
+ refererOrigin = new URL(referer).origin;
20
+ }
21
+ catch {
22
+ return forbidden(); // malformed Referer
23
+ }
24
+ return allowed.has(refererOrigin) ? undefined : forbidden();
25
+ }
26
+ // A state-changing request with neither header → reject (fail closed; a browser always sends one).
27
+ return forbidden();
28
+ },
29
+ };
30
+ }
31
+ //# sourceMappingURL=csrf.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"csrf.js","sourceRoot":"","sources":["../src/csrf.ts"],"names":[],"mappings":"AAQA,MAAM,YAAY,GAAG,IAAI,GAAG,CAAC,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,CAAC,CAAC,CAAA;AAWxD,MAAM,SAAS,GAAG,GAAa,EAAE,CAC/B,QAAQ,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,aAAa,EAAE,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAA;AAErE,MAAM,UAAU,IAAI,CAAC,UAAuB,EAAE;IAC5C,MAAM,UAAU,GAAG,OAAO,CAAC,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,SAAS,CAAA;IACvF,OAAO;QACL,IAAI,EAAE,MAAM;QACZ,SAAS,CAAC,GAAG;YACX,IAAI,YAAY,CAAC,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC;gBAAE,OAAO,SAAS,CAAA;YAClD,MAAM,OAAO,GAAG,UAAU,IAAI,IAAI,GAAG,CAAC,CAAC,IAAI,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,CAAA;YAEhE,MAAM,MAAM,GAAG,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAA;YACxC,IAAI,MAAM,KAAK,IAAI;gBAAE,OAAO,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,SAAS,EAAE,CAAA;YAEzE,iFAAiF;YACjF,MAAM,OAAO,GAAG,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,SAAS,CAAC,CAAA;YAC1C,IAAI,OAAO,KAAK,IAAI,EAAE,CAAC;gBACrB,IAAI,aAAqB,CAAA;gBACzB,IAAI,CAAC;oBACH,aAAa,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,CAAC,MAAM,CAAA;gBACzC,CAAC;gBAAC,MAAM,CAAC;oBACP,OAAO,SAAS,EAAE,CAAA,CAAC,oBAAoB;gBACzC,CAAC;gBACD,OAAO,OAAO,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,SAAS,EAAE,CAAA;YAC7D,CAAC;YAED,mGAAmG;YACnG,OAAO,SAAS,EAAE,CAAA;QACpB,CAAC;KACF,CAAA;AACH,CAAC"}
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Route guards — run at the top of a protected loader/action/handler. On a missing session they
3
+ * **throw a `Response`** (a 302 to your login path, or a 401); nifra returns a thrown Response as-is, so
4
+ * the guard short-circuits the rest of the handler. Pairs with `@nifrajs/auth` sessions but only needs the
5
+ * `Session` shape, so it's framework-agnostic.
6
+ */
7
+ import type { Session } from "./session.js";
8
+ /** What a guard does when the check fails: 302 to `redirectTo` (a same-origin path), or — omitted — a
9
+ * 401 JSON (`{ ok: false, error: "unauthorized" }`). */
10
+ export interface GuardOptions {
11
+ readonly redirectTo?: string;
12
+ }
13
+ /**
14
+ * Require a non-empty session. Returns it when present; otherwise throws a `Response` (302/401). Use at
15
+ * the top of a protected loader:
16
+ * `const session = requireSession(await sessions.get(c), { redirectTo: "/login" })`.
17
+ */
18
+ export declare function requireSession<Data extends Record<string, unknown>>(session: Session<Data>, options?: GuardOptions): Session<Data>;
19
+ /**
20
+ * Require a specific session key (e.g. the `userId` a login set) to be present. Returns its value
21
+ * (narrowed non-nullish); otherwise throws like {@link requireSession}. The common "who is the user"
22
+ * guard: `const userId = requireUser(await sessions.get(c), "userId", { redirectTo: "/login" })`.
23
+ */
24
+ export declare function requireUser<Data extends Record<string, unknown>, K extends keyof Data>(session: Session<Data>, key: K, options?: GuardOptions): NonNullable<Data[K]>;
25
+ //# sourceMappingURL=guards.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"guards.d.ts","sourceRoot":"","sources":["../src/guards.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AAE3C;wDACwD;AACxD,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAC7B;AAiBD;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,IAAI,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjE,OAAO,EAAE,OAAO,CAAC,IAAI,CAAC,EACtB,OAAO,GAAE,YAAiB,GACzB,OAAO,CAAC,IAAI,CAAC,CAGf;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,IAAI,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,SAAS,MAAM,IAAI,EACpF,OAAO,EAAE,OAAO,CAAC,IAAI,CAAC,EACtB,GAAG,EAAE,CAAC,EACN,OAAO,GAAE,YAAiB,GACzB,WAAW,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAKtB"}
package/dist/guards.js ADDED
@@ -0,0 +1,35 @@
1
+ const rejection = (options) => {
2
+ const to = options.redirectTo;
3
+ if (to === undefined) {
4
+ return Response.json({ ok: false, error: "unauthorized" }, { status: 401 });
5
+ }
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 — fail loud here.
8
+ if (!to.startsWith("/") || to.startsWith("//")) {
9
+ throw new Error(`[nifra/auth] guard redirectTo must be a same-origin path beginning with "/" (got ${JSON.stringify(to)})`);
10
+ }
11
+ return new Response(null, { status: 302, headers: { location: to } });
12
+ };
13
+ /**
14
+ * Require a non-empty session. Returns it when present; otherwise throws a `Response` (302/401). Use at
15
+ * the top of a protected loader:
16
+ * `const session = requireSession(await sessions.get(c), { redirectTo: "/login" })`.
17
+ */
18
+ export function requireSession(session, options = {}) {
19
+ if (!session.isEmpty)
20
+ return session;
21
+ throw rejection(options);
22
+ }
23
+ /**
24
+ * Require a specific session key (e.g. the `userId` a login set) to be present. Returns its value
25
+ * (narrowed non-nullish); otherwise throws like {@link requireSession}. The common "who is the user"
26
+ * guard: `const userId = requireUser(await sessions.get(c), "userId", { redirectTo: "/login" })`.
27
+ */
28
+ export function requireUser(session, key, options = {}) {
29
+ const value = session.get(key);
30
+ // Narrowed by the runtime check; the generic `Data[K] | undefined` doesn't auto-narrow to NonNullable.
31
+ if (value !== undefined && value !== null)
32
+ return value;
33
+ throw rejection(options);
34
+ }
35
+ //# sourceMappingURL=guards.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"guards.js","sourceRoot":"","sources":["../src/guards.ts"],"names":[],"mappings":"AAcA,MAAM,SAAS,GAAG,CAAC,OAAqB,EAAY,EAAE;IACpD,MAAM,EAAE,GAAG,OAAO,CAAC,UAAU,CAAA;IAC7B,IAAI,EAAE,KAAK,SAAS,EAAE,CAAC;QACrB,OAAO,QAAQ,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,cAAc,EAAE,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAA;IAC7E,CAAC;IACD,kGAAkG;IAClG,+FAA+F;IAC/F,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;QAC/C,MAAM,IAAI,KAAK,CACb,oFAAoF,IAAI,CAAC,SAAS,CAAC,EAAE,CAAC,GAAG,CAC1G,CAAA;IACH,CAAC;IACD,OAAO,IAAI,QAAQ,CAAC,IAAI,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,EAAE,QAAQ,EAAE,EAAE,EAAE,EAAE,CAAC,CAAA;AACvE,CAAC,CAAA;AAED;;;;GAIG;AACH,MAAM,UAAU,cAAc,CAC5B,OAAsB,EACtB,UAAwB,EAAE;IAE1B,IAAI,CAAC,OAAO,CAAC,OAAO;QAAE,OAAO,OAAO,CAAA;IACpC,MAAM,SAAS,CAAC,OAAO,CAAC,CAAA;AAC1B,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,WAAW,CACzB,OAAsB,EACtB,GAAM,EACN,UAAwB,EAAE;IAE1B,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;IAC9B,uGAAuG;IACvG,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,IAAI;QAAE,OAAO,KAA6B,CAAA;IAC/E,MAAM,SAAS,CAAC,OAAO,CAAC,CAAA;AAC1B,CAAC"}
@@ -0,0 +1,10 @@
1
+ /**
2
+ * `@nifrajs/auth` — session primitives for nifra. Signed-cookie + server-store sessions, with the same
3
+ * pluggable-store + prod-guard discipline as the ISR cache. **Bring your own identity** (Better Auth,
4
+ * Lucia, your own OAuth/password logic); nifra owns the session cookie, store, CSRF, and route guards.
5
+ */
6
+ export { type CsrfOptions, csrf } from "./csrf.js";
7
+ export { type GuardOptions, requireSession, requireUser } from "./guards.js";
8
+ export { createSessions, type Session, type SessionContext, type SessionCookieOptions, type SessionManager, type SessionOptions, } from "./session.js";
9
+ export { type KVNamespaceLike, KVSessionStore, MemorySessionStore, type MemorySessionStoreOptions, type SessionRecord, type SessionStore, } from "./store.js";
10
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EAAE,KAAK,WAAW,EAAE,IAAI,EAAE,MAAM,WAAW,CAAA;AAClD,OAAO,EAAE,KAAK,YAAY,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAC5E,OAAO,EACL,cAAc,EACd,KAAK,OAAO,EACZ,KAAK,cAAc,EACnB,KAAK,oBAAoB,EACzB,KAAK,cAAc,EACnB,KAAK,cAAc,GACpB,MAAM,cAAc,CAAA;AACrB,OAAO,EACL,KAAK,eAAe,EACpB,cAAc,EACd,kBAAkB,EAClB,KAAK,yBAAyB,EAC9B,KAAK,aAAa,EAClB,KAAK,YAAY,GAClB,MAAM,YAAY,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * `@nifrajs/auth` — session primitives for nifra. Signed-cookie + server-store sessions, with the same
3
+ * pluggable-store + prod-guard discipline as the ISR cache. **Bring your own identity** (Better Auth,
4
+ * Lucia, your own OAuth/password logic); nifra owns the session cookie, store, CSRF, and route guards.
5
+ */
6
+ export { csrf } from "./csrf.js";
7
+ export { requireSession, requireUser } from "./guards.js";
8
+ export { createSessions, } from "./session.js";
9
+ export { KVSessionStore, MemorySessionStore, } from "./store.js";
10
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EAAoB,IAAI,EAAE,MAAM,WAAW,CAAA;AAClD,OAAO,EAAqB,cAAc,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAC5E,OAAO,EACL,cAAc,GAMf,MAAM,cAAc,CAAA;AACrB,OAAO,EAEL,cAAc,EACd,kBAAkB,GAInB,MAAM,YAAY,CAAA"}
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The session manager — `createSessions<Data>()` returns get/commit/destroy/regenerate bound to a
3
+ * signing secret + cookie config. Two modes:
4
+ * • **store mode** (a {@link SessionStore} is given): a random session id rides a signed cookie; the
5
+ * data lives in the store. Big/sensitive data, server-side revocation.
6
+ * • **cookie mode** (no store): the data itself is signed into the cookie (stateless, edge-friendly,
7
+ * ≤4 KB). No server state.
8
+ * The cookie value is **always HMAC-signed** (tamper-evident; verified constant-time) and the session
9
+ * cookie is always `HttpOnly`. Reads **fail closed** — a tampered/missing/expired cookie yields a fresh
10
+ * anonymous session, never an error.
11
+ */
12
+ import { type CookieOptions } from "@nifrajs/core";
13
+ import type { SessionStore } from "./store.js";
14
+ /** The cookie + response surface the manager needs — a structural subset of nifra's `Context`, so any
15
+ * `c` satisfies it and it's testable with a stub. */
16
+ export interface SessionContext {
17
+ readonly cookies: Readonly<Record<string, string>>;
18
+ readonly set: {
19
+ cookie(name: string, value: string, options?: CookieOptions): void;
20
+ deleteCookie(name: string, options?: {
21
+ readonly path?: string;
22
+ readonly domain?: string;
23
+ }): void;
24
+ };
25
+ }
26
+ /** A typed session handle. Every key is optional — a fresh session is empty. */
27
+ export interface Session<Data extends Record<string, unknown> = Record<string, unknown>> {
28
+ get<K extends keyof Data>(key: K): Data[K] | undefined;
29
+ set<K extends keyof Data>(key: K, value: Data[K]): void;
30
+ unset(key: keyof Data): void;
31
+ has(key: keyof Data): boolean;
32
+ /** Wipe all data (the session id is kept). To end the session entirely, call `manager.destroy`. */
33
+ clear(): void;
34
+ /** A snapshot of the current data. */
35
+ readonly data: Readonly<Partial<Data>>;
36
+ readonly isEmpty: boolean;
37
+ }
38
+ /** Cookie attributes a session may tune. `httpOnly` is **not** offered — a session cookie is always
39
+ * HttpOnly; `maxAge`/`expires` are derived from the session's lifetime. */
40
+ export type SessionCookieOptions = Pick<CookieOptions, "secure" | "sameSite" | "path" | "domain">;
41
+ export interface SessionOptions {
42
+ /** Signing secret (≥ 16 chars). Rotating it invalidates all existing sessions. */
43
+ readonly secret: string;
44
+ /** Provide a store for **store mode**; omit for **cookie mode** (stateless). */
45
+ readonly store?: SessionStore;
46
+ /** Cookie name. Default `nifra_session`. */
47
+ readonly cookieName?: string;
48
+ /** Session lifetime in **seconds**. Default 7 days. */
49
+ readonly maxAge?: number;
50
+ /** Slide the expiry forward on every commit (default `true`); `false` = fixed absolute expiry. */
51
+ readonly rolling?: boolean;
52
+ /** Extra cookie attributes (the session cookie is always HttpOnly; `Secure`/`SameSite=Lax`/`Path=/`
53
+ * default via `c.set.cookie`). Pass `{ secure: false }` for local http dev. */
54
+ readonly cookie?: SessionCookieOptions;
55
+ /** Clock (ms) — injected for testability; production passes `() => Date.now()` (the default). */
56
+ readonly now?: () => number;
57
+ }
58
+ export interface SessionManager<Data extends Record<string, unknown> = Record<string, unknown>> {
59
+ /** Load the session from the request cookie (verified + un-expired), or a fresh anonymous one. */
60
+ get(c: SessionContext): Promise<Session<Data>>;
61
+ /** Read-only load from a raw `Request` — for `@nifrajs/web` loaders (which can read the request but not
62
+ * write cookies). Identical verify/expiry to {@link get}; commit/destroy in a route or action. */
63
+ read(request: Request): Promise<Session<Data>>;
64
+ /** Persist the session: write the store (store mode) + the signed cookie. */
65
+ commit(c: SessionContext, session: Session<Data>): Promise<void>;
66
+ /** End the session: drop the store record (store mode) + clear the cookie. */
67
+ destroy(c: SessionContext, session?: Session<Data>): Promise<void>;
68
+ /** Rotate the session id on the next commit (call on privilege change — login — to defend against
69
+ * session fixation). Data is preserved; the old store record is dropped. */
70
+ regenerate(session: Session<Data>): void;
71
+ }
72
+ export declare function createSessions<Data extends Record<string, unknown> = Record<string, unknown>>(options: SessionOptions): SessionManager<Data>;
73
+ //# sourceMappingURL=session.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,164 @@
1
+ /**
2
+ * The session manager — `createSessions<Data>()` returns get/commit/destroy/regenerate bound to a
3
+ * signing secret + cookie config. Two modes:
4
+ * • **store mode** (a {@link SessionStore} is given): a random session id rides a signed cookie; the
5
+ * data lives in the store. Big/sensitive data, server-side revocation.
6
+ * • **cookie mode** (no store): the data itself is signed into the cookie (stateless, edge-friendly,
7
+ * ≤4 KB). No server state.
8
+ * The cookie value is **always HMAC-signed** (tamper-evident; verified constant-time) and the session
9
+ * cookie is always `HttpOnly`. Reads **fail closed** — a tampered/missing/expired cookie yields a fresh
10
+ * anonymous session, never an error.
11
+ */
12
+ import { parseCookies, signValue, unsignValue } from "@nifrajs/core";
13
+ const DAY_SECONDS = 86_400;
14
+ // 256-bit floor, matching @nifrajs/core's signed-cookie/webhook guard. Sessions sign via core's
15
+ // signValue (which enforces the same bar); this early check gives an auth-specific message before
16
+ // delegating. Measured in UTF-8 bytes, not characters.
17
+ const MIN_SECRET_BYTES = 32;
18
+ const SECRET_ENCODER = new TextEncoder();
19
+ const encodeBase64Url = (bytes) => {
20
+ let bin = "";
21
+ for (const b of bytes)
22
+ bin += String.fromCharCode(b);
23
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
24
+ };
25
+ /** A 256-bit random session id (base64url). High-entropy + unguessable — the cookie also signs it. */
26
+ const randomId = () => encodeBase64Url(crypto.getRandomValues(new Uint8Array(32)));
27
+ /** Validate a cookie-mode payload at the trust boundary — malformed JSON / shape → `null` (no session). */
28
+ const parsePayload = (json) => {
29
+ let value;
30
+ try {
31
+ value = JSON.parse(json);
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ if (typeof value !== "object" || value === null)
37
+ return null;
38
+ const obj = value;
39
+ if (typeof obj.expiresAt !== "number" || !Number.isFinite(obj.expiresAt))
40
+ return null;
41
+ if (typeof obj.data !== "object" || obj.data === null)
42
+ return null;
43
+ return { data: obj.data, expiresAt: obj.expiresAt };
44
+ };
45
+ export function createSessions(options) {
46
+ const { secret, store } = options;
47
+ if (SECRET_ENCODER.encode(secret).length < MIN_SECRET_BYTES) {
48
+ throw new Error(`[nifra/auth] session secret must be at least ${MIN_SECRET_BYTES} bytes (256-bit). Generate one with: openssl rand -base64 32`);
49
+ }
50
+ const cookieName = options.cookieName ?? "nifra_session";
51
+ const maxAge = options.maxAge ?? 7 * DAY_SECONDS;
52
+ const rolling = options.rolling ?? true;
53
+ const now = options.now ?? (() => Date.now());
54
+ // Annotated (not just `?? {}`) so `cookieOpts.path`/`.domain` stay typed — `?? {}` alone widens the
55
+ // type to include the empty-object literal, which the package's build tsconfig rejects on access.
56
+ const cookieOpts = options.cookie ?? {};
57
+ // Per-manager so the WeakMap is typed to this `Data` (no `any`); a session can only be committed by
58
+ // the manager that created it.
59
+ const states = new WeakMap();
60
+ const make = (init) => {
61
+ const state = {
62
+ id: init.id,
63
+ // The Map erases per-key value types; the public generic signatures restore them at the edge.
64
+ data: new Map(Object.entries(init.data ?? {})),
65
+ expiresAt: init.expiresAt,
66
+ regenerate: false,
67
+ };
68
+ const session = {
69
+ get: (key) => state.data.get(key),
70
+ set: (key, value) => {
71
+ state.data.set(key, value);
72
+ },
73
+ unset: (key) => {
74
+ state.data.delete(key);
75
+ },
76
+ has: (key) => state.data.has(key),
77
+ clear: () => {
78
+ state.data.clear();
79
+ },
80
+ get data() {
81
+ return Object.fromEntries(state.data);
82
+ },
83
+ get isEmpty() {
84
+ return state.data.size === 0;
85
+ },
86
+ };
87
+ states.set(session, state);
88
+ return session;
89
+ };
90
+ const stateOf = (session, op) => {
91
+ const state = states.get(session);
92
+ if (state === undefined) {
93
+ throw new Error(`[nifra/auth] ${op}: session was not created by this manager`);
94
+ }
95
+ return state;
96
+ };
97
+ const loadFromRaw = async (raw) => {
98
+ if (raw === undefined || raw === "")
99
+ return make({});
100
+ const unsigned = await unsignValue(raw, secret);
101
+ if (unsigned === null)
102
+ return make({}); // tampered / wrong secret → fail closed (anonymous)
103
+ if (store !== undefined) {
104
+ const record = await store.get(unsigned);
105
+ if (record === undefined)
106
+ return make({});
107
+ if (record.expiresAt <= now()) {
108
+ await store.delete(unsigned); // expired — evict + treat as no session
109
+ return make({});
110
+ }
111
+ return make({ id: unsigned, data: record.data, expiresAt: record.expiresAt });
112
+ }
113
+ const payload = parsePayload(unsigned);
114
+ if (payload === null || payload.expiresAt <= now())
115
+ return make({});
116
+ return make({ data: payload.data, expiresAt: payload.expiresAt });
117
+ };
118
+ const get = (c) => loadFromRaw(c.cookies[cookieName]);
119
+ // Read-only load from a raw Request — for @nifrajs/web loaders, which have the request but can't write
120
+ // cookies (commit/destroy belong in a route/action with the full Context). Same verify + expiry.
121
+ const read = (request) => loadFromRaw(parseCookies(request.headers.get("cookie"))[cookieName]);
122
+ const writeCookie = (c, value, expiresAt) => {
123
+ const maxAgeSeconds = Math.max(0, Math.floor((expiresAt - now()) / 1000));
124
+ c.set.cookie(cookieName, value, { ...cookieOpts, maxAge: maxAgeSeconds });
125
+ };
126
+ const commit = async (c, session) => {
127
+ const state = stateOf(session, "commit");
128
+ // Rolling → slide the expiry; absolute → keep the original (set on the first commit).
129
+ const expiresAt = rolling || state.expiresAt === undefined ? now() + maxAge * 1000 : state.expiresAt;
130
+ state.expiresAt = expiresAt;
131
+ const data = Object.fromEntries(state.data);
132
+ if (store !== undefined) {
133
+ if (state.regenerate) {
134
+ if (state.id !== undefined)
135
+ await store.delete(state.id); // drop the pre-rotation session
136
+ state.id = undefined;
137
+ state.regenerate = false;
138
+ }
139
+ if (state.id === undefined)
140
+ state.id = randomId();
141
+ await store.set(state.id, { data, expiresAt });
142
+ writeCookie(c, await signValue(state.id, secret), expiresAt);
143
+ return;
144
+ }
145
+ // Cookie mode: the (signed) payload IS the cookie. serializeCookie enforces the 4 KB cap.
146
+ writeCookie(c, await signValue(JSON.stringify({ data, expiresAt }), secret), expiresAt);
147
+ };
148
+ const destroy = async (c, session) => {
149
+ if (store !== undefined && session !== undefined) {
150
+ const state = states.get(session);
151
+ if (state?.id !== undefined)
152
+ await store.delete(state.id);
153
+ }
154
+ c.set.deleteCookie(cookieName, {
155
+ path: cookieOpts.path ?? "/",
156
+ ...(cookieOpts.domain !== undefined ? { domain: cookieOpts.domain } : {}),
157
+ });
158
+ };
159
+ const regenerate = (session) => {
160
+ stateOf(session, "regenerate").regenerate = true;
161
+ };
162
+ return { get, read, commit, destroy, regenerate };
163
+ }
164
+ //# sourceMappingURL=session.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session.js","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Session stores (the **store mode** backend) — a pluggable `SessionStore` plus an in-memory
3
+ * implementation (dev / single-instance, prod-guarded) and a Cloudflare Workers KV implementation
4
+ * (the durable, shared production path). The store is dumb persistence: the session *manager* owns the
5
+ * clock and authoritatively enforces expiry, so the store stays clock-free and trivially testable.
6
+ */
7
+ /** A persisted session — its data plus an absolute expiry (ms epoch). */
8
+ export interface SessionRecord {
9
+ readonly data: Record<string, unknown>;
10
+ /** Absolute expiry (ms epoch). The manager treats `expiresAt <= now` as no session. */
11
+ readonly expiresAt: number;
12
+ }
13
+ /**
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 — {@link MemorySessionStore}
16
+ * prod-guards against the per-instance footgun.
17
+ */
18
+ export interface SessionStore {
19
+ /** The record for `id`, or `undefined` on a miss. */
20
+ get(id: string): Promise<SessionRecord | undefined>;
21
+ /** Store (or overwrite) the record for `id`. */
22
+ set(id: string, record: SessionRecord): Promise<void>;
23
+ /** Drop `id` (logout / regeneration). A no-op if absent. */
24
+ delete(id: string): Promise<void>;
25
+ }
26
+ export interface MemorySessionStoreOptions {
27
+ /** Allow the in-memory store in production. Off by default — per-instance sessions don't hold across
28
+ * instances and are lost on restart. */
29
+ readonly allowInProduction?: boolean;
30
+ /** Hard cap on stored sessions; the oldest-inserted is evicted past it (default 10_000). */
31
+ readonly max?: number;
32
+ }
33
+ /**
34
+ * In-process session store. Refuses to run in production unless explicitly allowed (mirrors the ISR
35
+ * `MemoryCacheStore` + the rate-limit `MemoryStore` — a per-instance store is unsafe across instances).
36
+ * Bounded: oldest-inserted entries evict past `max`.
37
+ */
38
+ export declare class MemorySessionStore implements SessionStore {
39
+ private readonly sessions;
40
+ private readonly max;
41
+ constructor(options?: MemorySessionStoreOptions);
42
+ get(id: string): Promise<SessionRecord | undefined>;
43
+ set(id: string, record: SessionRecord): Promise<void>;
44
+ delete(id: string): Promise<void>;
45
+ }
46
+ /**
47
+ * Minimal structural shape of a Cloudflare Workers **KV namespace** binding — just the three methods
48
+ * {@link KVSessionStore} uses. Structural (no `@cloudflare/workers-types` dependency) so any KV-like
49
+ * binding satisfies it and tests can pass an in-memory double.
50
+ */
51
+ export interface KVNamespaceLike {
52
+ get(key: string): Promise<string | null>;
53
+ /** `expiration` is an absolute unix-seconds timestamp (Cloudflare KV auto-evicts past it). */
54
+ put(key: string, value: string, options?: {
55
+ readonly expiration?: number;
56
+ }): Promise<void>;
57
+ delete(key: string): Promise<void>;
58
+ }
59
+ /**
60
+ * A {@link SessionStore} backed by a **Cloudflare Workers KV** namespace (or any {@link KVNamespaceLike})
61
+ * — the durable, shared production store: sessions hold across worker instances and survive restarts.
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 — the manager still authoritatively checks expiry on read).
64
+ * Every read is validated, so a corrupt/skewed entry reads as no session.
65
+ */
66
+ export declare class KVSessionStore implements SessionStore {
67
+ private readonly kv;
68
+ constructor(kv: KVNamespaceLike);
69
+ get(id: string): Promise<SessionRecord | undefined>;
70
+ set(id: string, record: SessionRecord): Promise<void>;
71
+ delete(id: string): Promise<void>;
72
+ }
73
+ //# sourceMappingURL=store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,yEAAyE;AACzE,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACtC,uFAAuF;IACvF,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;CAC3B;AAED;;;;GAIG;AACH,MAAM,WAAW,YAAY;IAC3B,qDAAqD;IACrD,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,SAAS,CAAC,CAAA;IACnD,gDAAgD;IAChD,GAAG,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACrD,4DAA4D;IAC5D,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CAClC;AAED,MAAM,WAAW,yBAAyB;IACxC;4CACwC;IACxC,QAAQ,CAAC,iBAAiB,CAAC,EAAE,OAAO,CAAA;IACpC,4FAA4F;IAC5F,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CACtB;AAED;;;;GAIG;AACH,qBAAa,kBAAmB,YAAW,YAAY;IACrD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAmC;IAC5D,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAQ;gBAEhB,OAAO,GAAE,yBAA8B;IAYnD,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,SAAS,CAAC;IAInD,GAAG,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC;IAWrD,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAIlC;AAED;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC9B,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAA;IACxC,8FAA8F;IAC9F,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC1F,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CACnC;AAgBD;;;;;;GAMG;AACH,qBAAa,cAAe,YAAW,YAAY;IACjD,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAiB;gBAExB,EAAE,EAAE,eAAe;IAIzB,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,SAAS,CAAC;IAYnD,GAAG,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC;IASrD,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAGxC"}
package/dist/store.js ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Session stores (the **store mode** backend) — a pluggable `SessionStore` plus an in-memory
3
+ * implementation (dev / single-instance, prod-guarded) and a Cloudflare Workers KV implementation
4
+ * (the durable, shared production path). The store is dumb persistence: the session *manager* owns the
5
+ * clock and authoritatively enforces expiry, so the store stays clock-free and trivially testable.
6
+ */
7
+ /**
8
+ * In-process session store. Refuses to run in production unless explicitly allowed (mirrors the ISR
9
+ * `MemoryCacheStore` + the rate-limit `MemoryStore` — a per-instance store is unsafe across instances).
10
+ * Bounded: oldest-inserted entries evict past `max`.
11
+ */
12
+ export class MemorySessionStore {
13
+ sessions = new Map();
14
+ max;
15
+ constructor(options = {}) {
16
+ const isProd = typeof process !== "undefined" && process.env?.NODE_ENV === "production";
17
+ if (options.allowInProduction !== true && isProd) {
18
+ throw new Error("[nifra/auth] MemorySessionStore is per-instance and unsafe in production (sessions don't hold " +
19
+ "across instances and are lost on restart). Use a shared store (KVSessionStore, Redis), or " +
20
+ "pass { allowInProduction: true } for a single-instance deploy.");
21
+ }
22
+ this.max = options.max ?? 10_000;
23
+ }
24
+ get(id) {
25
+ return Promise.resolve(this.sessions.get(id));
26
+ }
27
+ set(id, record) {
28
+ this.sessions.delete(id); // re-insert at the tail so Map order tracks recency for the bounded evict
29
+ this.sessions.set(id, record);
30
+ while (this.sessions.size > this.max) {
31
+ const oldest = this.sessions.keys().next().value;
32
+ if (oldest === undefined)
33
+ break;
34
+ this.sessions.delete(oldest);
35
+ }
36
+ return Promise.resolve();
37
+ }
38
+ delete(id) {
39
+ this.sessions.delete(id);
40
+ return Promise.resolve();
41
+ }
42
+ }
43
+ /** Structural validation of a KV-read value before it's trusted as a {@link SessionRecord}. The store
44
+ * is a trust boundary (corruption, version skew, tampering), so a malformed entry reads as a miss
45
+ * (no session) rather than a thrown error or a half-valid record. */
46
+ const isSessionRecord = (value) => {
47
+ if (typeof value !== "object" || value === null)
48
+ return false;
49
+ const v = value;
50
+ return (typeof v.expiresAt === "number" &&
51
+ Number.isFinite(v.expiresAt) &&
52
+ typeof v.data === "object" &&
53
+ v.data !== null);
54
+ };
55
+ /**
56
+ * A {@link SessionStore} backed by a **Cloudflare Workers KV** namespace (or any {@link KVNamespaceLike})
57
+ * — the durable, shared production store: sessions hold across worker instances and survive restarts.
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 — the manager still authoritatively checks expiry on read).
60
+ * Every read is validated, so a corrupt/skewed entry reads as no session.
61
+ */
62
+ export class KVSessionStore {
63
+ kv;
64
+ constructor(kv) {
65
+ this.kv = kv;
66
+ }
67
+ async get(id) {
68
+ const raw = await this.kv.get(id);
69
+ if (raw === null)
70
+ return undefined;
71
+ let parsed;
72
+ try {
73
+ parsed = JSON.parse(raw);
74
+ }
75
+ catch {
76
+ return undefined; // corrupt entry → no session (the next set overwrites it)
77
+ }
78
+ return isSessionRecord(parsed) ? parsed : undefined;
79
+ }
80
+ async set(id, record) {
81
+ // Absolute KV expiry (unix seconds) as a GC backstop. Cloudflare requires it be ≥ 60s out; session
82
+ // `maxAge` is realistically minutes+, so a real deploy satisfies that (the manager enforces the
83
+ // exact expiry regardless).
84
+ await this.kv.put(id, JSON.stringify(record), {
85
+ expiration: Math.floor(record.expiresAt / 1000),
86
+ });
87
+ }
88
+ async delete(id) {
89
+ await this.kv.delete(id);
90
+ }
91
+ }
92
+ //# sourceMappingURL=store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.js","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@nifrajs/auth",
3
+ "version": "0.1.0-alpha.1",
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
+ "keywords": [
6
+ "bun",
7
+ "typescript",
8
+ "nifra",
9
+ "web-framework",
10
+ "full-stack",
11
+ "edge",
12
+ "ssr"
13
+ ],
14
+ "type": "module",
15
+ "main": "./dist/index.js",
16
+ "module": "./dist/index.js",
17
+ "types": "./dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "bun": "./src/index.ts",
21
+ "types": "./dist/index.d.ts",
22
+ "default": "./dist/index.js"
23
+ }
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "src"
28
+ ],
29
+ "sideEffects": false,
30
+ "scripts": {
31
+ "build": "tsc -p tsconfig.build.json && bun run ../../scripts/fix-dts.ts dist"
32
+ },
33
+ "license": "MIT",
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "peerDependencies": {
38
+ "@nifrajs/core": "workspace:*"
39
+ },
40
+ "devDependencies": {
41
+ "@nifrajs/core": "workspace:*"
42
+ },
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "git+https://github.com/nifrajs/nifra.git",
46
+ "directory": "packages/auth"
47
+ },
48
+ "bugs": {
49
+ "url": "https://github.com/nifrajs/nifra/issues"
50
+ },
51
+ "homepage": "https://github.com/nifrajs/nifra#readme"
52
+ }
package/src/csrf.ts ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * CSRF protection via an **Origin / Referer check** on state-changing requests — OWASP's recommended
3
+ * defense for cookie-authenticated apps. A browser always attaches `Origin` (or at least `Referer`) to
4
+ * a cross-origin or same-origin *unsafe* request; it must match an allowed origin, else `403`. Safe
5
+ * methods (GET/HEAD/OPTIONS) pass. Apply with `app.use(csrf({ origins: ["https://example.com"] }))`.
6
+ */
7
+ import type { Middleware } from "@nifrajs/core"
8
+
9
+ const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"])
10
+
11
+ export interface CsrfOptions {
12
+ /**
13
+ * Allowed origins (e.g. `["https://example.com"]`). **Set this in production behind a proxy** — when
14
+ * omitted, the check derives same-origin from the request URL, which is correct in dev / when the
15
+ * proxy preserves `Host` but not when the public origin differs from the worker's.
16
+ */
17
+ readonly origins?: readonly string[]
18
+ }
19
+
20
+ const forbidden = (): Response =>
21
+ Response.json({ ok: false, error: "csrf_failed" }, { status: 403 })
22
+
23
+ export function csrf(options: CsrfOptions = {}): Middleware {
24
+ const configured = options.origins !== undefined ? new Set(options.origins) : undefined
25
+ return {
26
+ name: "csrf",
27
+ onRequest(req) {
28
+ if (SAFE_METHODS.has(req.method)) return undefined
29
+ const allowed = configured ?? new Set([new URL(req.url).origin])
30
+
31
+ const origin = req.headers.get("origin")
32
+ if (origin !== null) return allowed.has(origin) ? undefined : forbidden()
33
+
34
+ // Some same-origin requests omit `Origin` — fall back to the `Referer`'s origin.
35
+ const referer = req.headers.get("referer")
36
+ if (referer !== null) {
37
+ let refererOrigin: string
38
+ try {
39
+ refererOrigin = new URL(referer).origin
40
+ } catch {
41
+ return forbidden() // malformed Referer
42
+ }
43
+ return allowed.has(refererOrigin) ? undefined : forbidden()
44
+ }
45
+
46
+ // A state-changing request with neither header → reject (fail closed; a browser always sends one).
47
+ return forbidden()
48
+ },
49
+ }
50
+ }
package/src/guards.ts ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Route guards — run at the top of a protected loader/action/handler. On a missing session they
3
+ * **throw a `Response`** (a 302 to your login path, or a 401); nifra returns a thrown Response as-is, so
4
+ * the guard short-circuits the rest of the handler. Pairs with `@nifrajs/auth` sessions but only needs the
5
+ * `Session` shape, so it's framework-agnostic.
6
+ */
7
+ import type { Session } from "./session.ts"
8
+
9
+ /** What a guard does when the check fails: 302 to `redirectTo` (a same-origin path), or — omitted — a
10
+ * 401 JSON (`{ ok: false, error: "unauthorized" }`). */
11
+ export interface GuardOptions {
12
+ readonly redirectTo?: string
13
+ }
14
+
15
+ const rejection = (options: GuardOptions): Response => {
16
+ const to = options.redirectTo
17
+ if (to === undefined) {
18
+ return Response.json({ ok: false, error: "unauthorized" }, { status: 401 })
19
+ }
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 — fail loud here.
22
+ if (!to.startsWith("/") || to.startsWith("//")) {
23
+ throw new Error(
24
+ `[nifra/auth] guard redirectTo must be a same-origin path beginning with "/" (got ${JSON.stringify(to)})`,
25
+ )
26
+ }
27
+ return new Response(null, { status: 302, headers: { location: to } })
28
+ }
29
+
30
+ /**
31
+ * Require a non-empty session. Returns it when present; otherwise throws a `Response` (302/401). Use at
32
+ * the top of a protected loader:
33
+ * `const session = requireSession(await sessions.get(c), { redirectTo: "/login" })`.
34
+ */
35
+ export function requireSession<Data extends Record<string, unknown>>(
36
+ session: Session<Data>,
37
+ options: GuardOptions = {},
38
+ ): Session<Data> {
39
+ if (!session.isEmpty) return session
40
+ throw rejection(options)
41
+ }
42
+
43
+ /**
44
+ * Require a specific session key (e.g. the `userId` a login set) to be present. Returns its value
45
+ * (narrowed non-nullish); otherwise throws like {@link requireSession}. The common "who is the user"
46
+ * guard: `const userId = requireUser(await sessions.get(c), "userId", { redirectTo: "/login" })`.
47
+ */
48
+ export function requireUser<Data extends Record<string, unknown>, K extends keyof Data>(
49
+ session: Session<Data>,
50
+ key: K,
51
+ options: GuardOptions = {},
52
+ ): NonNullable<Data[K]> {
53
+ const value = session.get(key)
54
+ // Narrowed by the runtime check; the generic `Data[K] | undefined` doesn't auto-narrow to NonNullable.
55
+ if (value !== undefined && value !== null) return value as NonNullable<Data[K]>
56
+ throw rejection(options)
57
+ }
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * `@nifrajs/auth` — session primitives for nifra. Signed-cookie + server-store sessions, with the same
3
+ * pluggable-store + prod-guard discipline as the ISR cache. **Bring your own identity** (Better Auth,
4
+ * Lucia, your own OAuth/password logic); nifra owns the session cookie, store, CSRF, and route guards.
5
+ */
6
+ export { type CsrfOptions, csrf } from "./csrf.ts"
7
+ export { type GuardOptions, requireSession, requireUser } from "./guards.ts"
8
+ export {
9
+ createSessions,
10
+ type Session,
11
+ type SessionContext,
12
+ type SessionCookieOptions,
13
+ type SessionManager,
14
+ type SessionOptions,
15
+ } from "./session.ts"
16
+ export {
17
+ type KVNamespaceLike,
18
+ KVSessionStore,
19
+ MemorySessionStore,
20
+ type MemorySessionStoreOptions,
21
+ type SessionRecord,
22
+ type SessionStore,
23
+ } from "./store.ts"
package/src/session.ts ADDED
@@ -0,0 +1,256 @@
1
+ /**
2
+ * The session manager — `createSessions<Data>()` returns get/commit/destroy/regenerate bound to a
3
+ * signing secret + cookie config. Two modes:
4
+ * • **store mode** (a {@link SessionStore} is given): a random session id rides a signed cookie; the
5
+ * data lives in the store. Big/sensitive data, server-side revocation.
6
+ * • **cookie mode** (no store): the data itself is signed into the cookie (stateless, edge-friendly,
7
+ * ≤4 KB). No server state.
8
+ * The cookie value is **always HMAC-signed** (tamper-evident; verified constant-time) and the session
9
+ * cookie is always `HttpOnly`. Reads **fail closed** — a tampered/missing/expired cookie yields a fresh
10
+ * anonymous session, never an error.
11
+ */
12
+ import { type CookieOptions, parseCookies, signValue, unsignValue } from "@nifrajs/core"
13
+ import type { SessionRecord, SessionStore } from "./store.ts"
14
+
15
+ /** The cookie + response surface the manager needs — a structural subset of nifra's `Context`, so any
16
+ * `c` satisfies it and it's testable with a stub. */
17
+ export interface SessionContext {
18
+ readonly cookies: Readonly<Record<string, string>>
19
+ readonly set: {
20
+ cookie(name: string, value: string, options?: CookieOptions): void
21
+ deleteCookie(name: string, options?: { readonly path?: string; readonly domain?: string }): void
22
+ }
23
+ }
24
+
25
+ /** A typed session handle. Every key is optional — a fresh session is empty. */
26
+ export interface Session<Data extends Record<string, unknown> = Record<string, unknown>> {
27
+ get<K extends keyof Data>(key: K): Data[K] | undefined
28
+ set<K extends keyof Data>(key: K, value: Data[K]): void
29
+ unset(key: keyof Data): void
30
+ has(key: keyof Data): boolean
31
+ /** Wipe all data (the session id is kept). To end the session entirely, call `manager.destroy`. */
32
+ clear(): void
33
+ /** A snapshot of the current data. */
34
+ readonly data: Readonly<Partial<Data>>
35
+ readonly isEmpty: boolean
36
+ }
37
+
38
+ /** Cookie attributes a session may tune. `httpOnly` is **not** offered — a session cookie is always
39
+ * HttpOnly; `maxAge`/`expires` are derived from the session's lifetime. */
40
+ export type SessionCookieOptions = Pick<CookieOptions, "secure" | "sameSite" | "path" | "domain">
41
+
42
+ export interface SessionOptions {
43
+ /** Signing secret (≥ 16 chars). Rotating it invalidates all existing sessions. */
44
+ readonly secret: string
45
+ /** Provide a store for **store mode**; omit for **cookie mode** (stateless). */
46
+ readonly store?: SessionStore
47
+ /** Cookie name. Default `nifra_session`. */
48
+ readonly cookieName?: string
49
+ /** Session lifetime in **seconds**. Default 7 days. */
50
+ readonly maxAge?: number
51
+ /** Slide the expiry forward on every commit (default `true`); `false` = fixed absolute expiry. */
52
+ readonly rolling?: boolean
53
+ /** Extra cookie attributes (the session cookie is always HttpOnly; `Secure`/`SameSite=Lax`/`Path=/`
54
+ * default via `c.set.cookie`). Pass `{ secure: false }` for local http dev. */
55
+ readonly cookie?: SessionCookieOptions
56
+ /** Clock (ms) — injected for testability; production passes `() => Date.now()` (the default). */
57
+ readonly now?: () => number
58
+ }
59
+
60
+ export interface SessionManager<Data extends Record<string, unknown> = Record<string, unknown>> {
61
+ /** Load the session from the request cookie (verified + un-expired), or a fresh anonymous one. */
62
+ get(c: SessionContext): Promise<Session<Data>>
63
+ /** Read-only load from a raw `Request` — for `@nifrajs/web` loaders (which can read the request but not
64
+ * write cookies). Identical verify/expiry to {@link get}; commit/destroy in a route or action. */
65
+ read(request: Request): Promise<Session<Data>>
66
+ /** Persist the session: write the store (store mode) + the signed cookie. */
67
+ commit(c: SessionContext, session: Session<Data>): Promise<void>
68
+ /** End the session: drop the store record (store mode) + clear the cookie. */
69
+ destroy(c: SessionContext, session?: Session<Data>): Promise<void>
70
+ /** Rotate the session id on the next commit (call on privilege change — login — to defend against
71
+ * session fixation). Data is preserved; the old store record is dropped. */
72
+ regenerate(session: Session<Data>): void
73
+ }
74
+
75
+ const DAY_SECONDS = 86_400
76
+ // 256-bit floor, matching @nifrajs/core's signed-cookie/webhook guard. Sessions sign via core's
77
+ // signValue (which enforces the same bar); this early check gives an auth-specific message before
78
+ // delegating. Measured in UTF-8 bytes, not characters.
79
+ const MIN_SECRET_BYTES = 32
80
+ const SECRET_ENCODER = new TextEncoder()
81
+
82
+ const encodeBase64Url = (bytes: Uint8Array): string => {
83
+ let bin = ""
84
+ for (const b of bytes) bin += String.fromCharCode(b)
85
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
86
+ }
87
+
88
+ /** A 256-bit random session id (base64url). High-entropy + unguessable — the cookie also signs it. */
89
+ const randomId = (): string => encodeBase64Url(crypto.getRandomValues(new Uint8Array(32)))
90
+
91
+ interface Payload {
92
+ data: Record<string, unknown>
93
+ expiresAt: number
94
+ }
95
+
96
+ /** Validate a cookie-mode payload at the trust boundary — malformed JSON / shape → `null` (no session). */
97
+ const parsePayload = (json: string): Payload | null => {
98
+ let value: unknown
99
+ try {
100
+ value = JSON.parse(json)
101
+ } catch {
102
+ return null
103
+ }
104
+ if (typeof value !== "object" || value === null) return null
105
+ const obj = value as Record<string, unknown>
106
+ if (typeof obj.expiresAt !== "number" || !Number.isFinite(obj.expiresAt)) return null
107
+ if (typeof obj.data !== "object" || obj.data === null) return null
108
+ return { data: obj.data as Record<string, unknown>, expiresAt: obj.expiresAt }
109
+ }
110
+
111
+ export function createSessions<Data extends Record<string, unknown> = Record<string, unknown>>(
112
+ options: SessionOptions,
113
+ ): SessionManager<Data> {
114
+ const { secret, store } = options
115
+ if (SECRET_ENCODER.encode(secret).length < MIN_SECRET_BYTES) {
116
+ throw new Error(
117
+ `[nifra/auth] session secret must be at least ${MIN_SECRET_BYTES} bytes (256-bit). Generate one with: openssl rand -base64 32`,
118
+ )
119
+ }
120
+ const cookieName = options.cookieName ?? "nifra_session"
121
+ const maxAge = options.maxAge ?? 7 * DAY_SECONDS
122
+ const rolling = options.rolling ?? true
123
+ const now = options.now ?? (() => Date.now())
124
+ // Annotated (not just `?? {}`) so `cookieOpts.path`/`.domain` stay typed — `?? {}` alone widens the
125
+ // type to include the empty-object literal, which the package's build tsconfig rejects on access.
126
+ const cookieOpts: SessionCookieOptions = options.cookie ?? {}
127
+
128
+ interface State {
129
+ id: string | undefined
130
+ readonly data: Map<keyof Data, Data[keyof Data]>
131
+ expiresAt: number | undefined
132
+ regenerate: boolean
133
+ }
134
+ // Per-manager so the WeakMap is typed to this `Data` (no `any`); a session can only be committed by
135
+ // the manager that created it.
136
+ const states = new WeakMap<Session<Data>, State>()
137
+
138
+ const make = (init: {
139
+ id?: string
140
+ data?: Record<string, unknown>
141
+ expiresAt?: number
142
+ }): Session<Data> => {
143
+ const state: State = {
144
+ id: init.id,
145
+ // The Map erases per-key value types; the public generic signatures restore them at the edge.
146
+ data: new Map(Object.entries(init.data ?? {}) as [keyof Data, Data[keyof Data]][]),
147
+ expiresAt: init.expiresAt,
148
+ regenerate: false,
149
+ }
150
+ const session: Session<Data> = {
151
+ get: <K extends keyof Data>(key: K) => state.data.get(key) as Data[K] | undefined,
152
+ set: (key, value) => {
153
+ state.data.set(key, value)
154
+ },
155
+ unset: (key) => {
156
+ state.data.delete(key)
157
+ },
158
+ has: (key) => state.data.has(key),
159
+ clear: () => {
160
+ state.data.clear()
161
+ },
162
+ get data() {
163
+ return Object.fromEntries(state.data) as Partial<Data>
164
+ },
165
+ get isEmpty() {
166
+ return state.data.size === 0
167
+ },
168
+ }
169
+ states.set(session, state)
170
+ return session
171
+ }
172
+
173
+ const stateOf = (session: Session<Data>, op: string): State => {
174
+ const state = states.get(session)
175
+ if (state === undefined) {
176
+ throw new Error(`[nifra/auth] ${op}: session was not created by this manager`)
177
+ }
178
+ return state
179
+ }
180
+
181
+ const loadFromRaw = async (raw: string | undefined): Promise<Session<Data>> => {
182
+ if (raw === undefined || raw === "") return make({})
183
+ const unsigned = await unsignValue(raw, secret)
184
+ if (unsigned === null) return make({}) // tampered / wrong secret → fail closed (anonymous)
185
+
186
+ if (store !== undefined) {
187
+ const record = await store.get(unsigned)
188
+ if (record === undefined) return make({})
189
+ if (record.expiresAt <= now()) {
190
+ await store.delete(unsigned) // expired — evict + treat as no session
191
+ return make({})
192
+ }
193
+ return make({ id: unsigned, data: record.data, expiresAt: record.expiresAt })
194
+ }
195
+
196
+ const payload = parsePayload(unsigned)
197
+ if (payload === null || payload.expiresAt <= now()) return make({})
198
+ return make({ data: payload.data, expiresAt: payload.expiresAt })
199
+ }
200
+
201
+ const get = (c: SessionContext): Promise<Session<Data>> => loadFromRaw(c.cookies[cookieName])
202
+
203
+ // Read-only load from a raw Request — for @nifrajs/web loaders, which have the request but can't write
204
+ // cookies (commit/destroy belong in a route/action with the full Context). Same verify + expiry.
205
+ const read = (request: Request): Promise<Session<Data>> =>
206
+ loadFromRaw(parseCookies(request.headers.get("cookie"))[cookieName])
207
+
208
+ const writeCookie = (c: SessionContext, value: string, expiresAt: number): void => {
209
+ const maxAgeSeconds = Math.max(0, Math.floor((expiresAt - now()) / 1000))
210
+ c.set.cookie(cookieName, value, { ...cookieOpts, maxAge: maxAgeSeconds })
211
+ }
212
+
213
+ const commit = async (c: SessionContext, session: Session<Data>): Promise<void> => {
214
+ const state = stateOf(session, "commit")
215
+ // Rolling → slide the expiry; absolute → keep the original (set on the first commit).
216
+ const expiresAt =
217
+ rolling || state.expiresAt === undefined ? now() + maxAge * 1000 : state.expiresAt
218
+ state.expiresAt = expiresAt
219
+ const data = Object.fromEntries(state.data) as Record<string, unknown>
220
+
221
+ if (store !== undefined) {
222
+ if (state.regenerate) {
223
+ if (state.id !== undefined) await store.delete(state.id) // drop the pre-rotation session
224
+ state.id = undefined
225
+ state.regenerate = false
226
+ }
227
+ if (state.id === undefined) state.id = randomId()
228
+ await store.set(state.id, { data, expiresAt } satisfies SessionRecord)
229
+ writeCookie(c, await signValue(state.id, secret), expiresAt)
230
+ return
231
+ }
232
+ // Cookie mode: the (signed) payload IS the cookie. serializeCookie enforces the 4 KB cap.
233
+ writeCookie(
234
+ c,
235
+ await signValue(JSON.stringify({ data, expiresAt } satisfies Payload), secret),
236
+ expiresAt,
237
+ )
238
+ }
239
+
240
+ const destroy = async (c: SessionContext, session?: Session<Data>): Promise<void> => {
241
+ if (store !== undefined && session !== undefined) {
242
+ const state = states.get(session)
243
+ if (state?.id !== undefined) await store.delete(state.id)
244
+ }
245
+ c.set.deleteCookie(cookieName, {
246
+ path: cookieOpts.path ?? "/",
247
+ ...(cookieOpts.domain !== undefined ? { domain: cookieOpts.domain } : {}),
248
+ })
249
+ }
250
+
251
+ const regenerate = (session: Session<Data>): void => {
252
+ stateOf(session, "regenerate").regenerate = true
253
+ }
254
+
255
+ return { get, read, commit, destroy, regenerate }
256
+ }
package/src/store.ts ADDED
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Session stores (the **store mode** backend) — a pluggable `SessionStore` plus an in-memory
3
+ * implementation (dev / single-instance, prod-guarded) and a Cloudflare Workers KV implementation
4
+ * (the durable, shared production path). The store is dumb persistence: the session *manager* owns the
5
+ * clock and authoritatively enforces expiry, so the store stays clock-free and trivially testable.
6
+ */
7
+
8
+ /** A persisted session — its data plus an absolute expiry (ms epoch). */
9
+ export interface SessionRecord {
10
+ readonly data: Record<string, unknown>
11
+ /** Absolute expiry (ms epoch). The manager treats `expiresAt <= now` as no session. */
12
+ readonly expiresAt: number
13
+ }
14
+
15
+ /**
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 — {@link MemorySessionStore}
18
+ * prod-guards against the per-instance footgun.
19
+ */
20
+ export interface SessionStore {
21
+ /** The record for `id`, or `undefined` on a miss. */
22
+ get(id: string): Promise<SessionRecord | undefined>
23
+ /** Store (or overwrite) the record for `id`. */
24
+ set(id: string, record: SessionRecord): Promise<void>
25
+ /** Drop `id` (logout / regeneration). A no-op if absent. */
26
+ delete(id: string): Promise<void>
27
+ }
28
+
29
+ export interface MemorySessionStoreOptions {
30
+ /** Allow the in-memory store in production. Off by default — per-instance sessions don't hold across
31
+ * instances and are lost on restart. */
32
+ readonly allowInProduction?: boolean
33
+ /** Hard cap on stored sessions; the oldest-inserted is evicted past it (default 10_000). */
34
+ readonly max?: number
35
+ }
36
+
37
+ /**
38
+ * In-process session store. Refuses to run in production unless explicitly allowed (mirrors the ISR
39
+ * `MemoryCacheStore` + the rate-limit `MemoryStore` — a per-instance store is unsafe across instances).
40
+ * Bounded: oldest-inserted entries evict past `max`.
41
+ */
42
+ export class MemorySessionStore implements SessionStore {
43
+ private readonly sessions = new Map<string, SessionRecord>()
44
+ private readonly max: number
45
+
46
+ constructor(options: MemorySessionStoreOptions = {}) {
47
+ const isProd = typeof process !== "undefined" && process.env?.NODE_ENV === "production"
48
+ if (options.allowInProduction !== true && isProd) {
49
+ throw new Error(
50
+ "[nifra/auth] MemorySessionStore is per-instance and unsafe in production (sessions don't hold " +
51
+ "across instances and are lost on restart). Use a shared store (KVSessionStore, Redis), or " +
52
+ "pass { allowInProduction: true } for a single-instance deploy.",
53
+ )
54
+ }
55
+ this.max = options.max ?? 10_000
56
+ }
57
+
58
+ get(id: string): Promise<SessionRecord | undefined> {
59
+ return Promise.resolve(this.sessions.get(id))
60
+ }
61
+
62
+ set(id: string, record: SessionRecord): Promise<void> {
63
+ this.sessions.delete(id) // re-insert at the tail so Map order tracks recency for the bounded evict
64
+ this.sessions.set(id, record)
65
+ while (this.sessions.size > this.max) {
66
+ const oldest = this.sessions.keys().next().value
67
+ if (oldest === undefined) break
68
+ this.sessions.delete(oldest)
69
+ }
70
+ return Promise.resolve()
71
+ }
72
+
73
+ delete(id: string): Promise<void> {
74
+ this.sessions.delete(id)
75
+ return Promise.resolve()
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Minimal structural shape of a Cloudflare Workers **KV namespace** binding — just the three methods
81
+ * {@link KVSessionStore} uses. Structural (no `@cloudflare/workers-types` dependency) so any KV-like
82
+ * binding satisfies it and tests can pass an in-memory double.
83
+ */
84
+ export interface KVNamespaceLike {
85
+ get(key: string): Promise<string | null>
86
+ /** `expiration` is an absolute unix-seconds timestamp (Cloudflare KV auto-evicts past it). */
87
+ put(key: string, value: string, options?: { readonly expiration?: number }): Promise<void>
88
+ delete(key: string): Promise<void>
89
+ }
90
+
91
+ /** Structural validation of a KV-read value before it's trusted as a {@link SessionRecord}. The store
92
+ * is a trust boundary (corruption, version skew, tampering), so a malformed entry reads as a miss
93
+ * (no session) rather than a thrown error or a half-valid record. */
94
+ const isSessionRecord = (value: unknown): value is SessionRecord => {
95
+ if (typeof value !== "object" || value === null) return false
96
+ const v = value as Record<string, unknown>
97
+ return (
98
+ typeof v.expiresAt === "number" &&
99
+ Number.isFinite(v.expiresAt) &&
100
+ typeof v.data === "object" &&
101
+ v.data !== null
102
+ )
103
+ }
104
+
105
+ /**
106
+ * A {@link SessionStore} backed by a **Cloudflare Workers KV** namespace (or any {@link KVNamespaceLike})
107
+ * — the durable, shared production store: sessions hold across worker instances and survive restarts.
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 — the manager still authoritatively checks expiry on read).
110
+ * Every read is validated, so a corrupt/skewed entry reads as no session.
111
+ */
112
+ export class KVSessionStore implements SessionStore {
113
+ private readonly kv: KVNamespaceLike
114
+
115
+ constructor(kv: KVNamespaceLike) {
116
+ this.kv = kv
117
+ }
118
+
119
+ async get(id: string): Promise<SessionRecord | undefined> {
120
+ const raw = await this.kv.get(id)
121
+ if (raw === null) return undefined
122
+ let parsed: unknown
123
+ try {
124
+ parsed = JSON.parse(raw)
125
+ } catch {
126
+ return undefined // corrupt entry → no session (the next set overwrites it)
127
+ }
128
+ return isSessionRecord(parsed) ? parsed : undefined
129
+ }
130
+
131
+ async set(id: string, record: SessionRecord): Promise<void> {
132
+ // Absolute KV expiry (unix seconds) as a GC backstop. Cloudflare requires it be ≥ 60s out; session
133
+ // `maxAge` is realistically minutes+, so a real deploy satisfies that (the manager enforces the
134
+ // exact expiry regardless).
135
+ await this.kv.put(id, JSON.stringify(record), {
136
+ expiration: Math.floor(record.expiresAt / 1000),
137
+ })
138
+ }
139
+
140
+ async delete(id: string): Promise<void> {
141
+ await this.kv.delete(id)
142
+ }
143
+ }