From 7783a8f4218f47e04ac95194c758afe44d32194a Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 6 Jul 2026 20:21:24 -0400 Subject: [PATCH] docs: expand contributor and integration guides - CONTRIBUTING.md: add Testing (single-test runs, Postgres e2e, factory example), a 'what a good PR includes' checklist, and a config pointer. - docs/extending.md: message-delivery providers (direct + external), where custom behavior belongs, and what is intentionally not extensible. - docs/api-contract.md: which token comes from where, token shapes/claims, branch-significant status codes, and identifier-vs-email terminology. - Link the new guides from the README docs index and gitignore allowlist. Closes #63 Closes #64 Closes #65 --- .gitignore | 2 ++ CONTRIBUTING.md | 61 ++++++++++++++++++++++++++++++++++++ README.md | 4 +++ docs/api-contract.md | 73 ++++++++++++++++++++++++++++++++++++++++++++ docs/extending.md | 71 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 211 insertions(+) create mode 100644 docs/api-contract.md create mode 100644 docs/extending.md diff --git a/.gitignore b/.gitignore index ba138f9..8985f0f 100644 --- a/.gitignore +++ b/.gitignore @@ -36,10 +36,12 @@ docker-data/ .eslintcache docs/* !docs/admin-operations.md +!docs/api-contract.md !docs/architecture.md !docs/direct-http-quickstart.md !docs/configuration.md !docs/ecosystem.md +!docs/extending.md !docs/oauth.md !docs/production-operations.md !docs/webauthn-prf.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 214355a..9fe0e12 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -80,6 +80,52 @@ curl http://localhost:5312/health/status --- +## Configuration + +For a full reference of every environment variable and `system_config` key (which are +required, their defaults, and where each takes effect), see +[docs/configuration.md](./docs/configuration.md). + +## Testing + +The test suite runs on a mock database by default, so **no Postgres is required** for most work. + +```bash +npm run test:run # run the whole suite once (mock DB) +npm run coverage # run with coverage thresholds +``` + +Run a single file or a directory while iterating: + +```bash +npx vitest run tests/integration/otp/otp.spec.ts # one file +npx vitest run tests/unit/utils # a directory +npx vitest tests/unit/utils/redaction.spec.ts # watch mode +``` + +Only a few tests exercise real database behavior. To run those against a running Postgres: + +```bash +TEST_DB=postgres npm run test:run +``` + +### Writing a test + +Use the shared factories in [`tests/factories/`](./tests/factories) to build valid domain +objects instead of hand-rolling fixtures. For example: + +```ts +import { buildUser } from '../../factories/userFactory.js'; +import { buildSystemConfig } from '../../factories/systemConfigFactory.js'; + +const user = buildUser({ phone: null }); +const config = buildSystemConfig({ login_methods: ['passkey'] }); +``` + +Integration tests build the app with `createApp()` and drive it with `supertest`; see the +existing specs under `tests/integration/` for the pattern. Rate limiters and messaging are +mocked in `tests/setup/mocks.ts`, so you do not need to work around them. + ## Expectations When submitting a pull request: @@ -91,6 +137,21 @@ When submitting a pull request: This ensures changes remain aligned with real authentication flows and infrastructure behavior. +### What a good PR includes + +- **Scoped** to one change; unrelated cleanups go in their own PR. +- **Schemas + tests for new or changed routes.** Use the `schemas` option in the route + definition (request + response) so validation and OpenAPI stay aligned, and add a test under + `tests/`. +- **A changeset** for user-facing changes (`npm run changeset`). Do not hand-edit `CHANGELOG.md` + or the version in `package.json`. +- **The AGPL license header** on every new `src/**/*.ts` file (eslint enforces this). +- **Conventional Commit** messages (see below); commitlint enforces this on commit. +- **Green checks**: `npm run typecheck`, `npm run lint`, and the test suite. The pre-commit hook + runs these for you. +- For **contract changes** (routes, response/request schemas, token fields, status codes), + call out the downstream impact on the SDKs in the PR description. + ## Commit Conventions - feat: diff --git a/README.md b/README.md index 364fd55..0177428 100644 --- a/README.md +++ b/README.md @@ -464,6 +464,10 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md). - [AGENTS.md](./AGENTS.md) for a fast codebase briefing aimed at coding agents and maintainers - [docs/architecture.md](./docs/architecture.md) for runtime structure and request flow +- [docs/configuration.md](./docs/configuration.md) for the full env var and `system_config` reference +- [docs/api-contract.md](./docs/api-contract.md) for tokens, per-flow status codes, and terminology +- [docs/direct-http-quickstart.md](./docs/direct-http-quickstart.md) for a curl login/token/refresh walkthrough +- [docs/extending.md](./docs/extending.md) for message-delivery providers and extension points - [docs/oauth.md](./docs/oauth.md) for OAuth provider setup and security behavior - [docs/webauthn-prf.md](./docs/webauthn-prf.md) for PRF-capable passkey usage - [docs/admin-operations.md](./docs/admin-operations.md) for scoped admin and recovery operations diff --git a/docs/api-contract.md b/docs/api-contract.md new file mode 100644 index 0000000..bc0dfb1 --- /dev/null +++ b/docs/api-contract.md @@ -0,0 +1,73 @@ +# API Contract: Tokens and Status Codes + +This is the reference for what the API returns from each flow: which token comes back where, what +each token carries, and the status codes that callers (and the SDK adapters) branch on. For a +runnable walkthrough, see [direct-http-quickstart.md](./direct-http-quickstart.md); for the token +design rationale, see [architecture.md](./architecture.md#token-model). + +## Terminology + +- **Identifier** — the value a user logs in with at `POST /login`: either an email or a phone + number. The request field is `identifier` (not `email`), and the response echoes + `identifierType: "email" | "phone"`. Endpoints that clearly apply to one channel (for example + `/otp/generate-login-email-otp`) name that channel explicitly. +- **Ephemeral / access / refresh token** — the three token states below. +- **Service token** — a separate credential presented by a trusted server adapter + (`x-seamless-service-token`), unrelated to a user session. Not interchangeable with the bearer + tokens below. + +## Which token comes from where + +All tokens are returned in the JSON body (the API never sets cookies). Present them as +`Authorization: Bearer `. + +| Token | Issued by | Presented to | Purpose | Lifetime | +| ------------- | --------------------------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------ | ----------------------------------- | +| **Ephemeral** | `POST /login` (and registration start) | the continuation step (OTP generate/verify, magic-link request/poll) | carry a pre-authenticated identity between login steps | short (about 5 minutes) | +| **Access** | OTP/WebAuthn/magic-link completion, `POST /refresh` | protected routes (e.g. `GET /users/me`) | authenticated application access | `access_token_ttl` (system config) | +| **Refresh** | the same completion steps and `POST /refresh` | `POST /refresh` only | obtain a new access token | `refresh_token_ttl` (system config) | + +### Token shapes + +- **Access token** — a signed JWT (RS256). Claims include `sub` (user id), `sid` (session id), + `iss`, `typ: "access"`, `roles`, and `org_id` when the session has an active organization. + Verify it against the JWKS at `GET /.well-known/jwks.json`. +- **Ephemeral token** — a signed JWT scoped to the pre-auth step; treat it as opaque. +- **Refresh token** — an opaque random string (not a JWT), stored server-side only as a hash plus + a lookup fingerprint. It is **rotated** on every `POST /refresh`: the presented token is + invalidated and a new one returned, and reusing a retired refresh token revokes the session + chain. Always persist the newest `refreshToken`. + +> `POST /refresh` reads the refresh token from the `Authorization` header, not the request body. + +## Status codes to branch on + +Most endpoints use conventional codes (`200` success, `400` invalid input, `401`/`403` auth +failures, `500` server error). A few carry branch-significant meaning that clients must handle: + +| Endpoint | Code | Meaning | +| ------------------------------ | ----- | ---------------------------------------------------------------------- | +| `GET /magic-link/check` (poll) | `204` | Not yet verified — keep polling (no body). | +| `GET /magic-link/check` (poll) | `200` | Verified — body carries the issued session. | +| `GET /magic-link/check` (poll) | `403` | Polling device fingerprint does not match the pending link. | +| `POST /login` | `200` | Returns an ephemeral `token` plus `loginMethods` for this user/device. | +| `POST /login` | `401` | Unknown or unverified identifier (see the enumeration note below). | +| `POST /refresh` | `401` | Missing, invalid, expired, or already-rotated refresh token. | +| `POST /refresh` | `405` | Method other than POST. | + +The SDK adapters branch on these exact codes (for example, the magic-link poll treats `204` as +"pending"). Changing a branch-significant status code is a contract change; see the ripple +protocol in [ecosystem.md](./ecosystem.md). + +### Error body + +Error responses use `ErrorSchema`: `{ "error": string, "message"?: string }`. (Note: some +handlers historically returned `{ message }` instead; standardizing this is tracked separately.) + +## A note on login responses + +`POST /login` returns different content for a known-and-verified user (an ephemeral token plus +`loginMethods`) than for an unknown or unverified identifier (a `401`). This makes some user +enumeration possible, which is partly inherent to passwordless login where the client must learn +which continuation methods are available. The intended posture is tracked as a follow-up; do not +rely on the current exact shapes for unknown identifiers. diff --git a/docs/extending.md b/docs/extending.md new file mode 100644 index 0000000..fe6aaf6 --- /dev/null +++ b/docs/extending.md @@ -0,0 +1,71 @@ +# Extending Seamless Auth API + +This guide covers the supported extension points: message delivery and where custom behavior +belongs. It also states what is intentionally not extensible, so you do not build against seams +that are meant to stay closed. + +## Message delivery (OTP and magic links) + +OTP and magic-link flows can deliver messages in two ways. + +### Direct delivery + +The API sends email/SMS itself through provider adapters wired in +[`src/config/directMessaging.ts`](../src/config/directMessaging.ts) and driven by +[`src/services/messagingService.ts`](../src/services/messagingService.ts). Providers are +configured with environment variables (see the messaging section of +[configuration.md](./configuration.md)): + +- **Email:** AWS SES (`MESSAGING_EMAIL_FROM`, `MESSAGING_AWS_REGION`). +- **SMS:** AWS SNS or Twilio (`MESSAGING_SMS_PROVIDER`, plus provider credentials). + +To add a provider, implement the transport contract from `@seamless-auth/messaging` (an +`EmailTransport` / `SmsTransport` with a `send(...)` method) and wire it in +`directMessaging.ts` behind a `MESSAGING_SMS_PROVIDER` (or email) value. `validateEnvs.sh` +enforces that the required variables for the selected provider are present at boot, so add the +matching checks there when you introduce a new provider value. + +### External delivery + +A trusted caller can take over delivery entirely by sending the header +`x-seamless-auth-delivery-mode: external`. Instead of sending the message, the API returns a +delivery payload (recipient + token, and a URL for magic links) so the caller sends it through +its own channel. See [`src/lib/externalDelivery.ts`](../src/lib/externalDelivery.ts). + +- In development, external delivery is returned without additional credentials. +- In production, it requires a valid `x-seamless-service-token` from a trusted server adapter. + +This is how a server adapter integrates its own email/SMS stack without the API baking in a +provider. See the delivery payload shapes in +[`src/schemas/generic.responses.ts`](../src/schemas/generic.responses.ts) (`AuthDeliverySchema`) +and the end-to-end example in [direct-http-quickstart.md](./direct-http-quickstart.md). + +## Adding an endpoint + +Routes are auto-discovered: every `src/routes/*.routes.ts` file is loaded at startup. A new +endpoint is registered with `defineRoute` (via the `createRouter` wrapper), which wires the +Express handler, validates the request against Zod `schemas`, validates the JSON response, and +generates the OpenAPI metadata from the same schemas. Trace existing behavior +**route → controller → service → model** and follow that layering: + +- Put logic in a controller/service, not the route file. +- Declare `schemas` (request + response) so validation and docs stay aligned. +- If the route needs auth, use the `auth` option (`ephemeral` | `access`) so security metadata + is emitted; add `middleware` for admin checks or rate limits. + +## Token claims and auth behavior + +Token signing lives in [`src/lib/token.ts`](../src/lib/token.ts) (access, refresh, ephemeral) and +session issuance in [`src/services/sessionService.ts`](../src/services/sessionService.ts). Claims +are part of the contract that the verifier SDKs depend on, so changing them is a **coordinated, +contract-affecting change**, not a drop-in customization. If you need custom claims, treat it as a +contract change: see the ripple protocol in [ecosystem.md](./ecosystem.md). + +## What is intentionally not extensible + +- **Token format and transport.** The API issues Bearer/JSON tokens and never sets cookies. + Cookie handling belongs in a trusted server adapter, not here. +- **Crypto primitives.** Signing algorithm (RS256), refresh-token hashing, and constant-time + comparisons are fixed on purpose; do not swap them per deployment. +- **The response contract.** Response schemas intentionally strip undocumented fields. Add fields + through the schema, not by returning extra keys.