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
14 changes: 14 additions & 0 deletions .changeset/harden-magic-link-otp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'seamless-auth-api': patch
---

Harden and regression-test the magic link and OTP sign-in flows.

- Magic link: polling while waiting now returns `204` (no body) instead of `500`,
fixing the broken starter sign-in; removed dead device-binding code from verify
(binding is enforced at the poll step); the post-session write is awaited.
- OTP: the verify endpoints are now rate-limited; OTPs are stored and compared
hashed-only (the transitional plaintext fallback is removed); post-session writes
are awaited.
- CI: formatting is enforced (`prettier --check`) and coverage thresholds are
ratcheted so these flows cannot silently regress.
19 changes: 19 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"allow": [
"Bash(npm run typecheck:*)",
"Bash(npm run lint:*)",
"Bash(npm run test:run:*)",
"Bash(npm run test:ci:*)",
"Bash(npm run build:*)",
"Bash(npm run coverage:*)",
"Bash(npm run format:*)",
"Bash(git status:*)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(git show:*)",
"Bash(git branch:*)"
]
}
}
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ jobs:
- name: Lint
run: npm run lint

- name: Run formatting
run: npm run format
- name: Check formatting
run: npm run format:check

- name: Run Tests with Coverage
run: CI=true npm run coverage
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24.10.0
node-version: 20
cache: npm

- name: Install dependencies
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,15 @@ docker-data/
.vscode/
*.swp

# Claude Code — share settings.json, keep personal overrides local
.claude/settings.local.json

# Lint / tooling cache
.eslintcache
docs/*
!docs/admin-operations.md
!docs/architecture.md
!docs/ecosystem.md
!docs/oauth.md
!docs/production-operations.md
!docs/webauthn-prf.md
101 changes: 101 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# CLAUDE.md

This file is loaded automatically at the start of every Claude Code session.

The full architecture guide lives in **[AGENTS.md](AGENTS.md)** — imported below so
there is a single source of truth. Read it for the runtime shape, folder map, token
model, and config layers.

@AGENTS.md

## Working agreement

### Verify before declaring done

This project has a fast, reliable check loop. Run the relevant ones after changes and
report real output — never claim a change works without running them.

| Check | Command | Notes |
| -------- | ------------------- | --------------------------------------------------------------- |
| Types | `npm run typecheck` | `tsc --noEmit`, ~2s |
| Lint | `npm run lint` | eslint, ~2s. Enforces license headers + import sort |
| Tests | `npm run test:run` | vitest, ~2s. **Uses a mock DB by default — no Postgres needed** |
| Coverage | `npm run coverage` | thresholds: lines/functions/statements 70%, branches 65% |

Tests default to `TEST_DB=mock`. Only set `TEST_DB=postgres` (with a running Postgres)
when specifically exercising real DB behavior.

### Conventions enforced by tooling

- **License header** — every `src/**/*.ts` file must begin with the AGPL header (eslint
`license-header/header` errors otherwise). Copy it from any existing file, e.g.
[src/utils/otp.ts](src/utils/otp.ts):

```ts
/*
* Copyright © 2026 Fells Code, LLC
* Licensed under the GNU Affero General Public License v3.0
* See LICENSE file in the project root for full license information
*/
```

- **Commits** — Conventional Commits, enforced by commitlint + husky on commit
(`feat:`, `fix:`, `chore:`, `docs:`, `ci:`, …). Husky also runs lint-staged.
- **Releases** — managed by Changesets. User-facing changes need a changeset
(`npm run changeset`); don't hand-edit `CHANGELOG.md` or the version in `package.json`.
- **No `any`** (`@typescript-eslint/no-explicit-any` is an error) and imports/exports are
auto-sorted (`simple-import-sort`).

### Codebase shape (where to make changes)

Routes are auto-discovered: each `src/routes/*.routes.ts` registers handlers via
`defineRoute`, which also wires OpenAPI metadata and validates request/response against
Zod schemas in `src/schemas/`. Trace behavior **route → controller → service → model**.
A new endpoint usually touches: a `*.routes.ts`, a controller, request/response schemas,
and a test under `tests/`.

### Don't touch

- `keys/` and `.env*` — local secrets / signing keys. Never read, commit, or modify them.
- `dist/` and `coverage/` — generated output.

### Security posture

This is an authentication server. Treat auth, token, OTP, session, and crypto code as
high-stakes: prefer constant-time comparisons, hash secrets at rest, fail closed, and run
`/security-review` on changes to those paths before considering them done.

## Ecosystem & downstream impact

This repo is the **contract source** for sibling repos in the parent directory. Changes to
the HTTP/token/schema contract here can break them. Full coupling map (endpoints, schemas,
risks per repo): **[docs/ecosystem.md](docs/ecosystem.md)** — read it before contract changes.

Topology: `browser → @seamless-auth/react (cookies) → @seamless-auth/server / @seamless-auth/express (bridges to Bearer + JWKS) → THIS API (Bearer/JSON, no cookies)`.
This API also **consumes** `@seamless-auth/types` (shared Zod schemas) and
`@seamless-auth/messaging*` (email/SMS adapter contract), so changes in those repos ripple inward.

**Direct dependents (Tier 1):** `seamless-auth-server` (server adapter — ~50 routes + JWKS +
token claims), `seamless-auth-react` (client SDK — ~38 hardcoded routes, parses `message`/user
shape), `seamless-auth-types` (shared schemas — bidirectional), `seamless-messaging` (adapter
contract — bidirectional).
**Downstream (Tier 2):** `seamless-auth-admin-dashboard`, `seamless-auth-starter-express`,
`seamless-auth-starter-react`, `seamless-auth-internal-api`, `seamless-auth-docs`, `seamless-cli`.

All ten are granted in `.claude/settings.local.json` so I can read/edit them.

### Ripple protocol — when a change here touches the contract

A "contract change" = adding/renaming/removing a **route or method**, changing a **response/request
schema**, **token** fields/claims or the `/refresh` shape, **JWKS**, a branch-significant **status
code**, or an **auth mode** (ephemeral/access/service). When one happens:

1. **Flag it** — call out in the change summary that it's contract-affecting and name the
likely-affected repos (use the map).
2. **Survey** the dependents (Explore agents work well in parallel) for concrete break sites —
hardcoded route strings, parsed response fields, expected status codes.
3. **Report blast radius** and propose coordinated edits; apply them in the sibling repos only
with the user's go-ahead. `@seamless-auth/types` changes usually need a version bump +
coordinated release across this API and both SDKs.

Non-contract changes (internal refactors, tests, logging) don't trigger this.
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,8 @@ system config.
"nameJsonPath": "name",
"allowSignup": true,
"accountLinking": "email",
"requireEmailVerified": true
"requireEmailVerified": true,
"pkce": true
}
]
```
Expand Down Expand Up @@ -235,6 +236,10 @@ curl -X POST http://localhost:5312/oauth/google/callback \
Security notes:

- OAuth `state` is signed and expires after a short window.
- OAuth callback state is consumed in-process after first use. Use sticky callback routing or shared
state storage if you need replay detection across multiple API instances.
- PKCE is enabled by default; set `pkce: false` only for providers that cannot accept PKCE
parameters.
- `redirectUri` must exactly match a provider `redirectUris` entry when configured. Providers
without a redirect allowlist fall back to trusted configured origins.
- `returnTo` must match configured `origins`.
Expand Down
117 changes: 117 additions & 0 deletions docs/ecosystem.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# Ecosystem & downstream impact

`seamless-auth-api` is the contract source for a family of sibling repos under the same
parent directory (`fells-code` org). This document is the **deep coupling map**:
what depends on this API, how, and what changes here ripple outward. `CLAUDE.md` carries
the short version + the ripple protocol; this file is the detail to read before/while
making a contract-affecting change.

> Last surveyed: 2026-06-27. Versions and line numbers drift — treat specifics as leads to
> re-verify, not gospel. Re-run the survey when the dependency graph changes.

## Topology

```
browser
└─ @seamless-auth/react ............ client SDK (cookie-based session)
└─ @seamless-auth/express ..... server adapter (cookies ⇄ Bearer, JWKS verify)
└─ THIS API (seamless-auth-api) ... Bearer/JSON contract, no cookies
├─ @seamless-auth/types ..... shared Zod schemas (this API consumes)
└─ @seamless-auth/messaging* . email/SMS adapter contract (this API consumes)
```

Note the auth-style boundary: the **React SDK uses cookies** (`credentials: 'include'`),
while **this API is Bearer/JSON with no cookies** (see README "Bearer Token Contract").
The **server adapter** (`@seamless-auth/express` / `@seamless-auth/core`) is what bridges
them — it holds `seamless-access` / `seamless-refresh` / `seamless-ephemeral` cookies on
the browser side and speaks Bearer + service tokens + JWKS to this API. Direct
browser→API integration is possible ("Direct HTTP APIs (advanced)") but the recommended
path goes through the adapter.

## Tier 1 — direct contract dependents

### `seamless-auth-server` — `@seamless-auth/core` + `@seamless-auth/express` (v0.5.x)

The server-side adapter SDK; a thin stateless proxy + cookie manager. **Highest coupling.**

- Calls ~50 of this API's routes via `authFetch()` with `Authorization: Bearer`,
`x-seamless-service-token`, `x-seamless-client-ip`. Covers `/login`, `/registration/register`,
all `/otp/*`, `/webAuthn/*`, `/magic-link*`, `/refresh`, `/users/*`, `/organizations/*`,
`/step-up/*`, `/sessions*`, all `/admin/*`, all `/internal/*`, `/system-config/*`.
- **JWKS:** fetches `/.well-known/jwks.json` (`createRemoteJWKSet` + `jwtVerify`, RS256),
validates `iss` against the auth server URL. Reads claims `sub`, `sid`, `aud`, `iss`.
- **Refresh:** POST `/refresh` response unpacked directly into cookies — expects
`sub, token, refreshToken, ttl, refreshTtl, roles?, email?, phone?, organizationId?`.
- **Status-code coupling:** branches on exact codes, e.g. magic-link poll treats `204` as
"not yet verified".
- No shared types package — coupling is 100% string-literal route paths + response shapes.
- **Breaks if this API changes:** any route path/method, JWKS path or key format/alg, token
claim/field names (`sub`/`sid`/...), the `/refresh` response shape, or branch-significant
status codes.

### `seamless-auth-react` — `@seamless-auth/react` (v0.2.0)

Drop-in React auth UI (email/phone OTP, magic link, WebAuthn/passkeys, OAuth, step-up,
organizations). Hardcodes ~38 endpoint paths in `src/createSeamlessAuthClient.ts`.

- Parses response fields for flow control: `message === 'Success'`, plus `token`,
`refreshToken`, `delivery`, and the `/users/me` shape (`user`, `credentials[]`,
`organizations[]`, `activeOrganization`).
- Generic error handling (trusts `response.ok`), so contract breaks often fail **silently**.
- **Breaks if this API changes:** route renames, `message`/user/credential field renames,
switching an endpoint's auth mode (ephemeral ↔ access), or response-shape changes to
`/users/me`, OTP, or organization endpoints.

### `seamless-auth-types` — `@seamless-auth/types` (v0.1.3) ⇠ this API depends on it

Shared Zod schemas / TS types — the contract's source of truth. **Reverse coupling:** changes
here propagate _into_ this API and the SDKs.

- This API imports it at 5 sites: `schemas/internal.responses.ts` (`AuthEventSchema`),
`schemas/credential.responses.ts` (`CredentialApiSchema`, extended),
`schemas/admin.responses.ts` (`AuthEventSchema`, `SessionSchema`),
`schemas/session.responses.ts` (`SessionSchema`),
`routes/users.routes.ts` (`UpdateCredentialRequestSchema`, `DeleteCredentialRequestSchema`).
- **Highest-risk exports:** credential request/response schemas (used in route validation),
`AuthEventSchema` (snake_case, serialized directly), `SessionSchema`, `RoleSchema`.
- ⚠️ **Known drift:** `RoleSchema`'s regex is **duplicated** in this API at
`src/lib/scopedRoles.ts:6` (`ROLE_NAME_PATTERN`) instead of imported — they can silently
diverge. Consider importing from the types package.
- **Breaks if changed here:** rename/remove an exported schema, change/tighten a field or
validator (e.g. `RoleSchema` regex), change `IsoDate` transform → forces coordinated
releases across this API + both SDKs.

### `seamless-messaging` — `@seamless-auth/messaging` (+ `-aws`, `-twilio`, v0.1.0) ⇠ this API depends on it

Provider-agnostic email/SMS adapter contract. **Reverse coupling.**

- This API consumes it at `src/config/directMessaging.ts` (factory + AWS/Twilio transports,
`MessagingConfigurationError`) and `src/services/messagingService.ts` (calls
`sendOtpEmail`, `sendOtpSms`, `sendMagicLinkEmail`, `sendBootstrapInviteEmail` 1:1).
- Core contract: `EmailTransport` / `SmsTransport` (`send()` + `name`), `AuthMessagingService`
(the 4 send methods), and the `Send*Input` payload types (`to`, `token`, `magicLinkUrl`, …).
- **Breaks if changed here:** `AuthMessagingService` method signatures/payloads, transport
`send()` signature, package renames, new required adapter config, or removing
`MessagingConfigurationError`.

## Tier 2 — downstream consumers (transitively affected)

| Repo | What it is | Coupling |
| ---------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------ |
| `seamless-auth-admin-dashboard` | Admin UI | `@seamless-auth/react` + `@seamless-auth/types`; consumes `/admin/*` + `/internal/*` via the SDK |
| `seamless-auth-starter-express` | Express starter/demo | `@seamless-auth/express` — breaks only if the server adapter's public API changes |
| `seamless-auth-starter-react` | React starter/demo | `@seamless-auth/react` — same, via the client SDK |
| `seamless-auth-internal-api` | Internal/enterprise API variant | Tracks this codebase closely; likely needs parallel changes for shared logic |
| `seamless-auth-docs` | Public docs | Documents the HTTP contract — update when routes/schemas change |
| `seamless-cli` (`create-seamless`) | Scaffolding CLI | Generates projects wired to the SDKs |

## High-risk contract surfaces (change = survey downstream)

1. **Route paths / methods** — `src/routes/*.routes.ts`. Hardcoded as strings in both SDKs.
2. **Response schemas** — `src/schemas/*.responses.ts`. Field renames/removals break parsing.
3. **Token contract** — access/refresh/ephemeral/service tokens, claim names (`sub`, `sid`),
the `/refresh` response shape, JWKS at `/.well-known/jwks.json` (RS256).
4. **Status / error codes** — adapters branch on specific codes (e.g. magic-link `204`).
5. **Shared schemas** in `@seamless-auth/types` and the **messaging adapter contract**.
6. **Auth mode** of a route (ephemeral vs access vs service) — see
`src/middleware/attachAuthMiddleware.ts`.
9 changes: 8 additions & 1 deletion docs/oauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,14 @@ Use `requireEmailVerified: true` for providers that expose a reliable email veri

## OIDC Notes

When provider scopes include `openid`, Seamless Auth includes a nonce bound into signed state. PKCE support depends on provider and client flow requirements; keep provider callback handling server-side and avoid exposing provider tokens to browsers.
When provider scopes include `openid`, Seamless Auth includes a nonce bound into signed state. OAuth
start also includes PKCE challenge parameters by default, and the callback derives the matching
verifier server-side from the signed state. Set `pkce: false` only for providers that cannot accept
PKCE parameters.

Callback state is consumed in-process after first use and expires after a short window. In a
multi-instance deployment, keep OAuth callback traffic sticky to the same API instance or add shared
state storage before relying on replay detection across instances.

OAuth callback responses return the normal Seamless Auth JSON token payload. Provider tokens remain
server-side and must not be forwarded to browser clients.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@
"typecheck": "tsc --noEmit",
"test": "vitest",
"test:run": "vitest run",
"coverage": "vitest run --coverage",
"coverage": "vitest run --coverage --fileParallelism=false",
"coverage:badge": "node ./src/scripts/updateCoverageBadge.mjs",
"test:ci": "CI=true vitest",
"test:e2e": "CI=false vitest",
"lint": "eslint . --ext .ts",
"format": "prettier --write .",
"format:check": "prettier --check .",
"changeset": "changeset",
"version-packages": "changeset version",
"db:create": "env-cmd -f .env sequelize-cli db:create || sequelize-cli db:create",
Expand Down
8 changes: 4 additions & 4 deletions resources/coverage-badge.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading