Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ src/stores.ts storage interfaces (UserStore, OrgStore, RoleStore, AuditSink,
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, 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
scripts/test.ts 110 checks; session, JWT and device-binding flows run on both memory and Redis (ioredis-mock) stores
```

## Rules
Expand All @@ -27,6 +27,8 @@ scripts/test.ts 98 checks; session and JWT flows run on both memory and Redis (
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.
Device signals from clients are untrusted: always pass them through `cleanDeviceSignals`; a refresh
from another `deviceId` ends the session unless `deviceBinding` says otherwise.
7. Error codes are a public contract (`AuthError.code`). Add new ones; never rename existing ones.

## Commands
Expand Down
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-98-14264A) ![deps](https://img.shields.io/badge/runtime%20dependencies-0-E9A23B)
![tests](https://img.shields.io/badge/checks-110-14264A) ![deps](https://img.shields.io/badge/runtime%20dependencies-0-E9A23B)

## What it does

Expand Down Expand Up @@ -94,12 +94,27 @@ sequenceDiagram
all verify.
- `logout` accepts the access token (even expired), the refresh token, or an opaque session token.

### Device binding

Clients can send security signals with `login` and `refresh` (`device: DeviceSignals`). They are cleaned
(`cleanDeviceSignals`: ids 8–128 safe characters, at most 20 short `info` values) and stored on the session.

| Signal | What it is | On refresh |
| --- | --- | --- |
| `deviceId` | random id the client keeps per browser profile or app install | must match the login's, else (`deviceBinding: "enforce"`, default) `INVALID_TOKEN` and the session ends; `"flag"` only audits; `"off"` ignores |
| `fingerprint` | client-side hash of stable device attributes | a change is audited (`auth.fingerprint_changed`); browsers update, so it never blocks |
| `clientSessionId` | random id per tab or app run | recorded (latest) |
| `info` | platform, language, timezone, screen… | recorded (latest) |

A session that signed in without a `deviceId` is bound by the first refresh that sends one.
`sessionsOf` returns `device`, so a "your devices" page can show it.

## API

| Call | Returns / throws |
| --- | --- |
| `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` |
| `login({ email, password, orgCode?, ip?, userAgent?, device? })` | `{ 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?, userAgent?, device? })` | JWT mode: `{ accessToken, accessExpiresAt, refreshToken, refreshExpiresAt, user }` · `INVALID_TOKEN` (also: another device) |
| `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) |
Expand All @@ -116,6 +131,7 @@ Also exported:
- `hashPassword`, `verifyPassword`, `needsRehash`, `assertPasswordPolicy`;
- `randomToken`, `hashToken`;
- `jwtSigner`, `looksLikeJwt` (HS256 sign/verify on node:crypto);
- `cleanDeviceSignals` and the `DeviceSignals` type;
- `PERMISSIONS`, `SYSTEM_ROLES`;
- `can`, `branchesFor`, `permissionsOf`, `roleMap`.

Expand Down
Loading
Loading