diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c0bbd1..100397a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,3 +27,5 @@ jobs: - name: Tests (data integrity, lookups, ranges, search quality and speed) run: npm test - run: npm run build + - name: llms-full.txt is up to date (run `npm run llms` and commit if this fails) + run: npm run llms && git diff --exit-code -- llms-full.txt diff --git a/llms-full.txt b/llms-full.txt new file mode 100644 index 0000000..9ee402d --- /dev/null +++ b/llms-full.txt @@ -0,0 +1,1319 @@ +# @mgpl-diagnostics/auth-kit — full documentation and source + +> Generated by scripts/llms-full.mjs. Do not edit by hand. 14 files. + +## Contents + +- README.md +- AGENTS.md +- llms.txt +- package.json +- scripts/llms-full.mjs +- scripts/test.ts +- src/auth.ts +- src/errors.ts +- src/index.ts +- src/memory.ts +- src/password.ts +- src/rbac.ts +- src/redis.ts +- src/stores.ts + + +--- + +## File: README.md + +# auth-kit + +**Authentication and permissions for the MGPL Diagnostics backend, as an internal API.** +There is no HTTP server here. Your backend (GraphQL resolvers, REST handlers, jobs) calls these functions. + +![tests](https://img.shields.io/badge/checks-65-14264A) ![deps](https://img.shields.io/badge/runtime%20dependencies-0-E9A23B) + +## What it does + +| | | +| --- | --- | +| **Passwords** | scrypt (N=2¹⁵), salted, self-describing hashes; weaker hashes upgraded on the next login; NIST-style policy (length, common passwords, repeated characters, no email) | +| **Login** | rate-limited per IP and per email; timing-safe (an unknown email costs the same as a wrong password); same email in several organisations → `ORG_REQUIRED`; audited | +| **Sessions** | opaque tokens, stored only as SHA-256; idle timeout that slides with use plus an absolute maximum age; list devices, revoke one, log out everywhere | +| **Accounts** | disabling a user or changing roles applies on the very next request | +| **Passwords, again** | change (keeps this device, ends the others); reset (single-use, expiring token; ends every session); admin set | +| **Permissions** | `resource:action` with wildcards; roles per branch or organisation-wide; built-in lab roles plus each organisation's own | +| **Stores** | memory (tests, one server) or Redis (`/redis`, shared by all servers) | + +## Use + +```ts +import { createAuth, SYSTEM_ROLES } from "@mgpl-diagnostics/auth-kit"; +import { redisKeyValueStore, redisRateLimiter, redisSessionStore } from "@mgpl-diagnostics/auth-kit/redis"; + +// db-kit's repositories satisfy the user / org / role / audit stores as they are +const auth = createAuth({ + users: db.repos.users, + orgs: db.repos.orgs, + roles: db.repos.roles, + audit: db.repos.audit, + sessions: redisSessionStore(redis), + kv: redisKeyValueStore(redis), + limiter: redisRateLimiter(redis, { limit: 10, windowMs: 15 * 60_000 }), +}); + +const { token, user } = await auth.login({ email, password, ip, userAgent }); + +// per request, e.g. in the GraphQL context +const { viewer, can, branchesFor } = await auth.context(bearerToken); +can("invoice:create", branchId); // true / false +branchesFor("booking:read"); // "all" or ["branch-id", …] +``` + +When you create an organisation, seed the roles: `seedOrganization(repos, { …, roles: SYSTEM_ROLES })` +(db-kit). To create a user, call `auth.hashNewPassword(password, { email })`, which checks the policy +and then hashes. + +## API + +| Call | Returns / throws | +| --- | --- | +| `login({ email, password, orgCode?, ip?, userAgent? })` | `{ token, expiresAt, user }` · `INVALID_CREDENTIALS` `ACCOUNT_DISABLED` `INVITE_PENDING` `ORG_REQUIRED` `RATE_LIMITED` | +| `authenticate(token)` | `Viewer \| null` (slides the idle window) | +| `context(token)` | `{ viewer, can, branchesFor, permissions }` (anonymous when the token is missing or invalid) | +| `require(token, permission?, branchId?)` | `Viewer` · `UNAUTHENTICATED` `FORBIDDEN` | +| `logout(token)` · `logoutAll(userId, keepToken?)` | | +| `sessionsOf(userId)` · `revokeSession(userId, sessionId)` | devices; revoke one | +| `changePassword({ userId, currentPassword, newPassword, currentToken? })` | `INVALID_CREDENTIALS` `WEAK_PASSWORD` | +| `requestPasswordReset({ email, orgCode?, ip? })` | `{ token, expiresAt, user } \| null`. Deliver the token yourself; reply identically when it is null | +| `resetPassword({ token, newPassword })` | `INVALID_TOKEN` `WEAK_PASSWORD` | +| `setPassword(userId, newPassword, actorId?)` · `hashNewPassword(pw, user)` | admin tools | +| `invalidateRoles(orgId?)` | call after editing roles | + +Also exported: +- `hashPassword`, `verifyPassword`, `needsRehash`, `assertPasswordPolicy`; +- `randomToken`, `hashToken`; +- `PERMISSIONS`, `SYSTEM_ROLES`; +- `can`, `branchesFor`, `permissionsOf`, `roleMap`. + +Every failure is an `AuthError` with a stable `code`, an HTTP-style `statusCode`, and `details.retryAfterMs` +where it applies. + +## Built-in roles + +`owner` (everything) · `admin` · `branch_manager` · `front_desk` · `phlebotomist` · `technician` · +`pathologist` · `accountant` (including the books: `accounts:*`) · `doctor` (referring; read only). + +## Not here (by design) + +- **HTTP / GraphQL endpoints.** The backend exposes `login`, `logout`, `me` and the rest as GraphQL + mutations and queries, and calls this API. +- **Browser code.** A client package comes later. + +## Licence + +All rights reserved. See [LICENSE](LICENSE). + + +--- + +## File: AGENTS.md + +# AGENTS.md — @mgpl-diagnostics/auth-kit + +The backend's internal authentication API: passwords, sessions, login, reset, roles, permissions. +**No HTTP server, no GraphQL, no browser code.** The backend repo exposes these calls through its API. + +## Map + +``` +src/password.ts scrypt hash/verify/needsRehash, policy, randomToken, hashToken +src/rbac.ts PERMISSIONS, SYSTEM_ROLES, can, branchesFor, permissionsOf, roleMap +src/stores.ts storage interfaces (UserStore, OrgStore, RoleStore, AuditSink, SessionStore, KeyValueStore, RateLimiter) +src/memory.ts in-process stores (tests, single server) +src/redis.ts Redis stores ("/redis" entry): sessions, single-use values, sliding-window limiter +src/auth.ts createAuth(): login, authenticate, context, require, logout, sessions, password change/reset +scripts/test.ts 65 checks; session flows run on both memory and Redis (ioredis-mock) stores +``` + +## Rules + +1. **Never store or log raw tokens.** Stores receive `hashToken(token)` only. A test asserts Redis holds no raw token. +2. **Login stays timing-safe.** An unknown email must still run one scrypt verification. +3. **Every request re-reads the user** (`authenticate`), so disabling and role changes apply at once. Do not cache users across requests. +4. Interfaces stay **structural and small**. db-kit's repositories must keep satisfying them (users, orgs, roles, audit). +5. Permissions the backend checks must exist in `PERMISSIONS` and be granted by the right `SYSTEM_ROLES` + (db-kit's resolvers need `accounts:read|post|manage`, among others). +6. Error codes are a public contract (`AuthError.code`). Add new ones; never rename existing ones. + +## Commands + +```bash +npm run typecheck && npm test && npm run build +``` + +## Licence + +All rights reserved (see LICENSE). + + +--- + +## File: llms.txt + +# @mgpl-diagnostics/auth-kit + +> Internal authentication API for the MGPL Diagnostics backend (Node.js ≥ 20). Covers scrypt password hashing with rehash-on-login and a password policy; opaque session tokens stored as SHA-256, with a sliding idle timeout and an absolute max age (memory or Redis stores); timing-safe, rate-limited login with multi-organisation emails; password change and single-use reset tokens; role-based, branch-aware permissions. No HTTP server. + +Key facts for agents: +- `const auth = createAuth({ users, orgs, roles, audit, sessions, kv, limiter })`. db-kit repositories satisfy users/orgs/roles/audit. +- `auth.login({ email, password, orgCode?, ip })` → `{ token, expiresAt, user }`; `auth.context(token)` → `{ viewer, can, branchesFor, permissions }`. +- Errors: `AuthError` with `code` (INVALID_CREDENTIALS, ACCOUNT_DISABLED, INVITE_PENDING, ORG_REQUIRED, RATE_LIMITED, WEAK_PASSWORD, INVALID_TOKEN, UNAUTHENTICATED, FORBIDDEN) and `statusCode`. +- Redis stores: `import { redisSessionStore, redisKeyValueStore, redisRateLimiter } from "@mgpl-diagnostics/auth-kit/redis"`. +- Seed roles with `SYSTEM_ROLES`; permissions are "resource:action" with "*" wildcards. + +## Docs + +- [README](README.md): features, usage, API, roles +- [AGENTS.md](AGENTS.md): map and rules + +## Source + +- [src/auth.ts](src/auth.ts): createAuth +- [src/rbac.ts](src/rbac.ts): permissions and roles +- [src/password.ts](src/password.ts): hashing and policy +- [src/redis.ts](src/redis.ts): Redis stores + + +--- + +## File: package.json + +```json +{ + "name": "@mgpl-diagnostics/auth-kit", + "version": "1.0.0", + "description": "MGPL Diagnostics authentication for the backend: scrypt passwords, revocable sessions (Redis or memory), rate-limited timing-safe login, password reset, roles and branch-aware permissions. An internal API, no HTTP server.", + "type": "module", + "license": "SEE LICENSE IN LICENSE", + "repository": "github:mgpl-diagnostics/auth-kit", + "sideEffects": false, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./redis": { + "types": "./dist/redis.d.ts", + "import": "./dist/redis.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "README.md", + "AGENTS.md", + "llms.txt", + "LICENSE", + "llms-full.txt" + ], + "publishConfig": { + "registry": "https://npm.pkg.github.com" + }, + "scripts": { + "build": "tsup", + "typecheck": "tsc --noEmit", + "test": "tsx scripts/test.ts", + "prepare": "tsup", + "llms": "node scripts/llms-full.mjs @mgpl-diagnostics/auth-kit README.md AGENTS.md llms.txt package.json src scripts" + }, + "peerDependencies": { + "ioredis": ">=5" + }, + "peerDependenciesMeta": { + "ioredis": { + "optional": true + } + }, + "devDependencies": { + "@types/node": "^22.20.5", + "ioredis": "^5.11.1", + "ioredis-mock": "^8.13.1", + "tsup": "^8.5.0", + "tsx": "^4.23.15", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=20" + } +} +``` + + +--- + +## File: scripts/llms-full.mjs + +````js +// Builds llms-full.txt: every doc and source file of the repo in one plain-text document for AI tools. +// node scripts/llms-full.mjs "" <file-or-folder> [file-or-folder …] +// Folders are walked recursively. Output order: README, AGENTS, docs, then the rest alphabetically. +// Skipped: build output, dependencies, lockfiles, generated files, binaries. Files over 150 KB are +// listed (path + size) instead of inlined. CI checks the file is up to date (`npm run llms` + git diff). +import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { extname, join, relative } from "node:path"; + +const [title, ...inputs] = process.argv.slice(2); +if (!title || !inputs.length) { + console.error('usage: node scripts/llms-full.mjs "<title>" <file-or-folder> …'); + process.exit(1); +} + +const TEXT = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".md", ".mdx", ".json", ".css", ".yml", ".yaml", ".txt", ".graphql", ".gql", ".sql", ".html"]); +const SKIP_DIRS = new Set(["node_modules", "dist", ".next", ".git", ".turbo", "coverage", "examples", ".pg-test"]); +const SKIP_FILES = /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|llms-full\.txt|[^/]*\.generated\.[^/]+|[^/]*\.log)$/; +const MAX = 150 * 1024; +const LANG = { ".ts": "ts", ".tsx": "tsx", ".mts": "ts", ".cts": "ts", ".js": "js", ".mjs": "js", ".cjs": "js", ".json": "json", ".css": "css", ".yml": "yaml", ".yaml": "yaml", ".graphql": "graphql", ".gql": "graphql", ".sql": "sql", ".html": "html" }; + +const posix = (p) => p.split("\\").join("/"); +const files = new Set(); +function walk(p) { + const st = statSync(p); + if (st.isDirectory()) { + for (const name of readdirSync(p)) if (!SKIP_DIRS.has(name)) walk(join(p, name)); + } else if (TEXT.has(extname(p).toLowerCase()) && !SKIP_FILES.test(posix(p))) { + files.add(posix(relative(".", p))); + } +} +for (const input of inputs) walk(input); + +const rank = (f) => (/^README\.md$/i.test(f) ? 0 : /^AGENTS\.md$/i.test(f) ? 1 : /^docs\//.test(f) ? 2 : 3); +const ordered = [...files].sort((a, b) => rank(a) - rank(b) || (a < b ? -1 : a > b ? 1 : 0)); + +const parts = [`# ${title} — full documentation and source\n\n> Generated by scripts/llms-full.mjs. Do not edit by hand. ${ordered.length} files.\n\n## Contents\n\n${ordered.map((f) => `- ${f}`).join("\n")}\n`]; +const omitted = []; +for (const f of ordered) { + // LF-normalised text, so Windows and Linux checkouts produce byte-identical output + const body = readFileSync(f, "utf8").replace(/\r\n/g, "\n").trim(); + const size = Buffer.byteLength(body); + if (size > MAX) { + omitted.push(`${f} (${(size / 1024).toFixed(0)} KB)`); + parts.push(`\n\n---\n\n## File: ${f}\n\n_Omitted: ${(size / 1024).toFixed(0)} KB. Read the file directly._\n`); + continue; + } + const lang = LANG[extname(f).toLowerCase()]; + const fence = body.includes("```") ? "````" : "```"; + parts.push(`\n\n---\n\n## File: ${f}\n\n${lang ? `${fence}${lang}\n${body}\n${fence}` : body}\n`); +} +const out = parts.join(""); +writeFileSync("llms-full.txt", out); +console.log(`llms-full.txt: ${ordered.length} files, ${(out.length / 1024).toFixed(0)} KB${omitted.length ? `; omitted (too large): ${omitted.join(", ")}` : ""}`); +```` + + +--- + +## File: scripts/test.ts + +```ts +// npm test — passwords, login, sessions (memory + Redis stores), permissions, reset flow. +import RedisMock from "ioredis-mock"; +import { + AuthError, createAuth, hashPassword, hashToken, memoryKeyValueStore, memoryRateLimiter, memorySessionStore, needsRehash, + permissionsOf, roleMap, verifyPassword, assertPasswordPolicy, can, branchesFor, SYSTEM_ROLES, + type AuditSink, type AuthUser, type KeyValueStore, type RateLimiter, type RoleStore, type SessionStore, type UserStore, +} from "../src/index"; +import { redisKeyValueStore, redisRateLimiter, redisSessionStore } from "../src/redis"; + +let failed = 0, passed = 0; +const ok = (cond: unknown, msg: string) => { + if (cond) { passed++; console.log(` ok ${msg}`); } + else { failed++; console.log(` FAIL ${msg}`); } +}; +const code = async (p: Promise<unknown>) => { try { await p; return "none"; } catch (e) { return e instanceof AuthError ? e.code : `other:${(e as Error).message}`; } }; +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +// ------------------------------------------------------------------ in-memory stand-ins for db-kit repositories + +function fakeDb() { + const users = new Map<string, AuthUser>(); + const orgs = [{ id: "org-a", code: "MGPL" }, { id: "org-b", code: "OTHER" }]; + const orgRoles: Record<string, { key: string; permissions: string[] }[]> = { "org-a": [{ key: "lab_auditor", permissions: ["audit:read", "accounts:read"] }] }; + const auditLog: { action: string; userId?: string | null }[] = []; + const userStore: UserStore = { + get: async (id) => (users.has(id) ? { ...users.get(id)! } : null), + findByEmail: async (orgId, email) => [...users.values()].find((u) => u.orgId === orgId && u.email === email) ?? null, + findAllByEmail: async (email) => [...users.values()].filter((u) => u.email === email), + update: async (id, patch) => { users.set(id, { ...users.get(id)!, ...patch }); }, + }; + const roleStore: RoleStore = { find: async ({ orgId }) => orgRoles[orgId] ?? [] }; + const audit: AuditSink = { log: async (e) => { auditLog.push(e); } }; + const add = async (u: Omit<AuthUser, "passwordHash"> & { password: string; hashParams?: { N: number; r: number; p: number } }) => { + users.set(u.id, { ...u, passwordHash: await hashPassword(u.password, u.hashParams) }); + }; + return { users, userStore, orgStore: { findOne: async ({ code }: { code: string }) => orgs.find((o) => o.code === code) ?? null }, roleStore, audit, auditLog, orgRoles, add }; +} + +// ------------------------------------------------------------------ unit: passwords & RBAC + +async function units() { + console.log("\n[passwords]"); + const h = await hashPassword("correct horse battery"); + ok(h.startsWith("scrypt$32768$8$1$") && (await verifyPassword("correct horse battery", h)) && !(await verifyPassword("wrong horse", h)), "scrypt hash verifies right, rejects wrong"); + ok((await hashPassword("same")) !== (await hashPassword("same")), "salted: same password → different hashes"); + ok(needsRehash(await hashPassword("x".repeat(9), { N: 2 ** 14, r: 8, p: 1 })) && !needsRehash(h), "needsRehash spots weaker parameters"); + ok(!(await verifyPassword("a", "scrypt$99999999999$8$1$aa$bb")) && !(await verifyPassword("a", "md5$abc")), "absurd or foreign hashes are refused, not executed"); + const weak = (p: string, email?: string) => { try { assertPasswordPolicy(p, { email }); return null; } catch (e) { return (e as AuthError).code; } }; + ok(weak("short") === "WEAK_PASSWORD" && weak("password123") === "WEAK_PASSWORD" && weak("aaaaaaaaaa") === "WEAK_PASSWORD" && weak("ravi.kumar-2026", "ravi.kumar@x.in") === "WEAK_PASSWORD" && weak("tulsi-leaf-raipur") === null, "password policy: length, common, repeated, contains email"); + + console.log("\n[rbac]"); + const rm = roleMap([{ key: "lab_auditor", permissions: ["audit:read"] }]); + const desk = [{ role: "front_desk", branchId: "b1" }]; + ok(can(desk, "invoice:create", rm, "b1") && !can(desk, "invoice:create", rm, "b2") && !can(desk, "accounts:read", rm), "branch-scoped role: allowed in its branch only"); + ok(can([{ role: "owner", branchId: null }], "anything:goes", rm) && can([{ role: "admin", branchId: null }], "accounts:post", rm, "b9"), "owner *:*, admin accounts:* everywhere"); + ok(branchesFor(desk, "booking:read", rm).toString() === "b1" && branchesFor([{ role: "admin", branchId: null }], "booking:read", rm) === "all", "branchesFor: list vs all"); + ok(permissionsOf([{ role: "lab_auditor", branchId: null }], rm).join() === "audit:read", "custom organisation role"); + ok(SYSTEM_ROLES.some((r) => r.key === "accountant" && r.permissions.includes("accounts:*")), "accountant role covers the books"); +} + +// ------------------------------------------------------------------ flows, per store + +async function flows(label: string, stores: { sessions: SessionStore; kv: KeyValueStore; limiter: RateLimiter }) { + console.log(`\n[auth / ${label}]`); + const db = fakeDb(); + await db.add({ id: "u1", orgId: "org-a", name: "Asha", email: "asha@mgpl.in", password: "tulsi-leaf-raipur", status: "active", roles: [{ role: "front_desk", branchId: "b1" }] }); + await db.add({ id: "u2", orgId: "org-a", name: "Old", email: "old@mgpl.in", password: "neem-tree-arang", status: "active", roles: [{ role: "owner", branchId: null }], hashParams: { N: 2 ** 14, r: 8, p: 1 } }); + await db.add({ id: "u3", orgId: "org-a", name: "Gone", email: "gone@mgpl.in", password: "mango-season-27", status: "disabled", roles: [] }); + await db.add({ id: "u4", orgId: "org-a", name: "Dual A", email: "dual@x.in", password: "shared-pass-99", status: "active", roles: [{ role: "owner", branchId: null }] }); + await db.add({ id: "u5", orgId: "org-b", name: "Dual B", email: "dual@x.in", password: "shared-pass-99", status: "active", roles: [{ role: "owner", branchId: null }] }); + const auth = createAuth({ + users: db.userStore, orgs: db.orgStore, roles: db.roleStore, audit: db.audit, ...stores, + session: { idleMs: 60_000, maxAgeMs: 120_000, touchEveryMs: 0 }, + }); + + const login = await auth.login({ email: " ASHA@mgpl.in ", password: "tulsi-leaf-raipur", ip: "1.1.1.1", userAgent: "test" }); + ok(login.token.length === 43 && login.user.email === "asha@mgpl.in" && !("passwordHash" in login.user), "login: token issued, email normalised, no hash returned"); + const v = await auth.authenticate(login.token); + ok(v?.userId === "u1" && v.orgId === "org-a" && v.sessionId === hashToken(login.token), "authenticate: token → viewer"); + ok((await code(auth.login({ email: "asha@mgpl.in", password: "wrong-password" }))) === "INVALID_CREDENTIALS", "wrong password → INVALID_CREDENTIALS"); + ok((await code(auth.login({ email: "nobody@mgpl.in", password: "whatever-pass" }))) === "INVALID_CREDENTIALS", "unknown email → same INVALID_CREDENTIALS"); + + let t = Date.now(); await code(auth.login({ email: "nobody2@mgpl.in", password: "whatever-pass" })); const unknownMs = Date.now() - t; + t = Date.now(); await code(auth.login({ email: "asha@mgpl.in", password: "whatever-pass" })); const wrongMs = Date.now() - t; + ok(unknownMs > wrongMs * 0.4, `timing-safe: unknown email ${unknownMs} ms vs wrong password ${wrongMs} ms`); + + ok((await code(auth.login({ email: "gone@mgpl.in", password: "mango-season-27" }))) === "ACCOUNT_DISABLED", "disabled account → ACCOUNT_DISABLED"); + ok((await code(auth.login({ email: "dual@x.in", password: "shared-pass-99" }))) === "ORG_REQUIRED", "same email in two organisations → ORG_REQUIRED"); + ok((await auth.login({ email: "dual@x.in", password: "shared-pass-99", orgCode: "other" })).user.id === "u5", "…with orgCode → the right one"); + + const before = db.users.get("u2")!.passwordHash; + await auth.login({ email: "old@mgpl.in", password: "neem-tree-arang" }); + ok(before.startsWith("scrypt$16384$") && db.users.get("u2")!.passwordHash.startsWith("scrypt$32768$"), "weak hash upgraded on login"); + + // context & permissions + const ctx = await auth.context(login.token); + ok(ctx.can("invoice:create", "b1") && !ctx.can("invoice:create", "b2") && !ctx.can("accounts:read") && ctx.branchesFor("booking:read").toString() === "b1", "context: branch-aware can / branchesFor"); + db.users.set("u1", { ...db.users.get("u1")!, roles: [{ role: "lab_auditor", branchId: null }] }); + ok((await auth.context(login.token)).can("accounts:read"), "role change applies on the next request (custom org role)"); + db.orgRoles["org-a"] = [{ key: "lab_auditor", permissions: ["audit:read"] }]; + auth.invalidateRoles("org-a"); + ok(!(await auth.context(login.token)).can("accounts:read"), "invalidateRoles: edited role applies at once"); + ok((await code(auth.require(login.token, "settings:manage"))) === "FORBIDDEN" && (await code(auth.require("bad-token-xxxxxxxxxxxxxxxx"))) === "UNAUTHENTICATED", "require: FORBIDDEN / UNAUTHENTICATED"); + const anon = await auth.context(null); + ok(anon.viewer === null && !anon.can("patient:read"), "anonymous context can nothing"); + + // sessions + const second = await auth.login({ email: "asha@mgpl.in", password: "tulsi-leaf-raipur", userAgent: "phone" }); + ok((await auth.sessionsOf("u1")).length === 2 && (await auth.revokeSession("u1", hashToken(second.token))) && (await auth.authenticate(second.token)) === null, "two devices listed; one revoked by id"); + // idle timeout, with its own short window (logins themselves take ~100 ms of scrypt) + const short = createAuth({ users: db.userStore, ...stores, session: { idleMs: 500, maxAgeMs: 60_000, touchEveryMs: 0 } }); + const busy = await short.login({ email: "asha@mgpl.in", password: "tulsi-leaf-raipur" }); + const idle = await short.login({ email: "asha@mgpl.in", password: "tulsi-leaf-raipur" }); + await sleep(300); + await short.authenticate(busy.token); // used: slides its window + await sleep(300); + ok((await short.authenticate(busy.token)) !== null && (await short.authenticate(idle.token)) === null, "idle timeout: used session slides, unused one expires"); + const capped = createAuth({ users: db.userStore, ...stores, session: { idleMs: 60_000, maxAgeMs: 300, touchEveryMs: 0 } }); + const c = await capped.login({ email: "asha@mgpl.in", password: "tulsi-leaf-raipur" }); + await sleep(350); + ok((await capped.authenticate(c.token)) === null, "absolute max age ends even an active session"); + db.users.set("u1", { ...db.users.get("u1")!, status: "disabled" }); + ok((await auth.authenticate(login.token)) === null && (await auth.sessionsOf("u1")).length === 0, "disabling a user ends their sessions at once"); + db.users.set("u1", { ...db.users.get("u1")!, status: "active" }); + + const s1 = await auth.login({ email: "asha@mgpl.in", password: "tulsi-leaf-raipur" }); + const s2 = await auth.login({ email: "asha@mgpl.in", password: "tulsi-leaf-raipur" }); + await auth.logout(s1.token); + ok((await auth.authenticate(s1.token)) === null && (await auth.authenticate(s2.token)) !== null, "logout ends only that session"); + ok((await code(auth.changePassword({ userId: "u1", currentPassword: "nope-nope-nope", newPassword: "banyan-roots-7" }))) === "INVALID_CREDENTIALS", "change password needs the current one"); + const s3 = await auth.login({ email: "asha@mgpl.in", password: "tulsi-leaf-raipur" }); + await auth.changePassword({ userId: "u1", currentPassword: "tulsi-leaf-raipur", newPassword: "banyan-roots-7", currentToken: s3.token }); + ok((await auth.authenticate(s3.token)) !== null && (await auth.authenticate(s2.token)) === null, "change password keeps this session, ends the others"); + + // reset + ok((await auth.requestPasswordReset({ email: "ghost@mgpl.in" })) === null, "reset for unknown email → null (respond the same either way)"); + const r = (await auth.requestPasswordReset({ email: "asha@mgpl.in" }))!; + await auth.resetPassword({ token: r.token, newPassword: "peepal-shade-12" }); + ok((await auth.login({ email: "asha@mgpl.in", password: "peepal-shade-12" })).user.id === "u1" && (await auth.authenticate(s3.token)) === null, "reset sets the password and ends every session"); + ok((await code(auth.resetPassword({ token: r.token, newPassword: "another-one-55" }))) === "INVALID_TOKEN", "reset token is single-use"); + ok((await code(auth.resetPassword({ token: (await auth.requestPasswordReset({ email: "asha@mgpl.in" }))!.token, newPassword: "short" }))) === "WEAK_PASSWORD", "reset enforces the password policy"); + + // rate limit (fresh limiter: 3 attempts / window) + const strict = createAuth({ users: db.userStore, sessions: stores.sessions, limiter: label === "redis" ? redisRateLimiter(new RedisMock() as any, { limit: 3, windowMs: 60_000, prefix: `rl${Date.now()}:` }) : memoryRateLimiter({ limit: 3, windowMs: 60_000 }) }); + const codes: string[] = []; + for (let i = 0; i < 4; i++) codes.push(await code(strict.login({ email: "old@mgpl.in", password: "bad-guess-000", ip: "9.9.9.9" }))); + ok(codes.slice(0, 3).every((c) => c === "INVALID_CREDENTIALS") && codes[3] === "RATE_LIMITED", "4th attempt from the same IP/email → RATE_LIMITED"); + + ok(db.auditLog.some((e) => e.action === "auth.login") && db.auditLog.some((e) => e.action === "auth.login_failed") && db.auditLog.some((e) => e.action === "auth.password_reset"), "audit trail written"); +} + +async function main() { + const t0 = Date.now(); + await units(); + await flows("memory", { sessions: memorySessionStore(), kv: memoryKeyValueStore(), limiter: memoryRateLimiter({ limit: 100, windowMs: 60_000 }) }); + const redis = new RedisMock() as any; + await flows("redis", { sessions: redisSessionStore(redis), kv: redisKeyValueStore(redis), limiter: redisRateLimiter(redis, { limit: 100, windowMs: 60_000 }) }); + const keys: string[] = await redis.keys("*"); + ok(keys.length > 0 && !keys.some((k) => /[A-Za-z0-9_-]{43}$/.test(k) && !k.includes("sess:") && !k.includes("kv:")), "redis holds token hashes only"); + redis.disconnect(); + console.log(`\n${passed} passed, ${failed} failed in ${((Date.now() - t0) / 1000).toFixed(1)}s`); + process.exit(failed ? 1 : 0); +} + +main().catch((e) => { console.error(e); process.exit(1); }); +``` + + +--- + +## File: src/auth.ts + +```ts +import { AuthError } from "./errors"; +import { memoryRateLimiter } from "./memory"; +import { assertPasswordPolicy, hashPassword, hashToken, needsRehash, randomToken, verifyPassword, type PasswordPolicy } from "./password"; +import { branchesFor, can, permissionsOf, roleMap, type Permission, type RoleAssignment, type RoleMap } from "./rbac"; +import type { AuditSink, AuthUser, KeyValueStore, OrgStore, RateLimiter, RoleStore, Session, SessionStore, UserStore } from "./stores"; + +export interface AuthOptions { + users: UserStore; + sessions: SessionStore; + /** Needed for login with an organisation code. */ + orgs?: OrgStore; + /** The organisation's own roles; without it only SYSTEM_ROLES apply. */ + roles?: RoleStore; + audit?: AuditSink; + /** Needed for password reset. */ + kv?: KeyValueStore; + /** Login / reset attempts per IP and per email. Default: 10 per 15 minutes per key, in memory. */ + limiter?: RateLimiter; + session?: { + /** Logged out after this long without use. Default 12 h. */ + idleMs?: number; + /** Logged out after this long regardless. Default 30 days. */ + maxAgeMs?: number; + /** Extend the idle window at most this often (saves writes). Default 60 s. */ + touchEveryMs?: number; + }; + /** Password-reset link lifetime. Default 30 minutes. */ + resetTtlMs?: number; + /** How long an organisation's role list is cached. Default 30 s. */ + roleCacheMs?: number; + passwordPolicy?: PasswordPolicy; +} + +/** The signed-in user for one request. */ +export interface Viewer { + userId: string; + orgId: string; + name: string; + email: string; + roles: RoleAssignment[]; + sessionId: string; +} + +/** What a request handler needs: who is calling and what they may do (fits db-kit's GraphQL context). */ +export interface AuthContext { + viewer: Viewer | null; + can(permission: string, branchId?: string | null): boolean; + branchesFor(permission: string): "all" | string[]; + permissions(branchId?: string | null): Permission[]; +} + +export type PublicUser = Omit<AuthUser, "passwordHash">; + +export interface LoginInput { + email: string; + password: string; + /** Required only when the same email/password exists in several organisations. */ + orgCode?: string; + ip?: string; + userAgent?: string; +} + +export interface LoginResult { + /** Give this to the client (Authorization: Bearer … or an httpOnly cookie). It is never stored. */ + token: string; + expiresAt: Date; + user: PublicUser; +} + +const publicUser = ({ passwordHash: _p, ...u }: AuthUser): PublicUser => u; +const normalizeEmail = (e: string) => e.trim().toLowerCase(); + +export type Auth = ReturnType<typeof createAuth>; + +/** + * The backend's internal auth API. No HTTP: call it from GraphQL resolvers, REST handlers, jobs. + * + * const auth = createAuth({ users: db.repos.users, orgs: db.repos.orgs, roles: db.repos.roles, + * audit: db.repos.audit, sessions: redisSessionStore(redis), kv: redisKeyValueStore(redis), + * limiter: redisRateLimiter(redis, { limit: 10, windowMs: 15 * 60_000 }) }); + * const { token } = await auth.login({ email, password, ip }); + * const ctx = await auth.context(token); // { viewer, can, branchesFor, permissions } + */ +export function createAuth(o: AuthOptions) { + const idleMs = o.session?.idleMs ?? 12 * 3600_000; + const maxAgeMs = o.session?.maxAgeMs ?? 30 * 86400_000; + const touchEveryMs = o.session?.touchEveryMs ?? 60_000; + const resetTtlMs = o.resetTtlMs ?? 30 * 60_000; + const roleCacheMs = o.roleCacheMs ?? 30_000; + const limiter = o.limiter ?? memoryRateLimiter({ limit: 10, windowMs: 15 * 60_000 }); + const roleCache = new Map<string, { map: RoleMap; at: number }>(); + let dummy: Promise<string> | undefined; + + const audit = async (entry: Parameters<AuditSink["log"]>[0]) => { + try { + await o.audit?.log(entry); + } catch { + /* auditing must never break a login */ + } + }; + + async function limit(keys: (string | undefined)[]) { + for (const k of keys) { + if (!k) continue; + const r = await limiter.hit(k); + if (!r.allowed) throw new AuthError("RATE_LIMITED", "Too many attempts. Try again later.", { retryAfterMs: r.retryAfterMs }); + } + } + + async function findCandidates(email: string, orgCode?: string): Promise<AuthUser[]> { + if (!orgCode) return o.users.findAllByEmail(email); + if (!o.orgs) throw new Error("createAuth: pass `orgs` to log in with an organisation code"); + const org = await o.orgs.findOne({ code: orgCode.trim().toUpperCase() }); + const user = org ? await o.users.findByEmail(org.id, email) : null; + return user ? [user] : []; + } + + async function startSession(user: AuthUser, ip?: string, userAgent?: string): Promise<{ token: string; session: Session }> { + const token = randomToken(); + const now = Date.now(); + const session: Session = { id: hashToken(token), userId: user.id, orgId: user.orgId, createdAt: now, lastSeenAt: now, expiresAt: now + maxAgeMs, ip, userAgent }; + await o.sessions.create(session, idleMs); + return { token, session }; + } + + async function rolesFor(orgId: string): Promise<RoleMap> { + const hit = roleCache.get(orgId); + if (hit && Date.now() - hit.at < roleCacheMs) return hit.map; + const map = roleMap(o.roles ? await o.roles.find({ orgId }) : []); + roleCache.set(orgId, { map, at: Date.now() }); + return map; + } + + async function authenticate(token: string | null | undefined): Promise<Viewer | null> { + if (!token || token.length < 20 || token.length > 200) return null; + const s = await o.sessions.get(hashToken(token)); + if (!s) return null; + // the user is read on every request, so disabling an account or changing roles applies immediately + const user = await o.users.get(s.userId); + if (!user || user.status !== "active") { + await o.sessions.deleteByUser(s.userId); // disabled or deleted: end every device at once + return null; + } + if (user.orgId !== s.orgId) { + await o.sessions.delete(s.id); + return null; + } + const now = Date.now(); + if (now - s.lastSeenAt >= touchEveryMs) await o.sessions.touch(s.id, now, idleMs); + return { userId: user.id, orgId: user.orgId, name: user.name, email: user.email, roles: user.roles, sessionId: s.id }; + } + + async function setNewPassword(user: AuthUser, newPassword: string, keepSessionId?: string) { + assertPasswordPolicy(newPassword, { email: user.email, name: user.name }, o.passwordPolicy); + await o.users.update(user.id, { passwordHash: await hashPassword(newPassword) }); + await o.sessions.deleteByUser(user.id, keepSessionId); + } + + return { + /** Email + password → session token. Timing-safe, rate-limited, audited. */ + async login(input: LoginInput): Promise<LoginResult> { + const email = normalizeEmail(input.email); + await limit([input.ip && `login:ip:${input.ip}`, `login:email:${email}`]); + const candidates = await findCandidates(email, input.orgCode); + // unknown email still costs one scrypt verification, so its timing matches a wrong password + if (!candidates.length) await verifyPassword(input.password, await (dummy ??= hashPassword(randomToken()))); + const matched: AuthUser[] = []; + for (const u of candidates) if (await verifyPassword(input.password, u.passwordHash)) matched.push(u); + if (!matched.length) { + const first = candidates[0]; + if (first) await audit({ orgId: first.orgId, userId: first.id, action: "auth.login_failed", ip: input.ip }); + throw new AuthError("INVALID_CREDENTIALS", "Email or password is incorrect"); + } + if (matched.length > 1) throw new AuthError("ORG_REQUIRED", "This email belongs to several organisations. Choose one."); + const user = matched[0]!; + if (user.status === "disabled") throw new AuthError("ACCOUNT_DISABLED", "This account is disabled"); + if (user.status === "invited") throw new AuthError("INVITE_PENDING", "Accept your invitation first"); + + await limiter.reset(`login:email:${email}`); + const patch: { lastLoginAt: Date; passwordHash?: string } = { lastLoginAt: new Date() }; + if (needsRehash(user.passwordHash)) patch.passwordHash = await hashPassword(input.password); + await o.users.update(user.id, patch); + const { token, session } = await startSession(user, input.ip, input.userAgent); + await audit({ orgId: user.orgId, userId: user.id, action: "auth.login", ip: input.ip }); + return { token, expiresAt: new Date(session.expiresAt), user: publicUser({ ...user, lastLoginAt: patch.lastLoginAt }) }; + }, + + /** Token → viewer, or null (unknown, expired, logged out, disabled). Extends the idle window. */ + authenticate, + + /** Everything a request needs. Pass a null/invalid token for an anonymous context. */ + async context(token: string | null | undefined): Promise<AuthContext> { + const viewer = await authenticate(token); + if (!viewer) return { viewer: null, can: () => false, branchesFor: () => [], permissions: () => [] }; + const rm = await rolesFor(viewer.orgId); + return { + viewer, + can: (permission, branchId) => can(viewer.roles, permission, rm, branchId), + branchesFor: (permission) => branchesFor(viewer.roles, permission, rm), + permissions: (branchId) => permissionsOf(viewer.roles, rm, branchId), + }; + }, + + /** Throws UNAUTHENTICATED / FORBIDDEN; returns the viewer. */ + async require(token: string | null | undefined, permission?: string, branchId?: string | null): Promise<Viewer> { + const ctx = await this.context(token); + if (!ctx.viewer) throw new AuthError("UNAUTHENTICATED", "Sign in required"); + if (permission && !ctx.can(permission, branchId)) throw new AuthError("FORBIDDEN", `Missing permission ${permission}`, { permission }); + return ctx.viewer; + }, + + async logout(token: string): Promise<void> { + const s = await o.sessions.get(hashToken(token)); + if (!s) return; + await o.sessions.delete(s.id); + await audit({ orgId: s.orgId, userId: s.userId, action: "auth.logout" }); + }, + + /** Logs a user out everywhere (optionally keeping the current session). */ + async logoutAll(userId: string, keepToken?: string): Promise<number> { + return o.sessions.deleteByUser(userId, keepToken ? hashToken(keepToken) : undefined); + }, + + /** Active sessions ("devices") of a user. `id` can be passed to revokeSession. */ + async sessionsOf(userId: string): Promise<Omit<Session, "userId" | "orgId">[]> { + return (await o.sessions.listByUser(userId)).map(({ userId: _u, orgId: _o, ...s }) => s).sort((a, b) => b.lastSeenAt - a.lastSeenAt); + }, + + async revokeSession(userId: string, sessionId: string): Promise<boolean> { + const s = await o.sessions.get(sessionId); + if (!s || s.userId !== userId) return false; + await o.sessions.delete(sessionId); + return true; + }, + + /** Needs the current password; logs out every other session. */ + async changePassword(input: { userId: string; currentPassword: string; newPassword: string; currentToken?: string }): Promise<void> { + const user = await o.users.get(input.userId); + if (!user || !(await verifyPassword(input.currentPassword, user.passwordHash))) throw new AuthError("INVALID_CREDENTIALS", "Current password is incorrect"); + if (input.currentPassword === input.newPassword) throw new AuthError("WEAK_PASSWORD", "Choose a different password"); + await setNewPassword(user, input.newPassword, input.currentToken ? hashToken(input.currentToken) : undefined); + await audit({ orgId: user.orgId, userId: user.id, action: "auth.password_change" }); + }, + + /** + * Starts a reset. Returns the token to deliver (email / SMS link), or null when there is no such + * active user. Respond the same way in both cases, so the endpoint does not reveal accounts. + */ + async requestPasswordReset(input: { email: string; orgCode?: string; ip?: string }): Promise<{ token: string; expiresAt: Date; user: PublicUser } | null> { + if (!o.kv) throw new Error("createAuth: pass `kv` to enable password reset"); + const email = normalizeEmail(input.email); + await limit([input.ip && `reset:ip:${input.ip}`, `reset:email:${email}`]); + const candidates = (await findCandidates(email, input.orgCode)).filter((u) => u.status === "active"); + if (candidates.length !== 1) return null; + const user = candidates[0]!; + const token = randomToken(); + await o.kv.set(`reset:${hashToken(token)}`, user.id, resetTtlMs); + await audit({ orgId: user.orgId, userId: user.id, action: "auth.reset_requested", ip: input.ip }); + return { token, expiresAt: new Date(Date.now() + resetTtlMs), user: publicUser(user) }; + }, + + /** Completes a reset: single-use token, new password, every session logged out. */ + async resetPassword(input: { token: string; newPassword: string }): Promise<PublicUser> { + if (!o.kv) throw new Error("createAuth: pass `kv` to enable password reset"); + const userId = await o.kv.take(`reset:${hashToken(input.token)}`); + const user = userId ? await o.users.get(userId) : null; + if (!user || user.status !== "active") throw new AuthError("INVALID_TOKEN", "This reset link is invalid or has expired"); + await setNewPassword(user, input.newPassword); + await audit({ orgId: user.orgId, userId: user.id, action: "auth.password_reset" }); + return publicUser(user); + }, + + /** Admin sets a password (e.g. new staff). Logs the user out everywhere. */ + async setPassword(userId: string, newPassword: string, actorId?: string): Promise<void> { + const user = await o.users.get(userId); + if (!user) throw new AuthError("INVALID_TOKEN", "No such user"); + await setNewPassword(user, newPassword); + await audit({ orgId: user.orgId, userId: actorId ?? null, action: "auth.password_set", entity: "user", entityId: userId }); + }, + + /** Call after editing an organisation's roles, so permission changes apply at once. */ + invalidateRoles(orgId?: string): void { + if (orgId) roleCache.delete(orgId); + else roleCache.clear(); + }, + + /** For creating users: hash + policy check. */ + async hashNewPassword(password: string, user: { email?: string; name?: string } = {}): Promise<string> { + assertPasswordPolicy(password, user, o.passwordPolicy); + return hashPassword(password); + }, + }; +} +``` + + +--- + +## File: src/errors.ts + +```ts +export type AuthErrorCode = + | "INVALID_CREDENTIALS" + | "ACCOUNT_DISABLED" + | "INVITE_PENDING" + | "ORG_REQUIRED" + | "RATE_LIMITED" + | "WEAK_PASSWORD" + | "INVALID_TOKEN" + | "UNAUTHENTICATED" + | "FORBIDDEN"; + +const STATUS: Record<AuthErrorCode, number> = { + INVALID_CREDENTIALS: 401, + ACCOUNT_DISABLED: 403, + INVITE_PENDING: 403, + ORG_REQUIRED: 400, + RATE_LIMITED: 429, + WEAK_PASSWORD: 400, + INVALID_TOKEN: 400, + UNAUTHENTICATED: 401, + FORBIDDEN: 403, +}; + +/** Every auth failure. `code` is stable for clients; `statusCode` suits HTTP and GraphQL extensions. */ +export class AuthError extends Error { + readonly statusCode: number; + constructor( + readonly code: AuthErrorCode, + message: string, + readonly details: { retryAfterMs?: number; permission?: string } = {}, + ) { + super(message); + this.name = "AuthError"; + this.statusCode = STATUS[code]; + } +} +``` + + +--- + +## File: src/index.ts + +```ts +export { createAuth, type Auth, type AuthContext, type AuthOptions, type LoginInput, type LoginResult, type PublicUser, type Viewer } from "./auth"; +export { AuthError, type AuthErrorCode } from "./errors"; +export { assertPasswordPolicy, hashPassword, hashToken, needsRehash, randomToken, SCRYPT, verifyPassword, type PasswordPolicy } from "./password"; +export { + branchesFor, can, permissionMatches, permissionsOf, PERMISSIONS, roleMap, SYSTEM_ROLES, + type Permission, type RoleAssignment, type RoleDefinition, type RoleMap, +} from "./rbac"; +export { memoryKeyValueStore, memoryRateLimiter, memorySessionStore } from "./memory"; +export type { AuditSink, AuthUser, KeyValueStore, OrgStore, RateLimiter, RoleStore, Session, SessionStore, UserStatus, UserStore } from "./stores"; +``` + + +--- + +## File: src/memory.ts + +```ts +import type { KeyValueStore, RateLimiter, Session, SessionStore } from "./stores"; + +/** + * In-process stores: for tests, scripts and single-server development. Production with more than + * one server needs shared state: use the Redis stores ("@mgpl-diagnostics/auth-kit/redis"). + */ + +export function memorySessionStore(): SessionStore { + const sessions = new Map<string, { s: Session; idleUntil: number }>(); + const alive = (id: string) => { + const e = sessions.get(id); + if (!e) return null; + if (e.idleUntil <= Date.now() || e.s.expiresAt <= Date.now()) { + sessions.delete(id); + return null; + } + return e; + }; + return { + async create(s, idleMs) { + sessions.set(s.id, { s: { ...s }, idleUntil: Math.min(s.expiresAt, Date.now() + idleMs) }); + }, + async get(id) { + const e = alive(id); + return e ? { ...e.s } : null; + }, + async touch(id, lastSeenAt, idleMs) { + const e = alive(id); + if (e) { + e.s.lastSeenAt = lastSeenAt; + e.idleUntil = Math.min(e.s.expiresAt, Date.now() + idleMs); + } + }, + async delete(id) { + sessions.delete(id); + }, + async listByUser(userId) { + return [...sessions.keys()].map(alive).filter((e): e is NonNullable<typeof e> => !!e && e.s.userId === userId).map((e) => ({ ...e.s })); + }, + async deleteByUser(userId, exceptId) { + let n = 0; + for (const [id, e] of sessions) if (e.s.userId === userId && id !== exceptId) { sessions.delete(id); n++; } + return n; + }, + }; +} + +export function memoryKeyValueStore(): KeyValueStore { + const values = new Map<string, { v: string; until: number }>(); + return { + async set(key, value, ttlMs) { + values.set(key, { v: value, until: Date.now() + ttlMs }); + }, + async take(key) { + const e = values.get(key); + values.delete(key); + return e && e.until > Date.now() ? e.v : null; + }, + }; +} + +/** Sliding window: at most `limit` hits per `windowMs` per key. */ +export function memoryRateLimiter(o: { limit: number; windowMs: number }): RateLimiter { + const hits = new Map<string, number[]>(); + return { + async hit(key) { + const now = Date.now(); + const list = (hits.get(key) ?? []).filter((t) => t > now - o.windowMs); + if (list.length >= o.limit) { + hits.set(key, list); + return { allowed: false, retryAfterMs: list[0]! + o.windowMs - now }; + } + list.push(now); + hits.set(key, list); + return { allowed: true, retryAfterMs: 0 }; + }, + async reset(key) { + hits.delete(key); + }, + }; +} +``` + + +--- + +## File: src/password.ts + +```ts +import { createHash, randomBytes, scrypt as scryptCb, timingSafeEqual, type ScryptOptions } from "node:crypto"; +import { AuthError } from "./errors"; + +const scrypt = (password: string, salt: Buffer, keylen: number, options: ScryptOptions) => + new Promise<Buffer>((resolve, reject) => scryptCb(password.normalize("NFKC"), salt, keylen, options, (err, key) => (err ? reject(err) : resolve(key)))); + +/** scrypt cost: N=2^15, r=8, p=1 ≈ 50–100 ms per hash on a server. Slow for attackers, fine for logins. */ +export const SCRYPT = { N: 2 ** 15, r: 8, p: 1, keylen: 32 } as const; +const maxmem = (N: number, r: number) => 128 * N * r * 2; + +/** "scrypt$N$r$p$salt$hash" (base64url): self-describing, so the cost can be raised later. */ +export async function hashPassword(password: string, params: { N: number; r: number; p: number } = SCRYPT): Promise<string> { + const salt = randomBytes(16); + const key = await scrypt(password, salt, SCRYPT.keylen, { N: params.N, r: params.r, p: params.p, maxmem: maxmem(params.N, params.r) }); + return ["scrypt", params.N, params.r, params.p, salt.toString("base64url"), key.toString("base64url")].join("$"); +} + +export async function verifyPassword(password: string, stored: string): Promise<boolean> { + const [algo, n, r, p, saltB64, keyB64] = stored.split("$"); + if (algo !== "scrypt" || !saltB64 || !keyB64) return false; + const N = Number(n), R = Number(r), P = Number(p); + if (!(N >= 2 ** 10 && N <= 2 ** 20) || !(R >= 1 && R <= 32) || !(P >= 1 && P <= 16)) return false; // refuse absurd params + const expected = Buffer.from(keyB64, "base64url"); + const key = await scrypt(password, Buffer.from(saltB64, "base64url"), expected.length, { N, r: R, p: P, maxmem: maxmem(N, R) }); + return key.length === expected.length && timingSafeEqual(key, expected); +} + +/** True when the stored hash is weaker than today's parameters: re-hash after a successful login. */ +export function needsRehash(stored: string): boolean { + const [algo, n, r, p] = stored.split("$"); + return algo !== "scrypt" || Number(n) < SCRYPT.N || Number(r) < SCRYPT.r || Number(p) < SCRYPT.p; +} + +const COMMON = new Set([ + "password", "password1", "password123", "12345678", "123456789", "1234567890", "qwerty123", "11111111", "00000000", + "iloveyou", "admin123", "welcome1", "abcd1234", "india123", "letmein1", "changeme", "passw0rd", "p@ssw0rd", +]); + +export interface PasswordPolicy { + minLength?: number; + maxLength?: number; +} + +/** Throws WEAK_PASSWORD with a readable reason. Length first (NIST 800-63B), no forced symbol rules. */ +export function assertPasswordPolicy(password: string, context: { email?: string; name?: string } = {}, policy: PasswordPolicy = {}): void { + const min = policy.minLength ?? 8; + const max = policy.maxLength ?? 128; + const reason = + password.length < min ? `Use at least ${min} characters` + : password.length > max ? `Use at most ${max} characters` + : /^(.)\1+$/.test(password) ? "Do not repeat one character" + : COMMON.has(password.toLowerCase()) ? "This password is too common" + : context.email && password.toLowerCase().includes(context.email.split("@")[0]!.toLowerCase()) && context.email.split("@")[0]!.length >= 4 ? "Do not use your email in the password" + : null; + if (reason) throw new AuthError("WEAK_PASSWORD", reason); +} + +/** Random opaque token (sessions, reset links). 32 bytes → 43 base64url chars. */ +export function randomToken(bytes = 32): string { + return randomBytes(bytes).toString("base64url"); +} + +/** Stores keep only this: a leaked database or Redis does not reveal usable tokens. */ +export function hashToken(token: string): string { + return createHash("sha256").update(token).digest("base64url"); +} +``` + + +--- + +## File: src/rbac.ts + +```ts +/** + * Role-based access control. Permissions are "resource:action"; "*" works in either part + * ("invoice:*", "*:read", "*:*"). A user holds roles per branch (branchId) or organisation-wide (null). + */ + +export interface RoleAssignment { + role: string; + branchId: string | null; +} + +export const PERMISSIONS = [ + "org:read", "org:update", + "branch:read", "branch:create", "branch:update", + "user:read", "user:create", "user:update", "user:disable", + "role:read", "role:manage", + "patient:read", "patient:create", "patient:update", "patient:delete", + "doctor:read", "doctor:manage", + "booking:read", "booking:create", "booking:update", "booking:cancel", + "sample:read", "sample:collect", "sample:receive", "sample:reject", + "report:read", "report:enter", "report:verify", "report:release", "report:amend", + "invoice:read", "invoice:create", "invoice:discount", "invoice:cancel", + "payment:read", "payment:create", "payment:refund", + "accounts:read", "accounts:post", "accounts:manage", + "audit:read", + "settings:manage", +] as const; + +export type Permission = (typeof PERMISSIONS)[number]; + +export interface RoleDefinition { + key: string; + name: string; + permissions: string[]; +} + +/** Built-in roles. Seed them per organisation (db-kit: seedOrganization(repos, { …, roles: SYSTEM_ROLES })). */ +export const SYSTEM_ROLES: RoleDefinition[] = [ + { key: "owner", name: "Owner", permissions: ["*:*"] }, + { + key: "admin", + name: "Administrator", + permissions: ["org:read", "branch:*", "user:*", "role:*", "patient:*", "doctor:*", "booking:*", "sample:*", "report:*", "invoice:*", "payment:*", "accounts:*", "audit:read", "settings:manage"], + }, + { + key: "branch_manager", + name: "Branch manager", + permissions: ["org:read", "branch:read", "user:read", "patient:*", "doctor:*", "booking:*", "sample:*", "report:read", "report:release", "invoice:*", "payment:*", "accounts:read", "audit:read"], + }, + { + key: "front_desk", + name: "Front desk", + permissions: ["org:read", "branch:read", "patient:read", "patient:create", "patient:update", "doctor:read", "booking:read", "booking:create", "booking:update", "sample:read", "report:read", "invoice:read", "invoice:create", "payment:read", "payment:create"], + }, + { key: "phlebotomist", name: "Phlebotomist", permissions: ["branch:read", "patient:read", "booking:read", "sample:read", "sample:collect"] }, + { key: "technician", name: "Lab technician", permissions: ["branch:read", "patient:read", "booking:read", "sample:*", "report:read", "report:enter"] }, + { key: "pathologist", name: "Pathologist", permissions: ["branch:read", "patient:read", "booking:read", "sample:read", "report:*"] }, + { key: "accountant", name: "Accountant", permissions: ["org:read", "branch:read", "patient:read", "invoice:read", "invoice:cancel", "payment:*", "accounts:*", "audit:read"] }, + { key: "doctor", name: "Referring doctor", permissions: ["patient:read", "report:read"] }, +]; + +/** role key → permissions */ +export type RoleMap = Map<string, readonly string[]>; + +/** System roles, overridden / extended by the organisation's own roles. */ +export function roleMap(orgRoles: Pick<RoleDefinition, "key" | "permissions">[] = []): RoleMap { + const m: RoleMap = new Map(); + for (const r of SYSTEM_ROLES) m.set(r.key, r.permissions); + for (const r of orgRoles) m.set(r.key, r.permissions); + return m; +} + +export function permissionMatches(granted: string, wanted: string): boolean { + if (granted === wanted || granted === "*:*" || granted === "*") return true; + const [gr, ga] = granted.split(":"); + const [wr, wa] = wanted.split(":"); + return (gr === "*" || gr === wr) && (ga === "*" || ga === wa); +} + +/** + * Does someone holding `assignments` have `permission`? With `branchId`, only roles held in that + * branch or organisation-wide count; without it, a role in any branch counts. + */ +export function can(assignments: readonly RoleAssignment[], permission: string, roles: RoleMap, branchId?: string | null): boolean { + for (const a of assignments) { + if (branchId && a.branchId && a.branchId !== branchId) continue; + if (roles.get(a.role)?.some((g) => permissionMatches(g, permission))) return true; + } + return false; +} + +/** Where `permission` applies: "all" (an organisation-wide role grants it) or the list of branch ids. */ +export function branchesFor(assignments: readonly RoleAssignment[], permission: string, roles: RoleMap): "all" | string[] { + const out = new Set<string>(); + for (const a of assignments) { + if (!roles.get(a.role)?.some((g) => permissionMatches(g, permission))) continue; + if (!a.branchId) return "all"; + out.add(a.branchId); + } + return [...out]; +} + +/** Concrete permissions (wildcards expanded against PERMISSIONS) — e.g. to show or hide UI. */ +export function permissionsOf(assignments: readonly RoleAssignment[], roles: RoleMap, branchId?: string | null): Permission[] { + return PERMISSIONS.filter((p) => can(assignments, p, roles, branchId)); +} +``` + + +--- + +## File: src/redis.ts + +```ts +import { randomBytes } from "node:crypto"; +import type { Redis } from "ioredis"; +import type { KeyValueStore, RateLimiter, Session, SessionStore } from "./stores"; + +/** + * Redis stores: shared by every backend server, so a logout or password change on one server + * applies everywhere at once. + * sess:<id> session JSON, expires after the idle window (never past expiresAt) + * usess:<userId> set of a user's session ids (for "log out everywhere") + */ + +export function redisSessionStore(redis: Redis, prefix = "mgpl:auth:"): SessionStore { + const key = (id: string) => `${prefix}sess:${id}`; + const userKey = (userId: string) => `${prefix}usess:${userId}`; + const ttl = (s: Session, idleMs: number) => Math.max(1, Math.min(idleMs, s.expiresAt - Date.now())); + + return { + async create(s, idleMs) { + await redis + .multi() + .set(key(s.id), JSON.stringify(s), "PX", ttl(s, idleMs)) + .sadd(userKey(s.userId), s.id) + .pexpireat(userKey(s.userId), s.expiresAt) + .exec(); + }, + async get(id) { + const raw = await redis.get(key(id)); + if (!raw) return null; + const s = JSON.parse(raw) as Session; + return s.expiresAt > Date.now() ? s : null; + }, + async touch(id, lastSeenAt, idleMs) { + const raw = await redis.get(key(id)); + if (!raw) return; + const s = { ...(JSON.parse(raw) as Session), lastSeenAt }; + await redis.set(key(id), JSON.stringify(s), "PX", ttl(s, idleMs), "XX"); + }, + async delete(id) { + const raw = await redis.get(key(id)); + const multi = redis.multi().del(key(id)); + if (raw) multi.srem(userKey((JSON.parse(raw) as Session).userId), id); + await multi.exec(); + }, + async listByUser(userId) { + const ids = await redis.smembers(userKey(userId)); + if (!ids.length) return []; + const raws = await redis.mget(...ids.map(key)); + const gone = ids.filter((_, i) => !raws[i]); + if (gone.length) await redis.srem(userKey(userId), ...gone); // tidy expired ids + return raws.filter((r): r is string => !!r).map((r) => JSON.parse(r) as Session).filter((s) => s.expiresAt > Date.now()); + }, + async deleteByUser(userId, exceptId) { + const ids = (await redis.smembers(userKey(userId))).filter((id) => id !== exceptId); + if (!ids.length) return 0; + const removed = await redis.del(...ids.map(key)); + await redis.srem(userKey(userId), ...ids); + return removed; + }, + }; +} + +export function redisKeyValueStore(redis: Redis, prefix = "mgpl:auth:kv:"): KeyValueStore { + return { + async set(k, value, ttlMs) { + await redis.set(prefix + k, value, "PX", ttlMs); + }, + async take(k) { + // GET + DEL in one transaction: two servers cannot both take the same value + const res = await redis.multi().get(prefix + k).del(prefix + k).exec(); + return (res?.[0]?.[1] as string | null) ?? null; + }, + }; +} + +/** Sliding-window log in a sorted set: at most `limit` hits in any `windowMs`, across all servers. */ +export function redisRateLimiter(redis: Redis, o: { limit: number; windowMs: number; prefix?: string }): RateLimiter { + const prefix = o.prefix ?? "mgpl:auth:rl:"; + return { + async hit(k) { + const full = prefix + k; + const now = Date.now(); + const member = `${now}:${randomBytes(4).toString("hex")}`; + const res = await redis + .multi() + .zremrangebyscore(full, 0, now - o.windowMs) + .zadd(full, now, member) + .zcard(full) + .zrange(full, 0, 0, "WITHSCORES") + .pexpire(full, o.windowMs) + .exec(); + const count = Number(res?.[2]?.[1] ?? 0); + if (count <= o.limit) return { allowed: true, retryAfterMs: 0 }; + await redis.zrem(full, member); // a blocked attempt does not extend the block + const oldest = Number((res?.[3]?.[1] as string[] | undefined)?.[1] ?? now); + return { allowed: false, retryAfterMs: Math.max(0, oldest + o.windowMs - now) }; + }, + async reset(k) { + await redis.del(prefix + k); + }, + }; +} +``` + + +--- + +## File: src/stores.ts + +```ts +import type { RoleAssignment } from "./rbac"; + +/** + * Storage interfaces. They are deliberately small and structural: db-kit's repositories satisfy the + * user / org / role / audit ones as they are (db.repos.users, db.repos.orgs, …), and any other + * database can implement them in a few lines. + */ + +export type UserStatus = "active" | "disabled" | "invited"; + +export interface AuthUser { + id: string; + orgId: string; + name: string; + email: string; + passwordHash: string; + status: UserStatus; + roles: RoleAssignment[]; + lastLoginAt?: Date | null; +} + +export interface UserStore { + get(id: string): Promise<AuthUser | null>; + findByEmail(orgId: string, email: string): Promise<AuthUser | null>; + /** Same email may exist in several organisations. */ + findAllByEmail(email: string): Promise<AuthUser[]>; + update(id: string, patch: { passwordHash?: string; lastLoginAt?: Date; status?: UserStatus }): Promise<unknown>; +} + +export interface OrgStore { + findOne(filter: { code: string }): Promise<{ id: string; code: string } | null>; +} + +export interface RoleStore { + /** The organisation's own roles (custom ones and seeded system roles). */ + find(filter: { orgId: string }): Promise<{ key: string; permissions: string[] }[]>; +} + +export interface AuditSink { + log(entry: { orgId: string; userId?: string | null; action: string; ip?: string; entity?: string; entityId?: string | null; data?: Record<string, unknown> }): Promise<unknown>; +} + +// ------------------------------------------------------------------ sessions & short-lived values + +export interface Session { + /** sha256 of the token; the token itself is never stored. */ + id: string; + userId: string; + orgId: string; + createdAt: number; + lastSeenAt: number; + /** Absolute end (epoch ms): the session dies here even if it is used continuously. */ + expiresAt: number; + ip?: string; + userAgent?: string; +} + +export interface SessionStore { + /** Stores a session that disappears after `idleMs` without use (and never outlives `expiresAt`). */ + create(session: Session, idleMs: number): Promise<void>; + get(id: string): Promise<Session | null>; + /** Marks use; extends the idle window. */ + touch(id: string, lastSeenAt: number, idleMs: number): Promise<void>; + delete(id: string): Promise<void>; + listByUser(userId: string): Promise<Session[]>; + /** Deletes all of a user's sessions except `exceptId`; returns how many. */ + deleteByUser(userId: string, exceptId?: string): Promise<number>; +} + +/** Single-use values with expiry (password-reset tokens). */ +export interface KeyValueStore { + set(key: string, value: string, ttlMs: number): Promise<void>; + /** Reads and deletes atomically: a value can be taken once. */ + take(key: string): Promise<string | null>; +} + +export interface RateLimiter { + hit(key: string): Promise<{ allowed: boolean; retryAfterMs: number }>; + reset(key: string): Promise<void>; +} +``` diff --git a/package.json b/package.json index 33e76c5..bf7c180 100644 --- a/package.json +++ b/package.json @@ -9,20 +9,42 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { - ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" }, - "./redis": { "types": "./dist/redis.d.ts", "import": "./dist/redis.js" }, + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./redis": { + "types": "./dist/redis.d.ts", + "import": "./dist/redis.js" + }, "./package.json": "./package.json" }, - "files": ["dist", "README.md", "AGENTS.md", "llms.txt", "LICENSE"], - "publishConfig": { "registry": "https://npm.pkg.github.com" }, + "files": [ + "dist", + "README.md", + "AGENTS.md", + "llms.txt", + "LICENSE", + "llms-full.txt" + ], + "publishConfig": { + "registry": "https://npm.pkg.github.com" + }, "scripts": { "build": "tsup", "typecheck": "tsc --noEmit", "test": "tsx scripts/test.ts", - "prepare": "tsup" + "prepare": "tsup", + "llms": "node scripts/llms-full.mjs @mgpl-diagnostics/auth-kit README.md AGENTS.md llms.txt package.json src scripts" + }, + "peerDependencies": { + "ioredis": ">=5" + }, + "peerDependenciesMeta": { + "ioredis": { + "optional": true + } }, - "peerDependencies": { "ioredis": ">=5" }, - "peerDependenciesMeta": { "ioredis": { "optional": true } }, "devDependencies": { "@types/node": "^22.20.5", "ioredis": "^5.11.1", @@ -31,5 +53,7 @@ "tsx": "^4.23.15", "typescript": "^5.9.3" }, - "engines": { "node": ">=20" } + "engines": { + "node": ">=20" + } } diff --git a/scripts/llms-full.mjs b/scripts/llms-full.mjs new file mode 100644 index 0000000..77ff18a --- /dev/null +++ b/scripts/llms-full.mjs @@ -0,0 +1,53 @@ +// Builds llms-full.txt: every doc and source file of the repo in one plain-text document for AI tools. +// node scripts/llms-full.mjs "<title>" <file-or-folder> [file-or-folder …] +// Folders are walked recursively. Output order: README, AGENTS, docs, then the rest alphabetically. +// Skipped: build output, dependencies, lockfiles, generated files, binaries. Files over 150 KB are +// listed (path + size) instead of inlined. CI checks the file is up to date (`npm run llms` + git diff). +import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { extname, join, relative } from "node:path"; + +const [title, ...inputs] = process.argv.slice(2); +if (!title || !inputs.length) { + console.error('usage: node scripts/llms-full.mjs "<title>" <file-or-folder> …'); + process.exit(1); +} + +const TEXT = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".md", ".mdx", ".json", ".css", ".yml", ".yaml", ".txt", ".graphql", ".gql", ".sql", ".html"]); +const SKIP_DIRS = new Set(["node_modules", "dist", ".next", ".git", ".turbo", "coverage", "examples", ".pg-test"]); +const SKIP_FILES = /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|llms-full\.txt|[^/]*\.generated\.[^/]+|[^/]*\.log)$/; +const MAX = 150 * 1024; +const LANG = { ".ts": "ts", ".tsx": "tsx", ".mts": "ts", ".cts": "ts", ".js": "js", ".mjs": "js", ".cjs": "js", ".json": "json", ".css": "css", ".yml": "yaml", ".yaml": "yaml", ".graphql": "graphql", ".gql": "graphql", ".sql": "sql", ".html": "html" }; + +const posix = (p) => p.split("\\").join("/"); +const files = new Set(); +function walk(p) { + const st = statSync(p); + if (st.isDirectory()) { + for (const name of readdirSync(p)) if (!SKIP_DIRS.has(name)) walk(join(p, name)); + } else if (TEXT.has(extname(p).toLowerCase()) && !SKIP_FILES.test(posix(p))) { + files.add(posix(relative(".", p))); + } +} +for (const input of inputs) walk(input); + +const rank = (f) => (/^README\.md$/i.test(f) ? 0 : /^AGENTS\.md$/i.test(f) ? 1 : /^docs\//.test(f) ? 2 : 3); +const ordered = [...files].sort((a, b) => rank(a) - rank(b) || (a < b ? -1 : a > b ? 1 : 0)); + +const parts = [`# ${title} — full documentation and source\n\n> Generated by scripts/llms-full.mjs. Do not edit by hand. ${ordered.length} files.\n\n## Contents\n\n${ordered.map((f) => `- ${f}`).join("\n")}\n`]; +const omitted = []; +for (const f of ordered) { + // LF-normalised text, so Windows and Linux checkouts produce byte-identical output + const body = readFileSync(f, "utf8").replace(/\r\n/g, "\n").trim(); + const size = Buffer.byteLength(body); + if (size > MAX) { + omitted.push(`${f} (${(size / 1024).toFixed(0)} KB)`); + parts.push(`\n\n---\n\n## File: ${f}\n\n_Omitted: ${(size / 1024).toFixed(0)} KB. Read the file directly._\n`); + continue; + } + const lang = LANG[extname(f).toLowerCase()]; + const fence = body.includes("```") ? "````" : "```"; + parts.push(`\n\n---\n\n## File: ${f}\n\n${lang ? `${fence}${lang}\n${body}\n${fence}` : body}\n`); +} +const out = parts.join(""); +writeFileSync("llms-full.txt", out); +console.log(`llms-full.txt: ${ordered.length} files, ${(out.length / 1024).toFixed(0)} KB${omitted.length ? `; omitted (too large): ${omitted.join(", ")}` : ""}`);