@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 +21 -0
- package/README.md +18 -0
- package/dist/csrf.d.ts +17 -0
- package/dist/csrf.d.ts.map +1 -0
- package/dist/csrf.js +31 -0
- package/dist/csrf.js.map +1 -0
- package/dist/guards.d.ts +25 -0
- package/dist/guards.d.ts.map +1 -0
- package/dist/guards.js +35 -0
- package/dist/guards.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/session.d.ts +73 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +164 -0
- package/dist/session.js.map +1 -0
- package/dist/store.d.ts +73 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +92 -0
- package/dist/store.js.map +1 -0
- package/package.json +52 -0
- package/src/csrf.ts +50 -0
- package/src/guards.ts +57 -0
- package/src/index.ts +23 -0
- package/src/session.ts +256 -0
- package/src/store.ts +143 -0
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
|
package/dist/csrf.js.map
ADDED
|
@@ -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"}
|
package/dist/guards.d.ts
ADDED
|
@@ -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"}
|
package/dist/index.d.ts
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 { 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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"}
|
package/dist/session.js
ADDED
|
@@ -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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"}
|
package/dist/store.d.ts
ADDED
|
@@ -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
|
+
}
|