diff --git a/AGENTS.md b/AGENTS.md index fc5c649..0101fa1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,12 +7,13 @@ The backend's internal authentication API: passwords, sessions, login, reset, ro ``` src/password.ts scrypt hash/verify/needsRehash, policy, randomToken, hashToken +src/jwt.ts HS256 JWT sign/verify (node:crypto), secret rotation 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 +src/auth.ts createAuth(): login, refresh (JWT mode), authenticate, context, require, logout, sessions, password change/reset +scripts/test.ts 98 checks; session and JWT flows run on both memory and Redis (ioredis-mock) stores ``` ## Rules @@ -23,7 +24,10 @@ scripts/test.ts 65 checks; session flows run on both memory and Redis (ioredis- 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. +6. **JWT mode keeps rules 1 and 3.** Access JWTs carry the session id and every request still checks the + session and re-reads the user; refresh tokens are opaque, single-use and stored as hashes. Never accept + `alg` from the token (HS256 only), never sign with a secret under 32 characters. +7. Error codes are a public contract (`AuthError.code`). Add new ones; never rename existing ones. ## Commands diff --git a/README.md b/README.md index 96e7f34..733a916 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ **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) +![tests](https://img.shields.io/badge/checks-98-14264A) ![deps](https://img.shields.io/badge/runtime%20dependencies-0-E9A23B) ## What it does @@ -12,6 +12,7 @@ There is no HTTP server here. Your backend (GraphQL resolvers, REST handlers, jo | **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 | +| **JWT mode** | short-lived HS256 access tokens plus single-use refresh tokens, rotated on every refresh; a refresh token used twice ends its session (theft); secret rotation; logout and revocation still apply at once | | **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 | @@ -46,11 +47,60 @@ When you create an organisation, seed the roles: `seedOrganization(repos, { …, (db-kit). To create a user, call `auth.hashNewPassword(password, { email })`, which checks the policy and then hashes. +## JWT mode (access + refresh tokens) + +Pass `jwt` and login returns a short-lived **access token** (an HS256 JWT, sent as `Authorization: Bearer`) +and a long-lived **refresh token** (opaque, single-use). The client trades the refresh token for a new pair +when the access token expires. + +```ts +const auth = createAuth({ …, kv: redisKeyValueStore(redis), jwt: { secret: process.env.JWT_SECRET!, issuer: "mgpl-api", accessTtlMs: 15 * 60_000 } }); + +const { token: accessToken, refreshToken } = await auth.login({ email, password }); +const ctx = await auth.context(accessToken); // same context as before +const next = await auth.refresh({ refreshToken }); // { accessToken, refreshToken (new), … } +``` + +```mermaid +sequenceDiagram + participant C as Client + participant A as auth-kit + participant S as Session store + participant K as kv (Redis) + C->>A: login(email, password) + A->>S: session { id: random } + A->>K: refresh:hash(R1) → session id + A-->>C: access JWT (sub, org, sid, exp 15 min) + R1 + C->>A: context(JWT) — every request + A->>A: verify HS256, exp + A->>S: session still there? re-read the user + C->>A: refresh(R1) + A->>K: take refresh:hash(R1) (single use) + A->>K: refresh:hash(R2) → sid · refresh-used:hash(R1) + A-->>C: new JWT + R2 + C->>A: refresh(R1) again (stolen copy) + A->>S: delete the session → JWT and R2 die +``` + +- **Revocation stays instant.** The JWT carries the session id, and every request checks that the session + still exists and re-reads the user (rule 3). The signature alone is enough only for + `verifyAccessToken(token)`, which workers and other services can use without storage. +- **Refresh tokens rotate.** Each is single-use and stored only as a hash. A spent token presented again + within `reuseGraceMs` (default 15 s, two tabs refreshing at once) is just refused; later, it ends the + session. +- **Lifetimes.** Access: `accessTtlMs` (default 15 min). Refresh: the session's idle window, slid by each + refresh, and never past the absolute maximum age. +- **Secrets.** At least 32 characters. Pass `secret: [newSecret, oldSecret]` to rotate: the first signs and + all verify. +- `logout` accepts the access token (even expired), the refresh token, or an opaque session token. + ## API | Call | Returns / throws | | --- | --- | -| `login({ email, password, orgCode?, ip?, userAgent? })` | `{ token, expiresAt, user }` · `INVALID_CREDENTIALS` `ACCOUNT_DISABLED` `INVITE_PENDING` `ORG_REQUIRED` `RATE_LIMITED` | +| `login({ email, password, orgCode?, ip?, userAgent? })` | `{ token, expiresAt, user }`, plus `refreshToken, refreshExpiresAt` in JWT mode (`token` is then the access JWT) · `INVALID_CREDENTIALS` `ACCOUNT_DISABLED` `INVITE_PENDING` `ORG_REQUIRED` `RATE_LIMITED` | +| `refresh({ refreshToken, ip? })` | JWT mode: `{ accessToken, accessExpiresAt, refreshToken, refreshExpiresAt, user }` · `INVALID_TOKEN` | +| `verifyAccessToken(token)` | JWT mode: claims `{ sub, org, sid, iat, exp }` or null, no storage read | | `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` | @@ -65,6 +115,7 @@ and then hashes. Also exported: - `hashPassword`, `verifyPassword`, `needsRehash`, `assertPasswordPolicy`; - `randomToken`, `hashToken`; +- `jwtSigner`, `looksLikeJwt` (HS256 sign/verify on node:crypto); - `PERMISSIONS`, `SYSTEM_ROLES`; - `can`, `branchesFor`, `permissionsOf`, `roleMap`. diff --git a/llms-full.txt b/llms-full.txt index 9ee402d..0c99e26 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1,6 +1,6 @@ # @mgpl-diagnostics/auth-kit — full documentation and source -> Generated by scripts/llms-full.mjs. Do not edit by hand. 14 files. +> Generated by scripts/llms-full.mjs. Do not edit by hand. 15 files. ## Contents @@ -13,6 +13,7 @@ - src/auth.ts - src/errors.ts - src/index.ts +- src/jwt.ts - src/memory.ts - src/password.ts - src/rbac.ts @@ -29,7 +30,7 @@ **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) +![tests](https://img.shields.io/badge/checks-98-14264A) ![deps](https://img.shields.io/badge/runtime%20dependencies-0-E9A23B) ## What it does @@ -38,6 +39,7 @@ There is no HTTP server here. Your backend (GraphQL resolvers, REST handlers, jo | **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 | +| **JWT mode** | short-lived HS256 access tokens plus single-use refresh tokens, rotated on every refresh; a refresh token used twice ends its session (theft); secret rotation; logout and revocation still apply at once | | **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 | @@ -72,11 +74,60 @@ When you create an organisation, seed the roles: `seedOrganization(repos, { …, (db-kit). To create a user, call `auth.hashNewPassword(password, { email })`, which checks the policy and then hashes. +## JWT mode (access + refresh tokens) + +Pass `jwt` and login returns a short-lived **access token** (an HS256 JWT, sent as `Authorization: Bearer`) +and a long-lived **refresh token** (opaque, single-use). The client trades the refresh token for a new pair +when the access token expires. + +```ts +const auth = createAuth({ …, kv: redisKeyValueStore(redis), jwt: { secret: process.env.JWT_SECRET!, issuer: "mgpl-api", accessTtlMs: 15 * 60_000 } }); + +const { token: accessToken, refreshToken } = await auth.login({ email, password }); +const ctx = await auth.context(accessToken); // same context as before +const next = await auth.refresh({ refreshToken }); // { accessToken, refreshToken (new), … } +``` + +```mermaid +sequenceDiagram + participant C as Client + participant A as auth-kit + participant S as Session store + participant K as kv (Redis) + C->>A: login(email, password) + A->>S: session { id: random } + A->>K: refresh:hash(R1) → session id + A-->>C: access JWT (sub, org, sid, exp 15 min) + R1 + C->>A: context(JWT) — every request + A->>A: verify HS256, exp + A->>S: session still there? re-read the user + C->>A: refresh(R1) + A->>K: take refresh:hash(R1) (single use) + A->>K: refresh:hash(R2) → sid · refresh-used:hash(R1) + A-->>C: new JWT + R2 + C->>A: refresh(R1) again (stolen copy) + A->>S: delete the session → JWT and R2 die +``` + +- **Revocation stays instant.** The JWT carries the session id, and every request checks that the session + still exists and re-reads the user (rule 3). The signature alone is enough only for + `verifyAccessToken(token)`, which workers and other services can use without storage. +- **Refresh tokens rotate.** Each is single-use and stored only as a hash. A spent token presented again + within `reuseGraceMs` (default 15 s, two tabs refreshing at once) is just refused; later, it ends the + session. +- **Lifetimes.** Access: `accessTtlMs` (default 15 min). Refresh: the session's idle window, slid by each + refresh, and never past the absolute maximum age. +- **Secrets.** At least 32 characters. Pass `secret: [newSecret, oldSecret]` to rotate: the first signs and + all verify. +- `logout` accepts the access token (even expired), the refresh token, or an opaque session token. + ## API | Call | Returns / throws | | --- | --- | -| `login({ email, password, orgCode?, ip?, userAgent? })` | `{ token, expiresAt, user }` · `INVALID_CREDENTIALS` `ACCOUNT_DISABLED` `INVITE_PENDING` `ORG_REQUIRED` `RATE_LIMITED` | +| `login({ email, password, orgCode?, ip?, userAgent? })` | `{ token, expiresAt, user }`, plus `refreshToken, refreshExpiresAt` in JWT mode (`token` is then the access JWT) · `INVALID_CREDENTIALS` `ACCOUNT_DISABLED` `INVITE_PENDING` `ORG_REQUIRED` `RATE_LIMITED` | +| `refresh({ refreshToken, ip? })` | JWT mode: `{ accessToken, accessExpiresAt, refreshToken, refreshExpiresAt, user }` · `INVALID_TOKEN` | +| `verifyAccessToken(token)` | JWT mode: claims `{ sub, org, sid, iat, exp }` or null, no storage read | | `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` | @@ -91,6 +142,7 @@ and then hashes. Also exported: - `hashPassword`, `verifyPassword`, `needsRehash`, `assertPasswordPolicy`; - `randomToken`, `hashToken`; +- `jwtSigner`, `looksLikeJwt` (HS256 sign/verify on node:crypto); - `PERMISSIONS`, `SYSTEM_ROLES`; - `can`, `branchesFor`, `permissionsOf`, `roleMap`. @@ -126,12 +178,13 @@ The backend's internal authentication API: passwords, sessions, login, reset, ro ``` src/password.ts scrypt hash/verify/needsRehash, policy, randomToken, hashToken +src/jwt.ts HS256 JWT sign/verify (node:crypto), secret rotation 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 +src/auth.ts createAuth(): login, refresh (JWT mode), authenticate, context, require, logout, sessions, password change/reset +scripts/test.ts 98 checks; session and JWT flows run on both memory and Redis (ioredis-mock) stores ``` ## Rules @@ -142,7 +195,10 @@ scripts/test.ts 65 checks; session flows run on both memory and Redis (ioredis- 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. +6. **JWT mode keeps rules 1 and 3.** Access JWTs carry the session id and every request still checks the + session and re-reads the user; refresh tokens are opaque, single-use and stored as hashes. Never accept + `alg` from the token (HS256 only), never sign with a secret under 32 characters. +7. Error codes are a public contract (`AuthError.code`). Add new ones; never rename existing ones. ## Commands @@ -161,12 +217,13 @@ All rights reserved (see LICENSE). # @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. +> 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 or (JWT mode) HS256 access tokens with rotating single-use refresh tokens, 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`. +- JWT mode: `createAuth({ …, kv, jwt: { secret, issuer?, accessTtlMs? } })`; login returns `token` (access JWT) + `refreshToken`; `auth.refresh({ refreshToken })` rotates; reuse of a spent refresh token ends the session; `verifyAccessToken` checks a JWT without storage. - Redis stores: `import { redisSessionStore, redisKeyValueStore, redisRateLimiter } from "@mgpl-diagnostics/auth-kit/redis"`. - Seed roles with `SYSTEM_ROLES`; permissions are "resource:action" with "*" wildcards. @@ -179,6 +236,7 @@ Key facts for agents: - [src/auth.ts](src/auth.ts): createAuth - [src/rbac.ts](src/rbac.ts): permissions and roles +- [src/jwt.ts](src/jwt.ts): HS256 JWT sign/verify - [src/password.ts](src/password.ts): hashing and policy - [src/redis.ts](src/redis.ts): Redis stores @@ -190,7 +248,7 @@ Key facts for agents: ```json { "name": "@mgpl-diagnostics/auth-kit", - "version": "1.0.0", + "version": "1.1.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", @@ -467,12 +525,81 @@ async function flows(label: string, stores: { sessions: SessionStore; kv: KeyVal 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"); } +// ------------------------------------------------------------------ JWT mode: access + refresh tokens + +const SECRET = "test-secret-0123456789-abcdefghijklmnop"; +const issued: string[] = []; // refresh tokens handed out, to prove Redis never stores them + +async function jwtFlows(label: string, stores: { sessions: SessionStore; kv: KeyValueStore; limiter: RateLimiter }) { + console.log(`\n[jwt / ${label}]`); + const db = fakeDb(); + await db.add({ id: "j1", orgId: "org-a", name: "Asha", email: "asha@mgpl.in", password: "tulsi-leaf-raipur", status: "active", roles: [{ role: "front_desk", branchId: "b1" }] }); + const auth = createAuth({ users: db.userStore, audit: db.audit, ...stores, session: { idleMs: 60_000, maxAgeMs: 120_000, touchEveryMs: 0 }, jwt: { secret: SECRET, issuer: "mgpl-api", accessTtlMs: 60_000, reuseGraceMs: 200 } }); + + const l = await auth.login({ email: "asha@mgpl.in", password: "tulsi-leaf-raipur" }); + issued.push(l.refreshToken!); + const [h, p] = l.token.split("."); + const header = JSON.parse(Buffer.from(h!, "base64url").toString()); + const claims = JSON.parse(Buffer.from(p!, "base64url").toString()); + ok(header.alg === "HS256" && claims.sub === "j1" && claims.org === "org-a" && claims.iss === "mgpl-api" && claims.exp - claims.iat === 60, "login: HS256 access JWT (sub, org, sid, iss, 60 s)"); + ok(!!l.refreshToken && l.refreshToken.length === 43 && l.refreshExpiresAt! > l.expiresAt, "login: opaque refresh token outlives the access token"); + ok((await auth.authenticate(l.token))?.userId === "j1" && auth.verifyAccessToken(l.token)?.sid === claims.sid, "access token → viewer; verifyAccessToken without storage"); + ok((await auth.authenticate(l.refreshToken)) === null, "a refresh token is not a bearer token"); + + // tampering + const forged = `${h}.${Buffer.from(JSON.stringify({ ...claims, sub: "someone-else" })).toString("base64url")}.${l.token.split(".")[2]}`; + const none = `${Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url")}.${p}.`; + const other = createAuth({ users: db.userStore, ...stores, jwt: { secret: "another-secret-0123456789-abcdefghijk" } }); + ok((await auth.authenticate(forged)) === null && (await auth.authenticate(none)) === null && (await other.authenticate(l.token)) === null, "forged claims, alg none and a foreign secret are refused"); + const rotated = createAuth({ users: db.userStore, ...stores, jwt: { secret: ["new-secret-0123456789-abcdefghijklmnopq", SECRET], issuer: "mgpl-api" } }); + ok((await rotated.authenticate(l.token))?.userId === "j1", "secret rotation: old tokens verify while the old secret is listed"); + + // refresh rotation + const r1 = await auth.refresh({ refreshToken: l.refreshToken! }); + issued.push(r1.refreshToken); + ok(r1.refreshToken !== l.refreshToken && (await auth.authenticate(r1.accessToken))?.userId === "j1" && auth.verifyAccessToken(r1.accessToken)?.sid === claims.sid, "refresh: new access + new refresh token, same session"); + ok((await code(auth.refresh({ refreshToken: l.refreshToken! }))) === "INVALID_TOKEN" && (await auth.authenticate(r1.accessToken)) !== null, "spent refresh token within the grace window: refused, session kept"); + await sleep(250); + ok((await code(auth.refresh({ refreshToken: l.refreshToken! }))) === "INVALID_TOKEN" && (await auth.authenticate(r1.accessToken)) === null, "spent refresh token reused later: session ended (theft)"); + ok((await code(auth.refresh({ refreshToken: r1.refreshToken }))) === "INVALID_TOKEN" && db.auditLog.some((e) => e.action === "auth.refresh_reuse"), "…its newer refresh token dies too, and it is audited"); + + // logout / revocation apply at once despite a valid signature + const a = await auth.login({ email: "asha@mgpl.in", password: "tulsi-leaf-raipur" }); + const b = await auth.login({ email: "asha@mgpl.in", password: "tulsi-leaf-raipur" }); + issued.push(a.refreshToken!, b.refreshToken!); + await auth.logout(a.token); + ok((await auth.authenticate(a.token)) === null && (await code(auth.refresh({ refreshToken: a.refreshToken! }))) === "INVALID_TOKEN" && (await auth.authenticate(b.token)) !== null, "logout with the access token ends that session and its refresh token"); + await auth.logout(b.refreshToken!); + ok((await auth.authenticate(b.token)) === null, "logout with the refresh token works too"); + const c = await auth.login({ email: "asha@mgpl.in", password: "tulsi-leaf-raipur" }); + const d = await auth.login({ email: "asha@mgpl.in", password: "tulsi-leaf-raipur" }); + issued.push(c.refreshToken!, d.refreshToken!); + await auth.changePassword({ userId: "j1", currentPassword: "tulsi-leaf-raipur", newPassword: "banyan-roots-7", currentToken: c.token }); + ok((await auth.authenticate(c.token)) !== null && (await auth.authenticate(d.token)) === null, "change password with an access token keeps this session only"); + db.users.set("j1", { ...db.users.get("j1")!, status: "disabled" }); + ok((await code(auth.refresh({ refreshToken: c.refreshToken! }))) === "INVALID_TOKEN" && (await auth.authenticate(c.token)) === null, "disabled user: refresh refused, access token dead at once"); + + const expiring = createAuth({ users: db.userStore, ...stores, jwt: { secret: SECRET, accessTtlMs: 1000 } }); + db.users.set("j1", { ...db.users.get("j1")!, status: "active" }); + const e = await expiring.login({ email: "asha@mgpl.in", password: "banyan-roots-7" }); + issued.push(e.refreshToken!); + await sleep(1100); + ok((await expiring.authenticate(e.token)) === null && (await expiring.authenticate((await expiring.refresh({ refreshToken: e.refreshToken! })).accessToken)) !== null, "expired access token refused; refresh brings a working one"); + let threw = false; + try { createAuth({ users: db.userStore, sessions: stores.sessions, jwt: { secret: "short" } }); } catch { threw = true; } + ok(threw, "JWT mode refuses short secrets and a missing kv"); +} + 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 }) }); + await jwtFlows("memory", { sessions: memorySessionStore(), kv: memoryKeyValueStore(), limiter: memoryRateLimiter({ limit: 100, windowMs: 60_000 }) }); + await jwtFlows("redis", { sessions: redisSessionStore(redis), kv: redisKeyValueStore(redis), limiter: redisRateLimiter(redis, { limit: 100, windowMs: 60_000 }) }); + const dump = (await Promise.all(((await redis.keys("*")) as string[]).map(async (k) => `${k} ${(await redis.type(k)) === "string" ? await redis.get(k) : ""}`))).join("\n"); + ok(issued.length > 5 && issued.every((t) => !dump.includes(t)), "redis never holds a raw refresh token"); 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(); @@ -490,6 +617,7 @@ main().catch((e) => { console.error(e); process.exit(1); }); ```ts import { AuthError } from "./errors"; +import { jwtSigner, looksLikeJwt, type AccessClaims, type JwtOptions } from "./jwt"; 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"; @@ -520,6 +648,20 @@ export interface AuthOptions { /** How long an organisation's role list is cached. Default 30 s. */ roleCacheMs?: number; passwordPolicy?: PasswordPolicy; + /** + * JWT mode: login and refresh return a short-lived access token (HS256 JWT, sent as Bearer) and a + * long-lived, single-use refresh token (opaque, rotated on every refresh). Needs `kv`. Without it, + * login returns one opaque session token as before. + */ + jwt?: JwtOptions & { + /** Access-token lifetime. Default 15 minutes. */ + accessTtlMs?: number; + /** + * A refresh token used twice is a theft signal and ends its session. Within this window the second + * use only fails (two tabs refreshing at once). Default 15 s. + */ + reuseGraceMs?: number; + }; } /** The signed-in user for one request. */ @@ -552,10 +694,26 @@ export interface LoginInput { } export interface LoginResult { - /** Give this to the client (Authorization: Bearer … or an httpOnly cookie). It is never stored. */ + /** + * Give this to the client (Authorization: Bearer … or an httpOnly cookie). It is never stored. + * In JWT mode it is the access token and `expiresAt` its expiry. + */ token: string; expiresAt: Date; user: PublicUser; + /** JWT mode only: single-use, rotated by `refresh`. Only its hash is stored. */ + refreshToken?: string; + /** JWT mode only: the refresh token dies here unless used before (idle window, session max age). */ + refreshExpiresAt?: Date; +} + +/** What `refresh` returns: a new access token and the next refresh token (the old one is spent). */ +export interface TokenPair { + accessToken: string; + accessExpiresAt: Date; + refreshToken: string; + refreshExpiresAt: Date; + user: PublicUser; } const publicUser = ({ passwordHash: _p, ...u }: AuthUser): PublicUser => u; @@ -581,6 +739,10 @@ export function createAuth(o: AuthOptions) { const limiter = o.limiter ?? memoryRateLimiter({ limit: 10, windowMs: 15 * 60_000 }); const roleCache = new Map(); let dummy: Promise | undefined; + const jwt = o.jwt ? jwtSigner(o.jwt) : null; + const accessTtlMs = o.jwt?.accessTtlMs ?? 15 * 60_000; + const reuseGraceMs = o.jwt?.reuseGraceMs ?? 15_000; + if (jwt && !o.kv) throw new Error("createAuth: JWT mode needs `kv` (refresh tokens)"); const audit = async (entry: Parameters[0]) => { try { @@ -609,11 +771,29 @@ export function createAuth(o: AuthOptions) { 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 }; + // JWT mode: the session id is random and never handed out; the refresh token points at it through kv + const id = jwt ? hashToken(randomToken()) : hashToken(token); + const session: Session = { id, userId: user.id, orgId: user.orgId, createdAt: now, lastSeenAt: now, expiresAt: now + maxAgeMs, ip, userAgent }; await o.sessions.create(session, idleMs); + if (jwt) await o.kv!.set(`refresh:${hashToken(token)}`, id, maxAgeMs); return { token, session }; } + /** A signed access token for a session (JWT mode). */ + function accessToken(s: Session): { token: string; expiresAt: Date } { + const now = Date.now(); + const exp = Math.min(now + accessTtlMs, s.expiresAt); + return { token: jwt!.sign({ sub: s.userId, org: s.orgId, sid: s.id, iat: Math.floor(now / 1000), exp: Math.floor(exp / 1000) }), expiresAt: new Date(Math.floor(exp / 1000) * 1000) }; + } + + const refreshExpiry = (s: Session, now = Date.now()) => new Date(Math.min(s.expiresAt, now + idleMs)); + + /** The session a token belongs to: an access JWT's `sid` (expired ones too), else the opaque token's hash. */ + function sessionIdOf(token: string): string | null { + if (jwt && looksLikeJwt(token)) return jwt.verify(token, { ignoreExpiry: true })?.sid ?? null; + return hashToken(token); + } + async function rolesFor(orgId: string): Promise { const hit = roleCache.get(orgId); if (hit && Date.now() - hit.at < roleCacheMs) return hit.map; @@ -623,8 +803,18 @@ export function createAuth(o: AuthOptions) { } async function authenticate(token: string | null | undefined): Promise { - if (!token || token.length < 20 || token.length > 200) return null; - const s = await o.sessions.get(hashToken(token)); + if (!token || token.length < 20) return null; + let s: Session | null; + if (jwt && looksLikeJwt(token)) { + const claims = jwt.verify(token); + if (!claims) return null; + // the session is still checked, so logout, revocation and "log out everywhere" apply at once + s = await o.sessions.get(claims.sid); + if (!s || s.userId !== claims.sub) return null; + } else { + if (token.length > 200) return null; + 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); @@ -673,7 +863,58 @@ export function createAuth(o: AuthOptions) { 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 }) }; + const pub = publicUser({ ...user, lastLoginAt: patch.lastLoginAt }); + if (!jwt) return { token, expiresAt: new Date(session.expiresAt), user: pub }; + const access = accessToken(session); + return { token: access.token, expiresAt: access.expiresAt, refreshToken: token, refreshExpiresAt: refreshExpiry(session), user: pub }; + }, + + /** + * JWT mode: spends a refresh token and returns a new access token and the next refresh token. + * Fails with INVALID_TOKEN when the token is unknown, spent, expired, or its session or user is + * gone. A spent token presented again after `reuseGraceMs` ends the whole session (it was copied). + */ + async refresh(input: { refreshToken: string; ip?: string }): Promise { + if (!jwt) throw new Error("createAuth: refresh needs the `jwt` option"); + const invalid = () => new AuthError("INVALID_TOKEN", "Sign in again"); + const rt = input.refreshToken ?? ""; + if (rt.length < 20 || rt.length > 200) throw invalid(); + const h = hashToken(rt); + const now = Date.now(); + const sid = await o.kv!.take(`refresh:${h}`); + if (!sid) { + const used = await o.kv!.take(`refresh-used:${h}`); + if (used) { + const [usedSid, at] = used.split("|"); + if (now - Number(at) <= reuseGraceMs) { + await o.kv!.set(`refresh-used:${h}`, used, maxAgeMs); // keep the marker for later reuse + } else if (usedSid) { + const s = await o.sessions.get(usedSid); + await o.sessions.delete(usedSid); + if (s) await audit({ orgId: s.orgId, userId: s.userId, action: "auth.refresh_reuse", ip: input.ip }); + } + } + throw invalid(); + } + const s = await o.sessions.get(sid); + if (!s) throw invalid(); + const user = await o.users.get(s.userId); + if (!user || user.status !== "active" || user.orgId !== s.orgId) { + await o.sessions.deleteByUser(s.userId); + throw invalid(); + } + await o.sessions.touch(s.id, now, idleMs); + const next = randomToken(); + const ttl = Math.max(1, s.expiresAt - now); + await o.kv!.set(`refresh:${hashToken(next)}`, s.id, ttl); + await o.kv!.set(`refresh-used:${h}`, `${s.id}|${now}`, ttl); + const access = accessToken(s); + return { accessToken: access.token, accessExpiresAt: access.expiresAt, refreshToken: next, refreshExpiresAt: refreshExpiry(s, now), user: publicUser(user) }; + }, + + /** JWT mode: the claims of a valid access token, without touching storage (for workers or other services). */ + verifyAccessToken(token: string): AccessClaims | null { + return jwt && looksLikeJwt(token) ? jwt.verify(token) : null; }, /** Token → viewer, or null (unknown, expired, logged out, disabled). Extends the idle window. */ @@ -700,8 +941,14 @@ export function createAuth(o: AuthOptions) { return ctx.viewer; }, + /** Ends the session of an access token (even expired), a refresh token, or an opaque session token. */ async logout(token: string): Promise { - const s = await o.sessions.get(hashToken(token)); + const id = sessionIdOf(token); + let s = id ? await o.sessions.get(id) : null; + if (!s && jwt && !looksLikeJwt(token)) { + const sid = await o.kv!.take(`refresh:${hashToken(token)}`); + s = sid ? await o.sessions.get(sid) : null; + } if (!s) return; await o.sessions.delete(s.id); await audit({ orgId: s.orgId, userId: s.userId, action: "auth.logout" }); @@ -709,7 +956,7 @@ export function createAuth(o: AuthOptions) { /** Logs a user out everywhere (optionally keeping the current session). */ async logoutAll(userId: string, keepToken?: string): Promise { - return o.sessions.deleteByUser(userId, keepToken ? hashToken(keepToken) : undefined); + return o.sessions.deleteByUser(userId, (keepToken && sessionIdOf(keepToken)) || undefined); }, /** Active sessions ("devices") of a user. `id` can be passed to revokeSession. */ @@ -729,7 +976,7 @@ export function createAuth(o: AuthOptions) { 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 setNewPassword(user, input.newPassword, (input.currentToken && sessionIdOf(input.currentToken)) || undefined); await audit({ orgId: user.orgId, userId: user.id, action: "auth.password_change" }); }, @@ -834,7 +1081,8 @@ export class AuthError extends Error { ## File: src/index.ts ```ts -export { createAuth, type Auth, type AuthContext, type AuthOptions, type LoginInput, type LoginResult, type PublicUser, type Viewer } from "./auth"; +export { createAuth, type Auth, type AuthContext, type AuthOptions, type LoginInput, type LoginResult, type PublicUser, type TokenPair, type Viewer } from "./auth"; +export { jwtSigner, looksLikeJwt, type AccessClaims, type JwtOptions } from "./jwt"; export { AuthError, type AuthErrorCode } from "./errors"; export { assertPasswordPolicy, hashPassword, hashToken, needsRehash, randomToken, SCRYPT, verifyPassword, type PasswordPolicy } from "./password"; export { @@ -846,6 +1094,93 @@ export type { AuditSink, AuthUser, KeyValueStore, OrgStore, RateLimiter, RoleSto ``` +--- + +## File: src/jwt.ts + +```ts +import { createHash, createHmac, timingSafeEqual } from "node:crypto"; + +/** + * Minimal HS256 JSON Web Tokens (RFC 7519) on node:crypto: no dependency. Used for access tokens. + * Several secrets can be active: the first signs, every one verifies (rotate by prepending a new one, + * then drop the old one after the access-token lifetime). + */ +export interface JwtOptions { + /** One secret or a rotation list (first signs). Each at least 32 characters. */ + secret: string | string[]; + issuer?: string; + audience?: string; +} + +export interface AccessClaims { + /** user id */ + sub: string; + /** organisation id */ + org: string; + /** session id (revocable server-side) */ + sid: string; + iat: number; + exp: number; + iss?: string; + aud?: string; +} + +const b64 = (v: string | Buffer) => Buffer.from(v).toString("base64url"); +const kidOf = (secret: string) => createHash("sha256").update(secret).digest("base64url").slice(0, 8); + +export function jwtSigner(o: JwtOptions) { + const secrets = (Array.isArray(o.secret) ? o.secret : [o.secret]).filter(Boolean); + if (!secrets.length) throw new Error("jwt: give at least one secret"); + for (const s of secrets) if (s.length < 32) throw new Error("jwt: every secret needs at least 32 characters"); + const keys = new Map(secrets.map((s) => [kidOf(s), s])); + const signer = secrets[0]!; + const mac = (secret: string, data: string) => createHmac("sha256", secret).update(data).digest(); + + return { + sign(claims: Omit): string { + const header = b64(JSON.stringify({ alg: "HS256", typ: "JWT", kid: kidOf(signer) })); + const body = b64(JSON.stringify({ ...claims, ...(o.issuer ? { iss: o.issuer } : {}), ...(o.audience ? { aud: o.audience } : {}) })); + return `${header}.${body}.${b64(mac(signer, `${header}.${body}`))}`; + }, + + /** Claims when the signature, issuer and audience hold; null otherwise. `ignoreExpiry` is for logout. */ + verify(token: string, opts: { now?: number; ignoreExpiry?: boolean } = {}): AccessClaims | null { + if (token.length > 2048) return null; + const parts = token.split("."); + if (parts.length !== 3) return null; + const [h, p, s] = parts as [string, string, string]; + let header: { alg?: string; kid?: string }; + let claims: AccessClaims; + try { + header = JSON.parse(Buffer.from(h, "base64url").toString("utf8")); + claims = JSON.parse(Buffer.from(p, "base64url").toString("utf8")); + } catch { + return null; + } + if (header.alg !== "HS256") return null; // never "none", never an algorithm chosen by the token + const candidates = header.kid && keys.has(header.kid) ? [keys.get(header.kid)!] : secrets; + const sig = Buffer.from(s, "base64url"); + const ok = candidates.some((secret) => { + const expected = mac(secret, `${h}.${p}`); + return expected.length === sig.length && timingSafeEqual(expected, sig); + }); + if (!ok) return null; + if (typeof claims.sub !== "string" || typeof claims.org !== "string" || typeof claims.sid !== "string" || typeof claims.exp !== "number") return null; + if (o.issuer && claims.iss !== o.issuer) return null; + if (o.audience && claims.aud !== o.audience) return null; + const now = Math.floor((opts.now ?? Date.now()) / 1000); + if (!opts.ignoreExpiry && claims.exp <= now) return null; + return claims; + }, + }; +} + +/** A JWT has three dot-separated parts; opaque tokens (base64url) have none. */ +export const looksLikeJwt = (token: string) => token.split(".").length === 3; +``` + + --- ## File: src/memory.ts diff --git a/llms.txt b/llms.txt index fc27c9b..66368f7 100644 --- a/llms.txt +++ b/llms.txt @@ -1,11 +1,12 @@ # @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. +> 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 or (JWT mode) HS256 access tokens with rotating single-use refresh tokens, 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`. +- JWT mode: `createAuth({ …, kv, jwt: { secret, issuer?, accessTtlMs? } })`; login returns `token` (access JWT) + `refreshToken`; `auth.refresh({ refreshToken })` rotates; reuse of a spent refresh token ends the session; `verifyAccessToken` checks a JWT without storage. - Redis stores: `import { redisSessionStore, redisKeyValueStore, redisRateLimiter } from "@mgpl-diagnostics/auth-kit/redis"`. - Seed roles with `SYSTEM_ROLES`; permissions are "resource:action" with "*" wildcards. @@ -18,5 +19,6 @@ Key facts for agents: - [src/auth.ts](src/auth.ts): createAuth - [src/rbac.ts](src/rbac.ts): permissions and roles +- [src/jwt.ts](src/jwt.ts): HS256 JWT sign/verify - [src/password.ts](src/password.ts): hashing and policy - [src/redis.ts](src/redis.ts): Redis stores diff --git a/package.json b/package.json index bf7c180..6f8e719 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mgpl-diagnostics/auth-kit", - "version": "1.0.0", + "version": "1.1.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", diff --git a/scripts/test.ts b/scripts/test.ts index 1188f83..2712ff5 100644 --- a/scripts/test.ts +++ b/scripts/test.ts @@ -149,12 +149,81 @@ async function flows(label: string, stores: { sessions: SessionStore; kv: KeyVal 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"); } +// ------------------------------------------------------------------ JWT mode: access + refresh tokens + +const SECRET = "test-secret-0123456789-abcdefghijklmnop"; +const issued: string[] = []; // refresh tokens handed out, to prove Redis never stores them + +async function jwtFlows(label: string, stores: { sessions: SessionStore; kv: KeyValueStore; limiter: RateLimiter }) { + console.log(`\n[jwt / ${label}]`); + const db = fakeDb(); + await db.add({ id: "j1", orgId: "org-a", name: "Asha", email: "asha@mgpl.in", password: "tulsi-leaf-raipur", status: "active", roles: [{ role: "front_desk", branchId: "b1" }] }); + const auth = createAuth({ users: db.userStore, audit: db.audit, ...stores, session: { idleMs: 60_000, maxAgeMs: 120_000, touchEveryMs: 0 }, jwt: { secret: SECRET, issuer: "mgpl-api", accessTtlMs: 60_000, reuseGraceMs: 200 } }); + + const l = await auth.login({ email: "asha@mgpl.in", password: "tulsi-leaf-raipur" }); + issued.push(l.refreshToken!); + const [h, p] = l.token.split("."); + const header = JSON.parse(Buffer.from(h!, "base64url").toString()); + const claims = JSON.parse(Buffer.from(p!, "base64url").toString()); + ok(header.alg === "HS256" && claims.sub === "j1" && claims.org === "org-a" && claims.iss === "mgpl-api" && claims.exp - claims.iat === 60, "login: HS256 access JWT (sub, org, sid, iss, 60 s)"); + ok(!!l.refreshToken && l.refreshToken.length === 43 && l.refreshExpiresAt! > l.expiresAt, "login: opaque refresh token outlives the access token"); + ok((await auth.authenticate(l.token))?.userId === "j1" && auth.verifyAccessToken(l.token)?.sid === claims.sid, "access token → viewer; verifyAccessToken without storage"); + ok((await auth.authenticate(l.refreshToken)) === null, "a refresh token is not a bearer token"); + + // tampering + const forged = `${h}.${Buffer.from(JSON.stringify({ ...claims, sub: "someone-else" })).toString("base64url")}.${l.token.split(".")[2]}`; + const none = `${Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url")}.${p}.`; + const other = createAuth({ users: db.userStore, ...stores, jwt: { secret: "another-secret-0123456789-abcdefghijk" } }); + ok((await auth.authenticate(forged)) === null && (await auth.authenticate(none)) === null && (await other.authenticate(l.token)) === null, "forged claims, alg none and a foreign secret are refused"); + const rotated = createAuth({ users: db.userStore, ...stores, jwt: { secret: ["new-secret-0123456789-abcdefghijklmnopq", SECRET], issuer: "mgpl-api" } }); + ok((await rotated.authenticate(l.token))?.userId === "j1", "secret rotation: old tokens verify while the old secret is listed"); + + // refresh rotation + const r1 = await auth.refresh({ refreshToken: l.refreshToken! }); + issued.push(r1.refreshToken); + ok(r1.refreshToken !== l.refreshToken && (await auth.authenticate(r1.accessToken))?.userId === "j1" && auth.verifyAccessToken(r1.accessToken)?.sid === claims.sid, "refresh: new access + new refresh token, same session"); + ok((await code(auth.refresh({ refreshToken: l.refreshToken! }))) === "INVALID_TOKEN" && (await auth.authenticate(r1.accessToken)) !== null, "spent refresh token within the grace window: refused, session kept"); + await sleep(250); + ok((await code(auth.refresh({ refreshToken: l.refreshToken! }))) === "INVALID_TOKEN" && (await auth.authenticate(r1.accessToken)) === null, "spent refresh token reused later: session ended (theft)"); + ok((await code(auth.refresh({ refreshToken: r1.refreshToken }))) === "INVALID_TOKEN" && db.auditLog.some((e) => e.action === "auth.refresh_reuse"), "…its newer refresh token dies too, and it is audited"); + + // logout / revocation apply at once despite a valid signature + const a = await auth.login({ email: "asha@mgpl.in", password: "tulsi-leaf-raipur" }); + const b = await auth.login({ email: "asha@mgpl.in", password: "tulsi-leaf-raipur" }); + issued.push(a.refreshToken!, b.refreshToken!); + await auth.logout(a.token); + ok((await auth.authenticate(a.token)) === null && (await code(auth.refresh({ refreshToken: a.refreshToken! }))) === "INVALID_TOKEN" && (await auth.authenticate(b.token)) !== null, "logout with the access token ends that session and its refresh token"); + await auth.logout(b.refreshToken!); + ok((await auth.authenticate(b.token)) === null, "logout with the refresh token works too"); + const c = await auth.login({ email: "asha@mgpl.in", password: "tulsi-leaf-raipur" }); + const d = await auth.login({ email: "asha@mgpl.in", password: "tulsi-leaf-raipur" }); + issued.push(c.refreshToken!, d.refreshToken!); + await auth.changePassword({ userId: "j1", currentPassword: "tulsi-leaf-raipur", newPassword: "banyan-roots-7", currentToken: c.token }); + ok((await auth.authenticate(c.token)) !== null && (await auth.authenticate(d.token)) === null, "change password with an access token keeps this session only"); + db.users.set("j1", { ...db.users.get("j1")!, status: "disabled" }); + ok((await code(auth.refresh({ refreshToken: c.refreshToken! }))) === "INVALID_TOKEN" && (await auth.authenticate(c.token)) === null, "disabled user: refresh refused, access token dead at once"); + + const expiring = createAuth({ users: db.userStore, ...stores, jwt: { secret: SECRET, accessTtlMs: 1000 } }); + db.users.set("j1", { ...db.users.get("j1")!, status: "active" }); + const e = await expiring.login({ email: "asha@mgpl.in", password: "banyan-roots-7" }); + issued.push(e.refreshToken!); + await sleep(1100); + ok((await expiring.authenticate(e.token)) === null && (await expiring.authenticate((await expiring.refresh({ refreshToken: e.refreshToken! })).accessToken)) !== null, "expired access token refused; refresh brings a working one"); + let threw = false; + try { createAuth({ users: db.userStore, sessions: stores.sessions, jwt: { secret: "short" } }); } catch { threw = true; } + ok(threw, "JWT mode refuses short secrets and a missing kv"); +} + 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 }) }); + await jwtFlows("memory", { sessions: memorySessionStore(), kv: memoryKeyValueStore(), limiter: memoryRateLimiter({ limit: 100, windowMs: 60_000 }) }); + await jwtFlows("redis", { sessions: redisSessionStore(redis), kv: redisKeyValueStore(redis), limiter: redisRateLimiter(redis, { limit: 100, windowMs: 60_000 }) }); + const dump = (await Promise.all(((await redis.keys("*")) as string[]).map(async (k) => `${k} ${(await redis.type(k)) === "string" ? await redis.get(k) : ""}`))).join("\n"); + ok(issued.length > 5 && issued.every((t) => !dump.includes(t)), "redis never holds a raw refresh token"); 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(); diff --git a/src/auth.ts b/src/auth.ts index c3c04e5..d25aab0 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,4 +1,5 @@ import { AuthError } from "./errors"; +import { jwtSigner, looksLikeJwt, type AccessClaims, type JwtOptions } from "./jwt"; 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"; @@ -29,6 +30,20 @@ export interface AuthOptions { /** How long an organisation's role list is cached. Default 30 s. */ roleCacheMs?: number; passwordPolicy?: PasswordPolicy; + /** + * JWT mode: login and refresh return a short-lived access token (HS256 JWT, sent as Bearer) and a + * long-lived, single-use refresh token (opaque, rotated on every refresh). Needs `kv`. Without it, + * login returns one opaque session token as before. + */ + jwt?: JwtOptions & { + /** Access-token lifetime. Default 15 minutes. */ + accessTtlMs?: number; + /** + * A refresh token used twice is a theft signal and ends its session. Within this window the second + * use only fails (two tabs refreshing at once). Default 15 s. + */ + reuseGraceMs?: number; + }; } /** The signed-in user for one request. */ @@ -61,10 +76,26 @@ export interface LoginInput { } export interface LoginResult { - /** Give this to the client (Authorization: Bearer … or an httpOnly cookie). It is never stored. */ + /** + * Give this to the client (Authorization: Bearer … or an httpOnly cookie). It is never stored. + * In JWT mode it is the access token and `expiresAt` its expiry. + */ token: string; expiresAt: Date; user: PublicUser; + /** JWT mode only: single-use, rotated by `refresh`. Only its hash is stored. */ + refreshToken?: string; + /** JWT mode only: the refresh token dies here unless used before (idle window, session max age). */ + refreshExpiresAt?: Date; +} + +/** What `refresh` returns: a new access token and the next refresh token (the old one is spent). */ +export interface TokenPair { + accessToken: string; + accessExpiresAt: Date; + refreshToken: string; + refreshExpiresAt: Date; + user: PublicUser; } const publicUser = ({ passwordHash: _p, ...u }: AuthUser): PublicUser => u; @@ -90,6 +121,10 @@ export function createAuth(o: AuthOptions) { const limiter = o.limiter ?? memoryRateLimiter({ limit: 10, windowMs: 15 * 60_000 }); const roleCache = new Map(); let dummy: Promise | undefined; + const jwt = o.jwt ? jwtSigner(o.jwt) : null; + const accessTtlMs = o.jwt?.accessTtlMs ?? 15 * 60_000; + const reuseGraceMs = o.jwt?.reuseGraceMs ?? 15_000; + if (jwt && !o.kv) throw new Error("createAuth: JWT mode needs `kv` (refresh tokens)"); const audit = async (entry: Parameters[0]) => { try { @@ -118,11 +153,29 @@ export function createAuth(o: AuthOptions) { 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 }; + // JWT mode: the session id is random and never handed out; the refresh token points at it through kv + const id = jwt ? hashToken(randomToken()) : hashToken(token); + const session: Session = { id, userId: user.id, orgId: user.orgId, createdAt: now, lastSeenAt: now, expiresAt: now + maxAgeMs, ip, userAgent }; await o.sessions.create(session, idleMs); + if (jwt) await o.kv!.set(`refresh:${hashToken(token)}`, id, maxAgeMs); return { token, session }; } + /** A signed access token for a session (JWT mode). */ + function accessToken(s: Session): { token: string; expiresAt: Date } { + const now = Date.now(); + const exp = Math.min(now + accessTtlMs, s.expiresAt); + return { token: jwt!.sign({ sub: s.userId, org: s.orgId, sid: s.id, iat: Math.floor(now / 1000), exp: Math.floor(exp / 1000) }), expiresAt: new Date(Math.floor(exp / 1000) * 1000) }; + } + + const refreshExpiry = (s: Session, now = Date.now()) => new Date(Math.min(s.expiresAt, now + idleMs)); + + /** The session a token belongs to: an access JWT's `sid` (expired ones too), else the opaque token's hash. */ + function sessionIdOf(token: string): string | null { + if (jwt && looksLikeJwt(token)) return jwt.verify(token, { ignoreExpiry: true })?.sid ?? null; + return hashToken(token); + } + async function rolesFor(orgId: string): Promise { const hit = roleCache.get(orgId); if (hit && Date.now() - hit.at < roleCacheMs) return hit.map; @@ -132,8 +185,18 @@ export function createAuth(o: AuthOptions) { } async function authenticate(token: string | null | undefined): Promise { - if (!token || token.length < 20 || token.length > 200) return null; - const s = await o.sessions.get(hashToken(token)); + if (!token || token.length < 20) return null; + let s: Session | null; + if (jwt && looksLikeJwt(token)) { + const claims = jwt.verify(token); + if (!claims) return null; + // the session is still checked, so logout, revocation and "log out everywhere" apply at once + s = await o.sessions.get(claims.sid); + if (!s || s.userId !== claims.sub) return null; + } else { + if (token.length > 200) return null; + 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); @@ -182,7 +245,58 @@ export function createAuth(o: AuthOptions) { 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 }) }; + const pub = publicUser({ ...user, lastLoginAt: patch.lastLoginAt }); + if (!jwt) return { token, expiresAt: new Date(session.expiresAt), user: pub }; + const access = accessToken(session); + return { token: access.token, expiresAt: access.expiresAt, refreshToken: token, refreshExpiresAt: refreshExpiry(session), user: pub }; + }, + + /** + * JWT mode: spends a refresh token and returns a new access token and the next refresh token. + * Fails with INVALID_TOKEN when the token is unknown, spent, expired, or its session or user is + * gone. A spent token presented again after `reuseGraceMs` ends the whole session (it was copied). + */ + async refresh(input: { refreshToken: string; ip?: string }): Promise { + if (!jwt) throw new Error("createAuth: refresh needs the `jwt` option"); + const invalid = () => new AuthError("INVALID_TOKEN", "Sign in again"); + const rt = input.refreshToken ?? ""; + if (rt.length < 20 || rt.length > 200) throw invalid(); + const h = hashToken(rt); + const now = Date.now(); + const sid = await o.kv!.take(`refresh:${h}`); + if (!sid) { + const used = await o.kv!.take(`refresh-used:${h}`); + if (used) { + const [usedSid, at] = used.split("|"); + if (now - Number(at) <= reuseGraceMs) { + await o.kv!.set(`refresh-used:${h}`, used, maxAgeMs); // keep the marker for later reuse + } else if (usedSid) { + const s = await o.sessions.get(usedSid); + await o.sessions.delete(usedSid); + if (s) await audit({ orgId: s.orgId, userId: s.userId, action: "auth.refresh_reuse", ip: input.ip }); + } + } + throw invalid(); + } + const s = await o.sessions.get(sid); + if (!s) throw invalid(); + const user = await o.users.get(s.userId); + if (!user || user.status !== "active" || user.orgId !== s.orgId) { + await o.sessions.deleteByUser(s.userId); + throw invalid(); + } + await o.sessions.touch(s.id, now, idleMs); + const next = randomToken(); + const ttl = Math.max(1, s.expiresAt - now); + await o.kv!.set(`refresh:${hashToken(next)}`, s.id, ttl); + await o.kv!.set(`refresh-used:${h}`, `${s.id}|${now}`, ttl); + const access = accessToken(s); + return { accessToken: access.token, accessExpiresAt: access.expiresAt, refreshToken: next, refreshExpiresAt: refreshExpiry(s, now), user: publicUser(user) }; + }, + + /** JWT mode: the claims of a valid access token, without touching storage (for workers or other services). */ + verifyAccessToken(token: string): AccessClaims | null { + return jwt && looksLikeJwt(token) ? jwt.verify(token) : null; }, /** Token → viewer, or null (unknown, expired, logged out, disabled). Extends the idle window. */ @@ -209,8 +323,14 @@ export function createAuth(o: AuthOptions) { return ctx.viewer; }, + /** Ends the session of an access token (even expired), a refresh token, or an opaque session token. */ async logout(token: string): Promise { - const s = await o.sessions.get(hashToken(token)); + const id = sessionIdOf(token); + let s = id ? await o.sessions.get(id) : null; + if (!s && jwt && !looksLikeJwt(token)) { + const sid = await o.kv!.take(`refresh:${hashToken(token)}`); + s = sid ? await o.sessions.get(sid) : null; + } if (!s) return; await o.sessions.delete(s.id); await audit({ orgId: s.orgId, userId: s.userId, action: "auth.logout" }); @@ -218,7 +338,7 @@ export function createAuth(o: AuthOptions) { /** Logs a user out everywhere (optionally keeping the current session). */ async logoutAll(userId: string, keepToken?: string): Promise { - return o.sessions.deleteByUser(userId, keepToken ? hashToken(keepToken) : undefined); + return o.sessions.deleteByUser(userId, (keepToken && sessionIdOf(keepToken)) || undefined); }, /** Active sessions ("devices") of a user. `id` can be passed to revokeSession. */ @@ -238,7 +358,7 @@ export function createAuth(o: AuthOptions) { 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 setNewPassword(user, input.newPassword, (input.currentToken && sessionIdOf(input.currentToken)) || undefined); await audit({ orgId: user.orgId, userId: user.id, action: "auth.password_change" }); }, diff --git a/src/index.ts b/src/index.ts index 53a2996..b088876 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ -export { createAuth, type Auth, type AuthContext, type AuthOptions, type LoginInput, type LoginResult, type PublicUser, type Viewer } from "./auth"; +export { createAuth, type Auth, type AuthContext, type AuthOptions, type LoginInput, type LoginResult, type PublicUser, type TokenPair, type Viewer } from "./auth"; +export { jwtSigner, looksLikeJwt, type AccessClaims, type JwtOptions } from "./jwt"; export { AuthError, type AuthErrorCode } from "./errors"; export { assertPasswordPolicy, hashPassword, hashToken, needsRehash, randomToken, SCRYPT, verifyPassword, type PasswordPolicy } from "./password"; export { diff --git a/src/jwt.ts b/src/jwt.ts new file mode 100644 index 0000000..8616f5d --- /dev/null +++ b/src/jwt.ts @@ -0,0 +1,79 @@ +import { createHash, createHmac, timingSafeEqual } from "node:crypto"; + +/** + * Minimal HS256 JSON Web Tokens (RFC 7519) on node:crypto: no dependency. Used for access tokens. + * Several secrets can be active: the first signs, every one verifies (rotate by prepending a new one, + * then drop the old one after the access-token lifetime). + */ +export interface JwtOptions { + /** One secret or a rotation list (first signs). Each at least 32 characters. */ + secret: string | string[]; + issuer?: string; + audience?: string; +} + +export interface AccessClaims { + /** user id */ + sub: string; + /** organisation id */ + org: string; + /** session id (revocable server-side) */ + sid: string; + iat: number; + exp: number; + iss?: string; + aud?: string; +} + +const b64 = (v: string | Buffer) => Buffer.from(v).toString("base64url"); +const kidOf = (secret: string) => createHash("sha256").update(secret).digest("base64url").slice(0, 8); + +export function jwtSigner(o: JwtOptions) { + const secrets = (Array.isArray(o.secret) ? o.secret : [o.secret]).filter(Boolean); + if (!secrets.length) throw new Error("jwt: give at least one secret"); + for (const s of secrets) if (s.length < 32) throw new Error("jwt: every secret needs at least 32 characters"); + const keys = new Map(secrets.map((s) => [kidOf(s), s])); + const signer = secrets[0]!; + const mac = (secret: string, data: string) => createHmac("sha256", secret).update(data).digest(); + + return { + sign(claims: Omit): string { + const header = b64(JSON.stringify({ alg: "HS256", typ: "JWT", kid: kidOf(signer) })); + const body = b64(JSON.stringify({ ...claims, ...(o.issuer ? { iss: o.issuer } : {}), ...(o.audience ? { aud: o.audience } : {}) })); + return `${header}.${body}.${b64(mac(signer, `${header}.${body}`))}`; + }, + + /** Claims when the signature, issuer and audience hold; null otherwise. `ignoreExpiry` is for logout. */ + verify(token: string, opts: { now?: number; ignoreExpiry?: boolean } = {}): AccessClaims | null { + if (token.length > 2048) return null; + const parts = token.split("."); + if (parts.length !== 3) return null; + const [h, p, s] = parts as [string, string, string]; + let header: { alg?: string; kid?: string }; + let claims: AccessClaims; + try { + header = JSON.parse(Buffer.from(h, "base64url").toString("utf8")); + claims = JSON.parse(Buffer.from(p, "base64url").toString("utf8")); + } catch { + return null; + } + if (header.alg !== "HS256") return null; // never "none", never an algorithm chosen by the token + const candidates = header.kid && keys.has(header.kid) ? [keys.get(header.kid)!] : secrets; + const sig = Buffer.from(s, "base64url"); + const ok = candidates.some((secret) => { + const expected = mac(secret, `${h}.${p}`); + return expected.length === sig.length && timingSafeEqual(expected, sig); + }); + if (!ok) return null; + if (typeof claims.sub !== "string" || typeof claims.org !== "string" || typeof claims.sid !== "string" || typeof claims.exp !== "number") return null; + if (o.issuer && claims.iss !== o.issuer) return null; + if (o.audience && claims.aud !== o.audience) return null; + const now = Math.floor((opts.now ?? Date.now()) / 1000); + if (!opts.ignoreExpiry && claims.exp <= now) return null; + return claims; + }, + }; +} + +/** A JWT has three dot-separated parts; opaque tokens (base64url) have none. */ +export const looksLikeJwt = (token: string) => token.split(".").length === 3;