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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
61 changes: 61 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 73 additions & 0 deletions docs/api-contract.md
Original file line number Diff line number Diff line change
@@ -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>`.

| 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.
71 changes: 71 additions & 0 deletions docs/extending.md
Original file line number Diff line number Diff line change
@@ -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.
Loading