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
6 changes: 4 additions & 2 deletions 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 110 checks; session, JWT and device-binding flows run on both memory and Redis (ioredis-mock) stores
scripts/test.ts 126 checks; session, JWT, device-binding and TLS-fingerprint flows run on both memory and Redis (ioredis-mock) stores
```

## Rules
Expand All @@ -28,7 +28,9 @@ scripts/test.ts 110 checks; session, JWT and device-binding flows run on both m
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.
from another `deviceId` ends the session unless `deviceBinding` says otherwise. The TLS fingerprint
(`tlsFingerprint`) comes from the server (security-kit's `readTlsFingerprint`, trusted proxy only),
never from client input; only `hashTlsFingerprint` of it is stored, and a change is audited, never blocking.
7. Error codes are a public contract (`AuthError.code`). Add new ones; never rename existing ones.

## Commands
Expand Down
16 changes: 12 additions & 4 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-110-14264A) ![deps](https://img.shields.io/badge/runtime%20dependencies-0-E9A23B)
![tests](https://img.shields.io/badge/checks-126-14264A) ![deps](https://img.shields.io/badge/runtime%20dependencies-0-E9A23B)

## What it does

Expand Down Expand Up @@ -109,12 +109,20 @@ Clients can send security signals with `login` and `refresh` (`device: DeviceSig
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.

**TLS fingerprint.** The server can also pass `tlsFingerprint` (JA4 or JA3) to `login` and `refresh`. It
is computed by whatever terminates TLS, so it never comes from the client: read it with security-kit's
`readTlsFingerprint` (a header set by a trusted proxy). Only its SHA-256 (`hashTlsFingerprint`) is stored,
as `Session.tlsFingerprintHash`. On refresh a different one is audited (`auth.tls_fingerprint_changed`,
with both hashes) and the latest is kept; it never blocks, because browser updates and TLS-inspecting
networks change it. A refresh without one keeps the stored hash, and `auth.device_mismatch` records
`tls: "same" | "changed" | null`, which helps tell a copied token from a cleared browser.

## API

| Call | Returns / throws |
| --- | --- |
| `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) |
| `login({ email, password, orgCode?, ip?, userAgent?, device?, tlsFingerprint? })` | `{ 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?, tlsFingerprint? })` | 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 @@ -131,7 +139,7 @@ Also exported:
- `hashPassword`, `verifyPassword`, `needsRehash`, `assertPasswordPolicy`;
- `randomToken`, `hashToken`;
- `jwtSigner`, `looksLikeJwt` (HS256 sign/verify on node:crypto);
- `cleanDeviceSignals` and the `DeviceSignals` type;
- `cleanDeviceSignals` and the `DeviceSignals` type; `hashTlsFingerprint`;
- `PERMISSIONS`, `SYSTEM_ROLES`;
- `can`, `branchesFor`, `permissionsOf`, `roleMap`.

Expand Down
Loading
Loading