diff --git a/.config/nextest.toml b/.config/nextest.toml
new file mode 100644
index 0000000..0e788fc
--- /dev/null
+++ b/.config/nextest.toml
@@ -0,0 +1,26 @@
+# cargo-nextest configuration.
+#
+# Every test runs in its own process against its own database (cloned from a
+# migrated template), so tests are safe to run fully in parallel.
+
+[profile.default]
+# Flag a test as slow after 30 s and kill it after 2 minutes: a hung test (a
+# stalled Redis or NATS call) must fail loudly instead of freezing the run.
+slow-timeout = { period = "30s", terminate-after = 4 }
+# No retries: a flaky test is a bug to fix, not noise to hide.
+retries = 0
+
+# Simulations hold dependencies down or slow on purpose, and the long
+# scenarios run for minutes.
+[[profile.default.overrides]]
+filter = 'binary(simulation)'
+slow-timeout = { period = "60s", terminate-after = 20 }
+
+[profile.ci]
+# Report every failure in one run.
+fail-fast = false
+failure-output = "immediate-final"
+
+[profile.ci.junit]
+# target/nextest/ci/junit.xml
+path = "junit.xml"
diff --git a/.dockerignore b/.dockerignore
index d149f04..b9ced96 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,10 +1,12 @@
# Git
.git
.gitignore
-.gitleaks.toml
+trivy-secret.yaml
# Rust build artifacts
target/
+# The image builds with the toolchain of its pinned Rust base image.
+rust-toolchain.toml
# Environment files
.env
@@ -14,6 +16,15 @@ target/
# Documentation
docs/
reports/
+CHANGELOG.md
+
+# Not part of the build: release bundles, perf and ops tooling, fuzzing
+dist/
+perf/
+scripts/
+nginx/
+fuzz/
+config.prod.env
README.md
LICENSE
diff --git a/.env.dev b/.env.dev
index a3deec8..57c9465 100644
--- a/.env.dev
+++ b/.env.dev
@@ -4,6 +4,9 @@ APP_ENV=development
SERVER_HOST=0.0.0.0
SERVER_PORT=3000
APP_PUBLIC_URL=http://localhost:3000
+# Web application the emails link to (/verify-email, /reset-password).
+# Blank: same as APP_PUBLIC_URL.
+FRONTEND_URL=
TRUSTED_PROXY_CIDRS=
# Database (matches docker-compose.dev.yml)
@@ -27,9 +30,8 @@ JWT_ACCESS_EXPIRY_SECS=900
JWT_REFRESH_EXPIRY_SECS=2592000
JWT_STRICT_SESSION_BINDING=false
# Audience values stamped into the `aud` claim of access tokens (CSV).
-# Each entry must match the `expected_aud` (or APP_PUBLIC_URL fallback) of a
-# downstream resource server that will accept tokens minted here.
-# Local stack ports: billing-api on :3001, core-api on :3002.
+# Each entry names a downstream resource server that accepts tokens minted
+# here; APP_PUBLIC_URL is always added. Example: two local services.
JWT_AUDIENCE=http://localhost:3001,http://localhost:3002
# Argon2id (lighter settings for dev)
@@ -56,14 +58,6 @@ LOCKOUT_THRESHOLD=100
LOCKOUT_DURATION_SECS=60
SENSITIVE_ACTION_REAUTH_SECS=600
-# GeoIP (disabled in dev)
-GEOIP_DB_PATH=
-GEOIP_REQUIRED=false
-RISK_ALERT_THRESHOLD=30
-RISK_CHALLENGE_THRESHOLD=60
-RISK_BLOCK_THRESHOLD=80
-RISK_HISTORY_DAYS=90
-
# SMTP (Mailpit - matches docker-compose.dev.yml)
SMTP_HOST=mailpit
SMTP_PORT=1025
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..f08e917
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,67 @@
+# Dependency updates, monthly and grouped, to keep noise and CI runs low:
+# one pull request per ecosystem for minor and patch updates, one for major
+# updates (a deliberate migration, but visible rather than ignored).
+#
+# Pull requests target staging, the draft branch: they are checked by the CI
+# when staging is proposed to main. Security updates always target main.
+
+version: 2
+
+updates:
+ - package-ecosystem: cargo
+ directories: ["/", "/fuzz"]
+ target-branch: staging
+ open-pull-requests-limit: 2
+ schedule:
+ interval: monthly
+ time: "06:00"
+ timezone: Europe/Paris
+ groups:
+ cargo:
+ update-types: [minor, patch]
+ cargo-major:
+ update-types: [major]
+ ignore:
+ # sqlx 0.8's `ipnetwork` feature is tied to the 0.20 line: a second
+ # ipnetwork version breaks every sqlx Type/Encode/Decode bound on the
+ # IpNetwork columns. Drop this once sqlx supports ipnetwork 0.21+.
+ - dependency-name: ipnetwork
+ update-types: ["version-update:semver-major", "version-update:semver-minor"]
+
+ - package-ecosystem: github-actions
+ directory: "/"
+ target-branch: staging
+ open-pull-requests-limit: 1
+ schedule:
+ interval: monthly
+ time: "06:00"
+ timezone: Europe/Paris
+ groups:
+ actions:
+ patterns: ["*"]
+
+ # Base images of the Dockerfiles, pinned by digest.
+ - package-ecosystem: docker
+ directory: "/"
+ target-branch: staging
+ open-pull-requests-limit: 1
+ schedule:
+ interval: monthly
+ time: "06:00"
+ timezone: Europe/Paris
+ groups:
+ dockerfiles:
+ patterns: ["*"]
+
+ # Images of the compose files, pinned by digest.
+ - package-ecosystem: docker-compose
+ directories: ["/", "/deploy/monitoring"]
+ target-branch: staging
+ open-pull-requests-limit: 1
+ schedule:
+ interval: monthly
+ time: "06:00"
+ timezone: Europe/Paris
+ groups:
+ compose:
+ patterns: ["*"]
diff --git a/.github/workflows/backmerge.yml b/.github/workflows/backmerge.yml
new file mode 100644
index 0000000..6690b23
--- /dev/null
+++ b/.github/workflows/backmerge.yml
@@ -0,0 +1,46 @@
+name: Back-merge
+
+# staging is the draft branch that pull requests to main come from. After a
+# merge, main holds a merge commit that staging lacks: fast-forward staging so
+# the next pull request starts from main. When draft commits were pushed to
+# staging in the meantime, the fast-forward is impossible: the job leaves
+# staging alone and warns, and staging is brought up to date by hand.
+#
+# Merge staging into main with a merge commit (not squash or rebase): staging
+# then stays an ancestor of main and the fast-forward works.
+
+on:
+ push:
+ branches: [main]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: backmerge
+
+jobs:
+ fast-forward:
+ name: Fast-forward staging to main
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ permissions:
+ contents: write
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 0
+
+ - name: Fast-forward
+ run: |
+ if ! git fetch origin staging 2> /dev/null; then
+ echo "No staging branch: nothing to do."
+ exit 0
+ fi
+ if git merge-base --is-ancestor origin/staging HEAD; then
+ git push origin HEAD:refs/heads/staging
+ echo "staging fast-forwarded to $(git rev-parse --short HEAD)."
+ else
+ echo "::warning::staging has commits that are not on main; bring it up to date by hand."
+ fi
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..03244d3
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,216 @@
+name: CI
+
+# Pull requests to main, and main after a merge. staging is the draft branch:
+# nothing runs on it until it is proposed to main.
+#
+# Branch protection requires one check, `ci-ok`, which gathers the jobs below.
+# A job the change does not concern is skipped, and a skipped job does not
+# block the merge: a documentation-only pull request passes in a minute.
+# The jobs call the Makefile and scripts/infra-check.sh, so the CI runs exactly
+# what `make ci` and `make infra-check` run locally.
+
+on:
+ pull_request:
+ branches: [main]
+ types: [opened, synchronize, reopened, ready_for_review]
+ push:
+ branches: [main]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ci-${{ github.event.pull_request.number || github.ref }}
+ # A new push to a pull request supersedes the running checks. Runs on main
+ # are kept: they write the cache the next pull requests read.
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+env:
+ CARGO_TERM_COLOR: always
+ CARGO_INCREMENTAL: 0
+ # No debug info: a smaller target directory and cache, same test results.
+ CARGO_PROFILE_DEV_DEBUG: 0
+ CARGO_PROFILE_TEST_DEBUG: 0
+ RUST_BACKTRACE: 1
+
+jobs:
+ changes:
+ name: Changes
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ outputs:
+ rust: ${{ steps.filter.outputs.rust }}
+ infra: ${{ steps.filter.outputs.infra }}
+ image: ${{ steps.filter.outputs.image }}
+ js: ${{ steps.filter.outputs.js }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Classify the changed files
+ id: filter
+ env:
+ EVENT: ${{ github.event_name }}
+ BEFORE: ${{ github.event.before }}
+ run: |
+ set -euo pipefail
+ if [ "$EVENT" = pull_request ]; then
+ # The checkout is the pull request merged into main; its first
+ # parent is main.
+ files=$(git diff --name-only HEAD^1 HEAD)
+ elif [ "$EVENT" = push ] && git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then
+ files=$(git diff --name-only "$BEFORE" HEAD)
+ else
+ # Manual run, or a push whose previous commit is unknown: everything.
+ files=$(git ls-files)
+ fi
+ printf '%s\n' "$files"
+ matches() {
+ if printf '%s\n' "$files" | grep -E "$1" > /dev/null; then echo true; else echo false; fi
+ }
+ {
+ echo "rust=$(matches '^(src/|tests/|crates/|fuzz/|migrations/|templates/|benches/|\.config/|Cargo\.(toml|lock)$|rust-toolchain\.toml$|deny\.toml$|Makefile$|docker-compose\.test\.yml$|docs/dev/api/openapi\.yaml$|\.github/workflows/ci\.yml$)')"
+ echo "infra=$(matches '^(Dockerfile|\.dockerignore$|docker-compose[^/]*\.ya?ml$|nats\.conf$|config\.prod\.env$|nginx/|deploy/|scripts/|perf/[^/]*\.sh$|docs/deploy/guides/prometheus-alerts\.yml$|Makefile$|\.github/workflows/ci\.yml$)')"
+ echo "js=$(matches '^(clients/js/|Makefile$|\.github/workflows/ci\.yml$)')"
+ echo "image=$(matches '^(Dockerfile$|\.dockerignore$|Cargo\.lock$|rust-toolchain\.toml$|\.github/workflows/ci\.yml$)')"
+ } >> "$GITHUB_OUTPUT"
+
+ hygiene:
+ name: Hygiene
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Workflow lint and repository secret scan
+ run: CHECKS=hygiene scripts/infra-check.sh
+
+ rust:
+ name: Rust
+ needs: changes
+ if: needs.changes.outputs.rust == 'true' && github.event.pull_request.draft != true
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Toolchain (rust-toolchain.toml)
+ run: |
+ rustup toolchain install
+ rustc -V
+
+ - name: Cache
+ uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
+ with:
+ # Pull requests read the cache main wrote; only main writes it, so
+ # the 10 GB cache is not filled with one copy per pull request.
+ save-if: ${{ github.ref == 'refs/heads/main' }}
+
+ - name: Install nextest and cargo-deny
+ uses: taiki-e/install-action@26e9283f268b880168bdbd2c545dfcd60ec2c6ab # v2.87.13
+ with:
+ tool: cargo-nextest,cargo-deny
+
+ # Cheapest first: formatting fails in seconds, before anything compiles.
+ - name: Formatting
+ run: make fmt-check
+
+ - name: Clippy
+ run: make clippy
+
+ - name: Dependency policy
+ run: make deny
+
+ - name: Test infrastructure
+ run: make test-infra-up
+
+ - name: Tests and fuzz corpus
+ run: make ci-test
+
+ - name: Test report
+ if: failure()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: junit
+ path: target/nextest/ci/junit.xml
+ retention-days: 7
+ if-no-files-found: ignore
+
+ infra:
+ name: Infrastructure
+ needs: changes
+ if: needs.changes.outputs.infra == 'true' && github.event.pull_request.draft != true
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Compose, Dockerfiles, nginx, Prometheus, shell scripts
+ run: CHECKS=static scripts/infra-check.sh
+
+ js:
+ name: JavaScript
+ needs: changes
+ if: needs.changes.outputs.js == 'true' && github.event.pull_request.draft != true
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ # The runner's Node.js; the packages have no dependency to install.
+ - name: Tests
+ run: make js-test
+
+ image:
+ name: Image
+ needs: changes
+ if: needs.changes.outputs.image == 'true' && github.event.pull_request.draft != true
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Build, size and Trivy scan
+ run: CHECKS=image scripts/infra-check.sh
+
+ ci-ok:
+ name: ci-ok
+ needs: [changes, hygiene, rust, infra, js, image]
+ if: always()
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - name: Every required job passed or was not needed
+ env:
+ RESULTS: ${{ toJSON(needs) }}
+ DRAFT: ${{ github.event.pull_request.draft == true }}
+ run: |
+ jq -r 'to_entries[] | "\(.key): \(.value.result)"' <<< "$RESULTS"
+ if jq -e 'to_entries | any(.value.result == "failure" or .value.result == "cancelled")' <<< "$RESULTS" > /dev/null; then
+ echo "::error::a job failed or was cancelled"
+ exit 1
+ fi
+ # A draft skips the heavy jobs: it must not look ready to merge.
+ if [ "$DRAFT" = true ]; then
+ echo "::error::draft pull request: mark it ready for review to run every check"
+ exit 1
+ fi
diff --git a/.github/workflows/scheduled.yml b/.github/workflows/scheduled.yml
new file mode 100644
index 0000000..395c8c5
--- /dev/null
+++ b/.github/workflows/scheduled.yml
@@ -0,0 +1,212 @@
+name: Scheduled
+
+# What is too slow for every pull request, or finds problems without any
+# commit: new advisories against the dependencies, vulnerabilities in the base
+# images, coverage, the long simulations, the backup chain and fuzzing.
+#
+# A failure opens an issue (or comments the open one); the next run where
+# everything passes closes it. Every task can also be run by hand.
+
+on:
+ schedule:
+ # Mondays, 04:17 UTC.
+ - cron: "17 4 * * 1"
+ # The first of the month, 04:43 UTC.
+ - cron: "43 4 1 * *"
+ workflow_dispatch:
+ inputs:
+ task:
+ description: Task to run
+ type: choice
+ default: weekly
+ options: [weekly, monthly, advisories, image, coverage, simulation, backup-drill, fuzz]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: scheduled-${{ github.event.schedule || inputs.task }}
+
+env:
+ CARGO_TERM_COLOR: always
+ CARGO_INCREMENTAL: 0
+ CARGO_PROFILE_DEV_DEBUG: 0
+ CARGO_PROFILE_TEST_DEBUG: 0
+ WEEKLY: ${{ github.event.schedule == '17 4 * * 1' || inputs.task == 'weekly' }}
+ MONTHLY: ${{ github.event.schedule == '43 4 1 * *' || inputs.task == 'monthly' }}
+
+jobs:
+ advisories:
+ name: Dependency advisories
+ if: github.event.schedule == '17 4 * * 1' || inputs.task == 'weekly' || inputs.task == 'advisories'
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Install cargo-deny
+ uses: taiki-e/install-action@26e9283f268b880168bdbd2c545dfcd60ec2c6ab # v2.87.13
+ with:
+ tool: cargo-deny
+
+ - name: Advisories
+ run: cargo deny check advisories
+
+ image:
+ name: Image
+ if: github.event.schedule == '17 4 * * 1' || inputs.task == 'weekly' || inputs.task == 'image'
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Build, size and Trivy scan
+ run: CHECKS=image scripts/infra-check.sh
+
+ coverage:
+ name: Coverage
+ if: github.event.schedule == '17 4 * * 1' || inputs.task == 'weekly' || inputs.task == 'coverage'
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Toolchain (rust-toolchain.toml) and LLVM tools
+ run: |
+ rustup toolchain install
+ rustup component add llvm-tools-preview
+
+ - name: Cache
+ uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
+ with:
+ key: coverage
+
+ - name: Install nextest and cargo-llvm-cov
+ uses: taiki-e/install-action@26e9283f268b880168bdbd2c545dfcd60ec2c6ab # v2.87.13
+ with:
+ tool: cargo-nextest,cargo-llvm-cov
+
+ - name: Test infrastructure
+ run: make test-infra-up
+
+ - name: Coverage with floors
+ run: make coverage
+
+ simulation:
+ name: Long simulations
+ if: github.event.schedule == '17 4 * * 1' || inputs.task == 'weekly' || inputs.task == 'simulation'
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Toolchain (rust-toolchain.toml)
+ run: rustup toolchain install
+
+ - name: Cache
+ uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
+ with:
+ save-if: false
+
+ - name: Install nextest
+ uses: taiki-e/install-action@26e9283f268b880168bdbd2c545dfcd60ec2c6ab # v2.87.13
+ with:
+ tool: cargo-nextest
+
+ - name: Test infrastructure
+ run: make test-infra-up
+
+ - name: Simulation suite, long scenarios included
+ run: make test-sim
+
+ backup-drill:
+ name: Backup drill
+ if: github.event.schedule == '43 4 1 * *' || inputs.task == 'monthly' || inputs.task == 'backup-drill'
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Install age and the PostgreSQL client
+ run: |
+ sudo apt-get update -qq
+ sudo apt-get install -y -qq --no-install-recommends age postgresql-client
+
+ - name: Backup and restore drill
+ run: scripts/backup-drill.sh
+
+ fuzz:
+ name: Fuzzing
+ if: github.event.schedule == '43 4 1 * *' || inputs.task == 'monthly' || inputs.task == 'fuzz'
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Nightly toolchain
+ run: rustup toolchain install nightly --profile minimal
+
+ - name: Install cargo-fuzz
+ uses: taiki-e/install-action@26e9283f268b880168bdbd2c545dfcd60ec2c6ab # v2.87.13
+ with:
+ tool: cargo-fuzz
+
+ - name: One minute per target
+ run: make fuzz FUZZ_SECS=60
+
+ - name: Crash inputs
+ if: failure()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: fuzz-artifacts
+ path: fuzz/artifacts
+ retention-days: 30
+ if-no-files-found: ignore
+
+ report:
+ name: Report
+ needs: [advisories, image, coverage, simulation, backup-drill, fuzz]
+ if: always() && github.event_name == 'schedule'
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ permissions:
+ issues: write
+ steps:
+ - name: Open, comment or close the tracking issue
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GH_REPO: ${{ github.repository }}
+ RESULTS: ${{ toJSON(needs) }}
+ RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ run: |
+ title="Scheduled checks are failing"
+ failed=$(jq -r 'to_entries[] | select(.value.result == "failure" or .value.result == "cancelled") | .key' <<< "$RESULTS" | paste -sd, -)
+ open=$(gh issue list --state open --search "\"$title\" in:title" --json number --jq '.[0].number // empty')
+ if [ -n "$failed" ]; then
+ body="Failed: $failed. Run: $RUN_URL"
+ if [ -n "$open" ]; then
+ gh issue comment "$open" --body "$body"
+ else
+ gh issue create --title "$title" --body "$body"
+ fi
+ elif [ -n "$open" ]; then
+ gh issue close "$open" --comment "Every scheduled check passed again: $RUN_URL"
+ fi
diff --git a/.gitignore b/.gitignore
index fa3574b..77a8173 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,8 @@ reports/
docs/report/*
!docs/report/*.tex
!docs/report/*.pdf
+dist/
+__pycache__/
+
+# Broker token written from pass on the server (docker-compose.api.yml secret)
+nats-auth.conf
diff --git a/.gitleaks.toml b/.gitleaks.toml
deleted file mode 100644
index 7c4e8e5..0000000
--- a/.gitleaks.toml
+++ /dev/null
@@ -1,22 +0,0 @@
-# Gitleaks configuration: default rules plus a narrow allowlist.
-
-[extend]
-useDefault = true
-
-[allowlist]
-description = "Known non-secrets committed on purpose"
-paths = [
- # Test-only keys and fixture secrets used by the unit/integration harnesses
- # and the bench binaries. Never used outside tests; production keys come
- # from pass. The `src/bin/.*` pattern also covers the file's historical
- # location (gitleaks scans every commit, so moved files need both paths).
- '''src/config\.rs''',
- '''src/bin/.*bench_support\.rs''',
- '''tests/http/common/app\.rs''',
- '''tests/http/crypto/key_rotation\.rs''',
- '''tests/http/user/email\.rs''',
- '''benches/core_benches\.rs''',
- # Dev-stack credentials (postgres/redis on localhost) and a dev-only JWT
- # key pair; accepted as non-secret for local development.
- '''\.env\.dev''',
-]
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..b030b94
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,479 @@
+# Changelog
+
+All notable changes. Versions follow [Semantic Versioning](https://semver.org/)
+as described in the [versioning policy](docs/dev/guides/versioning.md).
+
+## [Unreleased]
+
+## [2.0.0] - 2026-09-16
+
+The release after 1.1.3, and the first under the
+[versioning policy](docs/dev/guides/versioning.md): from here on the contract
+only breaks in a major release. Compared with 1.1.3 it hardens every flow, adds
+administration, standard OAuth 2.1 and OpenID Connect, passkeys, external
+identity providers, webhooks, telemetry and high availability, and changes the
+HTTP contract and the configuration: read **Breaking changes** and
+**Upgrading** before deploying over 1.1.3.
+
+### Breaking changes
+
+**API**
+
+- Client flows moved to standard OAuth 2.1 endpoints. `POST /auth/authorize`,
+ `/auth/authorize/describe` and `/auth/authorize/token` are replaced by
+ `GET /oauth/authorize` (redirecting to `OAUTH_CONSENT_URI`),
+ `/oauth/authorization-requests/{id}` (describe, approve, deny) and
+ `POST /oauth/token`. `POST /auth/device` and `/auth/device/token` are replaced
+ by `POST /oauth/device_authorization` and `POST /oauth/token`;
+ `/auth/device/{user_code}` and `/auth/device/verify` move to `/oauth/device/`.
+ Token and device requests are form-encoded and answer RFC 6749 errors
+ (`{ "error", "error_description" }`); a device flow names its `client_id`.
+- Client sessions are refreshed at `POST /oauth/token` by their client;
+ `/auth/refresh` refuses them.
+- `POST /auth/register` answers `202` with `{ "status", "message" }` for every
+ request, whether or not the address is taken; the owner of a taken address is
+ emailed. The response no longer carries the account.
+- Signing in no longer grants a re-authentication. Changing the password,
+ username or email, deleting the account, revoking sessions, and adding or
+ removing a second factor need `POST /users/me/reauth` within
+ `SENSITIVE_ACTION_REAUTH_SECS`, or `current_password` in the body. The
+ `DELETE` routes accept an optional JSON body for it.
+- A pre-auth token completes only the method it was issued for
+ (`/auth/two-factor/complete` for TOTP, `/auth/two-factor/email/complete` for
+ email codes).
+- Validation errors return their message; conflicts return a specific `code`
+ (`email_taken`, `username_taken`, ...).
+- Tokens issued to a registered client carry only the permissions consented for
+ it, and no roles.
+- The device flow requires a registered client (`--register-client`); a flow
+ without `client_id` uses the primary client.
+- `429` responses carry a computed `Retry-After` instead of a fixed 60.
+- Email links point at `FRONTEND_URL` (defaults to `APP_PUBLIC_URL`).
+
+**Configuration**
+
+- `APP_ENV` is required; a present but unparsable value is an error instead of
+ falling back to the default.
+- Production refuses to start without `TRUSTED_PROXY_CIDRS`, with an HTTP
+ `FRONTEND_URL`, or with a committed or arithmetic `ENCRYPTION_KEY`.
+- Removed with risk scoring: `GEOIP_DB_PATH`, `GEOIP_REQUIRED`, `RISK_*`, the
+ `login_locations` table, and the new-device and suspicious-login emails.
+- `docker-compose.api.yml` runs `auth-api:${AUTH_API_VERSION}` built by
+ `make release`; no image is published to a registry.
+- Behind the compose file, `TRUSTED_PROXY_CIDRS` must be `172.30.0.1/32`.
+- Startup refuses `LOCKOUT_THRESHOLD=0`, `DEVICE_AUTH_TTL_SECS=0`, a
+ `DEVICE_AUTH_POLL_INTERVAL_SECS` of 0 or not shorter than the device code
+ lifetime, and `JWT_MAX_SESSION_LIFETIME_SECS=0`.
+
+**Data**
+
+- New TOTP secrets are written in a versioned format (`v1:{key id}:...`) that
+ earlier versions cannot read: once this version has written secrets, rolling
+ back breaks TOTP for those accounts.
+- Retention runs only in the application: the migrations install no pg_cron
+ job, which ran with the SQL defaults instead of the configured retention.
+- The migrations are consolidated into one file per domain, each creating its
+ tables in their final form. A database created by an earlier development
+ version is created again rather than upgraded.
+
+### Added
+
+- Administration API under `/admin`, open to accounts holding the new `admin`
+ role (or any role granted `users:read`, `users:manage`, `roles:manage`,
+ `clients:manage`, `audit:read`, `webhooks:manage`) with a second factor
+ enrolled. Permissions are checked in the token and again in the database, so
+ a revoked role stops working at once. `auth-api --grant-role admin --user
+ ` appoints the first administrator.
+- `/admin/users`: search by address or username prefix and status, account
+ detail (roles, second factors, sessions), suspend, reactivate, unlock (the
+ failed sign-ins that caused the lockout are forgiven), sign out everywhere,
+ forced password reset, and deletion after a re-authentication. Each change is
+ audited on the account with the administrator's id; suspensions and
+ reactivations publish `user.suspended` and `user.reactivated`.
+- `/admin/roles`, `/admin/permissions` and `/admin/users/{id}/roles`: create
+ roles, set the permissions they grant, grant and take them back (granting
+ needs a re-authentication). A change that would leave nobody with
+ `roles:manage` is refused with `409 last_administrator`.
+- `/admin/clients`: register, update (`PUT`) and remove client applications;
+ removing one revokes its sessions. `/admin/audit`: the audit log of every
+ account, filtered by account and action, paged newest first.
+- CORS allows `PUT`.
+- Sign-in links by email (`MAGIC_LINK_ENABLED`, off by default):
+ `POST /auth/magic-link` and `POST /auth/magic-link/complete`. A link stands
+ for the password only: accounts with a second factor still answer their
+ challenge. English and French emails.
+- Personal access tokens: `GET`/`POST /users/me/tokens` and
+ `DELETE /users/me/tokens/{id}` (creation needs a re-authentication; at most
+ 20 active, 1 to 365 days, scopes limited to the account's permissions), and
+ `POST /auth/personal-access-tokens/exchange` for a short-lived access token
+ carrying those scopes and no roles. Each token owns a session of the new type
+ `personal_access_token`, so revoking the session, changing the password or
+ suspending the account ends it too.
+- Webhooks: `/admin/webhooks` registers HTTPS endpoints for domain events
+ (`user.created` ... `user.deleted`, or `*`). Deliveries are recorded in the
+ transaction of the change, signed per Standard Webhooks (`webhook-id`,
+ `webhook-timestamp`, `webhook-signature` with HMAC-SHA256), retried with
+ backoff for about fourteen hours, and inspectable and retriable by an
+ administrator. Endpoints resolving to internal addresses are never called,
+ redirects are not followed, and the connection goes to the checked address.
+ `WEBHOOK_*`, `CLEANUP_WEBHOOK_DELIVERY_DAYS`, alert `AuthApiWebhooksFailing`;
+ `--rotate-totp-keys` also re-encrypts webhook secrets.
+- Passkeys (WebAuthn): `/users/me/passkeys` registers, lists and removes them;
+ `POST /auth/passkeys/options` and `/auth/passkeys/sign-in` sign in with one,
+ without password or second-factor challenge. Discoverable credentials with
+ user verification, ES256/EdDSA/RS256, challenge single use, signature
+ counters checked against clones; the first passkey comes with recovery codes,
+ and a passkey counts as an administrator's second factor (`WEBAUTHN_*`).
+- Sign-in with Google, GitHub or any OpenID Connect provider
+ (`IDENTITY_PROVIDERS`, `IDP_*`): identities are linked by the signed-in owner
+ (`/users/me/external-identities`) and never matched by email; the sign-in is
+ bound to the browser that started it, the ID token verified against the
+ provider's keys, and an enrolled second factor still applies.
+- `docs/dev/guides/integration.md`: which flow each kind of application uses,
+ token verification rules for resource servers, and following account events.
+- `clients/js/verifier` (`@auth-api/verifier`, internal): the same verification
+ for Node.js resource servers, without dependency, with an Express middleware;
+ `make js-test` and a CI job run its tests.
+- `crates/verifier` (`auth-api-verifier`, internal): verifies access tokens in
+ Rust resource servers, with JWKS caching, issuer and audience checks, an
+ optional introspection-backed revocation check and an axum extractor.
+- `DATABASE_READ_URL`: an optional read replica for security histories, the
+ admin audit log, account search and webhook delivery lists; sizing profile
+ XL (three API hosts, 450 sign-ins per second, extrapolated).
+- High availability, self-hosted: `docs/deploy/guides/high-availability.md`
+ (keepalived and nginx, Patroni behind HAProxy, Redis Sentinel, a NATS cluster,
+ failover drills). `NATS_URL` accepts the servers of a cluster and
+ `NATS_STREAM_REPLICAS` sets the copies of the event stream.
+- OpenTelemetry traces over OTLP/HTTP (`OTEL_EXPORTER_OTLP_ENDPOINT`,
+ `OTEL_SERVICE_NAME`, `OTEL_TRACES_SAMPLER_ARG`): one server span per request,
+ named after its route template, continuing a W3C `traceparent`.
+- `GET /users/me/export`: everything stored about the account as a JSON
+ download, after a recent re-authentication, audited as `data_exported`.
+- Simulations of random account lifecycles checked against a model, a timing
+ test comparing existing and unknown accounts, and `make soak`: an hour of
+ mixed traffic that fails on any error or on growing memory.
+- OAuth 2.1 authorization server: authorization code with PKCE, device
+ authorization and refresh at `POST /oauth/token`, `scope` requests narrowed to
+ the client's registration, metadata at `/.well-known/oauth-authorization-server`
+ (RFC 8414), confidential clients authenticating with `client_secret_basic` or
+ `client_secret_post` (`POST`/`DELETE /admin/clients/{client_id}/secret`),
+ the client credentials grant for confidential clients that enable it
+ (tokens with a `client_id` claim and no user), an OpenID Connect provider
+ (discovery, `openid`/`profile`/`email` scopes, ID tokens with `nonce` and
+ `at_hash`, `GET /oauth/userinfo`), token introspection for
+ resource servers (`POST /oauth/introspect`, RFC 7662)
+ and revocation (`POST /oauth/revoke`, RFC 7009).
+- Client registry: scopes, redirect URIs, loopback redirects, default session
+ limit; `auth-api --register-client`.
+- A sign-in from a browser and system family the account never used e-mails
+ its owner (English and French) and is audited as `new_device_login`
+ (`NEW_DEVICE_ALERTS_ENABLED`); devices unused for `CLEANUP_KNOWN_DEVICE_DAYS`
+ (90) are forgotten.
+- `PATCH /users/me/password` and `DELETE /users/me/sessions` accept
+ `keep_current_session`: every other session is revoked and the one making the
+ request stays signed in.
+- Passwords found in known data breaches are refused at registration, change
+ and reset (`422 password_compromised`), through the Pwned Passwords range API
+ with k-anonymity: only five characters of the SHA-1 leave the service
+ (`PWNED_PASSWORDS_*`, fail-open by default).
+- Personal data: deleting an account (or purging a never-verified one) also
+ deletes its sign-in attempts, including failures typed with its address before
+ it existed, and removes the client addresses of its audit entries; audit
+ addresses keep only their network after `AUDIT_IP_RETENTION_DAYS` (90); domain
+ events carry the user id only. `docs/dev/privacy.md` records what is stored,
+ why, for how long and what deletion removes.
+- `POST /auth/verify-email/resend`: a new verification link for a pending
+ account, answering alike for every address and capped per account and per
+ address; registering again on a pending address sends the verification again.
+- Accounts whose address was never verified are deleted after
+ `CLEANUP_UNVERIFIED_ACCOUNT_DAYS` (7), audited and announced with
+ `user.deleted`. A password reset verifies a pending account, so the owner of
+ an address takes back an account someone else registered with it.
+- `GET /oauth/device/{user_code}`: what the signed-in user is about to approve.
+- `GET /users/me/audit`: the caller's security history, cursor-paginated.
+- `GET /users/me/two-factor`: configured methods and remaining recovery codes.
+- Events `user.password_changed` and `user.sessions_revoked`.
+- Emails: account already exists, email address changed (to the previous
+ address), two-factor enabled; subjects translated in English and French.
+- Encryption keyring: `PREVIOUS_ENCRYPTION_KEY` stays readable during a
+ rotation; `--rotate-totp-keys` is resumable and audited as
+ `encryption_key_rotated`.
+- Access log (route template, status, latency, request id), nextest
+ configuration, `make ci`, `make release`, OpenAPI document generated from the
+ code, security model, this changelog.
+- The OpenAPI document lists the responses every operation can return (`400`,
+ `401`, `413`, `415`, `422`, `429`, `503`) with their error body.
+- Test suites by layer (`make test-unit`, `test-integration`, `test-security`,
+ `test-sim`), a shared harness (`crates/testkit`), every test response checked
+ against the OpenAPI document, an authorization matrix over every operation,
+ a control catalog in the security model, fuzz targets (`make fuzz`) replayed
+ on stable in `make ci`, and `migrations/SHA256SUMS` freezing released
+ migrations. Property tests pit the validators against the database
+ constraints; `make mutants` runs mutation testing over the security-relevant
+ pure code. `make coverage` fails under 90 % of lines, 85 % of regions and 79 %
+ of functions. See `docs/dev/guides/testing.md`.
+
+### Security
+
+- Second factors: pre-auth tokens bound to their method; failure budgets per
+ challenge and per account for TOTP, email and recovery codes; second-factor
+ failures no longer lock the account out.
+- No account oracle: the lockout answers the same whatever the password,
+ unknown identifiers pay a full hash, registration and forgot-password answer
+ identically; forgot-password is capped per account.
+- Sessions: an absolute lifetime measured from the sign-in; concurrent refreshes
+ within 2 seconds no longer revoke a legitimate family; email change keeps the
+ account status and records no addresses in the audit log.
+- Attempt budgets are consumed atomically before the check they guard.
+- IPv6 clients are rate limited per `/64`.
+- Device flow: user codes reserved atomically, approvals collected once, polling
+ paced, account status and session limits checked at issue.
+- `user.deleted` is recorded in the same transaction as the account deletion:
+ the account is never gone without its event, and the event never announces a
+ deletion that failed.
+- Nginx served `403` for `/.well-known/jwks.json` (hidden-file rule), appended
+ client-supplied `X-Forwarded-For` hops, and duplicated security headers.
+- Behind Docker's port proxy the trusted proxy never matched, so every client
+ shared one rate-limit bucket.
+- `AUDIT_LOG_RETENTION_MONTHS=0` deleted every past audit partition instead of
+ keeping them, and the pg_cron job deleted audit months beyond six whatever the
+ configuration.
+- Base images pinned by digest; OpenSSL removed from the images; development
+ ports bound to loopback.
+- Passwords need at least 10 characters, not 10 bytes: an accented password of
+ ten bytes could hold seven characters (found by fuzzing). The upper bound
+ stays 128 bytes so every accepted password remains usable at sign-in.
+- A loopback redirect on port 0 is refused.
+- TOTP verification refuses a time within the skew of the epoch instead of
+ panicking.
+- Every error response carries the documented `{ "code", "message" }` body:
+ rate limiter and timeout refusals, unknown routes and malformed or oversized
+ JSON no longer answer in plain text, and no longer quote the JSON parser.
+- `LOCKOUT_THRESHOLD=0` is refused at startup: it locked an account on every
+ wrong password instead of disabling the lockout.
+- The re-authentication budget is consumed atomically before the password is
+ hashed, and fails closed: parallel guesses could all pass a count read
+ before any of them was recorded.
+- Failed sign-ins are capped per IPv6 /64, like every other per-address
+ budget: rotating addresses inside one /64 reset the cap.
+- Device flow approvals check the client's session limit under the same lock
+ as code redemptions: concurrent polls could each count the same sessions and
+ exceed the limit.
+- A refresh no longer dates the new session past the absolute lifetime of its
+ sign-in, so session listings and revocation lifetimes match the real end.
+- Confirming a TOTP method records its code in the durable replay table shared
+ with sign-in (it could complete a sign-in in the same window, and the Redis
+ guard was skipped without Redis), under a budget of 5 wrong codes per 15
+ minutes.
+- Recovery codes have one per-account budget (10 per 24 hours) shared by the
+ sign-in challenge and `POST /users/me/two-factor/recovery-codes/use`, which
+ had its own; purging a user's challenges also clears their email-code
+ budgets.
+- Second factors and client flows answer `account_inactive` and
+ `email_not_verified` like the password sign-in, instead of
+ `account_suspended` for every status other than active.
+- An authorization code redirect keeps the query of its registered URI.
+- The API never authenticated to NATS: async-nats ignores the credentials of
+ a URL, so the documented `NATS_URL=nats://@nats:4222` against the
+ token-protected broker of `docker-compose.api.yml` was refused and the service
+ could not start. The credentials are now read from `NATS_URL` and presented
+ to the broker, production refuses a `NATS_URL` without them, and the test
+ broker requires a token so every suite exercises it.
+- The deployment docs said `CAPTCHA_SECRET` could be left unset and redeployed
+ a TOTP key rotation before storing the new key; production requires the
+ secret, and the rotation now stores both keys first.
+- The production image runs on distroless (no shell, no package manager): the
+ Debian runtime carried two HIGH vulnerabilities in `libpcre2`. The binary and
+ templates belong to root, the service runs as a numeric non-root user, and the
+ image carries its version and commit as OCI labels.
+- `make release` builds the image from the commit rather than the working tree,
+ refuses a dirty tree, a version that differs from `Cargo.toml` or an untagged
+ commit, stops on a HIGH or CRITICAL vulnerability, records the image
+ identifier and signs the checksums with an SSH key. `make docker-refresh-pins`
+ moves every pinned base image to its current digest; the development and test
+ compose files are pinned too.
+- `GET /live` (liveness, dependencies unchecked) and `GET /ready` (database,
+ Redis and NATS, one second each, 503 when one is down) sit outside the rate
+ limiter; `/health` stays as an alias of `/live`. The image health check calls
+ `/live`: a rate-limited `/health` turned every instance unhealthy during a
+ Redis outage.
+- A database failure while checking a token answers 503 instead of 401, which
+ signed users out during an outage and hid it from the server-error alerts.
+- Domain events go through a transactional outbox: each event is recorded in
+ the transaction of the change it announces and a background relay publishes
+ it to JetStream in order, with its id as message id (deduplication) and
+ `event_id` and `occurred_at` in the payload. An event is no longer lost when
+ NATS is down, requests never wait for the broker, and a rolled-back change
+ announces nothing. Registration, email verification, password changes and
+ resets, session revocation and email changes now commit their writes in one
+ transaction. `AuthApiEventsStalled` replaces `AuthApiEventsDropped`. Account
+ deletion no longer answers 503 while NATS is down. An unreachable broker no longer stops
+ the start (a refused token still does): the client reconnects in the
+ background, the stream is declared before the first acknowledged event, and
+ `/ready` reports NATS.
+- Stopping the service drains in bounded phases that fit a 40-second stop:
+ in-flight requests (32 s), then notifications and cache invalidations started
+ by requests (5 s, counted in `auth_background_tasks`), then events the NATS
+ client still buffers (2 s). Before, the drain had no deadline and background
+ tasks and buffered events were lost at every restart.
+- Notifications are capped at 1 000 pending (`auth_notifications_pending`;
+ `auth_notifications_failed_total` and `auth_notifications_dropped_total` by
+ task). The SMTP relay is reached through a connection pool with a 10-second
+ timeout instead of a new connection per message and lettre's one-minute
+ default, and a temporary refusal is retried twice (after 2 and 8 seconds).
+- New metrics for the alerts: pool saturation (`auth_db_pool_connections`,
+ `auth_redis_pool_connections`, `auth_redis_pool_waiting`), Redis failures by
+ operation (`auth_redis_errors_total`) and cleanup results
+ (`auth_cleanup_deleted_rows_total`, `auth_cleanup_failures_total`).
+- Every log line of a request carries its `request_id` through a span. A
+ client-supplied `X-Request-Id` is kept only when it has at most 64 letters,
+ digits or `-_.:`; otherwise a new identifier replaces it.
+- The `Debug` output of the configuration masks connection URLs, the JWT
+ private key, the encryption keys and the SMTP and CAPTCHA secrets.
+- `DB_ACQUIRE_TIMEOUT_SECS` defaults to 5 seconds instead of 30: an exhausted
+ pool answers fast instead of waiting out the request timeout.
+- A signing key rotation no longer makes resource servers refuse new tokens
+ for up to 5 minutes. `JWT_NEXT_PUBLIC_KEY` publishes the next key in the JWKS
+ and has it accepted before the signing key switches to it, and the API
+ verifies a token with the key its `kid` names (a token without a known `kid`
+ is tried against every key). The runbook rotation now runs in three phases.
+- The API VPS runs two API instances (`api-a` and `api-b`, on loopback ports
+ 3001 and 3002) sized by a profile: `deploy/profiles/s.env`, `m.env` or
+ `l.env`, passed with `--env-file` (`docker-compose.api.l.yml` adds two
+ instances for profile L). Each instance has CPU, memory and process limits, a
+ 40-second stop grace period and rotated logs, and its health check calls
+ `/live` every 10 seconds. `scripts/rolling-update.sh` replaces the instances
+ one at a time and waits for `/ready`, so an update no longer stops the
+ service. The NATS token moves to `nats-auth.conf`, mounted as a secret instead
+ of a command-line argument, and JetStream storage is capped in `nats.conf`.
+ Pool sizes and Argon2 concurrency leave `config.prod.env` for the profiles.
+- nginx balances the API instances: an instance that refuses or fails is
+ skipped for 10 seconds and the request goes to the other one, while a
+ request an instance already received is never resent. Proxy timeouts are 35
+ seconds (credential routes cut at 10 while a sign-in queued behind Argon2 may
+ take up to 30), HEAD is allowed for uptime probes, every request is logged as
+ one JSON line with the `request_id` passed on to the API, and the nginx rate
+ limits sit at twice the API's so clients see the API's 429 and `Retry-After`.
+- The DB VPS ships its settings in `deploy/db/`: PostgreSQL sized per profile
+ (memory, connections, WAL, `pg_stat_statements`, slow statement logs) with
+ session limits for the `auth_api` role (25-second statements, 10-second lock
+ waits, 60 seconds idle in a transaction); Redis with `maxmemory` and
+ `noeviction`, append-only persistence, the default user disabled and an
+ `auth_api` ACL user without administrative or dangerous commands
+ (`REDIS_URL` becomes `redis://auth_api:@10.0.0.2:6379`); kernel
+ settings (overcommit, swappiness, no transparent huge pages). Migration 0027
+ vacuums `sessions` and `login_attempts` once 2 % of their rows changed
+ instead of 20 %. The API VPS no longer opens a WireGuard port it never
+ listened on.
+- Backups fail loudly and restore safely. `backup-db.sh` reads
+ `/etc/auth-api/backup.env` instead of being edited, refuses the placeholder
+ key, writes through a temporary file so a failed run leaves nothing that looks
+ like a backup, fails when the offsite copy fails, and writes
+ `auth_backup_last_success_timestamp`, the size and the duration for
+ node_exporter after a complete run only: `AuthBackupMissing` could never fire
+ before, since nothing wrote its metric, and it now also fires when the metric
+ is absent. `AuthBackupShrunk` warns of a backup half the size of the previous
+ ones. `restore-db.sh` restores in a single transaction and `--force` empties
+ the target first. The drill restores as the non-superuser owner, checks that a
+ failed backup leaves nothing, that an overwrite without `--force` is refused,
+ and compares every table. Profiles M and L get point-in-time recovery with
+ pgBackRest (`deploy/db/pgbackrest.conf`, `postgresql.pitr.conf`).
+- Monitoring moves to its own host (`deploy/monitoring/`): Prometheus,
+ Alertmanager with a dead man's switch, and a blackbox probe of the public
+ `/ready` and its certificate. Exporters listen on WireGuard addresses only;
+ the API compose adds a NATS exporter and publishes the metrics listeners on
+ `METRICS_BIND_ADDRESS`. `rules/infrastructure.yml` alerts on hosts, disks,
+ PostgreSQL, Redis (including refused writes under `noeviction`), NATS, pool
+ saturation, dropped events and e-mails and failing retention jobs, each with
+ a promtool test. Each API instance publishes its container's memory against
+ its limit, CPU throttling and start time from its cgroup
+ (`auth_container_*`, `auth_process_start_time_seconds`), so the container
+ alerts need no host exporter.
+- The operations runbook covers a full Redis, NATS and SMTP outages and adding
+ an instance; its addresses match the two-instance deployment. The
+ `config.prod.env` comment no longer calls `JWT_AUDIENCE` required:
+ `APP_PUBLIC_URL` is always part of the audience.
+- GitHub Actions: `ci.yml` checks pull requests to `main` with one required
+ check, `ci-ok`, and runs only the jobs a change concerns (formatting, clippy,
+ dependency policy and every suite; deployment files; the image and Trivy);
+ `scheduled.yml` runs advisories, the image scan, coverage and the long
+ simulations weekly, the backup drill and fuzzing monthly, and tracks failures
+ in an issue; `backmerge.yml` fast-forwards `staging` after a merge; Dependabot
+ opens grouped pull requests monthly. Actions are pinned by commit, the token is
+ read-only by default, and the toolchain is pinned in `rust-toolchain.toml`.
+ `scripts/infra-check.sh` gains check families (`CHECKS=hygiene|static|image`),
+ actionlint, a repository secret scan and an image size limit.
+- `make stack-test` (`scripts/stack-smoke.sh`) runs the production compose
+ with profile M behind the repository's nginx configuration and checks the
+ container limits, balancing, a sign-in flow, failover, a rolling update under
+ load with no failed request, the deletion event through the authenticated
+ broker and a clean stop. `make sizing` (`perf/sizing.sh`) checks the profiles
+ under real container quotas: sign-ins per CPU, peak memory and overload,
+ Redis, NATS and PostgreSQL footprint at 100 000 and 1 million accounts, the
+ restore time, and a soak at a chosen profile.
+- The OpenAPI document said a password change and `DELETE /users/me/sessions`
+ revoke the other sessions; both revoke every session, the current one
+ included.
+- `X-Forwarded-For` is read across every header line, and a hop that is not an
+ address stops the walk at the trusted proxy instead of letting the value to
+ its left through. `X-Real-IP` only counts without `X-Forwarded-For`. The
+ bundled nginx configuration was not affected: it overwrites both headers.
+- A recovery code no longer completes a pre-auth state that names no method
+ (the format written before challenges were bound to their method).
+
+### Reliability
+
+- An unreachable PostgreSQL or Redis answers `503 service_unavailable` instead
+ of `500`, including connections dropped while being set up.
+- The Redis pool replaces a connection whose transport failed. Before, a Redis
+ restart under traffic was never recovered from: each request failed on a dead
+ connection and counted as a use, so the connection never went idle long
+ enough to be checked.
+- A refresh no longer fails when the Redis pool is unavailable: its per-address
+ budget fails open and the database decides, as documented.
+
+### Performance
+
+- Indexes matched to the queries: unused ones dropped, expiry and referencing
+ columns indexed.
+- A sign-in writes its session, account stamp, ledger entry and audit record in
+ one transaction; roles and permissions load in one query.
+- Cleanups run in bounded batches under an advisory lock.
+- Redis: recycled connections are pinged only after 5 seconds idle; the rate
+ limiter is O(1) and checks every bucket in one script; token checks read the
+ blocklist and session cache in one pipeline. Profile reads went from 1.05 ms to
+ 0.71 ms at p50 in the HTTP benchmark.
+- Retention batches select their rows through a TID scan:
+ a session purge batch at 1 million accounts went from 950 ms (the old query
+ gathered the whole backlog) to about 20 ms.
+- Indexes no query uses are dropped: `idx_sessions_family_active` and the
+ audit log's request id index (755 MB at 1 million accounts); the internal
+ `audit::find_by_request_id`, which no route called, is removed.
+- The audit history page skips the empty partitions created for future months.
+- The API logs its Argon2 capacity at startup and warns when the container
+ memory limit cannot hold it; Argon2 saturation alerts at 5 and 15 minutes.
+- Measurement campaign and report: `make perf`, `docs/perf/performance-report.md`.
+
+### Upgrading
+
+1. Back up the database.
+2. Create the database from the migrations (see the note under **Data**).
+3. Set `APP_ENV`, `FRONTEND_URL`, `DEVICE_AUTH_VERIFICATION_URI` and
+ `TRUSTED_PROXY_CIDRS=172.30.0.1/32`; remove `GEOIP_*` and `RISK_*`.
+4. Register the primary client, and any device or authorization code client,
+ with `auth-api --register-client`.
+5. Update client applications: re-authenticate before sensitive actions, and
+ stop reading the account from the registration response.
+6. Move client applications to the `/oauth` endpoints (**Breaking changes**),
+ give confidential clients their secret
+ (`POST /admin/clients/{client_id}/secret`), and set `OAUTH_CONSENT_URI` if
+ the consent page is not `{FRONTEND_URL}/authorize`.
+7. Appoint the first administrator with
+ `auth-api --grant-role admin --user `, and have them enroll a second
+ factor or a passkey.
+8. Review the new variables and their defaults: `MAGIC_LINK_ENABLED`,
+ `PWNED_PASSWORDS_*`, `NEW_DEVICE_ALERTS_ENABLED`, `WEBAUTHN_*`,
+ `IDENTITY_PROVIDERS` and `IDP_*`, `EXTERNAL_LOGIN_URI`, `WEBHOOK_*`,
+ `OTEL_*`, `NATS_STREAM_REPLICAS`, `DATABASE_READ_URL`, and the retention
+ variables `CLEANUP_UNVERIFIED_ACCOUNT_DAYS`, `CLEANUP_KNOWN_DEVICE_DAYS`,
+ `CLEANUP_WEBHOOK_DELIVERY_DAYS`, `AUDIT_IP_RETENTION_DAYS`.
diff --git a/Cargo.lock b/Cargo.lock
index 6ad5b0b..83842d1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -37,6 +37,20 @@ dependencies = [
"subtle",
]
+[[package]]
+name = "ahash"
+version = "0.8.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
+dependencies = [
+ "cfg-if",
+ "getrandom 0.3.4",
+ "once_cell",
+ "serde",
+ "version_check",
+ "zerocopy",
+]
+
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -141,7 +155,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -161,7 +175,7 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "auth-api"
-version = "0.1.0"
+version = "2.0.0"
dependencies = [
"aes-gcm",
"anyhow",
@@ -171,33 +185,65 @@ dependencies = [
"axum",
"axum-prometheus",
"base64",
+ "ciborium",
"criterion",
+ "deadpool",
"deadpool-redis",
"dotenvy",
"email_address",
- "ipnetwork 0.20.0",
+ "form_urlencoded",
+ "futures",
+ "hmac 0.13.0",
+ "ipnetwork",
"jsonwebtoken",
"lettre",
- "maxminddb",
"metrics",
+ "opentelemetry",
+ "opentelemetry-http",
+ "opentelemetry-otlp",
+ "opentelemetry_sdk",
"p256",
- "postgres",
+ "percent-encoding",
"proptest",
"rand 0.10.2",
"rand_core 0.6.4",
"reqwest",
"serde",
"serde_json",
+ "sha1",
"sha2 0.11.0",
"sqlx",
"tera",
+ "testkit",
"thiserror 2.0.18",
"time",
"tokio",
"totp-rs",
+ "tower",
"tower-http 0.7.0",
"tracing",
+ "tracing-opentelemetry",
"tracing-subscriber",
+ "utoipa",
+ "uuid",
+]
+
+[[package]]
+name = "auth-api-verifier"
+version = "0.1.0"
+dependencies = [
+ "auth-api",
+ "auth-api-verifier",
+ "axum",
+ "jsonwebtoken",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "sqlx",
+ "testkit",
+ "thiserror 2.0.18",
+ "tokio",
+ "tower",
"uuid",
]
@@ -209,9 +255,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "aws-lc-rs"
-version = "1.16.2"
+version = "1.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
+checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
dependencies = [
"aws-lc-sys",
"untrusted 0.7.1",
@@ -220,14 +266,15 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
-version = "0.39.0"
+version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a"
+checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
+ "pkg-config",
]
[[package]]
@@ -377,6 +424,12 @@ dependencies = [
"hybrid-array",
]
+[[package]]
+name = "borrow-or-share"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c"
+
[[package]]
name = "bstr"
version = "1.12.1"
@@ -393,6 +446,12 @@ version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
+[[package]]
+name = "bytecount"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
+
[[package]]
name = "byteorder"
version = "1.5.0"
@@ -446,9 +505,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
-version = "0.10.0"
+version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
+checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
@@ -676,9 +735,9 @@ dependencies = [
[[package]]
name = "crossbeam-epoch"
-version = "0.9.18"
+version = "0.9.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
+checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
dependencies = [
"crossbeam-utils",
]
@@ -778,7 +837,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -869,7 +928,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -1021,10 +1080,15 @@ dependencies = [
]
[[package]]
-name = "fallible-iterator"
-version = "0.2.0"
+name = "fancy-regex"
+version = "0.19.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7"
+checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912"
+dependencies = [
+ "bit-set",
+ "regex-automata",
+ "regex-syntax",
+]
[[package]]
name = "fastrand"
@@ -1054,6 +1118,17 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+[[package]]
+name = "fluent-uri"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e"
+dependencies = [
+ "borrow-or-share",
+ "ref-cast",
+ "serde",
+]
+
[[package]]
name = "flume"
version = "0.11.1"
@@ -1092,12 +1167,37 @@ dependencies = [
"percent-encoding",
]
+[[package]]
+name = "fraction"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e246562084dde8ebbcc943b261c406ce4f68e5032ec28029a251a47d6a295500"
+dependencies = [
+ "num",
+ "num-bigint",
+]
+
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
+[[package]]
+name = "futures"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
[[package]]
name = "futures-channel"
version = "0.3.32"
@@ -1142,6 +1242,17 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+[[package]]
+name = "futures-macro"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "futures-sink"
version = "0.3.32"
@@ -1160,8 +1271,10 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
+ "futures-channel",
"futures-core",
"futures-io",
+ "futures-macro",
"futures-sink",
"futures-task",
"memchr",
@@ -1204,7 +1317,7 @@ dependencies = [
"cfg-if",
"js-sys",
"libc",
- "wasi 0.11.1+wasi-snapshot-preview1",
+ "wasi",
"wasm-bindgen",
]
@@ -1229,11 +1342,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
+ "js-sys",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.0",
"wasip2",
"wasip3",
+ "wasm-bindgen",
]
[[package]]
@@ -1306,6 +1421,17 @@ dependencies = [
"foldhash 0.2.0",
]
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash 0.2.0",
+]
+
[[package]]
name = "hashlink"
version = "0.10.0"
@@ -1639,12 +1765,6 @@ dependencies = [
"serde",
]
-[[package]]
-name = "ipnetwork"
-version = "0.21.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763"
-
[[package]]
name = "iri-string"
version = "0.7.10"
@@ -1712,6 +1832,59 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "jsonschema"
+version = "0.56.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6a806f80c1f5560431009ce5ec29b59d38f950e0cd7db5f1b8925e6d8104e21"
+dependencies = [
+ "ahash",
+ "bytecount",
+ "data-encoding",
+ "email_address",
+ "fancy-regex",
+ "fraction",
+ "getrandom 0.3.4",
+ "itoa",
+ "jsonschema-regex",
+ "jsonschema-value",
+ "num-cmp",
+ "num-traits",
+ "percent-encoding",
+ "referencing",
+ "regex",
+ "serde",
+ "serde_json",
+ "strum",
+ "unicode-general-category",
+ "uuid-simd",
+]
+
+[[package]]
+name = "jsonschema-regex"
+version = "0.56.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb862addfa7782108933abcf842fe25334523a9df5b89ea4d9620e3dd7b42181"
+dependencies = [
+ "regex-syntax",
+]
+
+[[package]]
+name = "jsonschema-value"
+version = "0.56.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a05cd404c5ff6e2731dbbf7e290a27417750acfb59e329560a1d0af384c93fb0"
+dependencies = [
+ "ahash",
+ "bytecount",
+ "fraction",
+ "getrandom 0.3.4",
+ "num-cmp",
+ "num-traits",
+ "serde_json",
+ "zmij",
+]
+
[[package]]
name = "jsonwebtoken"
version = "10.4.0"
@@ -1879,19 +2052,6 @@ version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
-[[package]]
-name = "maxminddb"
-version = "0.29.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "65e84ef32bcbf18a95548989e880db4af6fafd563463753afb4b9a149fb2782c"
-dependencies = [
- "ipnetwork 0.21.1",
- "log",
- "memchr",
- "serde",
- "thiserror 2.0.18",
-]
-
[[package]]
name = "md-5"
version = "0.10.6"
@@ -1902,16 +2062,6 @@ dependencies = [
"digest 0.10.7",
]
-[[package]]
-name = "md-5"
-version = "0.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
-dependencies = [
- "cfg-if",
- "digest 0.11.3",
-]
-
[[package]]
name = "memchr"
version = "2.8.0"
@@ -1960,6 +2110,12 @@ dependencies = [
"sketches-ddsketch",
]
+[[package]]
+name = "micromap"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74"
+
[[package]]
name = "mime"
version = "0.3.17"
@@ -1973,7 +2129,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
dependencies = [
"libc",
- "wasi 0.11.1+wasi-snapshot-preview1",
+ "wasi",
"windows-sys 0.61.2",
]
@@ -2019,6 +2175,20 @@ dependencies = [
"rand 0.8.6",
]
+[[package]]
+name = "num"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
+dependencies = [
+ "num-bigint",
+ "num-complex",
+ "num-integer",
+ "num-iter",
+ "num-rational",
+ "num-traits",
+]
+
[[package]]
name = "num-bigint"
version = "0.4.8"
@@ -2045,6 +2215,21 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "num-cmp"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa"
+
+[[package]]
+name = "num-complex"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
+dependencies = [
+ "num-traits",
+]
+
[[package]]
name = "num-conv"
version = "0.2.2"
@@ -2071,6 +2256,17 @@ dependencies = [
"num-traits",
]
+[[package]]
+name = "num-rational"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
+dependencies = [
+ "num-bigint",
+ "num-integer",
+ "num-traits",
+]
+
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -2092,40 +2288,97 @@ dependencies = [
]
[[package]]
-name = "objc2-core-foundation"
-version = "0.3.2"
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "oorandom"
+version = "11.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
+
+[[package]]
+name = "openssl-probe"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
+
+[[package]]
+name = "opentelemetry"
+version = "0.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
+checksum = "b0142c63252a9e054e68a4c61a5778f7b14f576274d593f8ce883d191a099682"
dependencies = [
- "bitflags",
+ "futures-core",
+ "futures-sink",
+ "js-sys",
+ "pin-project-lite",
+ "thiserror 2.0.18",
]
[[package]]
-name = "objc2-system-configuration"
-version = "0.3.2"
+name = "opentelemetry-http"
+version = "0.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396"
+checksum = "5683015d09e2df236ef005b17f6f196f0d5f6313c4fa43a7b6a53b52776e4331"
dependencies = [
- "objc2-core-foundation",
+ "async-trait",
+ "bytes",
+ "http",
+ "opentelemetry",
]
[[package]]
-name = "once_cell"
-version = "1.21.4"
+name = "opentelemetry-otlp"
+version = "0.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+checksum = "9966929966d17620d7c316c643ba62631826e10021409357772d5eea84f62c35"
+dependencies = [
+ "http",
+ "opentelemetry",
+ "opentelemetry-http",
+ "opentelemetry-proto",
+ "opentelemetry_sdk",
+ "prost",
+ "thiserror 2.0.18",
+]
[[package]]
-name = "oorandom"
-version = "11.1.5"
+name = "opentelemetry-proto"
+version = "0.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
+checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638"
+dependencies = [
+ "opentelemetry",
+ "opentelemetry_sdk",
+ "prost",
+]
[[package]]
-name = "openssl-probe"
-version = "0.2.1"
+name = "opentelemetry_sdk"
+version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
+checksum = "9b59f80e1ac4d5ff7a2db8fb6c80badb7f0f3f858211fba08dd9aaec750894f9"
+dependencies = [
+ "futures-channel",
+ "futures-executor",
+ "futures-util",
+ "opentelemetry",
+ "percent-encoding",
+ "portable-atomic",
+ "rand 0.9.4",
+ "thiserror 2.0.18",
+ "tokio",
+ "tokio-stream",
+]
+
+[[package]]
+name = "outref"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
[[package]]
name = "p256"
@@ -2214,25 +2467,6 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
-[[package]]
-name = "phf"
-version = "0.13.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
-dependencies = [
- "phf_shared",
- "serde",
-]
-
-[[package]]
-name = "phf_shared"
-version = "0.13.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
-dependencies = [
- "siphasher",
-]
-
[[package]]
name = "pin-project"
version = "1.1.11"
@@ -2250,7 +2484,7 @@ checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -2343,51 +2577,6 @@ version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
-[[package]]
-name = "postgres"
-version = "0.19.14"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "33ad20e0aa0b24f5a394eab4f78c781d248982b22b25cecc7e3aa46a681605bd"
-dependencies = [
- "bytes",
- "fallible-iterator",
- "futures-util",
- "log",
- "tokio",
- "tokio-postgres",
-]
-
-[[package]]
-name = "postgres-protocol"
-version = "0.6.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514"
-dependencies = [
- "base64",
- "byteorder",
- "bytes",
- "fallible-iterator",
- "hmac 0.13.0",
- "md-5 0.11.0",
- "memchr",
- "rand 0.10.2",
- "sha2 0.11.0",
- "stringprep",
-]
-
-[[package]]
-name = "postgres-types"
-version = "0.2.14"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be"
-dependencies = [
- "bytes",
- "fallible-iterator",
- "postgres-protocol",
- "time",
- "uuid",
-]
-
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -2419,7 +2608,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -2459,6 +2648,29 @@ dependencies = [
"unarray",
]
+[[package]]
+name = "prost"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1"
+dependencies = [
+ "bytes",
+ "prost-derive",
+]
+
+[[package]]
+name = "prost-derive"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
+dependencies = [
+ "anyhow",
+ "itertools",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "quanta"
version = "0.12.6"
@@ -2469,7 +2681,7 @@ dependencies = [
"libc",
"once_cell",
"raw-cpuid",
- "wasi 0.11.1+wasi-snapshot-preview1",
+ "wasi",
"web-sys",
"winapi",
]
@@ -2502,15 +2714,16 @@ dependencies = [
[[package]]
name = "quinn-proto"
-version = "0.11.14"
+version = "0.11.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
+checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc"
dependencies = [
"aws-lc-rs",
"bytes",
- "getrandom 0.3.4",
+ "getrandom 0.4.2",
"lru-slab",
- "rand 0.9.4",
+ "rand 0.10.2",
+ "rand_pcg",
"ring",
"rustc-hash",
"rustls",
@@ -2639,6 +2852,15 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba"
+[[package]]
+name = "rand_pcg"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
+dependencies = [
+ "rand_core 0.10.0",
+]
+
[[package]]
name = "rand_xorshift"
version = "0.4.0"
@@ -2736,6 +2958,43 @@ dependencies = [
"bitflags",
]
+[[package]]
+name = "ref-cast"
+version = "1.0.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3"
+dependencies = [
+ "ref-cast-impl",
+]
+
+[[package]]
+name = "ref-cast-impl"
+version = "1.0.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.5",
+]
+
+[[package]]
+name = "referencing"
+version = "0.56.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3b4a92fac7e28c27de3ad26df2ecb9e652f227b258e845af034052e5b22c96c"
+dependencies = [
+ "ahash",
+ "fluent-uri",
+ "getrandom 0.3.4",
+ "hashbrown 0.17.1",
+ "itoa",
+ "micromap",
+ "parking_lot",
+ "percent-encoding",
+ "serde_json",
+]
+
[[package]]
name = "regex"
version = "1.12.3"
@@ -2750,9 +3009,9 @@ dependencies = [
[[package]]
name = "regex-automata"
-version = "0.4.14"
+version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [
"aho-corasick",
"memchr",
@@ -2877,9 +3136,9 @@ dependencies = [
[[package]]
name = "rustls"
-version = "0.23.37"
+version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
+checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"aws-lc-rs",
"log",
@@ -2942,9 +3201,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
-version = "0.103.13"
+version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
+checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"aws-lc-rs",
"ring",
@@ -3077,7 +3336,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -3102,6 +3361,19 @@ dependencies = [
"serde",
]
+[[package]]
+name = "serde_norway"
+version = "0.9.42"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e408f29489b5fd500fab51ff1484fc859bb655f32c671f307dcd733b72e8168c"
+dependencies = [
+ "indexmap",
+ "itoa",
+ "ryu",
+ "serde",
+ "unsafe-libyaml-norway",
+]
+
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
@@ -3121,7 +3393,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -3244,12 +3516,6 @@ dependencies = [
"time",
]
-[[package]]
-name = "siphasher"
-version = "1.0.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
-
[[package]]
name = "sketches-ddsketch"
version = "0.3.1"
@@ -3283,9 +3549,9 @@ dependencies = [
[[package]]
name = "spin"
-version = "0.9.8"
+version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
+checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
dependencies = [
"lock_api",
]
@@ -3332,7 +3598,7 @@ dependencies = [
"hashbrown 0.15.5",
"hashlink",
"indexmap",
- "ipnetwork 0.20.0",
+ "ipnetwork",
"log",
"memchr",
"once_cell",
@@ -3362,7 +3628,7 @@ dependencies = [
"quote",
"sqlx-core",
"sqlx-macros-core",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -3385,7 +3651,7 @@ dependencies = [
"sqlx-mysql",
"sqlx-postgres",
"sqlx-sqlite",
- "syn",
+ "syn 2.0.117",
"tokio",
"url",
]
@@ -3415,7 +3681,7 @@ dependencies = [
"hmac 0.12.1",
"itoa",
"log",
- "md-5 0.10.6",
+ "md-5",
"memchr",
"once_cell",
"percent-encoding",
@@ -3431,7 +3697,7 @@ dependencies = [
"time",
"tracing",
"uuid",
- "whoami 1.6.1",
+ "whoami",
]
[[package]]
@@ -3454,10 +3720,10 @@ dependencies = [
"hkdf",
"hmac 0.12.1",
"home",
- "ipnetwork 0.20.0",
+ "ipnetwork",
"itoa",
"log",
- "md-5 0.10.6",
+ "md-5",
"memchr",
"once_cell",
"rand 0.8.6",
@@ -3471,7 +3737,7 @@ dependencies = [
"time",
"tracing",
"uuid",
- "whoami 1.6.1",
+ "whoami",
]
[[package]]
@@ -3517,6 +3783,27 @@ dependencies = [
"unicode-properties",
]
+[[package]]
+name = "strum"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
+dependencies = [
+ "strum_macros",
+]
+
+[[package]]
+name = "strum_macros"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "subtle"
version = "2.6.1"
@@ -3534,6 +3821,17 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "syn"
+version = "3.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
[[package]]
name = "sync_wrapper"
version = "1.0.2"
@@ -3551,7 +3849,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -3578,6 +3876,34 @@ dependencies = [
"walkdir",
]
+[[package]]
+name = "testkit"
+version = "0.0.0"
+dependencies = [
+ "anyhow",
+ "auth-api",
+ "axum",
+ "base64",
+ "ciborium",
+ "deadpool-redis",
+ "dotenvy",
+ "jsonschema",
+ "jsonwebtoken",
+ "lettre",
+ "p256",
+ "rand_core 0.6.4",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "sha2 0.11.0",
+ "sqlx",
+ "time",
+ "tokio",
+ "tracing-subscriber",
+ "utoipa",
+ "uuid",
+]
+
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -3604,7 +3930,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -3615,7 +3941,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -3717,33 +4043,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
- "syn",
-]
-
-[[package]]
-name = "tokio-postgres"
-version = "0.7.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3"
-dependencies = [
- "async-trait",
- "byteorder",
- "bytes",
- "fallible-iterator",
- "futures-channel",
- "futures-util",
- "log",
- "parking_lot",
- "percent-encoding",
- "phf",
- "pin-project-lite",
- "postgres-protocol",
- "postgres-types",
- "rand 0.10.2",
- "socket2",
- "tokio",
- "tokio-util",
- "whoami 2.1.1",
+ "syn 2.0.117",
]
[[package]]
@@ -3898,7 +4198,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -3922,6 +4222,20 @@ dependencies = [
"tracing-core",
]
+[[package]]
+name = "tracing-opentelemetry"
+version = "0.33.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "adbc64cba7137545b8044cb1fe9814f7aacf3c6b5f9b45be8bb5db538befdb26"
+dependencies = [
+ "js-sys",
+ "opentelemetry",
+ "tracing",
+ "tracing-core",
+ "tracing-subscriber",
+ "web-time",
+]
+
[[package]]
name = "tracing-serde"
version = "0.2.0"
@@ -3987,6 +4301,12 @@ version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
+[[package]]
+name = "unicode-general-category"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f"
+
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -4024,6 +4344,12 @@ dependencies = [
"ctutils",
]
+[[package]]
+name = "unsafe-libyaml-norway"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39abd59bf32521c7f2301b52d05a6a2c975b6003521cbd0c6dc1582f0a22104"
+
[[package]]
name = "untrusted"
version = "0.7.1"
@@ -4054,6 +4380,31 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+[[package]]
+name = "utoipa"
+version = "5.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160"
+dependencies = [
+ "indexmap",
+ "serde",
+ "serde_json",
+ "serde_norway",
+ "utoipa-gen",
+]
+
+[[package]]
+name = "utoipa-gen"
+version = "5.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "uuid",
+]
+
[[package]]
name = "uuid"
version = "1.23.4"
@@ -4063,9 +4414,20 @@ dependencies = [
"getrandom 0.4.2",
"js-sys",
"serde_core",
+ "sha1_smol",
"wasm-bindgen",
]
+[[package]]
+name = "uuid-simd"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8"
+dependencies = [
+ "outref",
+ "vsimd",
+]
+
[[package]]
name = "valuable"
version = "0.1.1"
@@ -4084,6 +4446,12 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+[[package]]
+name = "vsimd"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
+
[[package]]
name = "wait-timeout"
version = "0.2.1"
@@ -4118,15 +4486,6 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
-[[package]]
-name = "wasi"
-version = "0.14.7+wasi-0.2.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c"
-dependencies = [
- "wasip2",
-]
-
[[package]]
name = "wasip2"
version = "1.0.2+wasi-0.2.9"
@@ -4151,15 +4510,6 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
-[[package]]
-name = "wasite"
-version = "1.0.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42"
-dependencies = [
- "wasi 0.14.7+wasi-0.2.4",
-]
-
[[package]]
name = "wasm-bindgen"
version = "0.2.114"
@@ -4206,7 +4556,7 @@ dependencies = [
"bumpalo",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
"wasm-bindgen-shared",
]
@@ -4307,20 +4657,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
dependencies = [
"libredox",
- "wasite 0.1.0",
-]
-
-[[package]]
-name = "whoami"
-version = "2.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d6a5b12f9df4f978d2cfdb1bd3bac52433f44393342d7ee9c25f5a1c14c0f45d"
-dependencies = [
- "libc",
- "libredox",
- "objc2-system-configuration",
- "wasite 1.0.2",
- "web-sys",
+ "wasite",
]
[[package]]
@@ -4687,7 +5024,7 @@ dependencies = [
"heck",
"indexmap",
"prettyplease",
- "syn",
+ "syn 2.0.117",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
@@ -4703,7 +5040,7 @@ dependencies = [
"prettyplease",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
"wit-bindgen-core",
"wit-bindgen-rust",
]
@@ -4776,7 +5113,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
"synstructure",
]
@@ -4797,7 +5134,7 @@ checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -4817,7 +5154,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
"synstructure",
]
@@ -4838,7 +5175,7 @@ checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -4871,11 +5208,11 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
name = "zmij"
-version = "1.0.21"
+version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/Cargo.toml b/Cargo.toml
index 2c4f8d2..fe55397 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,8 +1,14 @@
[package]
name = "auth-api"
-version = "0.1.0"
+version = "2.0.0"
edition = "2024"
+rust-version = "1.88"
publish = false
+autotests = false
+
+[workspace]
+members = ["crates/testkit", "crates/verifier"]
+exclude = ["fuzz"]
[dependencies]
aes-gcm = "0.11.0"
@@ -12,7 +18,9 @@ async-nats = "0.49"
axum = "0.8.8"
axum-prometheus = "0.10"
metrics = "0.24"
+async-trait = "0.1"
base64 = "0.22.1"
+deadpool = { version = "0.13", default-features = false, features = ["managed", "rt_tokio_1"] }
deadpool-redis = { version = "0.23.0", features = ["rt_tokio_1", "script"] }
dotenvy = "0.15.7"
p256 = { version = "0.13", features = ["ecdsa", "pem", "jwk"] }
@@ -21,11 +29,20 @@ ipnetwork = "0.20"
# pulls in the `rsa` crate, which carries the unfixed RUSTSEC-2023-0071
# (Marvin) advisory - RSA is never used here, we only sign/verify ES256.
jsonwebtoken = { version = "10", features = ["aws_lc_rs"] }
-lettre = { version = "0.11.19", default-features = false, features = ["tokio1-rustls-tls", "smtp-transport", "builder", "hostname"] }
+lettre = { version = "0.11.19", default-features = false, features = ["tokio1-rustls-tls", "smtp-transport", "builder", "hostname", "pool"] }
+opentelemetry = { version = "0.32", default-features = false, features = ["trace"] }
+opentelemetry-http = { version = "0.32", default-features = false }
+opentelemetry-otlp = { version = "0.32", default-features = false, features = ["trace", "http-proto"] }
+opentelemetry_sdk = { version = "0.32", default-features = false, features = ["trace", "rt-tokio"] }
+percent-encoding = "2.3"
rand = "0.10.0"
rand_core = { version = "0.6", features = ["getrandom"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
+sha1 = "0.10.6"
+ciborium = "0.2.2"
+form_urlencoded = "1.2"
+hmac = "0.13.0"
sha2 = "0.11.0"
sqlx = { version = "0.8.6", default-features = false, features = ["runtime-tokio-rustls", "postgres", "uuid", "time", "derive", "json", "ipnetwork", "migrate"] }
tera = { version = "2.0", features = ["glob_fs"] }
@@ -34,69 +51,58 @@ time = { version = "0.3.47", features = ["serde"] }
tokio = { version = "1.50.0", features = ["rt-multi-thread", "macros", "net", "time", "signal"] }
totp-rs = { version = "5.7.1", features = ["gen_secret"] }
tracing = "0.1.44"
+tracing-opentelemetry = { version = "0.33", default-features = false }
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "fmt", "json"] }
-uuid = { version = "1.22.0", features = ["v4", "serde"] }
-async-trait = "0.1.89"
+uuid = { version = "1.22.0", features = ["v4", "v5", "serde"] }
tower-http = { version = "0.7.0", features = ["cors", "timeout"] }
email_address = "0.2.9"
-maxminddb = "0.29.0"
reqwest = { version = "0.13.2", default-features = false, features = ["json", "rustls", "form"] }
+utoipa = { version = "5", features = ["time", "uuid", "yaml"] }
+
+[features]
+# Entry points of the fuzz targets (`fuzz/`, `tests/fuzz_corpus.rs`). Never
+# enabled in a build that is deployed.
+fuzzing = []
[dev-dependencies]
+opentelemetry_sdk = { version = "0.32", default-features = false, features = ["trace", "testing"] }
+testkit = { path = "crates/testkit" }
criterion = { version = "0.8.2", features = ["html_reports"] }
-p256 = { version = "0.13", features = ["ecdsa"] }
-postgres = { version = "0.19.12", features = ["with-uuid-1", "with-time-0_3"] }
+futures = "0.3"
proptest = "1.9.0"
-rand_core = { version = "0.6", features = ["getrandom"] }
-serde_json = "1.0"
-sqlx = { version = "0.8.6", default-features = false, features = ["runtime-tokio-rustls", "postgres", "uuid", "time", "derive", "json", "ipnetwork", "migrate"] }
-tokio = { version = "1.50.0", features = ["full"] }
-uuid = { version = "1.22.0", features = ["v4"] }
-
-
-[[test]]
-name = "migrations"
-path = "tests/db/migrations/mod.rs"
-
-[[test]]
-name = "users"
-path = "tests/db/users/mod.rs"
-
-[[test]]
-name = "sessions"
-path = "tests/db/sessions/mod.rs"
-
-[[test]]
-name = "rbac"
-path = "tests/db/rbac/mod.rs"
+tokio = { version = "1.50.0", features = ["full", "test-util"] }
+tower = { version = "0.5.3", features = ["util"] }
-[[test]]
-name = "two_factor"
-path = "tests/db/two_factor/mod.rs"
+# Argon2 optimized even in dev and test builds: unoptimized, a hash at the
+# production parameters takes several times longer, which slowed every test that
+# signs in and made the timing simulation measure the build instead of the code.
+[profile.dev.package.argon2]
+opt-level = 3
-[[test]]
-name = "tokens"
-path = "tests/db/tokens/mod.rs"
+[profile.dev.package.blake2]
+opt-level = 3
-[[test]]
-name = "audit"
-path = "tests/db/audit/mod.rs"
+[profile.release]
+lto = "thin"
+codegen-units = 1
+strip = "symbols"
[[test]]
-name = "flows"
-path = "tests/db/flows/mod.rs"
+name = "integration"
+path = "tests/integration/main.rs"
[[test]]
-name = "db_smoke_test"
-path = "tests/db/smoke_test.rs"
+name = "security"
+path = "tests/security/main.rs"
[[test]]
-name = "performance"
-path = "tests/db/performance/mod.rs"
+name = "simulation"
+path = "tests/simulation/main.rs"
[[test]]
-name = "http"
-path = "tests/http/mod.rs"
+name = "fuzz_corpus"
+path = "tests/fuzz_corpus.rs"
+required-features = ["fuzzing"]
[[bench]]
name = "core_benches"
diff --git a/Dockerfile b/Dockerfile
index 18a3e7c..43b082f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,15 +1,17 @@
+# Base images are pinned by digest: a tag can be repointed, a digest cannot.
+# Refresh with `docker buildx imagetools inspect :`.
+
# =============================================================================
# Stage 1: Chef - install cargo-chef
# =============================================================================
-FROM rust:1.96-slim-bookworm AS chef
+FROM rust:1.96-slim-bookworm@sha256:e18a79fc84dfcfc3ab5ba72290398a644c135c97eaa881447fddc354ee4701a3 AS chef
# hadolint ignore=DL3008
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config \
- libssl-dev \
&& rm -rf /var/lib/apt/lists/*
-RUN cargo install cargo-chef --locked
+RUN cargo install cargo-chef --version 0.1.78 --locked
WORKDIR /app
@@ -38,30 +40,32 @@ RUN cargo build --release --bin auth-api
# =============================================================================
# Stage 4: Runtime
# =============================================================================
-FROM debian:bookworm-slim AS runtime
-
-# hadolint ignore=DL3008
-RUN apt-get update && apt-get install -y --no-install-recommends \
- ca-certificates \
- libssl3 \
- && rm -rf /var/lib/apt/lists/*
+# Distroless: glibc, CA certificates and nothing else - no shell, no package
+# manager, no setuid binary to exploit. TLS is rustls, so no OpenSSL either.
+FROM gcr.io/distroless/cc-debian12:nonroot@sha256:9dac0a79194e45a7da0158a9c6da57b217585af0786db3845d1f0ec1a0dd182f AS runtime
-RUN useradd --uid 1001 --no-create-home --shell /bin/false appuser
+ARG VERSION=dev
+ARG REVISION=unknown
+LABEL org.opencontainers.image.title="auth-api" \
+ org.opencontainers.image.version="${VERSION}" \
+ org.opencontainers.image.revision="${REVISION}" \
+ org.opencontainers.image.source="https://github.com/SIIR3X/auth-api"
WORKDIR /app
+# Copied as root and left that way: the service account runs the binary and
+# reads the templates, and can change neither.
COPY --from=builder /app/target/release/auth-api ./auth-api
COPY --from=builder /app/templates ./templates
-RUN chown -R appuser:appuser /app
-
-USER appuser
+# The distroless `nonroot` account, by number so the runtime never has to
+# resolve a name.
+USER 65532:65532
-EXPOSE 3000
+EXPOSE 3000 9464
-# Self-healthcheck via the binary itself: avoids shipping curl/wget in the
-# slim runtime image (smaller attack surface) and keeps the check in-process
-# (no PATH lookups, no shell parsing).
+# Self-healthcheck via the binary itself: the image has no curl, wget or shell,
+# and the check runs in-process (no PATH lookups, no shell parsing).
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD ["./auth-api", "--healthcheck"]
diff --git a/Dockerfile.dev b/Dockerfile.dev
index 724d2be..2f1e8a7 100644
--- a/Dockerfile.dev
+++ b/Dockerfile.dev
@@ -1,16 +1,18 @@
+# Base images pinned by digest, as in the production Dockerfile. TLS is rustls
+# throughout (sqlx-cli included): no OpenSSL at build or run time.
+
# =============================================================================
# Stage 1: Chef - install cargo-chef and sqlx-cli
# =============================================================================
-FROM rust:1.96-slim-bookworm AS chef
+FROM rust:1.96-slim-bookworm@sha256:e18a79fc84dfcfc3ab5ba72290398a644c135c97eaa881447fddc354ee4701a3 AS chef
# hadolint ignore=DL3008
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config \
- libssl-dev \
&& rm -rf /var/lib/apt/lists/*
-RUN cargo install cargo-chef --locked
-RUN cargo install sqlx-cli --no-default-features --features rustls,postgres --locked
+RUN cargo install cargo-chef --version 0.1.78 --locked \
+ && cargo install sqlx-cli --no-default-features --features rustls,postgres --locked
WORKDIR /app
@@ -39,12 +41,11 @@ RUN cargo build --release --bin auth-api
# =============================================================================
# Stage 4: Runtime dev - includes migrations, sqlx-cli and postgresql-client
# =============================================================================
-FROM debian:bookworm-slim AS runtime
+FROM debian:bookworm-slim@sha256:88200866dfff7ea7f5cbcb6ec7c8a701889efe6fe859fe64d6990e4b07ea4171 AS runtime
# hadolint ignore=DL3008
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
- libssl3 \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --uid 1001 --no-create-home --shell /bin/false appuser
@@ -58,8 +59,9 @@ COPY --from=builder /app/templates ./templates
RUN chown -R appuser:appuser /app
-USER appuser
+USER 1001:1001
EXPOSE 3000
-CMD ["sh", "-c", "sqlx migrate run --database-url $DATABASE_URL && ./auth-api"]
+# `exec` makes the service PID 1, so `docker stop` reaches it as SIGTERM.
+CMD ["sh", "-c", "sqlx migrate run --database-url \"$DATABASE_URL\" && exec ./auth-api"]
diff --git a/Makefile b/Makefile
index a3f15a7..e10af04 100644
--- a/Makefile
+++ b/Makefile
@@ -9,9 +9,11 @@ TEST_COMPOSE := docker-compose.test.yml
TEST_PROJECT := auth-api-test
TEST_DB_URL := postgres://postgres:postgres@localhost:5433/postgres
TEST_REDIS_URL := redis://127.0.0.1:6380
-TEST_NATS_URL := nats://127.0.0.1:4224
+TEST_NATS_URL := nats://auth-api-test-token@127.0.0.1:4224
IMAGE_LOCAL := auth-api:local
IMAGE_DEV := auth-api:dev
+HADOLINT_IMAGE := hadolint/hadolint:v2.15.1
+TRIVY_IMAGE := aquasec/trivy:0.74.0
# =============================================================================
# Help
@@ -46,14 +48,6 @@ dev-reset: ## Stop the development stack and remove volumes (reset DB)
dev-logs: ## Stream logs from the development stack
docker compose -f $(DEV_COMPOSE) logs -f
-.PHONY: dev-admin
-dev-admin: ## Start the development stack with the Appsmith admin panel (http://localhost:8080)
- docker compose -f $(DEV_COMPOSE) --profile admin up --build -d
-
-.PHONY: dev-admin-stop
-dev-admin-stop: ## Stop the development stack including Appsmith
- docker compose -f $(DEV_COMPOSE) --profile admin down
-
# =============================================================================
# Code Quality
# =============================================================================
@@ -67,8 +61,8 @@ fmt-check: ## Check formatting without modifying files
cargo fmt --check
.PHONY: clippy
-clippy: ## Run Clippy linter
- cargo clippy -- -D warnings
+clippy: ## Run Clippy linter on every target (lib, bins, tests, benches)
+ cargo clippy --workspace --all-targets --all-features -- -D warnings
.PHONY: deny
deny: ## Enforce dependency policy and security audit (cargo-deny)
@@ -81,8 +75,12 @@ quality: fmt-check clippy deny ## Run all code quality checks
# Tests
# =============================================================================
+# Suites: `tests/integration`, `tests/security` and `tests/simulation` need the
+# test infrastructure; unit tests (`src/`, `crates/testkit`) need none.
+TEST_ENV := TEST_DATABASE_URL=$(TEST_DB_URL) TEST_REDIS_URL=$(TEST_REDIS_URL) TEST_NATS_URL=$(TEST_NATS_URL)
+
.PHONY: test-infra-up
-test-infra-up: ## Start test infrastructure (postgres + redis)
+test-infra-up: ## Start test infrastructure (PostgreSQL, Redis, NATS, Mailpit)
docker compose -p $(TEST_PROJECT) -f $(TEST_COMPOSE) up -d --wait
.PHONY: test-infra-down
@@ -90,22 +88,80 @@ test-infra-down: ## Stop test infrastructure
docker compose -p $(TEST_PROJECT) -f $(TEST_COMPOSE) down
.PHONY: test
-test: test-infra-up ## Run all tests (starts/stops infrastructure automatically)
- TEST_DATABASE_URL=$(TEST_DB_URL) TEST_REDIS_URL=$(TEST_REDIS_URL) TEST_NATS_URL=$(TEST_NATS_URL) cargo nextest run; \
+test: test-infra-up ## Run every suite (starts/stops infrastructure automatically)
+ $(TEST_ENV) cargo nextest run --workspace; \
EXIT=$$?; $(MAKE) test-infra-down; exit $$EXIT
+.PHONY: test-local
+test-local: ## Run every suite against already-running infrastructure
+ $(TEST_ENV) cargo nextest run --workspace
+
+.PHONY: test-unit
+test-unit: ## Unit tests of the service and of the test harness (no infrastructure)
+ cargo nextest run --workspace --lib --bins
+
+.PHONY: test-integration
+test-integration: ## Integration suite: API end to end, repositories, services
+ $(TEST_ENV) cargo nextest run --test integration
+
+.PHONY: test-security
+test-security: ## Security suite, fuzz corpus replay included
+ $(TEST_ENV) cargo nextest run --test security
+ cargo nextest run --test fuzz_corpus --features fuzzing
+
+FUZZ_SECS ?= 60
+
+.PHONY: fuzz
+fuzz: ## Fuzz every target FUZZ_SECS seconds each (nightly toolchain, cargo-fuzz)
+ @for target in $$(cargo +nightly fuzz list --fuzz-dir fuzz); do \
+ echo "== $$target"; \
+ mkdir -p fuzz/corpus/$$target; \
+ dirs="fuzz/corpus/$$target fuzz/seeds/$$target"; \
+ [ -d fuzz/regressions/$$target ] && dirs="$$dirs fuzz/regressions/$$target"; \
+ cargo +nightly fuzz run --fuzz-dir fuzz $$target $$dirs -- -max_total_time=$(FUZZ_SECS) || exit 1; \
+ done
+
+# Files whose logic the unit tests must pin down: a surviving mutant is a fault
+# no unit test notices. Copies are built under $(MUTANTS_TMP), not /tmp.
+MUTANTS_FILES := -f 'src/domain/*.rs' -f src/utils/crypto.rs -f src/utils/jwt.rs \
+ -f src/utils/totp.rs -f src/utils/time.rs -f src/utils/backoff.rs -f src/utils/password.rs \
+ -f src/middleware/client_ip.rs -f src/middleware/error_body.rs \
+ -f src/config/validate.rs -f src/handlers/audit.rs
+MUTANTS_TMP ?= $(HOME)/.cache/mutants-tmp
+
+.PHONY: mutants
+mutants: ## Mutation testing of the security-relevant pure code (cargo-mutants, unit tests)
+ mkdir -p $(MUTANTS_TMP) reports
+ TMPDIR=$(MUTANTS_TMP) cargo mutants --package auth-api -j 3 -o reports \
+ --test-tool nextest $(MUTANTS_FILES) -- --lib
+
+.PHONY: test-sim
+test-sim: ## Simulation suite, long scenarios included
+ $(TEST_ENV) cargo nextest run --test simulation --run-ignored all
+
.PHONY: test-verbose
-test-verbose: test-infra-up ## Run all tests with detailed output
- TEST_DATABASE_URL=$(TEST_DB_URL) TEST_REDIS_URL=$(TEST_REDIS_URL) TEST_NATS_URL=$(TEST_NATS_URL) cargo nextest run --no-capture; \
+test-verbose: test-infra-up ## Run every suite with detailed output
+ $(TEST_ENV) cargo nextest run --workspace --no-capture; \
EXIT=$$?; $(MAKE) test-infra-down; exit $$EXIT
+.PHONY: ci
+ci: quality ci-test ## Full local CI gate: formatting, lints, dependency policy, every suite
+
+.PHONY: ci-test
+ci-test: ## Every suite with the CI profile and the fuzz corpus, against running test infrastructure
+ $(TEST_ENV) cargo nextest run --workspace --profile ci
+ cargo nextest run --profile ci --test fuzz_corpus --features fuzzing
+
.PHONY: coverage
-coverage: test-infra-up ## Run tests with coverage report (tarpaulin) - outputs HTML to reports/coverage/
- TEST_DATABASE_URL=$(TEST_DB_URL) TEST_REDIS_URL=$(TEST_REDIS_URL) TEST_NATS_URL=$(TEST_NATS_URL) \
- cargo tarpaulin --tests --skip-clean \
- --exclude-files "src/main.rs" "src/bin/*" \
- --out html --out json --output-dir reports/coverage; \
- EXIT=$$?; $(MAKE) test-infra-down; exit $$EXIT
+coverage: ## Coverage of every suite, failing under 93% lines, 89% regions, 83% functions (HTML in reports/coverage/)
+ $(TEST_ENV) cargo llvm-cov nextest --workspace --profile ci \
+ --ignore-filename-regex '(src/bin/|crates/testkit/)' \
+ --fail-under-lines 93 --fail-under-regions 89 --fail-under-functions 83 \
+ --html --output-dir reports/coverage
+
+.PHONY: js-test
+js-test: ## Tests of the npm packages in clients/js (Node.js 20 or later, no install)
+ cd clients/js/verifier && node --test
.PHONY: bench
bench: ## Run Criterion benchmarks (CPU only, no infrastructure needed)
@@ -123,6 +179,23 @@ bench-sql: test-infra-up ## Run SQL integration benchmarks
cargo run --release --bin bench_sql; \
EXIT=$$?; $(MAKE) test-infra-down; exit $$EXIT
+.PHONY: perf
+perf: ## Run the performance campaign: data volume x load (hours, see perf/README.md)
+ perf/run.sh
+
+.PHONY: perf-report
+perf-report: ## Tables and charts of a campaign into docs/perf (RUN=reports/perf/)
+ @test -n "$(RUN)" || { echo "usage: make perf-report RUN=reports/perf/"; exit 1; }
+ python3 perf/report.py $(RUN) docs/perf
+
+.PHONY: soak
+soak: ## One hour of mixed traffic on one API process: no error, stable memory (perf/soak.sh)
+ perf/soak.sh
+
+.PHONY: sizing
+sizing: ## Validate the sizing profiles under real container limits (hours, see perf/README.md)
+ perf/sizing.sh
+
# =============================================================================
# Build
# =============================================================================
@@ -139,32 +212,66 @@ docker-build: ## Build the production Docker image
docker-build-dev: ## Build the development Docker image
docker build -f Dockerfile.dev -t $(IMAGE_DEV) .
+.PHONY: release
+release: infra-check stack-test ## Build a signed release bundle in dist/ (VERSION=x.y.z RELEASE_SIGNING_KEY=)
+ @test -n "$(VERSION)" || { echo "usage: make release VERSION=x.y.z RELEASE_SIGNING_KEY=~/.ssh/auth-api-release"; exit 1; }
+ @test -n "$(RELEASE_SIGNING_KEY)" || { echo "RELEASE_SIGNING_KEY must name the SSH private key that signs the bundle"; exit 1; }
+ @test -z "$$(git status --porcelain)" || { echo "commit or stash your changes first (untracked files included)"; exit 1; }
+ @test "$$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)" = "$(VERSION)" || { echo "VERSION $(VERSION) differs from the version in Cargo.toml"; exit 1; }
+ @test "$(ALLOW_UNTAGGED)" = 1 || test "$$(git rev-parse -q --verify 'refs/tags/v$(VERSION)^{commit}')" = "$$(git rev-parse HEAD)" || { echo "tag v$(VERSION) must point at HEAD (ALLOW_UNTAGGED=1 for a test bundle)"; exit 1; }
+ git archive HEAD | docker build -t auth-api:$(VERSION) --build-arg VERSION=$(VERSION) --build-arg REVISION=$$(git rev-parse HEAD) -
+ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock $(TRIVY_IMAGE) \
+ image --exit-code 1 --severity CRITICAL,HIGH --ignore-unfixed auth-api:$(VERSION)
+ rm -rf dist/auth-api-$(VERSION) && mkdir -p dist/auth-api-$(VERSION)
+ docker save auth-api:$(VERSION) | gzip > dist/auth-api-$(VERSION)/auth-api-$(VERSION).image.tar.gz
+ docker image inspect --format '{{.Id}}' auth-api:$(VERSION) > dist/auth-api-$(VERSION)/IMAGE_ID
+ git archive HEAD migrations docker-compose.api.yml docker-compose.api.l.yml config.prod.env \
+ nats.conf deploy/profiles deploy/db nginx/nginx.conf \
+ scripts/backup-db.sh scripts/restore-db.sh scripts/backup-drill.sh scripts/rolling-update.sh \
+ docs/deploy/guides/prometheus-alerts.yml deploy/monitoring | tar -x -C dist/auth-api-$(VERSION)
+ cd dist/auth-api-$(VERSION) && find . -type f ! -name 'SHA256SUMS*' -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS
+ ssh-keygen -Y sign -q -f $(RELEASE_SIGNING_KEY) -n auth-api-release dist/auth-api-$(VERSION)/SHA256SUMS
+ @echo "bundle ready: dist/auth-api-$(VERSION) (signature: SHA256SUMS.sig)"
+
# =============================================================================
# Docker Security
# =============================================================================
.PHONY: docker-lint
-docker-lint: ## Lint the Dockerfile (hadolint)
- docker run --rm -i hadolint/hadolint < Dockerfile
+docker-lint: ## Lint the Dockerfiles (hadolint)
+ docker run --rm -i $(HADOLINT_IMAGE) < Dockerfile
+ docker run --rm -i $(HADOLINT_IMAGE) < Dockerfile.dev
.PHONY: docker-scan
docker-scan: docker-build ## Scan the production image for vulnerabilities (Trivy)
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
- aquasec/trivy image --severity CRITICAL,HIGH --ignore-unfixed $(IMAGE_LOCAL)
+ $(TRIVY_IMAGE) image --exit-code 1 --severity CRITICAL,HIGH --ignore-unfixed $(IMAGE_LOCAL)
.PHONY: docker-scan-dev
docker-scan-dev: docker-build-dev ## Scan the development image for vulnerabilities (Trivy)
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
- aquasec/trivy image --severity CRITICAL,HIGH --ignore-unfixed $(IMAGE_DEV)
+ $(TRIVY_IMAGE) image --exit-code 1 --severity CRITICAL,HIGH --ignore-unfixed $(IMAGE_DEV)
.PHONY: docker-scan-secrets
docker-scan-secrets: docker-build ## Scan the production image for secrets (Trivy)
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
- aquasec/trivy image --scanners secret $(IMAGE_LOCAL)
+ $(TRIVY_IMAGE) image --exit-code 1 --scanners secret $(IMAGE_LOCAL)
.PHONY: docker-check
docker-check: docker-lint docker-scan docker-scan-secrets ## Run all Docker checks
+.PHONY: infra-check
+infra-check: ## Check the deployment files: compose, Dockerfiles, nginx, Prometheus, shell scripts, image scan (scripts/infra-check.sh)
+ HADOLINT_IMAGE=$(HADOLINT_IMAGE) TRIVY_IMAGE=$(TRIVY_IMAGE) scripts/infra-check.sh
+
+.PHONY: stack-test
+stack-test: ## Production stack end to end: two instances behind nginx, failover, rolling update (scripts/stack-smoke.sh)
+ scripts/stack-smoke.sh
+
+.PHONY: docker-refresh-pins
+docker-refresh-pins: ## Point every pinned image digest at its tag's current image (then rebuild, scan, commit)
+ scripts/refresh-image-pins.sh
+
# =============================================================================
# Utilities
# =============================================================================
diff --git a/README.md b/README.md
index bd7d8e0..023d5e3 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,6 @@
-
@@ -13,87 +12,75 @@
-**Production-ready authentication and authorization API for Rust services - JWT
-access and refresh tokens, two-factor auth, RBAC, per-device sessions and
-risk-based login, built on Axum, PostgreSQL and Redis.**
+**A dedicated authentication API: accounts, sessions, second factors and
+client applications, with ES256 tokens other services verify through a JWKS.
+Built on Axum, PostgreSQL, Redis and NATS.**
## Description
-Auth API is a complete authentication and authorization backend for production
-services. It covers the full account lifecycle - registration, email
-verification, login, logout, password reset and email change - and issues
-short-lived JWT access tokens backed by rotating refresh tokens with replay and
-family-compromise detection.
+Auth API owns the whole account lifecycle - registration, email verification,
+sign-in, password reset and email change - and issues short-lived ES256 access
+tokens backed by rotating refresh tokens. Resource servers verify tokens
+offline with the published JWKS; nothing else needs to call it on every request.
-Security is built in rather than bolted on: two-factor authentication (TOTP and
-email OTP) with recovery codes, role-based access control, per-device session
-management, risk scoring on every login (GeoIP, new-device detection,
-behavioral history), account lockout, per-IP rate limiting and CAPTCHA on
-sensitive endpoints. Sensitive data is protected at rest - TOTP secrets are
-encrypted with AES-256-GCM - and every security-relevant action is written to an
-append-only audit log partitioned by month.
+Security is the design constraint rather than a feature list: sign-in answers
+never reveal whether an account exists, sensitive changes require a recent
+re-authentication, second factors cannot be bypassed or brute-forced, a replayed
+refresh token revokes its whole session, and production refuses to start with a
+configuration that disables any of it. The [security model](docs/dev/security-model.md)
+describes each control and the tests that pin it.
### What it provides
| Area | Capabilities |
|------|--------------|
-| Account lifecycle | registration, login, logout, email verification, password reset, email change with OTP at each step |
-| Tokens | JWT access + refresh, rotation, replay detection |
-| Two-factor | TOTP and email OTP, recovery codes for backup access |
-| Authorization | RBAC with roles and permissions |
-| Sessions | per-device visibility, revocation, family compromise detection |
-| Threat protection | risk scoring (GeoIP, new device, behavioral history), account lockout, per-IP rate limiting with a separate auth bucket, CAPTCHA |
-| Audit & crypto | append-only audit log partitioned by month, AES-256-GCM encryption for TOTP secrets at rest |
+| Accounts | Registration, email verification, sign-in by email or username, password reset, email change confirmed on both addresses, account deletion |
+| Tokens | ES256 access tokens, rotating refresh tokens with replay detection, absolute session lifetime, JWKS with zero-downtime key rotation |
+| Second factors | TOTP and email codes, recovery codes, replay guard, per-challenge and per-account budgets |
+| Client applications | Registered clients, device authorization (RFC 8628), authorization code with PKCE (RFC 7636, RFC 8252), per-client scopes and session limits |
+| Sessions | Per-device listing and revocation, re-authentication for sensitive actions |
+| Protection | Sliding-window rate limits per client (IPv6 per /64), account lockout, CAPTCHA, trusted-proxy address resolution |
+| Records | Append-only audit log partitioned by month, readable by each user; durable domain events on NATS JetStream |
+| Localization | English and French emails |
## Requirements
-**To run locally:**
+**To develop:** Rust (2024 edition), Docker with Compose, GNU Make - see
+[prerequisites](docs/dev/guides/prerequisites.md).
-- **Docker** and **Docker Compose**
-- **GNU Make**
-
-See [prerequisites](docs/dev/guides/prerequisites.md) for the full list.
-
-**To deploy:** PostgreSQL, Redis and the shared infrastructure (NATS, API
-Gateway) - see the [Deployment Guide](docs/deploy/README.md).
+**To deploy:** a server with Docker and a PostgreSQL and Redis reachable from
+it - see the [Deployment Guide](docs/deploy/README.md). NATS ships in the
+compose file.
## Installation
-### From source (development)
-
```bash
-git clone https://github.com/SIIR3X/auth-api.git
+git clone auth-api
cd auth-api
make dev
```
-The API is available at `http://localhost:3000`. A Mailpit instance for catching
-emails is available at `http://localhost:8025`.
-
-### Container image
-
-Released images are published to the GitHub Container Registry, tagged `latest`,
-the full version and `major.minor`:
-
-```bash
-docker pull ghcr.io/siir3x/auth-api:latest
-```
+The API serves at `http://localhost:3000`, and Mailpit catches emails at
+`http://localhost:8025`. Every port is bound to loopback.
## Usage
-`make dev` brings up the full stack (API, PostgreSQL, Redis, Mailpit) with
-migrations applied. The API then serves at `http://localhost:3000`.
+| Task | Command |
+|------|---------|
+| Run the full quality gate | `make test-infra-up && make ci` |
+| Register a client application | `auth-api --register-client --name [--primary]` |
+| Build a release bundle | `make release VERSION=x.y.z` |
-For all available commands see [commands](docs/dev/guides/commands.md). API
-routes and the database schema are documented in the
-[Developer Guide](docs/dev/README.md).
+All commands are in [commands](docs/dev/guides/commands.md); routes in
+[routes](docs/dev/api/routes.md) and the generated [OpenAPI document](docs/dev/api/openapi.yaml).
## Documentation
| Document | Contents |
|----------|----------|
-| [Developer Guide](docs/dev/README.md) | Prerequisites, commands, workflows, configuration, API routes, database schema |
-| [Deployment Guide](docs/deploy/README.md) | Secrets, database setup, API deployment, release process |
+| [Developer Guide](docs/dev/README.md) | Prerequisites, commands, quality gate, release, configuration, routes, schema, security model |
+| [Deployment Guide](docs/deploy/README.md) | Secrets, database, API and Nginx deployment, updates, operations runbook |
+| [`CHANGELOG.md`](CHANGELOG.md) | Changes per release, breaking changes and upgrade notes |
| [`LICENSE`](LICENSE) | MIT license terms |
## License
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..02fec32
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,59 @@
+# Security Policy
+
+## Supported versions
+
+| Version | Supported |
+|---------|-----------|
+| Latest 2.x minor release | Yes |
+| Previous 2.x minor release | Security fixes for 90 days after the next minor release |
+| Earlier releases | No |
+
+See the [versioning policy](docs/dev/guides/versioning.md).
+
+## Reporting a vulnerability
+
+Do not open a public issue. Report privately through GitHub's **Report a
+vulnerability** button on the repository's Security tab (private vulnerability
+reporting).
+
+Please include:
+
+- the affected version or commit, and the configuration if relevant;
+- the steps to reproduce, or a proof of concept;
+- the impact as you understand it: which account, token or data is at risk,
+ and what an attacker needs first.
+
+## What happens next
+
+| Step | Target |
+|------|--------|
+| Acknowledgement | 3 business days |
+| Triage and severity assessment | 7 days |
+| Fix for a critical or high severity issue | 30 days |
+| Fix for a medium or low severity issue | Next release |
+
+We keep you informed along the way, agree on a disclosure date with you (90
+days after the report at the latest, earlier once a fix is released), and credit
+you in the release notes unless you prefer otherwise.
+
+## Scope
+
+In scope: the code of this repository, its deployment files and documented
+configurations, and the verifier packages in `crates/verifier` and
+`clients/js/verifier`.
+
+Out of scope: vulnerabilities of dependencies (report them upstream; tell us if
+auth-api is affected in a way the advisory does not cover), volumetric denial of
+service, social engineering, findings that require an attacker with code
+execution on the host or access to the secrets store, and missing hardening of a
+deployment that ignores the production checks.
+
+## Safe harbour
+
+Research carried out in good faith within this policy, on your own deployment,
+without accessing other people's data or degrading a service others rely on, is
+welcome: we will not pursue it.
+
+The [security model](docs/dev/security-model.md) and
+[threat model](docs/dev/threat-model.md) describe what auth-api protects and the
+risks it accepts.
diff --git a/benches/core_benches.rs b/benches/core_benches.rs
index 2add520..49b4607 100644
--- a/benches/core_benches.rs
+++ b/benches/core_benches.rs
@@ -2,16 +2,11 @@ use std::hint::black_box;
use auth_api::{
config::CryptoConfig,
- repositories::login_location::RiskHistoryEntry,
- services::{
- auth::{CachedRiskContext, CachedRiskEvaluation, PreAuthState},
- risk_score::{self, LoginContext, RiskDecision, RiskResult},
- },
- utils::{geoip::GeoLocation, jwt, password, totp},
+ services::auth::{ChallengeMethod, PreAuthState},
+ utils::{jwt, password, totp},
};
-use criterion::{BenchmarkId, Criterion, SamplingMode, criterion_group, criterion_main};
-use ipnetwork::IpNetwork;
-use time::{Duration, OffsetDateTime};
+use criterion::{Criterion, SamplingMode, criterion_group, criterion_main};
+use time::OffsetDateTime;
use totp_rs::{Algorithm, Secret, TOTP};
use uuid::Uuid;
@@ -33,11 +28,8 @@ fn jwt_benches(c: &mut Criterion) {
}
let mut group = c.benchmark_group("jwt");
- let claims = jwt::Claims::new(
- Uuid::new_v4(),
- Uuid::new_v4(),
- OffsetDateTime::now_utc().unix_timestamp() + 3600,
- );
+ let now = OffsetDateTime::now_utc().unix_timestamp();
+ let claims = jwt::Claims::new(Uuid::new_v4(), Uuid::new_v4(), now, now + 3600);
let (signing_key, verifying_key) = generate_key_pair();
let (old_signing_key, old_verifying_key) = generate_key_pair();
let token = jwt::encode_token(&claims, &signing_key, None).expect("failed to encode token");
@@ -53,7 +45,8 @@ fn jwt_benches(c: &mut Criterion) {
group.bench_function("decode_es256", |b| {
b.iter(|| {
- jwt::decode_token(black_box(&token), black_box(&verifying_key)).expect("decode failed")
+ jwt::decode_token(black_box(&token), black_box(&verifying_key), now)
+ .expect("decode failed")
})
});
@@ -63,6 +56,7 @@ fn jwt_benches(c: &mut Criterion) {
black_box(&rotated_token),
black_box(&verifying_key),
Some(black_box(&old_verifying_key)),
+ now,
)
.expect("decode with fallback failed")
})
@@ -74,21 +68,7 @@ fn pre_auth_benches(c: &mut Criterion) {
let state = PreAuthState {
user_id: Uuid::new_v4(),
remember_me: false,
- risk: Some(CachedRiskEvaluation {
- context: CachedRiskContext {
- ip: "203.0.113.42/32".to_string(),
- user_agent: "bench-agent/1.0".to_string(),
- country: "FR".to_string(),
- city: "Paris".to_string(),
- latitude: Some(48.8566),
- longitude: Some(2.3522),
- },
- result: Some(RiskResult {
- score: 60,
- decision: RiskDecision::Challenge,
- signals: vec!["new_device".into(), "new_country:FR".into()],
- }),
- }),
+ method: Some(ChallengeMethod::Totp),
};
let json = serde_json::to_string(&state).expect("failed to serialize pre-auth state");
let legacy_uuid = state.user_id.to_string();
@@ -108,35 +88,13 @@ fn pre_auth_benches(c: &mut Criterion) {
});
}
-fn risk_score_benches(c: &mut Criterion) {
- let mut group = c.benchmark_group("risk_score");
- let login_time = OffsetDateTime::now_utc();
- let ctx = LoginContext {
- user_id: Uuid::new_v4(),
- ip: "198.51.100.20/32".parse::().expect("valid cidr"),
- user_agent: "Mozilla/5.0 (Benchmark)".into(),
- geo: Some(GeoLocation {
- country: "FR".into(),
- city: "Paris".into(),
- latitude: Some(48.8566),
- longitude: Some(2.3522),
- }),
- login_time,
- };
-
- for size in [0usize, 8, 64, 256] {
- let history = make_risk_history(size, login_time);
- group.bench_with_input(BenchmarkId::from_parameter(size), &history, |b, history| {
- b.iter(|| risk_score::compute_score(black_box(&ctx), black_box(history)))
- });
- }
-}
-
fn totp_benches(c: &mut Criterion) {
let mut group = c.benchmark_group("totp");
let secret = totp::generate_secret();
- let encryption_key = [7u8; 32];
- let encrypted = auth_api::utils::crypto::encrypt(&secret, &encryption_key)
+ // The production path: a keyring and a versioned ciphertext.
+ let keyring = auth_api::utils::crypto::Keyring::new([7u8; 32], None);
+ let encrypted = keyring
+ .encrypt(&secret)
.expect("failed to encrypt benchmark secret");
let secret_bytes = Secret::Encoded(secret.clone())
.to_bytes()
@@ -159,8 +117,9 @@ fn totp_benches(c: &mut Criterion) {
totp::verify_code(
black_box(&encrypted),
black_box(&code),
- black_box(&encryption_key),
+ black_box(&keyring),
1,
+ OffsetDateTime::now_utc().unix_timestamp(),
)
.expect("verify")
})
@@ -200,22 +159,9 @@ fn password_benches(c: &mut Criterion) {
});
}
-fn make_risk_history(size: usize, login_time: OffsetDateTime) -> Vec {
- (0..size)
- .map(|index| RiskHistoryEntry {
- country: if index % 4 == 0 { "FR" } else { "DE" }.to_string(),
- city: format!("city-{index}"),
- user_agent: format!("agent/{}", index % 12),
- latitude: Some(48.0 + (index as f64 / 100.0)),
- longitude: Some(2.0 + (index as f64 / 100.0)),
- last_seen: login_time - Duration::hours((index % 72) as i64 + 1),
- })
- .collect()
-}
-
criterion_group!(
name = benches;
config = Criterion::default().configure_from_args();
- targets = jwt_benches, pre_auth_benches, risk_score_benches, totp_benches, password_benches
+ targets = jwt_benches, pre_auth_benches, totp_benches, password_benches
);
criterion_main!(benches);
diff --git a/clients/js/verifier/README.md b/clients/js/verifier/README.md
new file mode 100644
index 0000000..a4d4e3d
--- /dev/null
+++ b/clients/js/verifier/README.md
@@ -0,0 +1,51 @@
+# @auth-api/verifier
+
+Verify auth-api access tokens in Node.js resource servers. Internal package,
+not published: install it from the repository.
+
+```bash
+npm install "git+ssh://git@github.com//auth-api.git#path:clients/js/verifier"
+```
+
+No dependency: WebCrypto and `fetch` from Node.js 20 or later.
+
+```js
+import express from "express";
+import { Verifier, middleware } from "@auth-api/verifier";
+
+const verifier = new Verifier({
+ issuer: "https://auth.example.com", // auth-api's APP_PUBLIC_URL
+ audience: "https://api.example.com", // this service, in auth-api's JWT_AUDIENCE
+});
+
+const app = express();
+app.get("/invoices", middleware(verifier), (req, res) => {
+ if (!req.auth.hasPermission("invoices:read")) return res.sendStatus(403);
+ res.json({ owner: req.auth.subject });
+});
+```
+
+Outside Express, call `await verifier.verify(token)`: it returns the verified
+token (`subject`, `sessionId`, `clientId`, `roles`, `permissions`, `expiresAt`)
+or throws a `VerifyError` whose `code` is `malformed`, `unknown_key`, `invalid`,
+`expired`, `revoked` or `unavailable`.
+
+Checked on every call, without contacting auth-api: the ES256 signature against
+the published keys (fetched once, refetched when a token names an unknown key,
+at most once a minute), `iss`, `aud`, `exp` and `nbf`.
+
+A revocation before expiry (logout, password change, revoked session) is only
+seen with introspection: register the service as a confidential client in
+auth-api, then
+
+```js
+const verifier = new Verifier({
+ issuer: "https://auth.example.com",
+ audience: "https://api.example.com",
+ introspection: { clientId: "invoices-api", clientSecret: process.env.AUTH_CLIENT_SECRET },
+});
+```
+
+Answers are cached 30 seconds per token (`cacheTtlMs`).
+
+Tests: `make js-test`.
diff --git a/clients/js/verifier/package.json b/clients/js/verifier/package.json
new file mode 100644
index 0000000..00ef1de
--- /dev/null
+++ b/clients/js/verifier/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "@auth-api/verifier",
+ "version": "0.1.0",
+ "description": "Verify auth-api access tokens in Node.js resource servers: JWKS, issuer, audience, permissions, optional revocation checks.",
+ "private": true,
+ "license": "MIT",
+ "type": "module",
+ "exports": {
+ ".": {
+ "types": "./src/index.d.ts",
+ "default": "./src/index.js"
+ }
+ },
+ "types": "./src/index.d.ts",
+ "files": ["src"],
+ "engines": {
+ "node": ">=20"
+ },
+ "scripts": {
+ "test": "node --test"
+ }
+}
diff --git a/clients/js/verifier/src/index.d.ts b/clients/js/verifier/src/index.d.ts
new file mode 100644
index 0000000..c2e1554
--- /dev/null
+++ b/clients/js/verifier/src/index.d.ts
@@ -0,0 +1,62 @@
+export interface IntrospectionOptions {
+ clientId: string;
+ clientSecret: string;
+ /** Default: 30 000. */
+ cacheTtlMs?: number;
+ /** Default: `${issuer}/oauth/introspect`. */
+ endpoint?: string;
+}
+
+export interface VerifierOptions {
+ /** auth-api's APP_PUBLIC_URL, the `iss` of its tokens. */
+ issuer: string;
+ /** This service, one of auth-api's JWT_AUDIENCE. */
+ audience: string;
+ /** Default: `${issuer}/.well-known/jwks.json`. */
+ jwksUri?: string;
+ /** Default: 30. */
+ leewaySeconds?: number;
+ /** Default: 60 000. */
+ minRefreshIntervalMs?: number;
+ introspection?: IntrospectionOptions;
+ /** For tests. */
+ fetch?: typeof fetch;
+ now?: () => number;
+}
+
+export interface VerifiedToken {
+ /** The user, or for a client credentials token a UUID standing for the client. */
+ subject: string;
+ /** Nil UUID for a client credentials token. */
+ sessionId: string;
+ tokenId: string;
+ clientId: string | null;
+ roles: string[];
+ permissions: string[];
+ /** Unix seconds. */
+ expiresAt: number;
+ hasPermission(permission: string): boolean;
+ hasRole(role: string): boolean;
+ isClientToken(): boolean;
+}
+
+export type VerifyErrorCode =
+ | "malformed"
+ | "unknown_key"
+ | "invalid"
+ | "expired"
+ | "revoked"
+ | "unavailable";
+
+export class VerifyError extends Error {
+ code: VerifyErrorCode;
+}
+
+export class Verifier {
+ constructor(options: VerifierOptions);
+ verify(token: string): Promise;
+}
+
+export function middleware(
+ verifier: Verifier,
+): (request: any, response: any, next: (error?: unknown) => void) => Promise;
diff --git a/clients/js/verifier/src/index.js b/clients/js/verifier/src/index.js
new file mode 100644
index 0000000..581d5aa
--- /dev/null
+++ b/clients/js/verifier/src/index.js
@@ -0,0 +1,238 @@
+// Verify auth-api access tokens in Node.js resource servers.
+//
+// Checked on every call, without contacting auth-api: the ES256 signature
+// against the issuer's published keys (fetched once, refetched when a token
+// names an unknown key, at most once a minute), `iss`, `aud`, `exp` and `nbf`.
+// A revocation before expiry (logout, password change) is only seen with
+// `introspection`, which asks auth-api and caches the answer briefly.
+//
+// No dependency: WebCrypto and fetch from Node.js 20.
+
+const { subtle } = globalThis.crypto;
+const encoder = new TextEncoder();
+const decoder = new TextDecoder();
+
+export class VerifyError extends Error {
+ /**
+ * @param {"malformed" | "unknown_key" | "invalid" | "expired" | "revoked" | "unavailable"} code
+ * @param {string} message
+ */
+ constructor(code, message) {
+ super(message);
+ this.name = "VerifyError";
+ this.code = code;
+ }
+}
+
+function base64UrlDecode(value) {
+ return new Uint8Array(Buffer.from(value, "base64url"));
+}
+
+function parseJson(bytes) {
+ try {
+ return JSON.parse(decoder.decode(bytes));
+ } catch {
+ throw new VerifyError("malformed", "the token is not a well-formed JWT");
+ }
+}
+
+export class Verifier {
+ /**
+ * @param {import("./index.d.ts").VerifierOptions} options
+ */
+ constructor(options) {
+ if (!options?.issuer || !options?.audience) {
+ throw new TypeError("issuer and audience are required");
+ }
+ this.issuer = options.issuer.replace(/\/+$/, "");
+ this.audience = options.audience;
+ this.jwksUri = options.jwksUri ?? `${this.issuer}/.well-known/jwks.json`;
+ this.leewaySeconds = options.leewaySeconds ?? 30;
+ this.minRefreshIntervalMs = options.minRefreshIntervalMs ?? 60_000;
+ this.introspection = options.introspection
+ ? {
+ cacheTtlMs: 30_000,
+ endpoint: `${this.issuer}/oauth/introspect`,
+ ...options.introspection,
+ }
+ : null;
+ this.fetch = options.fetch ?? globalThis.fetch;
+ this.now = options.now ?? (() => Date.now());
+ /** @type {Map} */
+ this.keys = new Map();
+ this.fetchedAt = 0;
+ /** @type {Promise | null} */
+ this.refreshing = null;
+ /** @type {Map} */
+ this.introspected = new Map();
+ }
+
+ /**
+ * Verify a token, without the `Bearer ` prefix.
+ * @param {string} token
+ * @returns {Promise}
+ */
+ async verify(token) {
+ const parts = typeof token === "string" ? token.split(".") : [];
+ if (parts.length !== 3) {
+ throw new VerifyError("malformed", "the token is not a well-formed JWT");
+ }
+ const header = parseJson(base64UrlDecode(parts[0]));
+ const claims = parseJson(base64UrlDecode(parts[1]));
+ if (header.alg !== "ES256") {
+ throw new VerifyError("invalid", "only ES256 tokens are accepted");
+ }
+ if (typeof header.kid !== "string") {
+ throw new VerifyError("unknown_key", "the token names no signing key");
+ }
+ const key = await this.#key(header.kid);
+ const valid = await subtle.verify(
+ { name: "ECDSA", hash: "SHA-256" },
+ key,
+ base64UrlDecode(parts[2]),
+ encoder.encode(`${parts[0]}.${parts[1]}`),
+ );
+ if (!valid) {
+ throw new VerifyError("invalid", "the signature does not verify");
+ }
+
+ const now = Math.floor(this.now() / 1000);
+ if (claims.iss !== this.issuer) {
+ throw new VerifyError("invalid", "the token was issued by someone else");
+ }
+ const audiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
+ if (!audiences.includes(this.audience)) {
+ throw new VerifyError("invalid", "the token is not meant for this audience");
+ }
+ if (typeof claims.exp !== "number" || claims.exp + this.leewaySeconds <= now) {
+ throw new VerifyError("expired", "the token has expired");
+ }
+ if (typeof claims.nbf === "number" && claims.nbf - this.leewaySeconds > now) {
+ throw new VerifyError("invalid", "the token is not valid yet");
+ }
+
+ const verified = {
+ subject: claims.sub,
+ sessionId: claims.sid,
+ tokenId: claims.jti,
+ clientId: claims.client_id ?? null,
+ roles: claims.roles ?? [],
+ permissions: claims.permissions ?? [],
+ expiresAt: claims.exp,
+ hasPermission(permission) {
+ return this.permissions.includes(permission);
+ },
+ hasRole(role) {
+ return this.roles.includes(role);
+ },
+ isClientToken() {
+ return this.clientId !== null && this.sessionId === "00000000-0000-0000-0000-000000000000";
+ },
+ };
+ if (this.introspection && !(await this.#active(token, verified.tokenId))) {
+ throw new VerifyError("revoked", "the token was revoked");
+ }
+ return verified;
+ }
+
+ async #key(kid) {
+ const known = this.keys.get(kid);
+ if (known) return known;
+ if (!this.refreshing) {
+ if (this.fetchedAt && this.now() - this.fetchedAt < this.minRefreshIntervalMs) {
+ throw new VerifyError("unknown_key", "the token is signed with a key the issuer does not publish");
+ }
+ this.refreshing = this.#refresh().finally(() => {
+ this.refreshing = null;
+ });
+ }
+ await this.refreshing;
+ const key = this.keys.get(kid);
+ if (!key) {
+ throw new VerifyError("unknown_key", "the token is signed with a key the issuer does not publish");
+ }
+ return key;
+ }
+
+ async #refresh() {
+ let set;
+ try {
+ const response = await this.fetch(this.jwksUri);
+ if (!response.ok) throw new Error(`JWKS answered ${response.status}`);
+ set = await response.json();
+ } catch (error) {
+ throw new VerifyError("unavailable", `auth-api could not be reached: ${error.message}`);
+ }
+ const keys = new Map();
+ for (const jwk of set.keys ?? []) {
+ if (jwk.kty !== "EC" || jwk.crv !== "P-256" || typeof jwk.kid !== "string") continue;
+ const key = await subtle.importKey(
+ "jwk",
+ { kty: "EC", crv: "P-256", x: jwk.x, y: jwk.y },
+ { name: "ECDSA", namedCurve: "P-256" },
+ false,
+ ["verify"],
+ );
+ keys.set(jwk.kid, key);
+ }
+ this.keys = keys;
+ this.fetchedAt = this.now();
+ }
+
+ async #active(token, tokenId) {
+ const { clientId, clientSecret, cacheTtlMs, endpoint } = this.introspection;
+ const cached = this.introspected.get(tokenId);
+ if (cached && this.now() - cached.at < cacheTtlMs) return cached.active;
+
+ let answer;
+ try {
+ const response = await this.fetch(endpoint, {
+ method: "POST",
+ headers: {
+ authorization: `Basic ${Buffer.from(
+ `${encodeURIComponent(clientId)}:${encodeURIComponent(clientSecret)}`,
+ ).toString("base64")}`,
+ "content-type": "application/x-www-form-urlencoded",
+ },
+ body: new URLSearchParams({ token }),
+ });
+ if (!response.ok) throw new Error(`introspection answered ${response.status}`);
+ answer = await response.json();
+ } catch (error) {
+ throw new VerifyError("unavailable", `auth-api could not be reached: ${error.message}`);
+ }
+ for (const [id, entry] of this.introspected) {
+ if (this.now() - entry.at >= cacheTtlMs) this.introspected.delete(id);
+ }
+ this.introspected.set(tokenId, { at: this.now(), active: answer.active === true });
+ return answer.active === true;
+ }
+}
+
+/**
+ * Express or Connect middleware: sets `request.auth` to the verified token, or
+ * answers 401 (503 when auth-api cannot be reached for keys or introspection).
+ * @param {Verifier} verifier
+ */
+export function middleware(verifier) {
+ return async (request, response, next) => {
+ const header = request.headers?.authorization ?? "";
+ if (!header.startsWith("Bearer ")) {
+ response.statusCode = 401;
+ response.setHeader("www-authenticate", "Bearer");
+ return response.end();
+ }
+ try {
+ request.auth = await verifier.verify(header.slice("Bearer ".length));
+ return next();
+ } catch (error) {
+ if (error instanceof VerifyError && error.code === "unavailable") {
+ response.statusCode = 503;
+ return response.end();
+ }
+ response.statusCode = 401;
+ response.setHeader("www-authenticate", 'Bearer error="invalid_token"');
+ return response.end();
+ }
+ };
+}
diff --git a/clients/js/verifier/test/verify.test.js b/clients/js/verifier/test/verify.test.js
new file mode 100644
index 0000000..436b39f
--- /dev/null
+++ b/clients/js/verifier/test/verify.test.js
@@ -0,0 +1,172 @@
+import assert from "node:assert/strict";
+import { createServer } from "node:http";
+import { after, before, test } from "node:test";
+
+import { Verifier, VerifyError, middleware } from "../src/index.js";
+
+const { subtle } = globalThis.crypto;
+const ISSUER = "https://auth.example.com";
+const AUDIENCE = "https://api.example.com";
+
+let keyPair;
+let otherKeyPair;
+let server;
+let base;
+let jwksRequests = 0;
+const revoked = new Set();
+
+const b64 = (value) => Buffer.from(value).toString("base64url");
+
+async function sign(claims, { kid = "k1", keys = keyPair, alg = "ES256" } = {}) {
+ const header = b64(JSON.stringify({ alg, typ: "JWT", kid }));
+ const payload = b64(JSON.stringify(claims));
+ const signature = await subtle.sign(
+ { name: "ECDSA", hash: "SHA-256" },
+ keys.privateKey,
+ new TextEncoder().encode(`${header}.${payload}`),
+ );
+ return `${header}.${payload}.${b64(new Uint8Array(signature))}`;
+}
+
+function claims(overrides = {}) {
+ const now = Math.floor(Date.now() / 1000);
+ return {
+ iss: ISSUER,
+ aud: [AUDIENCE, ISSUER],
+ sub: "0f8fad5b-d9cb-469f-a165-70867728950e",
+ sid: "7c9e6679-7425-40de-944b-e07fc1f90ae7",
+ jti: crypto.randomUUID(),
+ iat: now,
+ nbf: now,
+ exp: now + 900,
+ roles: ["user"],
+ permissions: ["invoices:read"],
+ ...overrides,
+ };
+}
+
+before(async () => {
+ keyPair = await subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]);
+ otherKeyPair = await subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]);
+ const jwk = await subtle.exportKey("jwk", keyPair.publicKey);
+ server = createServer(async (request, response) => {
+ if (request.url === "/.well-known/jwks.json") {
+ jwksRequests += 1;
+ response.setHeader("content-type", "application/json");
+ return response.end(JSON.stringify({ keys: [{ ...jwk, kid: "k1", alg: "ES256", use: "sig" }] }));
+ }
+ if (request.url === "/oauth/introspect") {
+ let body = "";
+ for await (const chunk of request) body += chunk;
+ const token = new URLSearchParams(body).get("token");
+ const authorized = request.headers.authorization === `Basic ${Buffer.from("rs:secret").toString("base64")}`;
+ response.statusCode = authorized ? 200 : 401;
+ response.setHeader("content-type", "application/json");
+ return response.end(JSON.stringify({ active: authorized && !revoked.has(token) }));
+ }
+ response.statusCode = 404;
+ response.end();
+ });
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ base = `http://127.0.0.1:${server.address().port}`;
+});
+
+after(() => server.close());
+
+function verifier(options = {}) {
+ return new Verifier({
+ issuer: ISSUER,
+ audience: AUDIENCE,
+ jwksUri: `${base}/.well-known/jwks.json`,
+ ...options,
+ });
+}
+
+test("a valid token verifies with its permissions, keys fetched once", async () => {
+ const v = verifier();
+ const before = jwksRequests;
+ const token = await v.verify(await sign(claims()));
+ assert.equal(token.subject, "0f8fad5b-d9cb-469f-a165-70867728950e");
+ assert.ok(token.hasPermission("invoices:read"));
+ assert.ok(token.hasRole("user"));
+ assert.ok(!token.isClientToken());
+ await v.verify(await sign(claims()));
+ assert.equal(jwksRequests - before, 1);
+});
+
+test("expired, premature, foreign and forged tokens are refused", async () => {
+ const v = verifier();
+ const now = Math.floor(Date.now() / 1000);
+ const refused = async (token, code) => {
+ await assert.rejects(v.verify(token), (error) => error instanceof VerifyError && error.code === code);
+ };
+ await refused(await sign(claims({ exp: now - 60 })), "expired");
+ await refused(await sign(claims({ nbf: now + 3600 })), "invalid");
+ await refused(await sign(claims({ iss: "https://evil.example.com" })), "invalid");
+ await refused(await sign(claims({ aud: ["https://other.example.com"] })), "invalid");
+ await refused(await sign(claims(), { keys: otherKeyPair }), "invalid");
+ await refused(await sign(claims(), { alg: "HS256" }), "invalid");
+ await refused("not.a-token", "malformed");
+ await refused("nope", "malformed");
+});
+
+test("an unknown key triggers one fetch, then waits before the next", async () => {
+ const v = verifier();
+ const before = jwksRequests;
+ const forged = await sign(claims(), { kid: "unknown", keys: otherKeyPair });
+ await assert.rejects(v.verify(forged), { code: "unknown_key" });
+ await assert.rejects(v.verify(forged), { code: "unknown_key" });
+ assert.equal(jwksRequests - before, 1);
+});
+
+test("introspection catches a revoked token", async () => {
+ const v = verifier({
+ introspection: { clientId: "rs", clientSecret: "secret", cacheTtlMs: 0, endpoint: `${base}/oauth/introspect` },
+ });
+ const token = await sign(claims());
+ await v.verify(token);
+ revoked.add(token);
+ await assert.rejects(v.verify(token), { code: "revoked" });
+
+ const unreachable = verifier({
+ introspection: { clientId: "rs", clientSecret: "secret", endpoint: "http://127.0.0.1:1/oauth/introspect" },
+ });
+ await assert.rejects(unreachable.verify(await sign(claims())), { code: "unavailable" });
+});
+
+test("a client credentials token is recognized", async () => {
+ const token = await verifier().verify(
+ await sign(claims({ sid: "00000000-0000-0000-0000-000000000000", client_id: "backend", roles: undefined })),
+ );
+ assert.ok(token.isClientToken());
+ assert.equal(token.clientId, "backend");
+ assert.deepEqual(token.roles, []);
+});
+
+test("the middleware sets request.auth or answers 401", async () => {
+ const handle = middleware(verifier());
+ const call = async (authorization) => {
+ const request = { headers: authorization ? { authorization } : {} };
+ const response = {
+ statusCode: 200,
+ headers: {},
+ setHeader(name, value) {
+ this.headers[name] = value;
+ },
+ end() {},
+ };
+ let passed = false;
+ await handle(request, response, () => {
+ passed = true;
+ });
+ return { request, response, passed };
+ };
+ const ok = await call(`Bearer ${await sign(claims())}`);
+ assert.ok(ok.passed);
+ assert.ok(ok.request.auth.hasPermission("invoices:read"));
+ const missing = await call();
+ assert.equal(missing.response.statusCode, 401);
+ assert.equal(missing.response.headers["www-authenticate"], "Bearer");
+ const invalid = await call("Bearer nope");
+ assert.equal(invalid.response.headers["www-authenticate"], 'Bearer error="invalid_token"');
+});
diff --git a/config.prod.env b/config.prod.env
index 72aacc3..660d94c 100644
--- a/config.prod.env
+++ b/config.prod.env
@@ -4,15 +4,19 @@ APP_ENV=production
SERVER_HOST=0.0.0.0
SERVER_PORT=3000
APP_PUBLIC_URL=https://api.example.com
-TRUSTED_PROXY_CIDRS=127.0.0.1/32
+# Web application the emails link to (/verify-email, /reset-password).
+FRONTEND_URL=https://app.example.com
+# The host nginx reaches the container through Docker's port proxy: the peer
+# the application sees is the compose network gateway (docker-compose.api.yml).
+TRUSTED_PROXY_CIDRS=172.30.0.1/32
# Database
-DB_MAX_CONNECTIONS=20
+# DB_MAX_CONNECTIONS, REDIS_POOL_SIZE and ARGON2_MAX_CONCURRENCY come from the
+# sizing profile (deploy/profiles/*.env), passed to compose with --env-file.
DB_MIN_CONNECTIONS=2
-DB_ACQUIRE_TIMEOUT_SECS=30
+DB_ACQUIRE_TIMEOUT_SECS=5
# Redis
-REDIS_POOL_SIZE=10
REDIS_WAIT_TIMEOUT_MS=2000
# JWT (ES256 asymmetric keys - secrets injected via environment)
@@ -20,19 +24,16 @@ REDIS_WAIT_TIMEOUT_MS=2000
JWT_ACCESS_EXPIRY_SECS=900
JWT_REFRESH_EXPIRY_SECS=2592000
JWT_STRICT_SESSION_BINDING=true
-# REQUIRED in production. CSV of audience values stamped into the `aud`
-# claim of access tokens. Each entry must exactly match the `expected_aud`
-# (or APP_PUBLIC_URL fallback) configured on the corresponding downstream
-# resource server. Empty value will refuse to start.
+# CSV of audience values stamped into the `aud` claim of access tokens, one
+# per downstream resource server, each matching exactly the audience that
+# server expects. APP_PUBLIC_URL is always added, so tokens are never issued
+# without an audience; a blank entry in the list refuses to start.
# JWT_AUDIENCE=https://core.example.com,https://billing.example.com
# Argon2id (tune for your hardware)
ARGON2_MEMORY_KIB=65536
ARGON2_ITERATIONS=3
ARGON2_PARALLELISM=4
-# Max concurrent Argon2 operations (bounds worst-case RAM: N x ARGON2_MEMORY_KIB).
-# Defaults to the number of CPU cores when unset.
-# ARGON2_MAX_CONCURRENCY=4
# TOTP / 2FA
TOTP_ISSUER=MyApp
@@ -50,14 +51,6 @@ LOCKOUT_THRESHOLD=10
LOCKOUT_DURATION_SECS=1800
SENSITIVE_ACTION_REAUTH_SECS=600
-# GeoIP
-GEOIP_DB_PATH=
-GEOIP_REQUIRED=false
-RISK_ALERT_THRESHOLD=30
-RISK_CHALLENGE_THRESHOLD=60
-RISK_BLOCK_THRESHOLD=80
-RISK_HISTORY_DAYS=90
-
# SMTP
SMTP_HOST=smtp.example.com
SMTP_PORT=587
@@ -73,6 +66,12 @@ CAPTCHA_VERIFY_URL=https://hcaptcha.com/siteverify
CAPTCHA_TIMEOUT_SECS=5
CAPTCHA_FAIL_OPEN=false
+# Breached passwords (Pwned Passwords range API, k-anonymity)
+PWNED_PASSWORDS_ENABLED=true
+PWNED_PASSWORDS_URL=https://api.pwnedpasswords.com
+PWNED_PASSWORDS_TIMEOUT_MS=1500
+PWNED_PASSWORDS_FAIL_OPEN=true
+
# CORS
CORS_ALLOWED_ORIGINS=https://app.example.com
CORS_ALLOW_CREDENTIALS=true
@@ -86,6 +85,10 @@ DEVICE_AUTH_VERIFICATION_URI=https://auth.example.com/device
# Metrics (Prometheus exposition on an internal listener - never behind nginx)
METRICS_ENABLED=true
METRICS_PORT=9464
+# Traces over OTLP/HTTP to a collector on the WireGuard network; unset exports nothing.
+# OTEL_EXPORTER_OTLP_ENDPOINT=http://10.0.0.3:4318
+OTEL_SERVICE_NAME=auth-api
+OTEL_TRACES_SAMPLER_ARG=0.1
# Logging
LOG_LEVEL=info
diff --git a/crates/testkit/Cargo.toml b/crates/testkit/Cargo.toml
new file mode 100644
index 0000000..0db1e74
--- /dev/null
+++ b/crates/testkit/Cargo.toml
@@ -0,0 +1,31 @@
+[package]
+name = "testkit"
+version = "0.0.0"
+edition = "2024"
+rust-version = "1.88"
+publish = false
+description = "Shared harness of the auth-api test suites: test app, databases, clock, mail capture, fault injection."
+
+[dependencies]
+anyhow = "1.0.102"
+auth-api = { path = "../.." }
+axum = "0.8.8"
+base64 = "0.22.1"
+ciborium = "0.2.2"
+deadpool-redis = "0.23.0"
+dotenvy = "0.15.7"
+jsonschema = { version = "0.56.0", default-features = false }
+jsonwebtoken = { version = "10", features = ["aws_lc_rs"] }
+p256 = { version = "0.13", features = ["ecdsa", "pem"] }
+rand_core = { version = "0.6", features = ["getrandom"] }
+lettre = { version = "0.11.19", default-features = false, features = ["builder"] }
+reqwest = { version = "0.13.2", default-features = false, features = ["json", "rustls", "form"] }
+serde = { version = "1.0.228", features = ["derive"] }
+serde_json = "1.0.149"
+sha2 = "0.11.0"
+sqlx = { version = "0.8.6", default-features = false, features = ["runtime-tokio-rustls", "postgres", "uuid", "time", "migrate"] }
+time = "0.3.47"
+tokio = { version = "1.50.0", features = ["full"] }
+tracing-subscriber = { version = "0.3.23", features = ["env-filter", "fmt"] }
+utoipa = "5"
+uuid = { version = "1.22.0", features = ["v4"] }
diff --git a/crates/testkit/src/app.rs b/crates/testkit/src/app.rs
new file mode 100644
index 0000000..ed508d0
--- /dev/null
+++ b/crates/testkit/src/app.rs
@@ -0,0 +1,664 @@
+//! TestApp: the real router on a random port, backed by its own database.
+//!
+//! Every app gets a fresh database cloned from the migrated template, a
+//! [`TestClock`] installed as the application clock, and a [`MailOutbox`]
+//! capturing what it sends. Its requests are attributed to a client address
+//! unique to the app, so per-address budgets never leak between tests.
+
+use std::sync::Arc;
+
+use auth_api::{
+ config::Config,
+ handlers,
+ services::mailer::Mailer,
+ state::AppState,
+ utils::{
+ jwt::{self, Claims},
+ redis_pool::RedisPool,
+ },
+};
+use deadpool_redis::redis::AsyncCommands;
+use reqwest::{Client, Response};
+use serde::Serialize;
+use sqlx::{PgPool, postgres::PgPoolOptions};
+use tokio::{net::TcpListener, task::JoinHandle};
+
+use crate::{
+ clock::TestClock,
+ contract,
+ db::TestDb,
+ env,
+ faults::FaultProxy,
+ keys,
+ mail::MailOutbox,
+ mailpit::{self, MailpitClient},
+};
+
+type Configure = Box;
+
+/// Options of a [`TestApp`] beyond its configuration.
+#[derive(Default)]
+pub struct TestAppBuilder {
+ configure: Vec,
+ mailpit: bool,
+ fault_proxies: bool,
+ no_event_relay: bool,
+}
+
+impl TestAppBuilder {
+ pub fn config(mut self, configure: impl FnOnce(&mut Config) + Send + 'static) -> Self {
+ self.configure.push(Box::new(configure));
+ self
+ }
+
+ /// Send mail over SMTP to the shared Mailpit instead of capturing it.
+ pub fn smtp_to_mailpit(mut self) -> Self {
+ self.mailpit = true;
+ self
+ }
+
+ /// Reach PostgreSQL, Redis and NATS through [`FaultProxy`]s, available in
+ /// [`TestApp::dependencies`]. The test's own `db` pool stays direct.
+ pub fn fault_proxies(mut self) -> Self {
+ self.fault_proxies = true;
+ self
+ }
+
+ /// Leave the event relay off, for tests that drive `relay_once` themselves.
+ pub fn without_event_relay(mut self) -> Self {
+ self.no_event_relay = true;
+ self
+ }
+
+ pub async fn spawn(self) -> TestApp {
+ let Self {
+ configure,
+ mailpit,
+ fault_proxies,
+ no_event_relay,
+ } = self;
+ TestApp::spawn_inner(mailpit, fault_proxies, !no_event_relay, move |config| {
+ for configure in configure {
+ configure(config);
+ }
+ })
+ .await
+ }
+}
+
+/// The dependencies of an app spawned with [`TestAppBuilder::fault_proxies`].
+pub struct Dependencies {
+ pub postgres: FaultProxy,
+ pub redis: FaultProxy,
+ pub nats: FaultProxy,
+}
+
+pub struct TestApp {
+ pub base_url: String,
+ /// Client address this app's requests are attributed to (forwarded through
+ /// the trusted loopback proxy).
+ pub client_ip: String,
+ /// Direct pool to the app's database, for setup and assertions.
+ pub db: PgPool,
+ pub db_url: String,
+ pub redis: RedisPool,
+ pub client: Client,
+ pub state: AppState,
+ pub clock: TestClock,
+ pub mail: MailOutbox,
+ pub dependencies: Option,
+ /// Responses outside the OpenAPI contract, checked when the app is dropped.
+ pub contract: contract::Recorder,
+ server: JoinHandle<()>,
+ /// The event relay: stopped before the database is dropped, or a connection
+ /// it opens while `DROP DATABASE ... WITH (FORCE)` runs holds the drop until
+ /// PostgreSQL's 60-second authentication timeout.
+ relay: Option>,
+ /// The webhook dispatcher, stopped for the same reason.
+ webhooks: JoinHandle<()>,
+ mailpit_api_port: Option,
+ // Declared last: dropped after everything holding a connection to it.
+ _database: TestDb,
+}
+
+impl TestApp {
+ pub fn builder() -> TestAppBuilder {
+ TestAppBuilder::default()
+ }
+
+ pub async fn spawn() -> Self {
+ Self::spawn_inner(false, false, true, |_| {}).await
+ }
+
+ pub async fn spawn_with_config(configure: F) -> Self
+ where
+ F: FnOnce(&mut Config),
+ {
+ Self::spawn_inner(false, false, true, configure).await
+ }
+
+ /// Spawn the app with SMTP wired to the shared Mailpit instance, for the
+ /// few tests covering the SMTP transport itself.
+ pub async fn spawn_with_mailpit() -> Self {
+ Self::spawn_inner(true, false, true, |_| {}).await
+ }
+
+ pub async fn spawn_with_mailpit_and_config(configure: F) -> Self
+ where
+ F: FnOnce(&mut Config),
+ {
+ Self::spawn_inner(true, false, true, configure).await
+ }
+
+ async fn spawn_inner(
+ mailpit: bool,
+ fault_proxies: bool,
+ event_relay: bool,
+ configure: F,
+ ) -> Self
+ where
+ F: FnOnce(&mut Config),
+ {
+ crate::init_tracing();
+
+ let database = TestDb::new().await;
+ let redis_url = env::redis_url();
+ let nats_url = env::nats_url();
+
+ let dependencies = if fault_proxies {
+ Some(Dependencies {
+ postgres: FaultProxy::start(host_port(&database.url, 5432)).await,
+ redis: FaultProxy::start(host_port(&redis_url, 6379)).await,
+ nats: FaultProxy::start(host_port(&nats_url, 4222)).await,
+ })
+ } else {
+ None
+ };
+ let (app_db_url, app_redis_url, app_nats_url) = match &dependencies {
+ Some(d) => (
+ through(&database.url, &d.postgres),
+ through(&redis_url, &d.redis),
+ through(&nats_url, &d.nats),
+ ),
+ None => (database.url.clone(), redis_url.clone(), nats_url.clone()),
+ };
+
+ let mut config = test_config(&app_db_url, &app_redis_url, &app_nats_url);
+ let mailpit_ports = mailpit.then(mailpit::mailpit_ports);
+ if let Some(ports) = &mailpit_ports {
+ // An empty username selects the plain (no TLS) transport.
+ config.mail.smtp.host = "127.0.0.1".into();
+ config.mail.smtp.port = ports.smtp_port;
+ config.mail.smtp.username = String::new();
+ config.mail.smtp.password = String::new();
+ }
+ configure(&mut config);
+
+ let app_pool = if dependencies.is_some() {
+ PgPoolOptions::new()
+ .max_connections(10)
+ .acquire_timeout(std::time::Duration::from_secs(
+ config.database.acquire_timeout_secs,
+ ))
+ .connect(&app_db_url)
+ .await
+ .expect("connect to the test database through its proxy")
+ } else {
+ database.pool.clone()
+ };
+
+ let mut state = AppState::from_config_with_pool(config, app_pool)
+ .await
+ .expect("failed to build app state");
+ let clock = TestClock::new();
+ state.clock = Arc::new(clock.clone());
+ let mail = MailOutbox::new();
+ if !mailpit {
+ state.mailer = Mailer::new(mail.clone());
+ }
+
+ // Events recorded by the requests reach NATS as in production.
+ let relay = event_relay
+ .then(|| auth_api::services::events::spawn_relay(state.db.clone(), state.nats.clone()));
+ let webhooks = auth_api::services::webhooks::spawn_dispatcher(state.clone());
+
+ let contract = contract::Recorder::default();
+ let router = handlers::router(state.clone()).layer(axum::middleware::from_fn_with_state(
+ contract.clone(),
+ contract::record,
+ ));
+ let listener = TcpListener::bind("127.0.0.1:0")
+ .await
+ .expect("failed to bind test listener");
+ let port = listener.local_addr().unwrap().port();
+ let server = tokio::spawn(async move {
+ axum::serve(
+ listener,
+ router.into_make_service_with_connect_info::(),
+ )
+ .await
+ .unwrap();
+ });
+
+ let id = uuid::Uuid::new_v4();
+ let b = id.as_bytes();
+ let client_ip = format!("10.{}.{}.{}", 100 + b[0] % 100, b[1], 1 + b[2] % 254);
+ let mut default_headers = reqwest::header::HeaderMap::new();
+ default_headers.insert(
+ "x-forwarded-for",
+ client_ip.parse().expect("valid client ip header"),
+ );
+ let client = Client::builder()
+ .default_headers(default_headers)
+ .build()
+ .unwrap();
+
+ Self {
+ base_url: format!("http://127.0.0.1:{port}"),
+ client_ip,
+ db: database.pool.clone(),
+ db_url: database.url.clone(),
+ redis: state.redis.clone(),
+ client,
+ state,
+ clock,
+ mail,
+ dependencies,
+ contract,
+ server,
+ relay,
+ webhooks,
+ mailpit_api_port: mailpit_ports.map(|p| p.api_port),
+ _database: database,
+ }
+ }
+
+ pub fn url(&self, path: &str) -> String {
+ format!("{}{}", self.base_url, path)
+ }
+
+ /// Mailpit client of an app spawned with `spawn_with_mailpit`.
+ pub fn mailpit(&self) -> MailpitClient {
+ let port = self
+ .mailpit_api_port
+ .expect("TestApp was not spawned with spawn_with_mailpit()");
+ MailpitClient::new(port)
+ }
+
+ /// Claims of an access token issued by this app, checked like the API does.
+ pub fn decode_access_token(&self, token: &str) -> Claims {
+ jwt::decode_token(
+ token,
+ &self.state.jwt_verifying_key,
+ self.state.clock.now().unix_timestamp(),
+ )
+ .expect("failed to decode test access token")
+ }
+
+ pub async fn get(&self, path: &str) -> Response {
+ self.client
+ .get(self.url(path))
+ .send()
+ .await
+ .expect("request failed")
+ }
+
+ pub async fn post(&self, path: &str, body: &B) -> Response {
+ self.client
+ .post(self.url(path))
+ .json(body)
+ .send()
+ .await
+ .expect("request failed")
+ }
+
+ pub async fn post_auth(&self, path: &str, token: &str, body: &B) -> Response {
+ self.client
+ .post(self.url(path))
+ .bearer_auth(token)
+ .json(body)
+ .send()
+ .await
+ .expect("request failed")
+ }
+
+ pub async fn get_auth(&self, path: &str, token: &str) -> Response {
+ self.client
+ .get(self.url(path))
+ .bearer_auth(token)
+ .send()
+ .await
+ .expect("request failed")
+ }
+
+ pub async fn patch_auth(&self, path: &str, token: &str, body: &B) -> Response {
+ self.client
+ .patch(self.url(path))
+ .bearer_auth(token)
+ .json(body)
+ .send()
+ .await
+ .expect("request failed")
+ }
+
+ pub async fn delete_auth(&self, path: &str, token: &str) -> Response {
+ self.client
+ .delete(self.url(path))
+ .bearer_auth(token)
+ .send()
+ .await
+ .expect("request failed")
+ }
+
+ pub async fn delete_auth_json(
+ &self,
+ path: &str,
+ token: &str,
+ body: &B,
+ ) -> Response {
+ self.client
+ .delete(self.url(path))
+ .bearer_auth(token)
+ .json(body)
+ .send()
+ .await
+ .expect("request failed")
+ }
+
+ async fn delete_redis_key(&self, key: &str) {
+ if let Ok(mut conn) = self.redis.get().await {
+ let _: Result<(), _> = conn.del(key).await;
+ }
+ }
+
+ /// Delete the anti-spam cooldown of email 2FA, so the next login can send
+ /// a code right away.
+ pub async fn clear_email_2fa_cooldown(&self, user_id: uuid::Uuid) {
+ self.delete_redis_key(&format!("email2fa_cd:{user_id}"))
+ .await;
+ }
+
+ /// Recover the active OTP of an email-change flow from its hash in Redis.
+ pub async fn read_email_change_otp(&self, flow_token: &str) -> String {
+ use base64::Engine;
+
+ let mut conn = self.redis.get().await.expect("redis connection failed");
+ let raw: String = conn
+ .get(format!("email_change_flow:{flow_token}"))
+ .await
+ .expect("email_change flow state not found in Redis");
+
+ let state: serde_json::Value = serde_json::from_str(&raw).unwrap();
+ let hash_b64 = state["otp_hash"]
+ .as_str()
+ .expect("otp_hash missing from flow state");
+ let hash_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
+ .decode(hash_b64)
+ .unwrap();
+
+ brute_force_otp(&hash_bytes)
+ }
+
+ /// Clear the per-user email-change cooldown so a second flow can start.
+ pub async fn clear_email_change_cooldown(&self, user_id: uuid::Uuid) {
+ self.delete_redis_key(&format!("email_change_cd:{user_id}"))
+ .await;
+ }
+
+ pub async fn clear_recent_reauth(&self, access_token: &str) {
+ let claims = self.decode_access_token(access_token);
+ self.delete_redis_key(&format!("reauth:{}", claims.sid))
+ .await;
+ }
+
+ /// Clear the general rate-limit window of `ip`.
+ pub async fn clear_rate_limit_key(&self, ip: &str) {
+ self.delete_redis_key(&format!("rl:{ip}")).await;
+ }
+
+ /// Clear the auth rate-limit window of `ip`.
+ pub async fn clear_auth_rate_limit_key(&self, ip: &str) {
+ self.delete_redis_key(&format!("rl_auth:{ip}")).await;
+ }
+
+ pub async fn clear_forgot_password_rate_limit(&self, ip: &str) {
+ self.delete_redis_key(&format!("fp_req:{ip}")).await;
+ }
+
+ pub async fn clear_reset_password_rate_limit(&self, ip: &str) {
+ self.delete_redis_key(&format!("rp_fail:{ip}")).await;
+ }
+
+ pub async fn clear_verify_email_rate_limit(&self, ip: &str) {
+ self.delete_redis_key(&format!("vf_fail:{ip}")).await;
+ }
+
+ /// Clear the per-token budget of a verify-email token, so the same token
+ /// can be submitted again.
+ pub async fn clear_verify_email_token_hash_rate_limit(&self, raw_token: &str) {
+ let hash = auth_api::utils::crypto::sha256(raw_token.as_bytes());
+ let hex: String = hash.iter().map(|b| format!("{b:02x}")).collect();
+ self.delete_redis_key(&format!("vf_tok:{hex}")).await;
+ }
+}
+
+impl Drop for TestApp {
+ fn drop(&mut self) {
+ if let Some(relay) = &self.relay {
+ relay.abort();
+ }
+ self.webhooks.abort();
+ self.server.abort();
+ contract::settle(&self.contract);
+ }
+}
+
+/// Configuration of a test app: permissive rate limits, cheap Argon2, the
+/// test keys, and no SMTP relay.
+pub fn test_config(db_url: &str, redis_url: &str, nats_url: &str) -> Config {
+ use auth_api::config::*;
+
+ Config {
+ env: Environment::Test,
+ server: ServerConfig {
+ host: "127.0.0.1".into(),
+ port: 0,
+ public_url: "http://localhost".into(),
+ frontend_url: "http://localhost".into(),
+ // The test client talks to the server over loopback and forwards a
+ // per-app address in X-Forwarded-For (see `TestApp::client_ip`).
+ trusted_proxy_cidrs: vec!["127.0.0.1/32".parse().unwrap()],
+ },
+ database: DatabaseConfig {
+ url: db_url.into(),
+ max_connections: 10,
+ min_connections: 1,
+ acquire_timeout_secs: 30,
+ read_url: None,
+ },
+ redis: RedisConfig {
+ url: redis_url.into(),
+ pool_size: 5,
+ wait_timeout_ms: 2000,
+ },
+ nats: NatsConfig {
+ url: nats_url.into(),
+ stream_replicas: 1,
+ },
+ jwt: JwtConfig {
+ private_key: keys::PRIVATE_KEY_PEM.into(),
+ public_key: keys::PUBLIC_KEY_PEM.into(),
+ previous_public_key: None,
+ next_public_key: None,
+ access_expiry_secs: 900,
+ refresh_expiry_secs: 86400,
+ short_session_expiry_secs: 3600,
+ strict_session_binding: false,
+ max_session_lifetime_secs: 60 * 60 * 24 * 90,
+ audience: Vec::new(),
+ },
+ crypto: CryptoConfig {
+ argon2_memory_kib: 8192,
+ argon2_iterations: 1,
+ argon2_parallelism: 1,
+ argon2_max_concurrency: 4,
+ totp_issuer: "test".into(),
+ encryption_key: keys::ENCRYPTION_KEY.into(),
+ previous_encryption_key: None,
+ totp_skew: 1,
+ recovery_code_expiry_days: 365,
+ },
+ rate_limit: RateLimitConfig {
+ requests_per_minute: 10_000,
+ auth_requests_per_minute: 10_000,
+ fail_open_on_redis_error: true,
+ allow_requests_without_ip: true,
+ },
+ security: SecurityConfig {
+ lockout_threshold: 3,
+ lockout_duration_secs: 1800,
+ sensitive_action_reauth_secs: 600,
+ new_device_alerts: true,
+ magic_links: true,
+ },
+ captcha: CaptchaConfig {
+ secret: None,
+ verify_url: "https://hcaptcha.com/siteverify".into(),
+ request_timeout_secs: 1,
+ fail_open_on_error: false,
+ },
+ cors: CorsConfig {
+ allowed_origins: vec!["*".into()],
+ allow_credentials: false,
+ },
+ mail: MailConfig {
+ smtp: SmtpConfig {
+ host: String::new(),
+ port: 1025,
+ username: String::new(),
+ password: String::new(),
+ from_name: "Test".into(),
+ from_address: "test@example.com".into(),
+ },
+ templates_dir: crate::workspace_path("templates")
+ .to_string_lossy()
+ .into_owned(),
+ default_locale: "en".into(),
+ },
+ pwned_passwords: PwnedPasswordsConfig {
+ enabled: false,
+ api_url: "https://api.pwnedpasswords.com".into(),
+ timeout_ms: 1500,
+ fail_open: true,
+ },
+ webauthn: WebAuthnConfig {
+ rp_id: "localhost".into(),
+ rp_name: "Auth API".into(),
+ origins: vec!["http://localhost:5173".into()],
+ },
+ identity_providers: Vec::new(),
+ external_login_uri: "http://localhost:5173/external-login".into(),
+ webhooks: WebhookConfig {
+ allow_http: true,
+ allow_private_networks: true,
+ timeout_ms: 5000,
+ },
+ cleanup: CleanupConfig {
+ interval_secs: 3600,
+ sessions_grace_days: 7,
+ tokens_grace_days: 1,
+ login_attempts_retention_days: 90,
+ recovery_codes_grace_days: 7,
+ unverified_accounts_retention_days: 7,
+ known_devices_retention_days: 90,
+ webhook_deliveries_retention_days: 7,
+ },
+ audit: AuditConfig {
+ retention_months: 6,
+ ip_retention_days: 90,
+ },
+ telemetry: TelemetryConfig {
+ otlp_endpoint: None,
+ service_name: "auth-api".into(),
+ sample_ratio: 0.1,
+ },
+ log: LogConfig {
+ level: "error".into(),
+ format: LogFormat::Pretty,
+ },
+ device_auth: DeviceAuthConfig {
+ ttl_secs: 300,
+ poll_interval_secs: 5,
+ verification_uri: "http://localhost:5173/device".into(),
+ consent_uri: "http://localhost:5173/authorize".into(),
+ },
+ metrics: MetricsConfig {
+ enabled: false,
+ port: 9464,
+ },
+ }
+}
+
+/// Recover a 6-digit OTP from its SHA-256 digest.
+pub fn brute_force_otp(expected_hash: &[u8]) -> String {
+ use sha2::{Digest, Sha256};
+ (0u32..1_000_000)
+ .map(|n| format!("{n:06}"))
+ .find(|candidate| Sha256::digest(candidate.as_bytes()).as_slice() == expected_hash)
+ .expect("OTP not found in the 6-digit space")
+}
+
+/// `base` with its logical database number replaced by `db`.
+pub fn redis_url_with_db(base: &str, db: u8) -> String {
+ let stripped = match base.rfind('/') {
+ Some(pos)
+ if !base[pos + 1..].is_empty()
+ && base[pos + 1..].chars().all(|c| c.is_ascii_digit()) =>
+ {
+ &base[..pos]
+ }
+ _ => base,
+ };
+ format!("{stripped}/{db}")
+}
+
+fn host_port(url: &str, default_port: u16) -> String {
+ let parsed = reqwest::Url::parse(url).expect("valid dependency URL");
+ format!(
+ "{}:{}",
+ parsed.host_str().expect("dependency URL has a host"),
+ parsed.port().unwrap_or(default_port)
+ )
+}
+
+fn through(url: &str, proxy: &FaultProxy) -> String {
+ let mut parsed = reqwest::Url::parse(url).expect("valid dependency URL");
+ parsed.set_host(Some("127.0.0.1")).expect("settable host");
+ parsed
+ .set_port(Some(proxy.addr().port()))
+ .expect("settable port");
+ parsed.to_string()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn redis_url_with_db_replaces_only_a_numeric_suffix() {
+ assert_eq!(redis_url_with_db("redis://h:6380", 2), "redis://h:6380/2");
+ assert_eq!(redis_url_with_db("redis://h:6380/1", 3), "redis://h:6380/3");
+ }
+
+ #[test]
+ fn dependency_urls_are_rewritten_to_the_proxy_port() {
+ assert_eq!(host_port("redis://127.0.0.1:6380", 6379), "127.0.0.1:6380");
+ assert_eq!(host_port("nats://localhost", 4222), "localhost:4222");
+ }
+
+ #[test]
+ fn otp_is_recovered_from_its_digest() {
+ use sha2::{Digest, Sha256};
+ assert_eq!(brute_force_otp(&Sha256::digest(b"004217")), "004217");
+ }
+}
diff --git a/crates/testkit/src/authenticator.rs b/crates/testkit/src/authenticator.rs
new file mode 100644
index 0000000..526056a
--- /dev/null
+++ b/crates/testkit/src/authenticator.rs
@@ -0,0 +1,137 @@
+//! A passkey authenticator in memory: it answers the options of a WebAuthn
+//! ceremony with the JSON a browser returns (`PublicKeyCredential.toJSON()`).
+//! ES256, `none` attestation. Its fields can be bent to forge bad responses.
+
+use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD as B64URL};
+use ciborium::Value as Cbor;
+use p256::ecdsa::{Signature, SigningKey, signature::Signer};
+use serde_json::{Value, json};
+use sha2::{Digest, Sha256};
+
+pub struct SoftAuthenticator {
+ pub key: SigningKey,
+ pub credential_id: Vec,
+ /// The user handle given at registration.
+ pub user_handle: Vec,
+ pub counter: u32,
+ /// Origin written in the client data.
+ pub origin: String,
+ /// Relying party id hashed in the authenticator data.
+ pub rp_id: String,
+ /// Authenticator data flags: user present and verified by default.
+ pub flags: u8,
+}
+
+impl SoftAuthenticator {
+ pub fn new(rp_id: &str, origin: &str) -> Self {
+ Self {
+ key: SigningKey::random(&mut rand_core::OsRng),
+ credential_id: uuid::Uuid::new_v4().as_bytes().to_vec(),
+ user_handle: Vec::new(),
+ counter: 0,
+ origin: origin.to_owned(),
+ rp_id: rp_id.to_owned(),
+ flags: 0x05,
+ }
+ }
+
+ /// The authenticator of the test app's configuration.
+ pub fn for_app(app: &crate::TestApp) -> Self {
+ let config = &app.state.config.webauthn;
+ Self::new(&config.rp_id, &config.origins[0])
+ }
+
+ fn client_data(&self, kind: &str, challenge: &str) -> Vec {
+ serde_json::to_vec(&json!({
+ "type": kind,
+ "challenge": challenge,
+ "origin": self.origin,
+ "crossOrigin": false,
+ }))
+ .unwrap()
+ }
+
+ fn auth_data(&self, flags: u8, attested: Option<&[u8]>) -> Vec {
+ let mut data = Sha256::digest(self.rp_id.as_bytes()).to_vec();
+ data.push(flags);
+ data.extend_from_slice(&self.counter.to_be_bytes());
+ if let Some(public_key) = attested {
+ data.extend_from_slice(&[0u8; 16]);
+ data.extend_from_slice(
+ &u16::try_from(self.credential_id.len())
+ .unwrap()
+ .to_be_bytes(),
+ );
+ data.extend_from_slice(&self.credential_id);
+ data.extend_from_slice(public_key);
+ }
+ data
+ }
+
+ fn cose_key(&self) -> Vec {
+ let point = self.key.verifying_key().to_encoded_point(false);
+ let key = Cbor::Map(vec![
+ (Cbor::Integer(1.into()), Cbor::Integer(2.into())),
+ (Cbor::Integer(3.into()), Cbor::Integer((-7).into())),
+ (Cbor::Integer((-1).into()), Cbor::Integer(1.into())),
+ (
+ Cbor::Integer((-2).into()),
+ Cbor::Bytes(point.x().unwrap().to_vec()),
+ ),
+ (
+ Cbor::Integer((-3).into()),
+ Cbor::Bytes(point.y().unwrap().to_vec()),
+ ),
+ ]);
+ let mut out = Vec::new();
+ ciborium::ser::into_writer(&key, &mut out).unwrap();
+ out
+ }
+
+ /// Answer `PublicKeyCredentialCreationOptions`.
+ pub fn create(&mut self, options: &Value) -> Value {
+ self.user_handle = B64URL
+ .decode(options["user"]["id"].as_str().unwrap())
+ .unwrap();
+ let client_data =
+ self.client_data("webauthn.create", options["challenge"].as_str().unwrap());
+ let auth_data = self.auth_data(self.flags | 0x40, Some(&self.cose_key()));
+ let object = Cbor::Map(vec![
+ (Cbor::Text("fmt".into()), Cbor::Text("none".into())),
+ (Cbor::Text("attStmt".into()), Cbor::Map(vec![])),
+ (Cbor::Text("authData".into()), Cbor::Bytes(auth_data)),
+ ]);
+ let mut attestation = Vec::new();
+ ciborium::ser::into_writer(&object, &mut attestation).unwrap();
+ json!({
+ "id": B64URL.encode(&self.credential_id),
+ "rawId": B64URL.encode(&self.credential_id),
+ "type": "public-key",
+ "response": {
+ "clientDataJSON": B64URL.encode(client_data),
+ "attestationObject": B64URL.encode(attestation),
+ },
+ })
+ }
+
+ /// Answer `PublicKeyCredentialRequestOptions`, counting one more use.
+ pub fn get(&mut self, options: &Value) -> Value {
+ self.counter += 1;
+ let client_data = self.client_data("webauthn.get", options["challenge"].as_str().unwrap());
+ let auth_data = self.auth_data(self.flags, None);
+ let mut message = auth_data.clone();
+ message.extend_from_slice(&Sha256::digest(&client_data));
+ let signature: Signature = self.key.sign(&message);
+ json!({
+ "id": B64URL.encode(&self.credential_id),
+ "rawId": B64URL.encode(&self.credential_id),
+ "type": "public-key",
+ "response": {
+ "clientDataJSON": B64URL.encode(client_data),
+ "authenticatorData": B64URL.encode(auth_data),
+ "signature": B64URL.encode(signature.to_der().as_bytes()),
+ "userHandle": B64URL.encode(&self.user_handle),
+ },
+ })
+ }
+}
diff --git a/crates/testkit/src/clock.rs b/crates/testkit/src/clock.rs
new file mode 100644
index 0000000..5124cc8
--- /dev/null
+++ b/crates/testkit/src/clock.rs
@@ -0,0 +1,74 @@
+//! The clock the test app runs with: the wall clock plus an adjustable offset.
+//!
+//! Time keeps flowing, so timeouts and database timestamps stay coherent, and
+//! a test jumps forward with [`TestClock::advance`] instead of sleeping.
+//!
+//! Only decisions the application takes in Rust follow this clock: token
+//! expiry, TOTP steps, lockout, session lifetime, brute-force windows. SQL
+//! `NOW()` and Redis TTLs keep real time; a test covering those ages the stored
+//! rows or deletes the key instead.
+
+use std::sync::{
+ Arc,
+ atomic::{AtomicI64, Ordering},
+};
+
+use auth_api::utils::time::Clock;
+use time::{Duration, OffsetDateTime};
+
+#[derive(Debug, Clone, Default)]
+pub struct TestClock {
+ offset_nanos: Arc,
+}
+
+impl TestClock {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Move the clock forward (or back, with a negative duration).
+ pub fn advance(&self, by: Duration) {
+ let nanos = i64::try_from(by.whole_nanoseconds()).expect("offset fits in i64 nanoseconds");
+ self.offset_nanos.fetch_add(nanos, Ordering::SeqCst);
+ }
+
+ /// Current offset from the wall clock.
+ pub fn offset(&self) -> Duration {
+ Duration::nanoseconds(self.offset_nanos.load(Ordering::SeqCst))
+ }
+
+ pub fn reset(&self) {
+ self.offset_nanos.store(0, Ordering::SeqCst);
+ }
+}
+
+impl Clock for TestClock {
+ fn now(&self) -> OffsetDateTime {
+ OffsetDateTime::now_utc() + self.offset()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn advancing_moves_every_clone() {
+ let clock = TestClock::new();
+ let shared = clock.clone();
+ let before = shared.now();
+
+ clock.advance(Duration::days(91));
+
+ let moved = shared.now() - before;
+ assert!(moved >= Duration::days(91) && moved < Duration::days(91) + Duration::minutes(1));
+ }
+
+ #[test]
+ fn reset_returns_to_the_wall_clock() {
+ let clock = TestClock::new();
+ clock.advance(Duration::hours(-3));
+ clock.reset();
+ assert_eq!(clock.offset(), Duration::ZERO);
+ }
+}
diff --git a/crates/testkit/src/contract.rs b/crates/testkit/src/contract.rs
new file mode 100644
index 0000000..49bf50e
--- /dev/null
+++ b/crates/testkit/src/contract.rs
@@ -0,0 +1,403 @@
+//! The OpenAPI document as an executable contract.
+//!
+//! Every [`TestApp`](crate::TestApp) routes its responses through [`record`],
+//! which checks them against the document generated from the handlers
+//! (`auth_api::openapi::ApiDoc`): the status must be documented for the
+//! operation, and a JSON body must match the documented schema. Every
+//! integration and security test is thereby a contract test as well.
+//!
+//! A response outside the contract fails the test when its app is dropped
+//! (`TEST_CONTRACT=strict`, the default). `TEST_CONTRACT=report` appends the
+//! violations to `target/contract-report/` instead, to survey a change;
+//! `TEST_CONTRACT=off` disables the check.
+
+use std::{
+ collections::BTreeMap,
+ io::Write,
+ sync::{Arc, Mutex, OnceLock},
+};
+
+use axum::{
+ body::Body,
+ extract::{Request, State},
+ http::header::CONTENT_TYPE,
+ middleware::Next,
+ response::Response,
+};
+use serde_json::{Value, json};
+use utoipa::OpenApi;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Mode {
+ Strict,
+ Report,
+ Off,
+}
+
+pub fn mode() -> Mode {
+ match std::env::var("TEST_CONTRACT").as_deref() {
+ Ok("report") => Mode::Report,
+ Ok("off") => Mode::Off,
+ _ => Mode::Strict,
+ }
+}
+
+pub struct Contract {
+ operations: Vec,
+}
+
+struct Operation {
+ method: String,
+ template: String,
+ segments: Vec,
+ responses: BTreeMap,
+}
+
+enum Segment {
+ Literal(String),
+ Parameter,
+}
+
+struct ResponseSpec {
+ /// Validator of the `application/json` body, when one is documented.
+ json: Option,
+ /// Whether any content is documented at all.
+ has_content: bool,
+}
+
+/// The contract of the code under test, built once per process.
+pub fn contract() -> &'static Contract {
+ static CONTRACT: OnceLock = OnceLock::new();
+ CONTRACT.get_or_init(|| {
+ let document =
+ serde_json::to_value(auth_api::openapi::ApiDoc::openapi()).expect("document to JSON");
+ Contract::from_document(&document)
+ })
+}
+
+impl Contract {
+ pub fn from_document(document: &Value) -> Self {
+ let components = document.get("components").cloned().unwrap_or(json!({}));
+ let mut operations = Vec::new();
+
+ for (template, item) in document["paths"].as_object().expect("paths") {
+ for method in ["get", "post", "put", "patch", "delete"] {
+ let Some(operation) = item.get(method) else {
+ continue;
+ };
+ let responses = operation["responses"]
+ .as_object()
+ .map(|responses| {
+ responses
+ .iter()
+ .map(|(status, response)| {
+ (status.clone(), response_spec(response, &components))
+ })
+ .collect()
+ })
+ .unwrap_or_default();
+ operations.push(Operation {
+ method: method.to_ascii_uppercase(),
+ template: template.clone(),
+ segments: segments(template),
+ responses,
+ });
+ }
+ }
+
+ // Literal segments win over parameters (`/oauth/device/verify` before
+ // `/oauth/device/{user_code}`).
+ operations.sort_by_key(|op| {
+ std::cmp::Reverse(
+ op.segments
+ .iter()
+ .filter(|s| matches!(s, Segment::Literal(_)))
+ .count(),
+ )
+ });
+ Self { operations }
+ }
+
+ /// Operation template of `method path`, if the document describes one.
+ pub fn template(&self, method: &str, path: &str) -> Option<&str> {
+ self.find(method, path).map(|op| op.template.as_str())
+ }
+
+ fn find(&self, method: &str, path: &str) -> Option<&Operation> {
+ let parts: Vec<&str> = path.trim_end_matches('/').split('/').collect();
+ self.operations.iter().find(|op| {
+ op.method.eq_ignore_ascii_case(method)
+ && op.segments.len() == parts.len()
+ && op
+ .segments
+ .iter()
+ .zip(&parts)
+ .all(|(segment, part)| match segment {
+ Segment::Literal(literal) => literal == part,
+ Segment::Parameter => !part.is_empty(),
+ })
+ })
+ }
+
+ /// Check one response. Requests to paths the document does not describe
+ /// are not the contract's business and pass.
+ pub fn check(
+ &self,
+ method: &str,
+ path: &str,
+ status: u16,
+ content_type: Option<&str>,
+ body: &[u8],
+ ) -> Result<(), String> {
+ let Some(operation) = self.find(method, path) else {
+ return Ok(());
+ };
+ let context = format!("{method} {} -> {status}", operation.template);
+
+ let Some(spec) = operation
+ .responses
+ .get(&status.to_string())
+ .or_else(|| operation.responses.get("default"))
+ else {
+ return Err(format!("{context}: status not documented"));
+ };
+
+ match &spec.json {
+ Some(validator) => {
+ let is_json = content_type.is_some_and(|ct| ct.starts_with("application/json"));
+ if !is_json {
+ return Err(format!(
+ "{context}: documented as application/json, got {} ({})",
+ content_type.unwrap_or("no content type"),
+ String::from_utf8_lossy(&body[..body.len().min(80)])
+ ));
+ }
+ let instance: Value = serde_json::from_slice(body)
+ .map_err(|e| format!("{context}: body is not JSON: {e}"))?;
+ let errors: Vec = validator
+ .iter_errors(&instance)
+ .take(5)
+ .map(|error| format!("at `{}`: {error}", error.instance_path()))
+ .collect();
+ if errors.is_empty() {
+ Ok(())
+ } else {
+ Err(format!(
+ "{context}: body does not match the schema: {}; body: {instance}",
+ errors.join("; ")
+ ))
+ }
+ }
+ None if !spec.has_content && !body.is_empty() => Err(format!(
+ "{context}: no body documented, got {} ({})",
+ content_type.unwrap_or("no content type"),
+ String::from_utf8_lossy(&body[..body.len().min(80)])
+ )),
+ None => Ok(()),
+ }
+ }
+}
+
+fn segments(template: &str) -> Vec {
+ template
+ .trim_end_matches('/')
+ .split('/')
+ .map(|part| {
+ if part.starts_with('{') && part.ends_with('}') {
+ Segment::Parameter
+ } else {
+ Segment::Literal(part.to_owned())
+ }
+ })
+ .collect()
+}
+
+fn response_spec(response: &Value, components: &Value) -> ResponseSpec {
+ let content = response.get("content").and_then(Value::as_object);
+ let json = content
+ .and_then(|content| content.get("application/json"))
+ .and_then(|media| media.get("schema"))
+ .map(|schema| {
+ // `#/components/...` references resolve against this root.
+ let root = json!({
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "components": components,
+ "allOf": [schema],
+ });
+ jsonschema::options()
+ .should_validate_formats(true)
+ .build(&root)
+ .unwrap_or_else(|e| panic!("invalid schema in the OpenAPI document: {e}"))
+ });
+ ResponseSpec {
+ json,
+ has_content: content.is_some_and(|content| !content.is_empty()),
+ }
+}
+
+/// Violations seen by one app.
+#[derive(Clone, Default)]
+pub struct Recorder {
+ violations: Arc>>,
+}
+
+impl Recorder {
+ pub fn take(&self) -> Vec {
+ std::mem::take(&mut self.violations.lock().unwrap())
+ }
+
+ fn push(&self, violation: String) {
+ self.violations.lock().unwrap().push(violation);
+ }
+}
+
+/// Middleware checking every response of the app against the contract.
+pub async fn record(State(recorder): State, request: Request, next: Next) -> Response {
+ if mode() == Mode::Off {
+ return next.run(request).await;
+ }
+ let method = request.method().as_str().to_owned();
+ let path = request.uri().path().to_owned();
+
+ let response = next.run(request).await;
+ let (parts, body) = response.into_parts();
+ let bytes = axum::body::to_bytes(body, usize::MAX)
+ .await
+ .expect("buffer the response body");
+ let content_type = parts
+ .headers
+ .get(CONTENT_TYPE)
+ .and_then(|value| value.to_str().ok());
+
+ if let Err(violation) =
+ contract().check(&method, &path, parts.status.as_u16(), content_type, &bytes)
+ {
+ recorder.push(violation);
+ }
+ Response::from_parts(parts, Body::from(bytes))
+}
+
+/// Act on the violations of an app being dropped.
+pub fn settle(recorder: &Recorder) {
+ let violations = recorder.take();
+ if violations.is_empty() {
+ return;
+ }
+ match mode() {
+ Mode::Strict if !std::thread::panicking() => panic!(
+ "responses outside the OpenAPI contract (docs/dev/api/openapi.yaml):\n {}",
+ violations.join("\n ")
+ ),
+ Mode::Report => {
+ let dir = crate::workspace_path("target/contract-report");
+ let _ = std::fs::create_dir_all(&dir);
+ if let Ok(mut file) = std::fs::OpenOptions::new()
+ .create(true)
+ .append(true)
+ .open(dir.join(format!("{}.txt", std::process::id())))
+ {
+ let test = std::thread::current()
+ .name()
+ .unwrap_or("unnamed test")
+ .to_owned();
+ for violation in violations {
+ let _ = writeln!(file, "{test}\t{violation}");
+ }
+ }
+ }
+ _ => {}
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn sample() -> Contract {
+ Contract::from_document(&json!({
+ "paths": {
+ "/items/{id}": {
+ "get": {
+ "responses": {
+ "200": {
+ "description": "ok",
+ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Item"}}}
+ },
+ "204": {"description": "empty"}
+ }
+ }
+ },
+ "/items/special": {
+ "get": {"responses": {"418": {"description": "teapot"}}}
+ }
+ },
+ "components": {
+ "schemas": {
+ "Item": {
+ "type": "object",
+ "required": ["id"],
+ "properties": {"id": {"type": "string", "format": "uuid"}}
+ }
+ }
+ }
+ }))
+ }
+
+ const JSON: Option<&str> = Some("application/json");
+
+ #[test]
+ fn a_conforming_response_passes() {
+ let body = br#"{"id":"6f1c1a3e-7d5b-4b8e-9a51-3e0b1f7f3c2a"}"#;
+ assert_eq!(sample().check("GET", "/items/1", 200, JSON, body), Ok(()));
+ assert_eq!(sample().check("GET", "/items/1", 204, None, b""), Ok(()));
+ }
+
+ #[test]
+ fn undocumented_statuses_and_bodies_are_violations() {
+ let contract = sample();
+ assert!(contract.check("GET", "/items/1", 500, JSON, b"{}").is_err());
+ assert!(
+ contract
+ .check("GET", "/items/1", 204, Some("text/plain"), b"x")
+ .is_err()
+ );
+ assert!(
+ contract
+ .check("GET", "/items/1", 200, Some("text/plain"), b"{}")
+ .is_err()
+ );
+ }
+
+ #[test]
+ fn schemas_are_enforced_through_references() {
+ let contract = sample();
+ assert!(contract.check("GET", "/items/1", 200, JSON, b"{}").is_err());
+ assert!(
+ contract
+ .check("GET", "/items/1", 200, JSON, br#"{"id":"not-a-uuid"}"#)
+ .is_err()
+ );
+ }
+
+ #[test]
+ fn literal_segments_win_and_unknown_paths_pass() {
+ let contract = sample();
+ assert_eq!(
+ contract.template("GET", "/items/special"),
+ Some("/items/special")
+ );
+ assert_eq!(contract.template("GET", "/items/42"), Some("/items/{id}"));
+ assert_eq!(contract.check("GET", "/nowhere", 404, None, b""), Ok(()));
+ assert_eq!(contract.check("POST", "/items/1", 405, None, b""), Ok(()));
+ }
+
+ #[test]
+ fn the_service_document_builds_a_contract() {
+ let contract = contract();
+ assert_eq!(contract.template("GET", "/users/me"), Some("/users/me"));
+ assert_eq!(
+ contract.template("GET", "/oauth/device/ABCD-2345"),
+ Some("/oauth/device/{user_code}")
+ );
+ }
+}
diff --git a/crates/testkit/src/db.rs b/crates/testkit/src/db.rs
new file mode 100644
index 0000000..071bc9f
--- /dev/null
+++ b/crates/testkit/src/db.rs
@@ -0,0 +1,322 @@
+//! Test databases, cloned from a template migrated once per migration set.
+//!
+//! The template is named after a fingerprint of `migrations/`. The first test
+//! process that needs it builds it under an advisory lock; every later process
+//! reuses it until a migration changes. A test then clones it in milliseconds
+//! instead of replaying every migration.
+//!
+//! A test database is dropped with its [`TestDb`]. A process killed before
+//! that leaves its database behind: whichever process next takes the template
+//! lock drops the databases of processes that no longer exist, and templates of
+//! other migration sets once they are a day old.
+
+use std::{
+ path::Path,
+ time::{SystemTime, UNIX_EPOCH},
+};
+
+use sha2::{Digest, Sha256};
+use sqlx::{Connection, Executor, PgConnection, PgPool, postgres::PgPoolOptions};
+use tokio::sync::OnceCell;
+
+pub const TEST_DB_PREFIX: &str = "auth_api_t_";
+pub const TEMPLATE_PREFIX: &str = "auth_api_tpl_";
+
+/// Templates of another migration set may belong to another checkout running
+/// its own suites; they are kept this long.
+const STALE_TEMPLATE_SECS: u64 = 24 * 3600;
+
+/// Serializes template builds and sweeps across test processes.
+const TEMPLATE_LOCK: i64 = 0x6175_7468_5f74_706c;
+
+static TEMPLATE: OnceCell = OnceCell::const_new();
+
+pub struct TestDb {
+ pub pool: PgPool,
+ pub name: String,
+ /// Connection URL of this database.
+ pub url: String,
+ admin_url: String,
+}
+
+impl TestDb {
+ /// A database with every migration applied.
+ pub async fn new() -> Self {
+ let template = template().await;
+ Self::create(&template).await
+ }
+
+ /// An empty database, for tests that apply migrations themselves.
+ pub async fn empty() -> Self {
+ Self::create("template0").await
+ }
+
+ async fn create(template: &str) -> Self {
+ let admin_url = crate::env::database_url();
+ let name = format!(
+ "{TEST_DB_PREFIX}{}_{}",
+ std::process::id(),
+ uuid::Uuid::new_v4().simple()
+ );
+
+ let mut admin = connect(&admin_url).await;
+ admin
+ .execute(format!(r#"CREATE DATABASE "{name}" TEMPLATE "{template}""#).as_str())
+ .await
+ .unwrap_or_else(|e| panic!("create test database {name}: {e}"));
+ let _ = admin.close().await;
+
+ let url = with_database(&admin_url, &name);
+ let pool = PgPoolOptions::new()
+ .max_connections(10)
+ .connect(&url)
+ .await
+ .unwrap_or_else(|e| panic!("connect to test database {name}: {e}"));
+
+ Self {
+ pool,
+ name,
+ url,
+ admin_url,
+ }
+ }
+}
+
+impl Drop for TestDb {
+ fn drop(&mut self) {
+ let admin_url = self.admin_url.clone();
+ let name = self.name.clone();
+ // Drop runs inside the test's runtime: finish on a thread of its own,
+ // and wait for it so the database is gone before the process exits.
+ let _ = std::thread::spawn(move || {
+ let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ else {
+ return;
+ };
+ runtime.block_on(async move {
+ if let Ok(mut admin) = PgConnection::connect(&admin_url).await {
+ drop_database(&mut admin, &name).await;
+ }
+ });
+ })
+ .join();
+ }
+}
+
+/// Name of the migrated template, built on first use.
+pub async fn template() -> String {
+ TEMPLATE
+ .get_or_init(|| async {
+ let admin_url = crate::env::database_url();
+ let name = format!("{TEMPLATE_PREFIX}{}", migrations_fingerprint());
+ let mut admin = connect(&admin_url).await;
+
+ sqlx::query("SELECT pg_advisory_lock($1)")
+ .bind(TEMPLATE_LOCK)
+ .execute(&mut admin)
+ .await
+ .expect("take the template lock");
+
+ let exists: bool =
+ sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM pg_database WHERE datname = $1)")
+ .bind(&name)
+ .fetch_one(&mut admin)
+ .await
+ .expect("look up the template");
+ if !exists {
+ build_template(&mut admin, &admin_url, &name).await;
+ }
+ sweep(&mut admin, &name).await;
+
+ // Closing the connection releases the lock as well.
+ let _ = admin.close().await;
+ name
+ })
+ .await
+ .clone()
+}
+
+/// Connection to the administrative database of the test server.
+pub async fn admin() -> PgConnection {
+ connect(&crate::env::database_url()).await
+}
+
+/// Apply every migration to `pool`, as the service does at deployment.
+pub async fn migrate(pool: &PgPool) {
+ sqlx::migrate::Migrator::new(crate::workspace_path("migrations"))
+ .await
+ .expect("load migrations")
+ .run(pool)
+ .await
+ .expect("apply migrations");
+}
+
+/// Assert that `result` failed on the database constraint `expected`.
+pub fn assert_constraint(result: Result, expected: &str) {
+ match result {
+ Ok(value) => {
+ panic!("expected a violation of `{expected}`, the statement returned {value:?}")
+ }
+ Err(sqlx::Error::Database(error)) => assert_eq!(
+ error.constraint(),
+ Some(expected),
+ "unexpected database error: {error}"
+ ),
+ Err(error) => panic!("expected a violation of `{expected}`, got: {error}"),
+ }
+}
+
+async fn connect(url: &str) -> PgConnection {
+ PgConnection::connect(url)
+ .await
+ .unwrap_or_else(|e| panic!("connect to the test PostgreSQL server: {e}"))
+}
+
+async fn build_template(admin: &mut PgConnection, admin_url: &str, name: &str) {
+ let building = format!("{name}_build");
+ drop_database(admin, &building).await;
+ admin
+ .execute(format!(r#"CREATE DATABASE "{building}" TEMPLATE template0"#).as_str())
+ .await
+ .unwrap_or_else(|e| panic!("create template {building}: {e}"));
+
+ let pool = PgPoolOptions::new()
+ .max_connections(1)
+ .connect(&with_database(admin_url, &building))
+ .await
+ .expect("connect to the template being built");
+ migrate(&pool).await;
+ pool.close().await;
+
+ for statement in [
+ format!(r#"ALTER DATABASE "{building}" RENAME TO "{name}""#),
+ // No session may stay connected to a template, or cloning fails.
+ format!(r#"ALTER DATABASE "{name}" WITH IS_TEMPLATE true ALLOW_CONNECTIONS false"#),
+ format!(
+ r#"COMMENT ON DATABASE "{name}" IS 'auth-api test template, created {}'"#,
+ unix_now()
+ ),
+ ] {
+ admin
+ .execute(statement.as_str())
+ .await
+ .unwrap_or_else(|e| panic!("finish template {name}: {e}"));
+ }
+}
+
+async fn sweep(admin: &mut PgConnection, current_template: &str) {
+ let databases: Vec<(String, Option)> = sqlx::query_as(
+ r"SELECT datname, shobj_description(oid, 'pg_database') FROM pg_database
+ WHERE datname LIKE 'auth\_api\_t\_%' OR datname LIKE 'auth\_api\_tpl\_%'",
+ )
+ .fetch_all(&mut *admin)
+ .await
+ .unwrap_or_default();
+
+ for (name, comment) in databases {
+ let stale = if let Some(rest) = name.strip_prefix(TEST_DB_PREFIX) {
+ rest.split('_')
+ .next()
+ .and_then(|pid| pid.parse::().ok())
+ .is_some_and(|pid| !process_alive(pid))
+ } else if name == current_template {
+ false
+ } else {
+ // Under the lock, no template is being built: a `_build` is left over.
+ name.ends_with("_build")
+ || template_age(comment.as_deref()).is_none_or(|age| age > STALE_TEMPLATE_SECS)
+ };
+ if stale {
+ drop_database(admin, &name).await;
+ }
+ }
+}
+
+async fn drop_database(admin: &mut PgConnection, name: &str) {
+ let _ = admin
+ .execute(format!(r#"ALTER DATABASE "{name}" WITH IS_TEMPLATE false"#).as_str())
+ .await;
+ let _ = admin
+ .execute(format!(r#"DROP DATABASE IF EXISTS "{name}" WITH (FORCE)"#).as_str())
+ .await;
+}
+
+/// Hash of the migration file names and contents.
+fn migrations_fingerprint() -> String {
+ let dir = crate::workspace_path("migrations");
+ let mut files: Vec<_> = std::fs::read_dir(&dir)
+ .unwrap_or_else(|e| panic!("read {}: {e}", dir.display()))
+ .filter_map(Result::ok)
+ .map(|entry| entry.path())
+ .filter(|path| path.extension().is_some_and(|ext| ext == "sql"))
+ .collect();
+ files.sort();
+
+ let mut hasher = Sha256::new();
+ for path in files {
+ hasher.update(path.file_name().unwrap().as_encoded_bytes());
+ hasher.update([0]);
+ hasher.update(std::fs::read(&path).expect("read migration"));
+ }
+ hasher.finalize()[..6]
+ .iter()
+ .map(|b| format!("{b:02x}"))
+ .collect()
+}
+
+fn template_age(comment: Option<&str>) -> Option {
+ let created: u64 = comment?.rsplit(' ').next()?.parse().ok()?;
+ Some(unix_now().saturating_sub(created))
+}
+
+fn process_alive(pid: u32) -> bool {
+ let proc = Path::new("/proc");
+ // Without procfs, liveness is unknown: keep the database.
+ !proc.is_dir() || proc.join(pid.to_string()).exists()
+}
+
+fn unix_now() -> u64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_secs())
+ .unwrap_or_default()
+}
+
+/// `url` pointing at database `name`.
+pub fn with_database(url: &str, name: &str) -> String {
+ let mut parsed = reqwest::Url::parse(url).expect("valid database URL");
+ parsed.set_path(&format!("/{name}"));
+ parsed.to_string()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn database_url_keeps_credentials_and_options() {
+ assert_eq!(
+ with_database(
+ "postgres://u:p@127.0.0.1:5433/postgres?sslmode=disable",
+ "x"
+ ),
+ "postgres://u:p@127.0.0.1:5433/x?sslmode=disable"
+ );
+ }
+
+ #[test]
+ fn template_age_reads_the_creation_comment() {
+ let created = unix_now() - 90;
+ let age = template_age(Some(&format!("auth-api test template, created {created}")));
+ assert!(age.is_some_and(|age| (90..100).contains(&age)));
+ assert_eq!(template_age(Some("hand made")), None);
+ assert_eq!(template_age(None), None);
+ }
+
+ #[test]
+ fn own_process_is_alive() {
+ assert!(process_alive(std::process::id()));
+ }
+}
diff --git a/crates/testkit/src/env.rs b/crates/testkit/src/env.rs
new file mode 100644
index 0000000..d755f06
--- /dev/null
+++ b/crates/testkit/src/env.rs
@@ -0,0 +1,41 @@
+//! Test infrastructure addresses.
+//!
+//! A missing variable fails the test instead of skipping it: a suite that
+//! silently passes without its database has checked nothing.
+
+use std::sync::Once;
+
+pub const DATABASE_URL: &str = "TEST_DATABASE_URL";
+pub const REDIS_URL: &str = "TEST_REDIS_URL";
+pub const NATS_URL: &str = "TEST_NATS_URL";
+
+fn load_dotenv() {
+ static ONCE: Once = Once::new();
+ ONCE.call_once(|| {
+ dotenvy::dotenv().ok();
+ });
+}
+
+fn required(name: &str) -> String {
+ load_dotenv();
+ std::env::var(name).unwrap_or_else(|_| {
+ panic!(
+ "{name} must be set: start the test infrastructure (`make test-infra-up`) \
+ and run the suites through `make test-local`, or export \
+ TEST_DATABASE_URL, TEST_REDIS_URL and TEST_NATS_URL"
+ )
+ })
+}
+
+/// Administrative connection to the test PostgreSQL server.
+pub fn database_url() -> String {
+ required(DATABASE_URL)
+}
+
+pub fn redis_url() -> String {
+ required(REDIS_URL)
+}
+
+pub fn nats_url() -> String {
+ required(NATS_URL)
+}
diff --git a/crates/testkit/src/faults.rs b/crates/testkit/src/faults.rs
new file mode 100644
index 0000000..959656a
--- /dev/null
+++ b/crates/testkit/src/faults.rs
@@ -0,0 +1,222 @@
+//! Fault injection between the application and a dependency.
+//!
+//! A [`FaultProxy`] listens on loopback and relays every connection to the
+//! real PostgreSQL, Redis or NATS. Switching its [`Fault`] affects open
+//! connections immediately, which is what a pool sees during an incident.
+
+use std::{net::SocketAddr, time::Duration};
+
+use tokio::{
+ io::{AsyncReadExt, AsyncWriteExt},
+ net::{
+ TcpListener, TcpStream,
+ tcp::{OwnedReadHalf, OwnedWriteHalf},
+ },
+ sync::watch,
+ task::JoinHandle,
+};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Fault {
+ /// Relay traffic untouched.
+ None,
+ /// Delay every chunk of data, in both directions.
+ Latency(Duration),
+ /// Accept connections and hold every byte: the dependency hangs.
+ Blackhole,
+ /// Close open connections and every new one: the dependency is down.
+ Refuse,
+}
+
+pub struct FaultProxy {
+ addr: SocketAddr,
+ fault: watch::Sender,
+ accept: JoinHandle<()>,
+}
+
+impl FaultProxy {
+ /// Relay to `upstream` (`host:port`).
+ pub async fn start(upstream: impl Into) -> Self {
+ let upstream = upstream.into();
+ let listener = TcpListener::bind("127.0.0.1:0")
+ .await
+ .expect("bind fault proxy");
+ let addr = listener.local_addr().expect("fault proxy address");
+ let (fault, watcher) = watch::channel(Fault::None);
+ let accept = tokio::spawn(accept_loop(listener, upstream, watcher));
+ Self {
+ addr,
+ fault,
+ accept,
+ }
+ }
+
+ pub fn addr(&self) -> SocketAddr {
+ self.addr
+ }
+
+ pub fn set(&self, fault: Fault) {
+ self.fault.send_replace(fault);
+ }
+
+ pub fn current(&self) -> Fault {
+ *self.fault.borrow()
+ }
+}
+
+impl Drop for FaultProxy {
+ fn drop(&mut self) {
+ self.accept.abort();
+ // Open relays notice the closed channel and shut their connections.
+ }
+}
+
+async fn accept_loop(listener: TcpListener, upstream: String, watcher: watch::Receiver) {
+ loop {
+ let Ok((client, _)) = listener.accept().await else {
+ continue;
+ };
+ if *watcher.borrow() == Fault::Refuse {
+ drop(client);
+ continue;
+ }
+ tokio::spawn(relay(client, upstream.clone(), watcher.clone()));
+ }
+}
+
+async fn relay(client: TcpStream, upstream: String, watcher: watch::Receiver) {
+ let Ok(server) = TcpStream::connect(&upstream).await else {
+ return;
+ };
+ let _ = client.set_nodelay(true);
+ let _ = server.set_nodelay(true);
+ let (client_read, client_write) = client.into_split();
+ let (server_read, server_write) = server.into_split();
+
+ tokio::select! {
+ _ = pump(client_read, server_write, watcher.clone()) => {}
+ _ = pump(server_read, client_write, watcher.clone()) => {}
+ _ = refused(watcher) => {}
+ }
+}
+
+async fn pump(
+ mut from: OwnedReadHalf,
+ mut to: OwnedWriteHalf,
+ mut watcher: watch::Receiver,
+) -> std::io::Result<()> {
+ let mut buf = vec![0u8; 16 * 1024];
+ loop {
+ let n = from.read(&mut buf).await?;
+ if n == 0 {
+ return Ok(());
+ }
+ loop {
+ let fault = *watcher.borrow_and_update();
+ match fault {
+ Fault::Blackhole => {
+ if watcher.changed().await.is_err() {
+ return Ok(());
+ }
+ }
+ Fault::Latency(delay) => {
+ tokio::time::sleep(delay).await;
+ break;
+ }
+ Fault::None | Fault::Refuse => break,
+ }
+ }
+ to.write_all(&buf[..n]).await?;
+ }
+}
+
+/// Resolves once the proxy refuses traffic or is dropped.
+async fn refused(mut watcher: watch::Receiver) {
+ loop {
+ if *watcher.borrow_and_update() == Fault::Refuse {
+ return;
+ }
+ if watcher.changed().await.is_err() {
+ return;
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ async fn echo_server() -> SocketAddr {
+ let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let addr = listener.local_addr().unwrap();
+ tokio::spawn(async move {
+ loop {
+ let (mut socket, _) = listener.accept().await.unwrap();
+ tokio::spawn(async move {
+ let mut buf = [0u8; 1024];
+ while let Ok(n) = socket.read(&mut buf).await {
+ if n == 0 || socket.write_all(&buf[..n]).await.is_err() {
+ return;
+ }
+ }
+ });
+ }
+ });
+ addr
+ }
+
+ async fn round_trip(stream: &mut TcpStream) -> std::io::Result> {
+ stream.write_all(b"ping").await?;
+ let mut buf = [0u8; 4];
+ stream.read_exact(&mut buf).await?;
+ Ok(buf.to_vec())
+ }
+
+ #[tokio::test]
+ async fn relays_until_told_otherwise() {
+ let proxy = FaultProxy::start(echo_server().await.to_string()).await;
+ let mut stream = TcpStream::connect(proxy.addr()).await.unwrap();
+ assert_eq!(round_trip(&mut stream).await.unwrap(), b"ping");
+ }
+
+ #[tokio::test]
+ async fn latency_delays_traffic() {
+ let proxy = FaultProxy::start(echo_server().await.to_string()).await;
+ proxy.set(Fault::Latency(Duration::from_millis(100)));
+ let mut stream = TcpStream::connect(proxy.addr()).await.unwrap();
+
+ let started = std::time::Instant::now();
+ round_trip(&mut stream).await.unwrap();
+ // One delay each way.
+ assert!(started.elapsed() >= Duration::from_millis(200));
+ }
+
+ #[tokio::test]
+ async fn blackhole_hangs_until_lifted() {
+ let proxy = FaultProxy::start(echo_server().await.to_string()).await;
+ let mut stream = TcpStream::connect(proxy.addr()).await.unwrap();
+ proxy.set(Fault::Blackhole);
+
+ let hung = tokio::time::timeout(Duration::from_millis(200), round_trip(&mut stream)).await;
+ assert!(hung.is_err(), "traffic passed a blackhole");
+
+ proxy.set(Fault::None);
+ let mut buf = [0u8; 4];
+ stream.read_exact(&mut buf).await.unwrap();
+ assert_eq!(&buf, b"ping");
+ }
+
+ #[tokio::test]
+ async fn refuse_closes_open_and_new_connections() {
+ let proxy = FaultProxy::start(echo_server().await.to_string()).await;
+ let mut open = TcpStream::connect(proxy.addr()).await.unwrap();
+ round_trip(&mut open).await.unwrap();
+
+ proxy.set(Fault::Refuse);
+ tokio::time::sleep(Duration::from_millis(50)).await;
+ assert!(round_trip(&mut open).await.is_err());
+
+ let mut fresh = TcpStream::connect(proxy.addr()).await.unwrap();
+ assert!(round_trip(&mut fresh).await.is_err());
+ }
+}
diff --git a/tests/http/common/fixtures.rs b/crates/testkit/src/fixtures.rs
similarity index 84%
rename from tests/http/common/fixtures.rs
rename to crates/testkit/src/fixtures.rs
index ed5582c..1aeb4c6 100644
--- a/tests/http/common/fixtures.rs
+++ b/crates/testkit/src/fixtures.rs
@@ -1,15 +1,13 @@
-//! Database fixtures for HTTP integration tests.
+//! Database fixtures for the HTTP suites.
//!
//! These functions insert data directly into the database, bypassing the HTTP
//! layer so that tests can set up preconditions without going through the API.
-#![allow(dead_code)]
-
use serde_json::Value;
use sqlx::PgPool;
use uuid::Uuid;
-use super::app::TestApp;
+use crate::app::TestApp;
pub struct RegisteredUser {
pub id: Uuid,
@@ -53,13 +51,18 @@ pub async fn register_user(app: &TestApp, index: usize) -> RegisteredUser {
.await;
let status = res.status().as_u16();
- if status != 201 {
+ if status != 202 {
let body = res.text().await.unwrap_or_default();
panic!("register failed for user {index}: status={status} body={body}");
}
- let body: Value = res.json().await.unwrap();
- let id = Uuid::parse_str(body["id"].as_str().unwrap()).unwrap();
+ // The registration response is deliberately identical for new and taken
+ // addresses and carries no id: read it back from the database.
+ let id: Uuid = sqlx::query_scalar("SELECT id FROM users WHERE email = $1")
+ .bind(&email)
+ .fetch_one(&app.db)
+ .await
+ .expect("registered user not found");
RegisteredUser {
id,
@@ -183,6 +186,21 @@ pub async fn authenticated_user(app: &TestApp, index: usize) -> AuthenticatedUse
let access_token = body["access_token"].as_str().unwrap().to_owned();
let refresh_token = body["refresh_token"].as_str().unwrap().to_owned();
+ // Signing in no longer grants re-authentication; the typical client flow
+ // for sensitive actions confirms the password explicitly first.
+ let reauth = app
+ .post_auth(
+ "/users/me/reauth",
+ &access_token,
+ &serde_json::json!({ "current_password": user.password }),
+ )
+ .await;
+ assert_eq!(
+ reauth.status().as_u16(),
+ 204,
+ "reauth failed for user {index}"
+ );
+
AuthenticatedUser {
id: user.id,
username: user.username,
@@ -192,3 +210,11 @@ pub async fn authenticated_user(app: &TestApp, index: usize) -> AuthenticatedUse
refresh_token,
}
}
+
+/// `prefix` followed by 12 random hex digits: unique across tests and runs.
+pub fn unique(prefix: &str) -> String {
+ format!(
+ "{prefix}{}",
+ &uuid::Uuid::new_v4().simple().to_string()[..12]
+ )
+}
diff --git a/crates/testkit/src/keys.rs b/crates/testkit/src/keys.rs
new file mode 100644
index 0000000..e5c36a0
--- /dev/null
+++ b/crates/testkit/src/keys.rs
@@ -0,0 +1,9 @@
+//! Key material of the test configuration. Never valid outside the suites:
+//! production refuses these keys (see `config::validate`).
+
+pub const PRIVATE_KEY_PEM: &str = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgL+1qOaZ7C+H1mGbV\njUP83/W450N4GfOnZSrQ7P//4Y2hRANCAAR4BApTJy8Anvp+O7YNVlTeCbBZ+1YJ\nk+r5ELHGFIXciAEGSrCTOkCm3yChSYroYWLE3ZN4reh6JDbIMX/QnBGx\n-----END PRIVATE KEY-----";
+
+pub const PUBLIC_KEY_PEM: &str = "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEeAQKUycvAJ76fju2DVZU3gmwWftW\nCZPq+RCxxhSF3IgBBkqwkzpApt8goUmK6GFixN2TeK3oeiQ2yDF/0JwRsQ==\n-----END PUBLIC KEY-----";
+
+/// Base64 of the bytes 0..32.
+pub const ENCRYPTION_KEY: &str = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=";
diff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs
new file mode 100644
index 0000000..fd9f5b2
--- /dev/null
+++ b/crates/testkit/src/lib.rs
@@ -0,0 +1,57 @@
+//! Shared harness of the auth-api test suites.
+//!
+//! - [`TestApp`]: the real router on a random port, with its own database, a
+//! controllable clock and an in-memory mail outbox;
+//! - [`TestDb`]: a database cloned from a template migrated once per
+//! migration set, dropped when the value is;
+//! - [`TestClock`]: the application clock, moved forward without sleeping;
+//! - [`MailOutbox`]: every message the application sends, decoded;
+//! - [`authenticator::SoftAuthenticator`]: a passkey authenticator in memory,
+//! answering WebAuthn ceremonies like a browser would pass them on;
+//! - [`FaultProxy`]: a TCP proxy between the application and a dependency,
+//! adding latency, hanging or refusing connections on demand.
+//!
+//! The suites need PostgreSQL, Redis and NATS: `make test-infra-up`, then
+//! `TEST_DATABASE_URL`, `TEST_REDIS_URL` and `TEST_NATS_URL` (see
+//! [`env`]). The unit and property suites need none of it.
+
+pub mod app;
+pub mod authenticator;
+pub mod clock;
+pub mod contract;
+pub mod db;
+pub mod env;
+pub mod faults;
+pub mod fixtures;
+pub mod keys;
+pub mod logs;
+pub mod mail;
+pub mod mailpit;
+pub mod sql;
+pub mod tokens;
+
+/// Re-exported for [`pg_args!`].
+pub use sqlx;
+
+pub use app::{TestApp, TestAppBuilder};
+pub use clock::TestClock;
+pub use db::TestDb;
+pub use faults::{Fault, FaultProxy};
+pub use mail::{CapturedMail, MailOutbox};
+
+/// Install the test log subscriber once per process. `RUST_LOG` style
+/// filtering goes through `TEST_LOG`, e.g. `TEST_LOG=auth_api=debug`.
+pub fn init_tracing() {
+ let filter = std::env::var("TEST_LOG").unwrap_or_else(|_| "auth_api=error".into());
+ let _ = tracing_subscriber::fmt()
+ .with_env_filter(tracing_subscriber::EnvFilter::new(filter))
+ .with_test_writer()
+ .try_init();
+}
+
+/// Absolute path of a file or directory of the auth-api package.
+pub fn workspace_path(relative: &str) -> std::path::PathBuf {
+ std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("../..")
+ .join(relative)
+}
diff --git a/crates/testkit/src/logs.rs b/crates/testkit/src/logs.rs
new file mode 100644
index 0000000..364d73f
--- /dev/null
+++ b/crates/testkit/src/logs.rs
@@ -0,0 +1,55 @@
+//! Log capture, for asserting what the service writes to its logs.
+
+use std::{
+ io,
+ sync::{Arc, Mutex},
+};
+
+use tracing_subscriber::{EnvFilter, fmt::MakeWriter};
+
+#[derive(Clone, Default)]
+pub struct LogCapture {
+ buffer: Arc>>,
+}
+
+impl LogCapture {
+ /// Record every event `filter` selects (`auth_api=trace,access=info`).
+ ///
+ /// Installs the process-wide subscriber, so it must run before anything
+ /// logs; nextest runs each test in a process of its own.
+ pub fn install(filter: &str) -> Self {
+ let capture = Self::default();
+ tracing_subscriber::fmt()
+ .with_env_filter(EnvFilter::new(filter))
+ .with_writer(capture.clone())
+ .with_ansi(false)
+ .try_init()
+ .expect("the log capture must be installed before any other subscriber");
+ capture
+ }
+
+ pub fn contents(&self) -> String {
+ String::from_utf8_lossy(&self.buffer.lock().unwrap()).into_owned()
+ }
+}
+
+pub struct Writer(Arc>>);
+
+impl io::Write for Writer {
+ fn write(&mut self, buf: &[u8]) -> io::Result {
+ self.0.lock().unwrap().extend_from_slice(buf);
+ Ok(buf.len())
+ }
+
+ fn flush(&mut self) -> io::Result<()> {
+ Ok(())
+ }
+}
+
+impl<'a> MakeWriter<'a> for LogCapture {
+ type Writer = Writer;
+
+ fn make_writer(&'a self) -> Self::Writer {
+ Writer(self.buffer.clone())
+ }
+}
diff --git a/crates/testkit/src/mail.rs b/crates/testkit/src/mail.rs
new file mode 100644
index 0000000..636a768
--- /dev/null
+++ b/crates/testkit/src/mail.rs
@@ -0,0 +1,302 @@
+//! In-memory mail transport: every message the application sends, decoded.
+//!
+//! Notifications are sent from background tasks, so a test waits for the
+//! message it expects with [`MailOutbox::wait_for`] rather than reading the
+//! outbox right after the request.
+
+use std::sync::{Arc, Mutex};
+
+use auth_api::services::mailer::{MailTransport, SendFuture};
+use base64::{Engine, engine::general_purpose::STANDARD};
+use lettre::Message;
+use tokio::sync::Notify;
+
+/// How long `wait_for` waits before failing the test.
+pub const WAIT: std::time::Duration = std::time::Duration::from_secs(5);
+
+#[derive(Debug, Clone)]
+pub struct CapturedMail {
+ /// Envelope recipients.
+ pub to: Vec,
+ pub subject: String,
+ /// Decoded HTML body.
+ pub html: String,
+}
+
+impl CapturedMail {
+ pub fn is_for(&self, address: &str) -> bool {
+ self.to.iter().any(|to| to.eq_ignore_ascii_case(address))
+ }
+
+ /// The first run of exactly six ASCII digits in the body: the one-time
+ /// code of a code email.
+ pub fn six_digit_code(&self) -> Option {
+ let bytes = self.html.as_bytes();
+ let mut start = None;
+ for (i, b) in bytes.iter().chain(std::iter::once(&b' ')).enumerate() {
+ match (b.is_ascii_digit(), start) {
+ (true, None) => start = Some(i),
+ (false, Some(s)) if i - s == 6 => return Some(self.html[s..i].to_owned()),
+ (false, Some(_)) => start = None,
+ _ => {}
+ }
+ }
+ None
+ }
+
+ /// The URL-safe token following `marker` in the body, such as the value
+ /// after `#token=` in a link.
+ pub fn value_after(&self, marker: &str) -> Option {
+ let start = self.html.find(marker)? + marker.len();
+ let value: String = self.html[start..]
+ .chars()
+ .take_while(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
+ .collect();
+ (!value.is_empty()).then_some(value)
+ }
+}
+
+#[derive(Clone, Default)]
+pub struct MailOutbox {
+ inner: Arc,
+}
+
+#[derive(Default)]
+struct Inner {
+ messages: Mutex>,
+ arrived: Notify,
+}
+
+impl MailOutbox {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ pub fn messages(&self) -> Vec {
+ self.inner.messages.lock().unwrap().clone()
+ }
+
+ pub fn messages_to(&self, address: &str) -> Vec {
+ self.messages()
+ .into_iter()
+ .filter(|m| m.is_for(address))
+ .collect()
+ }
+
+ /// The latest message to `address` whose subject contains `subject`,
+ /// waiting up to [`WAIT`] for it to be sent.
+ pub async fn wait_for(&self, address: &str, subject: &str) -> CapturedMail {
+ self.wait_until(|messages| {
+ messages
+ .iter()
+ .rev()
+ .find(|m| m.is_for(address) && m.subject.contains(subject))
+ .cloned()
+ })
+ .await
+ .unwrap_or_else(|| {
+ panic!(
+ "no message to {address} with a subject containing {subject:?} within {WAIT:?}; sent: {:?}",
+ self.summary()
+ )
+ })
+ }
+
+ /// Wait until `count` messages were sent to `address`.
+ pub async fn wait_for_count(&self, address: &str, count: usize) -> Vec {
+ self.wait_until(|messages| {
+ let matching: Vec<_> = messages
+ .iter()
+ .filter(|m| m.is_for(address))
+ .cloned()
+ .collect();
+ (matching.len() >= count).then_some(matching)
+ })
+ .await
+ .unwrap_or_else(|| {
+ panic!(
+ "expected {count} messages to {address} within {WAIT:?}; sent: {:?}",
+ self.summary()
+ )
+ })
+ }
+
+ /// Subjects and recipients of everything sent, for failure messages.
+ pub fn summary(&self) -> Vec<(Vec, String)> {
+ self.messages()
+ .into_iter()
+ .map(|m| (m.to, m.subject))
+ .collect()
+ }
+
+ async fn wait_until(&self, find: impl Fn(&[CapturedMail]) -> Option) -> Option {
+ let deadline = tokio::time::Instant::now() + WAIT;
+ loop {
+ let arrived = self.inner.arrived.notified();
+ tokio::pin!(arrived);
+ arrived.as_mut().enable();
+
+ if let Some(found) = find(&self.inner.messages.lock().unwrap()) {
+ return Some(found);
+ }
+ if tokio::time::timeout_at(deadline, arrived).await.is_err() {
+ return None;
+ }
+ }
+ }
+
+ fn record(&self, mail: CapturedMail) {
+ self.inner.messages.lock().unwrap().push(mail);
+ self.inner.arrived.notify_waiters();
+ }
+}
+
+impl MailTransport for MailOutbox {
+ fn send(&self, message: Message) -> SendFuture<'_> {
+ Box::pin(async move {
+ self.record(decode(&message)?);
+ Ok(())
+ })
+ }
+}
+
+/// Recipients, subject and body of a message as a mail client would show them.
+pub fn decode(message: &Message) -> anyhow::Result {
+ let to = message
+ .envelope()
+ .to()
+ .iter()
+ .map(ToString::to_string)
+ .collect();
+ let headers = message.headers();
+ // Raw header values are kept unencoded; encoding happens when formatting.
+ let subject = headers.get_raw("Subject").unwrap_or_default().to_owned();
+ let encoding = headers
+ .get_raw("Content-Transfer-Encoding")
+ .unwrap_or("7bit")
+ .to_ascii_lowercase();
+
+ let formatted = message.formatted();
+ let body_start = formatted
+ .windows(4)
+ .position(|w| w == b"\r\n\r\n")
+ .map(|p| p + 4)
+ .ok_or_else(|| anyhow::anyhow!("message without a body"))?;
+ let raw = &formatted[body_start..];
+
+ let bytes = match encoding.as_str() {
+ "base64" => {
+ let compact: Vec = raw
+ .iter()
+ .copied()
+ .filter(|b| !b.is_ascii_whitespace())
+ .collect();
+ STANDARD.decode(compact)?
+ }
+ "quoted-printable" => decode_quoted_printable(raw)?,
+ _ => raw.to_vec(),
+ };
+
+ Ok(CapturedMail {
+ to,
+ subject,
+ html: String::from_utf8(bytes)?,
+ })
+}
+
+/// RFC 2045 section 6.7: `=XX` escapes and `=` soft line breaks.
+fn decode_quoted_printable(input: &[u8]) -> anyhow::Result> {
+ let mut out = Vec::with_capacity(input.len());
+ let mut i = 0;
+ while i < input.len() {
+ match input[i] {
+ b'=' if input[i + 1..].starts_with(b"\r\n") => i += 3,
+ b'=' if input[i + 1..].starts_with(b"\n") => i += 2,
+ b'=' => {
+ let hex = input
+ .get(i + 1..i + 3)
+ .ok_or_else(|| anyhow::anyhow!("truncated quoted-printable escape"))?;
+ out.push(u8::from_str_radix(std::str::from_utf8(hex)?, 16)?);
+ i += 3;
+ }
+ b => {
+ out.push(b);
+ i += 1;
+ }
+ }
+ }
+ Ok(out)
+}
+
+#[cfg(test)]
+mod tests {
+ use lettre::message::header::ContentType;
+
+ use super::*;
+
+ fn message(subject: &str, body: &str) -> Message {
+ Message::builder()
+ .from("App ".parse().unwrap())
+ .to("Jane ".parse().unwrap())
+ .subject(subject)
+ .header(ContentType::TEXT_HTML)
+ .body(body.to_owned())
+ .unwrap()
+ }
+
+ #[test]
+ fn decodes_non_ascii_subjects_and_bodies() {
+ let body = format!(
+ "Your code: 042917. Try again. {}
",
+ "\u{e9}".repeat(200)
+ );
+ let mail = decode(&message("Verify your address \u{2713}", &body)).unwrap();
+
+ assert_eq!(mail.to, vec!["jane@example.com"]);
+ assert_eq!(mail.subject, "Verify your address \u{2713}");
+ assert_eq!(mail.html, body);
+ assert_eq!(mail.six_digit_code().as_deref(), Some("042917"));
+ }
+
+ #[test]
+ fn decodes_ascii_bodies() {
+ let mail = decode(&message(
+ "Reset",
+ "go",
+ ))
+ .unwrap();
+ assert_eq!(mail.value_after("#token=").as_deref(), Some("abc-DEF_123"));
+ }
+
+ #[test]
+ fn six_digit_code_ignores_longer_digit_runs() {
+ let mail = CapturedMail {
+ to: vec![],
+ subject: String::new(),
+ html: "order 1234567 then 12345 then 654321".into(),
+ };
+ assert_eq!(mail.six_digit_code().as_deref(), Some("654321"));
+ }
+
+ #[test]
+ fn quoted_printable_handles_escapes_and_soft_breaks() {
+ assert_eq!(
+ decode_quoted_printable(b"na=C3=AFve =\r\nbyte=3D").unwrap(),
+ "na\u{ef}ve byte=".as_bytes()
+ );
+ assert!(decode_quoted_printable(b"broken=4").is_err());
+ }
+
+ #[tokio::test]
+ async fn waiting_sees_messages_sent_later() {
+ let outbox = MailOutbox::new();
+ let sender = outbox.clone();
+ tokio::spawn(async move {
+ tokio::task::yield_now().await;
+ sender.send(message("Welcome", "hi
")).await.unwrap();
+ });
+
+ let mail = outbox.wait_for("jane@example.com", "Welcome").await;
+ assert_eq!(mail.html, "hi
");
+ }
+}
diff --git a/tests/http/common/mailpit.rs b/crates/testkit/src/mailpit.rs
similarity index 99%
rename from tests/http/common/mailpit.rs
rename to crates/testkit/src/mailpit.rs
index a0bbba8..5d3996b 100644
--- a/tests/http/common/mailpit.rs
+++ b/crates/testkit/src/mailpit.rs
@@ -11,7 +11,6 @@
//! ```
use serde::Deserialize;
-use time;
/// Fixed ports matching docker-compose.test.yml.
pub struct MailpitPorts {
diff --git a/crates/testkit/src/sql.rs b/crates/testkit/src/sql.rs
new file mode 100644
index 0000000..585a11f
--- /dev/null
+++ b/crates/testkit/src/sql.rs
@@ -0,0 +1,121 @@
+//! Helpers of the schema suites, which exercise the database directly:
+//! row fixtures, constraint assertions and query plans.
+
+use sqlx::{PgPool, postgres::PgArguments};
+use uuid::Uuid;
+
+pub const SAMPLE_PASSWORD_HASH: &str =
+ "$argon2id$v=19$m=65536,t=3,p=1$c2FsdHlzYWx0$abcdefghijklmnopqrstuv";
+
+pub const SAMPLE_TOKEN_HASH: [u8; 32] = [7; 32];
+pub const SAMPLE_CODE_HASH: [u8; 32] = [9; 32];
+
+/// Bind values of mixed types for [`explain_plan`] or `sqlx::query_with`:
+/// `pg_args![&email, &cutoff, &limit]`.
+#[macro_export]
+macro_rules! pg_args {
+ ($($value:expr),* $(,)?) => {{
+ #[allow(unused_imports)]
+ use $crate::sqlx::Arguments as _;
+ #[allow(unused_mut)]
+ let mut args = $crate::sqlx::postgres::PgArguments::default();
+ $( args.add($value).expect("encode query argument"); )*
+ args
+ }};
+}
+
+pub fn sample_email(index: usize) -> String {
+ format!("user{index}@example.com")
+}
+
+pub fn sample_username(index: usize) -> String {
+ format!("user_{index}")
+}
+
+pub fn fixed_hash(seed: u8) -> Vec {
+ vec![seed; 32]
+}
+
+pub async fn insert_user(pool: &PgPool, index: usize) -> Uuid {
+ sqlx::query_scalar(
+ "INSERT INTO users (username, email, password_hash)
+ VALUES ($1, $2, $3)
+ RETURNING id",
+ )
+ .bind(sample_username(index))
+ .bind(sample_email(index))
+ .bind(SAMPLE_PASSWORD_HASH)
+ .fetch_one(pool)
+ .await
+ .expect("failed to insert test user")
+}
+
+pub async fn insert_active_user(pool: &PgPool, index: usize) -> Uuid {
+ sqlx::query_scalar(
+ "INSERT INTO users (username, email, password_hash, status, email_verified_at)
+ VALUES ($1, $2, $3, 'active', NOW())
+ RETURNING id",
+ )
+ .bind(sample_username(index))
+ .bind(sample_email(index))
+ .bind(SAMPLE_PASSWORD_HASH)
+ .fetch_one(pool)
+ .await
+ .expect("failed to insert active test user")
+}
+
+pub async fn insert_permission(pool: &PgPool, resource: &str, action: &str) -> Uuid {
+ sqlx::query_scalar(
+ "INSERT INTO permissions (resource, action)
+ VALUES ($1, $2)
+ RETURNING id",
+ )
+ .bind(resource)
+ .bind(action)
+ .fetch_one(pool)
+ .await
+ .expect("failed to insert permission")
+}
+
+/// Assert that `error` is a violation of the database constraint `expected`.
+pub fn assert_constraint_error(error: &sqlx::Error, expected: &str) {
+ let Some(database_error) = error.as_database_error() else {
+ panic!("expected a violation of `{expected}`, got: {error}");
+ };
+ assert_eq!(
+ database_error.constraint(),
+ Some(expected),
+ "unexpected database error: {database_error}"
+ );
+}
+
+/// Plan of `sql` with sequential and TID scans disabled, so a missing index
+/// shows up as a plan without it rather than hiding behind a cheap scan of a
+/// small fixture table.
+pub async fn explain_plan(pool: &PgPool, sql: &str, args: PgArguments) -> String {
+ let mut conn = pool.acquire().await.expect("acquire a connection");
+ sqlx::raw_sql("SET enable_seqscan = off; SET enable_tidscan = off;")
+ .execute(&mut *conn)
+ .await
+ .expect("configure planner guardrails for explain");
+
+ let explain = format!("EXPLAIN (COSTS OFF) {sql}");
+ let lines: Vec = sqlx::query_scalar_with(&explain, args)
+ .fetch_all(&mut *conn)
+ .await
+ .expect("failed to explain query plan");
+
+ // The connection returns to the pool: leave no planner setting behind.
+ sqlx::raw_sql("RESET enable_seqscan; RESET enable_tidscan;")
+ .execute(&mut *conn)
+ .await
+ .expect("reset planner settings");
+ lines.join("\n")
+}
+
+pub fn assert_plan_contains(plan: &str, needle: &str) {
+ assert!(
+ plan.contains(needle),
+ "expected plan to contain `{needle}`, got:\n{plan}"
+ );
+}
diff --git a/crates/testkit/src/tokens.rs b/crates/testkit/src/tokens.rs
new file mode 100644
index 0000000..d0cf857
--- /dev/null
+++ b/crates/testkit/src/tokens.rs
@@ -0,0 +1,87 @@
+//! Access tokens forged for attacking the authentication of the API.
+
+use auth_api::utils::jwt::{self, Claims};
+use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
+use jsonwebtoken::{Algorithm, EncodingKey, Header};
+use p256::{ecdsa::SigningKey, pkcs8::EncodePrivateKey};
+use uuid::Uuid;
+
+use crate::{app::TestApp, keys};
+
+impl TestApp {
+ /// The claims the API puts in an access token for this session, issued now.
+ pub fn access_claims(&self, user_id: Uuid, session_id: Uuid) -> Claims {
+ let now = self.state.clock.now().unix_timestamp();
+ let mut claims = Claims::new(user_id, session_id, now, now + 900);
+ claims.iss = Some(self.state.config.server.public_url.clone());
+ claims.aud = self.state.config.jwt.audience.clone();
+ claims
+ }
+
+ /// `claims` signed with the app's own key, as the API signs them.
+ pub fn sign(&self, claims: &Claims) -> String {
+ jwt::encode_token(
+ claims,
+ &self.state.jwt_signing_key,
+ Some(&self.state.jwt_kid),
+ )
+ .expect("sign test claims")
+ }
+}
+
+/// `claims` signed with a P-256 key generated on the spot, which no deployment holds.
+pub fn sign_with_foreign_key(claims: &Claims) -> String {
+ let key = SigningKey::random(&mut rand_core::OsRng);
+ let pem = key
+ .to_pkcs8_pem(Default::default())
+ .expect("encode the foreign key");
+ let encoding = jwt::parse_encoding_key(&pem).expect("parse the foreign key");
+ jwt::encode_token(claims, &encoding, None).expect("sign with the foreign key")
+}
+
+/// `claims` in an unsigned token (`alg: none`).
+pub fn unsigned(claims: &Claims) -> String {
+ let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"none","typ":"JWT"}"#);
+ let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).expect("claims to JSON"));
+ format!("{header}.{payload}.")
+}
+
+/// `claims` signed with HS256 using the public key as the HMAC secret: the
+/// algorithm confusion attack against verifiers that trust the header.
+pub fn hs256_with_public_key(claims: &Claims) -> String {
+ jsonwebtoken::encode(
+ &Header::new(Algorithm::HS256),
+ claims,
+ &EncodingKey::from_secret(keys::PUBLIC_KEY_PEM.as_bytes()),
+ )
+ .expect("sign with HS256")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn claims() -> Claims {
+ Claims::new(Uuid::new_v4(), Uuid::new_v4(), 1_000, 2_000)
+ }
+
+ fn header(token: &str) -> serde_json::Value {
+ let encoded = token.split('.').next().unwrap();
+ serde_json::from_slice(&URL_SAFE_NO_PAD.decode(encoded).unwrap()).unwrap()
+ }
+
+ #[test]
+ fn forged_tokens_declare_what_they_claim_to_be() {
+ assert_eq!(header(&unsigned(&claims()))["alg"], "none");
+ assert!(unsigned(&claims()).ends_with('.'));
+ assert_eq!(header(&hs256_with_public_key(&claims()))["alg"], "HS256");
+ assert_eq!(header(&sign_with_foreign_key(&claims()))["alg"], "ES256");
+ }
+
+ #[test]
+ fn a_foreign_signature_does_not_verify_with_the_test_key() {
+ let key = jwt::parse_verifying_key(keys::PUBLIC_KEY_PEM).unwrap();
+ let token = sign_with_foreign_key(&claims());
+ assert!(jwt::decode_token(&token, &key, 1_500).is_err());
+ }
+}
diff --git a/crates/verifier/Cargo.toml b/crates/verifier/Cargo.toml
new file mode 100644
index 0000000..34e7261
--- /dev/null
+++ b/crates/verifier/Cargo.toml
@@ -0,0 +1,32 @@
+[package]
+name = "auth-api-verifier"
+version = "0.1.0"
+edition = "2024"
+rust-version = "1.88"
+publish = false
+description = "Verify auth-api access tokens in a Rust resource server: JWKS, issuer, audience, permissions, and optional revocation checks."
+license = "MIT"
+
+[features]
+default = []
+# `Authenticated` extractor for axum handlers.
+axum = ["dep:axum"]
+
+[dependencies]
+axum = { version = "0.8.8", optional = true, default-features = false }
+jsonwebtoken = { version = "10", features = ["aws_lc_rs"] }
+reqwest = { version = "0.13.2", default-features = false, features = ["json", "rustls", "form"] }
+serde = { version = "1.0.228", features = ["derive"] }
+serde_json = "1.0.149"
+thiserror = "2.0.18"
+tokio = { version = "1.50.0", features = ["sync", "time"] }
+uuid = { version = "1.22.0", features = ["serde"] }
+
+[dev-dependencies]
+auth-api-verifier = { path = ".", features = ["axum"] }
+auth-api = { path = "../.." }
+axum = "0.8.8"
+sqlx = { version = "0.8.6", default-features = false, features = ["runtime-tokio-rustls", "postgres"] }
+testkit = { path = "../testkit" }
+tokio = { version = "1.50.0", features = ["full"] }
+tower = { version = "0.5.3", features = ["util"] }
diff --git a/crates/verifier/README.md b/crates/verifier/README.md
new file mode 100644
index 0000000..1ce6a33
--- /dev/null
+++ b/crates/verifier/README.md
@@ -0,0 +1,49 @@
+# auth-api-verifier
+
+Verify auth-api access tokens in a Rust resource server. Internal crate, not
+published: depend on it by path or git revision.
+
+```toml
+[dependencies]
+auth-api-verifier = { git = "ssh://git@github.com//auth-api", features = ["axum"] }
+```
+
+```rust
+use std::sync::Arc;
+
+use auth_api_verifier::{Authenticated, Verifier, VerifierConfig};
+use axum::{Router, routing::get};
+
+let verifier = Arc::new(Verifier::new(VerifierConfig::new(
+ "https://auth.example.com", // auth-api's APP_PUBLIC_URL
+ "https://api.example.com", // this service, listed in auth-api's JWT_AUDIENCE
+)));
+
+let app = Router::new()
+ .route("/invoices", get(|Authenticated(token): Authenticated| async move {
+ if !token.has_permission("invoices:read") {
+ return Err(axum::http::StatusCode::FORBIDDEN);
+ }
+ Ok(format!("invoices of {}", token.subject))
+ }))
+ .with_state(verifier);
+```
+
+Checked on every call, without contacting auth-api: the ES256 signature against
+the published keys (fetched once, refetched when a token names an unknown key,
+at most once a minute), `iss`, `aud`, `exp` and `nbf`.
+
+Not checked offline: a revocation (logout, password change, revoked session)
+before the token expires, 15 minutes by default. For the operations where that
+matters, register the service as a confidential client in auth-api and enable
+introspection; answers are cached 30 seconds per token:
+
+```rust
+use auth_api_verifier::Introspection;
+
+let mut config = VerifierConfig::new("https://auth.example.com", "https://api.example.com");
+config.introspection = Some(Introspection::new("invoices-api", std::env::var("AUTH_CLIENT_SECRET")?));
+```
+
+`VerifiedToken::is_client_token` tells a token a client obtained for itself
+(client credentials: no user, `client_id` set) from a user's.
diff --git a/crates/verifier/src/extractor.rs b/crates/verifier/src/extractor.rs
new file mode 100644
index 0000000..cbeb90f
--- /dev/null
+++ b/crates/verifier/src/extractor.rs
@@ -0,0 +1,49 @@
+//! axum integration: `Authenticated` rejects a request without a valid token.
+
+use std::sync::Arc;
+
+use axum::{
+ extract::{FromRef, FromRequestParts},
+ http::{StatusCode, header, request::Parts},
+ response::{IntoResponse, Response},
+};
+
+use crate::{VerifiedToken, Verifier, VerifyError};
+
+/// A handler argument holding the verified token of the request. The state
+/// must provide an `Arc` (`FromRef`).
+pub struct Authenticated(pub VerifiedToken);
+
+impl FromRequestParts for Authenticated
+where
+ S: Send + Sync,
+ Arc: FromRef,
+{
+ type Rejection = Response;
+
+ async fn from_request_parts(parts: &mut Parts, state: &S) -> Result {
+ let token = parts
+ .headers
+ .get(header::AUTHORIZATION)
+ .and_then(|value| value.to_str().ok())
+ .and_then(|value| value.strip_prefix("Bearer "))
+ .ok_or_else(|| challenge(StatusCode::UNAUTHORIZED, None))?;
+ Arc::::from_ref(state)
+ .verify(token)
+ .await
+ .map(Authenticated)
+ .map_err(|error| match error {
+ VerifyError::Unavailable(_) => StatusCode::SERVICE_UNAVAILABLE.into_response(),
+ _ => challenge(StatusCode::UNAUTHORIZED, Some("invalid_token")),
+ })
+ }
+}
+
+/// RFC 6750 section 3.
+fn challenge(status: StatusCode, error: Option<&str>) -> Response {
+ let value = match error {
+ Some(error) => format!("Bearer error=\"{error}\""),
+ None => "Bearer".to_owned(),
+ };
+ (status, [(header::WWW_AUTHENTICATE, value)]).into_response()
+}
diff --git a/crates/verifier/src/lib.rs b/crates/verifier/src/lib.rs
new file mode 100644
index 0000000..ddbbe0c
--- /dev/null
+++ b/crates/verifier/src/lib.rs
@@ -0,0 +1,310 @@
+//! Verify auth-api access tokens in a Rust resource server.
+//!
+//! ```no_run
+//! # async fn example() -> Result<(), auth_api_verifier::VerifyError> {
+//! use auth_api_verifier::{Verifier, VerifierConfig};
+//!
+//! let verifier = Verifier::new(VerifierConfig::new(
+//! "https://auth.example.com",
+//! "https://api.example.com",
+//! ));
+//! let token = verifier.verify("eyJ...").await?;
+//! if token.has_permission("invoices:read") {
+//! // serve the request for token.subject
+//! }
+//! # Ok(())
+//! # }
+//! ```
+//!
+//! What is checked offline, on every call: the ES256 signature against the
+//! issuer's published keys (fetched once, refreshed when an unknown key id
+//! appears), the issuer, the audience, and the validity period. What is not:
+//! whether the token was revoked (a logout, a password change, a revoked
+//! session) before it expires. Access tokens live 15 minutes by default; when
+//! that is too long for an operation, configure [`Introspection`] and the
+//! verifier asks auth-api, caching the answer briefly.
+
+use std::{
+ collections::HashMap,
+ time::{Duration, Instant},
+};
+
+use jsonwebtoken::{Algorithm, DecodingKey, Validation, jwk::JwkSet};
+use serde::Deserialize;
+use tokio::sync::{Mutex, RwLock};
+use uuid::Uuid;
+
+#[cfg(feature = "axum")]
+mod extractor;
+#[cfg(feature = "axum")]
+pub use extractor::Authenticated;
+
+/// Why a token was refused.
+#[derive(Debug, thiserror::Error, PartialEq, Eq)]
+pub enum VerifyError {
+ #[error("the token is not a well-formed JWT")]
+ Malformed,
+ #[error("the token is signed with a key the issuer does not publish")]
+ UnknownKey,
+ #[error("the token's signature or claims do not verify: {0}")]
+ Invalid(String),
+ #[error("the token has expired")]
+ Expired,
+ #[error("the token was revoked")]
+ Revoked,
+ #[error("auth-api could not be reached: {0}")]
+ Unavailable(String),
+}
+
+/// Checking revocation with auth-api's introspection endpoint, as a
+/// confidential client.
+#[derive(Debug, Clone)]
+pub struct Introspection {
+ pub client_id: String,
+ pub client_secret: String,
+ /// How long an answer is reused for the same token. Default: 30 seconds.
+ pub cache_ttl: Duration,
+ /// Default: `{issuer}/oauth/introspect`.
+ pub endpoint: Option,
+}
+
+#[derive(Debug, Clone)]
+pub struct VerifierConfig {
+ /// `APP_PUBLIC_URL` of auth-api, the `iss` of its tokens.
+ pub issuer: String,
+ /// This resource server's identifier, one of auth-api's `JWT_AUDIENCE`.
+ pub audience: String,
+ /// Default: `{issuer}/.well-known/jwks.json`.
+ pub jwks_uri: Option,
+ /// Clock difference tolerated on `exp` and `nbf`. Default: 30 seconds.
+ pub leeway: Duration,
+ /// Shortest wait between two key fetches caused by unknown key ids, so a
+ /// flood of forged tokens cannot turn into a flood of requests. Default: 60 s.
+ pub min_refresh_interval: Duration,
+ pub introspection: Option,
+}
+
+impl VerifierConfig {
+ pub fn new(issuer: impl Into, audience: impl Into) -> Self {
+ Self {
+ issuer: issuer.into().trim_end_matches('/').to_owned(),
+ audience: audience.into(),
+ jwks_uri: None,
+ leeway: Duration::from_secs(30),
+ min_refresh_interval: Duration::from_secs(60),
+ introspection: None,
+ }
+ }
+}
+
+/// A verified access token.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct VerifiedToken {
+ /// The user, or for a client credentials token a UUID standing for the client.
+ pub subject: Uuid,
+ /// The session the token belongs to; nil for a client credentials token.
+ pub session_id: Uuid,
+ pub token_id: Uuid,
+ /// The client application the token was issued through, when there was one.
+ pub client_id: Option,
+ pub roles: Vec,
+ pub permissions: Vec,
+ /// Unix seconds.
+ pub expires_at: i64,
+}
+
+impl VerifiedToken {
+ pub fn has_permission(&self, permission: &str) -> bool {
+ self.permissions.iter().any(|p| p == permission)
+ }
+
+ pub fn has_role(&self, role: &str) -> bool {
+ self.roles.iter().any(|r| r == role)
+ }
+
+ /// A token a client obtained for itself: no user behind it.
+ pub fn is_client_token(&self) -> bool {
+ self.session_id.is_nil() && self.client_id.is_some()
+ }
+}
+
+#[derive(Deserialize)]
+struct Claims {
+ sub: Uuid,
+ sid: Uuid,
+ jti: Uuid,
+ exp: i64,
+ #[serde(default)]
+ roles: Vec,
+ #[serde(default)]
+ permissions: Vec,
+ #[serde(default)]
+ client_id: Option,
+}
+
+#[derive(Default)]
+struct Keys {
+ by_id: HashMap,
+ fetched_at: Option,
+}
+
+pub struct Verifier {
+ config: VerifierConfig,
+ jwks_uri: String,
+ http: reqwest::Client,
+ keys: RwLock,
+ /// Serializes key fetches.
+ refreshing: Mutex<()>,
+ introspected: Mutex>,
+}
+
+impl Verifier {
+ pub fn new(config: VerifierConfig) -> Self {
+ let jwks_uri = config
+ .jwks_uri
+ .clone()
+ .unwrap_or_else(|| format!("{}/.well-known/jwks.json", config.issuer));
+ Self {
+ config,
+ jwks_uri,
+ http: reqwest::Client::builder()
+ .timeout(Duration::from_secs(5))
+ .build()
+ .unwrap_or_default(),
+ keys: RwLock::default(),
+ refreshing: Mutex::default(),
+ introspected: Mutex::default(),
+ }
+ }
+
+ /// Verify `token` (without the `Bearer ` prefix).
+ pub async fn verify(&self, token: &str) -> Result {
+ let header = jsonwebtoken::decode_header(token).map_err(|_| VerifyError::Malformed)?;
+ if header.alg != Algorithm::ES256 {
+ return Err(VerifyError::Invalid(
+ "only ES256 tokens are accepted".into(),
+ ));
+ }
+ let kid = header.kid.ok_or(VerifyError::UnknownKey)?;
+ let key = self.key(&kid).await?;
+
+ let mut validation = Validation::new(Algorithm::ES256);
+ validation.set_issuer(&[&self.config.issuer]);
+ validation.set_audience(&[&self.config.audience]);
+ validation.leeway = self.config.leeway.as_secs();
+ validation.validate_nbf = true;
+ validation.set_required_spec_claims(&["exp", "iss", "aud", "sub"]);
+ let claims = jsonwebtoken::decode::(token, &key, &validation)
+ .map_err(|e| match e.kind() {
+ jsonwebtoken::errors::ErrorKind::ExpiredSignature => VerifyError::Expired,
+ _ => VerifyError::Invalid(e.to_string()),
+ })?
+ .claims;
+
+ let verified = VerifiedToken {
+ subject: claims.sub,
+ session_id: claims.sid,
+ token_id: claims.jti,
+ client_id: claims.client_id,
+ roles: claims.roles,
+ permissions: claims.permissions,
+ expires_at: claims.exp,
+ };
+ if let Some(introspection) = &self.config.introspection
+ && !self.active(introspection, token, verified.token_id).await?
+ {
+ return Err(VerifyError::Revoked);
+ }
+ Ok(verified)
+ }
+
+ async fn key(&self, kid: &str) -> Result {
+ if let Some(key) = self.keys.read().await.by_id.get(kid) {
+ return Ok(key.clone());
+ }
+ let _one_fetch = self.refreshing.lock().await;
+ // Another call may have fetched while this one waited.
+ {
+ let keys = self.keys.read().await;
+ if let Some(key) = keys.by_id.get(kid) {
+ return Ok(key.clone());
+ }
+ if keys
+ .fetched_at
+ .is_some_and(|at| at.elapsed() < self.config.min_refresh_interval)
+ {
+ return Err(VerifyError::UnknownKey);
+ }
+ }
+ let set: JwkSet = self
+ .http
+ .get(&self.jwks_uri)
+ .send()
+ .await
+ .and_then(reqwest::Response::error_for_status)
+ .map_err(|e| VerifyError::Unavailable(e.to_string()))?
+ .json()
+ .await
+ .map_err(|e| VerifyError::Unavailable(e.to_string()))?;
+ let by_id: HashMap = set
+ .keys
+ .iter()
+ .filter_map(|jwk| Some((jwk.common.key_id.clone()?, DecodingKey::from_jwk(jwk).ok()?)))
+ .collect();
+ let found = by_id.get(kid).cloned();
+ *self.keys.write().await = Keys {
+ by_id,
+ fetched_at: Some(Instant::now()),
+ };
+ found.ok_or(VerifyError::UnknownKey)
+ }
+
+ async fn active(
+ &self,
+ introspection: &Introspection,
+ token: &str,
+ token_id: Uuid,
+ ) -> Result {
+ if let Some((at, active)) = self.introspected.lock().await.get(&token_id)
+ && at.elapsed() < introspection.cache_ttl
+ {
+ return Ok(*active);
+ }
+ #[derive(Deserialize)]
+ struct Answer {
+ active: bool,
+ }
+ let answer: Answer = self
+ .http
+ .post(
+ introspection
+ .endpoint
+ .clone()
+ .unwrap_or_else(|| format!("{}/oauth/introspect", self.config.issuer)),
+ )
+ .basic_auth(&introspection.client_id, Some(&introspection.client_secret))
+ .form(&[("token", token)])
+ .send()
+ .await
+ .and_then(reqwest::Response::error_for_status)
+ .map_err(|e| VerifyError::Unavailable(e.to_string()))?
+ .json()
+ .await
+ .map_err(|e| VerifyError::Unavailable(e.to_string()))?;
+ let mut cache = self.introspected.lock().await;
+ cache.retain(|_, (at, _)| at.elapsed() < introspection.cache_ttl);
+ cache.insert(token_id, (Instant::now(), answer.active));
+ Ok(answer.active)
+ }
+}
+
+impl Introspection {
+ pub fn new(client_id: impl Into, client_secret: impl Into) -> Self {
+ Self {
+ client_id: client_id.into(),
+ client_secret: client_secret.into(),
+ cache_ttl: Duration::from_secs(30),
+ endpoint: None,
+ }
+ }
+}
diff --git a/crates/verifier/tests/verify.rs b/crates/verifier/tests/verify.rs
new file mode 100644
index 0000000..5d99991
--- /dev/null
+++ b/crates/verifier/tests/verify.rs
@@ -0,0 +1,151 @@
+//! The verifier against a running auth-api.
+
+use std::{sync::Arc, time::Duration};
+
+use auth_api_verifier::{Authenticated, Introspection, Verifier, VerifierConfig, VerifyError};
+use axum::{Router, body::Body, http::Request, routing::get};
+use testkit::{TestApp, fixtures, tokens};
+use tower::ServiceExt;
+
+fn config(app: &TestApp) -> VerifierConfig {
+ let issuer = app.state.config.server.public_url.clone();
+ let mut config = VerifierConfig::new(issuer.clone(), issuer);
+ config.jwks_uri = Some(app.url("/.well-known/jwks.json"));
+ config
+}
+
+#[tokio::test]
+async fn a_token_of_auth_api_verifies_with_its_permissions() {
+ let app = TestApp::spawn().await;
+ let user = fixtures::authenticated_user(&app, 1).await;
+ let verifier = Verifier::new(config(&app));
+
+ let token = verifier.verify(&user.access_token).await.unwrap();
+ assert_eq!(token.subject, user.id);
+ assert!(token.has_role("user"));
+ assert!(!token.has_permission("users:manage"));
+ assert!(!token.is_client_token());
+}
+
+#[tokio::test]
+async fn forged_expired_and_foreign_tokens_are_refused() {
+ let app = TestApp::spawn().await;
+ let user = fixtures::authenticated_user(&app, 1).await;
+ let claims = app.decode_access_token(&user.access_token);
+ let verifier = Verifier::new(config(&app));
+
+ let mut expired = claims.clone();
+ expired.exp = expired.iat - 3600;
+ expired.iat -= 7200;
+ expired.nbf = Some(expired.iat);
+ assert_eq!(
+ verifier.verify(&app.sign(&expired)).await,
+ Err(VerifyError::Expired)
+ );
+
+ let mut foreign_issuer = claims.clone();
+ foreign_issuer.iss = Some("https://evil.example.com".into());
+ assert!(matches!(
+ verifier.verify(&app.sign(&foreign_issuer)).await,
+ Err(VerifyError::Invalid(_))
+ ));
+
+ let mut other_audience = config(&app);
+ other_audience.audience = "https://other.example.com".into();
+ assert!(matches!(
+ Verifier::new(other_audience)
+ .verify(&user.access_token)
+ .await,
+ Err(VerifyError::Invalid(_))
+ ));
+
+ assert!(matches!(
+ verifier
+ .verify(&tokens::sign_with_foreign_key(&claims))
+ .await,
+ Err(VerifyError::UnknownKey | VerifyError::Invalid(_))
+ ));
+ assert!(verifier.verify(&tokens::unsigned(&claims)).await.is_err());
+ assert_eq!(
+ verifier.verify("not-a-token").await,
+ Err(VerifyError::Malformed)
+ );
+}
+
+#[tokio::test]
+async fn introspection_catches_a_token_revoked_before_it_expires() {
+ let app = TestApp::spawn().await;
+ sqlx::query(
+ "INSERT INTO registered_clients (client_id, display_name, client_secret_hash)
+ VALUES ('resource-server', 'Resource server', $1)",
+ )
+ .bind(auth_api::utils::crypto::sha256(b"aacs_rs-secret").to_vec())
+ .execute(&app.db)
+ .await
+ .unwrap();
+ let user = fixtures::authenticated_user(&app, 1).await;
+
+ let offline = Verifier::new(config(&app));
+ let mut checked = config(&app);
+ let mut introspection = Introspection::new("resource-server", "aacs_rs-secret");
+ introspection.cache_ttl = Duration::ZERO;
+ introspection.endpoint = Some(app.url("/oauth/introspect"));
+ checked.introspection = Some(introspection);
+ let online = Verifier::new(checked);
+ assert!(online.verify(&user.access_token).await.is_ok());
+
+ let logout = app
+ .post_auth("/auth/logout", &user.access_token, &serde_json::json!({}))
+ .await;
+ assert_eq!(logout.status(), 204);
+
+ assert!(
+ offline.verify(&user.access_token).await.is_ok(),
+ "offline checks cannot see a revocation"
+ );
+ assert_eq!(
+ online.verify(&user.access_token).await,
+ Err(VerifyError::Revoked)
+ );
+}
+
+#[tokio::test]
+async fn the_axum_extractor_refuses_missing_and_invalid_tokens() {
+ let app = TestApp::spawn().await;
+ let user = fixtures::authenticated_user(&app, 1).await;
+ let verifier = Arc::new(Verifier::new(config(&app)));
+ let router = Router::new()
+ .route(
+ "/invoices",
+ get(|Authenticated(token): Authenticated| async move { token.subject.to_string() }),
+ )
+ .with_state(verifier);
+
+ let call = |authorization: Option| {
+ let router = router.clone();
+ async move {
+ let mut request = Request::get("/invoices");
+ if let Some(value) = authorization {
+ request = request.header("authorization", value);
+ }
+ router
+ .oneshot(request.body(Body::empty()).unwrap())
+ .await
+ .unwrap()
+ }
+ };
+ assert_eq!(
+ call(Some(format!("Bearer {}", user.access_token)))
+ .await
+ .status(),
+ 200
+ );
+ let missing = call(None).await;
+ assert_eq!(missing.status(), 401);
+ assert_eq!(missing.headers()["www-authenticate"], "Bearer");
+ let invalid = call(Some("Bearer nope".into())).await;
+ assert_eq!(
+ invalid.headers()["www-authenticate"],
+ "Bearer error=\"invalid_token\""
+ );
+}
diff --git a/deny.toml b/deny.toml
index 44f76a4..5c8117d 100644
--- a/deny.toml
+++ b/deny.toml
@@ -1,5 +1,8 @@
[graph]
-all-features = true
+# Only the features actually built: `all-features` would pull sqlx-mysql and
+# its `rsa` dependency (RUSTSEC-2023-0071) into the graph, although neither is
+# ever compiled for this service.
+all-features = false
[advisories]
version = 2
@@ -7,18 +10,20 @@ yanked = "deny"
ignore = []
[bans]
+# Path dependencies inside this repository (the test harness) carry no
+# version; they are never published.
+allow-wildcard-paths = true
multiple-versions = "warn"
wildcards = "deny"
highlight = "all"
-# Reviewed, accepted duplicate versions. Most stem from the `postgres`
-# dev-dependency (older rand/getrandom ecosystem) and the Windows platform stub
-# crates (build-time only, never used on the Linux runtime target). Listed by
+# Reviewed, accepted duplicate versions. They stem from sqlx, proptest and
+# p256 pinning older rand/getrandom generations, and from the Windows platform
+# stub crates (build-time only, never used on the Linux runtime target). Listed by
# name so that any *new*, unexpected duplicate still surfaces as a warning.
skip = [
{ crate = "cpufeatures" },
{ crate = "getrandom" },
{ crate = "hashbrown" },
- { crate = "ipnetwork" },
{ crate = "r-efi" },
{ crate = "rand" },
{ crate = "rand_chacha" },
@@ -46,6 +51,8 @@ allow = [
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"MIT",
+ # MIT without the attribution clause; pulled by the test harness (jsonschema).
+ "MIT-0",
"0BSD",
"BSD-2-Clause",
"BSD-3-Clause",
diff --git a/deploy/db/auth-api-role.sql b/deploy/db/auth-api-role.sql
new file mode 100644
index 0000000..a732e9c
--- /dev/null
+++ b/deploy/db/auth-api-role.sql
@@ -0,0 +1,10 @@
+-- Session limits of the auth-api role. Run once, as postgres:
+-- sudo -u postgres psql -d auth_api -f auth-api-role.sql
+--
+-- A statement or a lock wait gives up before the API's own 30-second request
+-- timeout, and a connection left idle inside a transaction is closed instead of
+-- holding its locks. Migrations lift the statement timeout for their own
+-- session (see docs/deploy/database/deployment.md, section 2.5).
+ALTER ROLE auth_api SET statement_timeout = '25s';
+ALTER ROLE auth_api SET lock_timeout = '10s';
+ALTER ROLE auth_api SET idle_in_transaction_session_timeout = '60s';
diff --git a/deploy/db/disable-thp.service b/deploy/db/disable-thp.service
new file mode 100644
index 0000000..8b268db
--- /dev/null
+++ b/deploy/db/disable-thp.service
@@ -0,0 +1,12 @@
+# Transparent huge pages cause latency spikes in Redis and PostgreSQL.
+# Copy to /etc/systemd/system/, then `sudo systemctl enable --now disable-thp`.
+[Unit]
+Description=Disable transparent huge pages (Redis, PostgreSQL)
+Before=redis-server.service postgresql.service
+
+[Service]
+Type=oneshot
+ExecStart=/bin/sh -c 'echo never > /sys/kernel/mm/transparent_hugepage/enabled && echo never > /sys/kernel/mm/transparent_hugepage/defrag'
+
+[Install]
+WantedBy=multi-user.target
diff --git a/deploy/db/pgbackrest.conf b/deploy/db/pgbackrest.conf
new file mode 100644
index 0000000..8b13757
--- /dev/null
+++ b/deploy/db/pgbackrest.conf
@@ -0,0 +1,23 @@
+# pgBackRest for auth-api, profiles M and L: continuous WAL archiving for
+# point-in-time recovery, an encrypted repository, a full backup every week and a
+# differential one every day, two full backups kept (about two weeks of history).
+# Copy to /etc/pgbackrest/pgbackrest.conf (owner postgres, mode 640) and set the
+# cipher passphrase from pass (docs/deploy/database/deployment.md, section 4.7).
+
+[global]
+repo1-path=/var/lib/pgbackrest
+repo1-retention-full=2
+repo1-cipher-type=aes-256-cbc
+repo1-cipher-pass=REPLACE_WITH_pass_prod/auth-api/pgbackrest-cipher-pass
+compress-type=zst
+process-max=2
+start-fast=y
+archive-async=y
+spool-path=/var/spool/pgbackrest
+log-level-console=info
+log-level-file=detail
+# Offsite copy: add a second repository (repo2-type=s3, repo2-s3-bucket=...,
+# repo2-retention-full=4); pgBackRest pushes every backup and WAL segment to both.
+
+[auth_api]
+pg1-path=/var/lib/postgresql/17/main
diff --git a/deploy/db/postgresql.auth-api.conf b/deploy/db/postgresql.auth-api.conf
new file mode 100644
index 0000000..fd7aef3
--- /dev/null
+++ b/deploy/db/postgresql.auth-api.conf
@@ -0,0 +1,41 @@
+# PostgreSQL settings for auth-api, profile M: a DB VPS with 16 GB of RAM, 8 GB
+# of it for PostgreSQL. Copy to /etc/postgresql/17/main/conf.d/auth-api.conf
+# (Debian's postgresql.conf includes that directory), then restart PostgreSQL.
+# Other profiles: see the comments; sizing rules in
+# docs/deploy/guides/operations.md, section 9.
+
+# Network: the WireGuard address only.
+listen_addresses = '10.0.0.2'
+password_encryption = 'scram-sha-256'
+
+# Connections: every API instance's pool (profile M: 2 x 12), the one-off
+# commands, maintenance and the exporter. Profile S: 50, L: 100.
+max_connections = 60
+
+# Memory: a quarter of PostgreSQL's RAM in shared buffers, three quarters as
+# the planner's cache estimate. Profile S: 512MB / 1536MB, L: 8GB / 24GB.
+shared_buffers = 2GB
+effective_cache_size = 6GB
+maintenance_work_mem = 512MB
+work_mem = 16MB
+
+# Storage: SSD.
+random_page_cost = 1.1
+effective_io_concurrency = 200
+
+# WAL and checkpoints.
+wal_compression = on
+max_wal_size = 4GB
+min_wal_size = 1GB
+checkpoint_completion_target = 0.9
+
+# Query statistics, read by the exporter and the cache check of the guide.
+shared_preload_libraries = 'pg_stat_statements'
+track_io_timing = on
+
+# Logs: slow statements, lock waits, long autovacuum runs and checkpoints.
+log_min_duration_statement = 250ms
+log_lock_waits = on
+log_autovacuum_min_duration = 1s
+log_checkpoints = on
+log_line_prefix = '%m [%p] %q%u@%d '
diff --git a/deploy/db/postgresql.pitr.conf b/deploy/db/postgresql.pitr.conf
new file mode 100644
index 0000000..b3aab50
--- /dev/null
+++ b/deploy/db/postgresql.pitr.conf
@@ -0,0 +1,9 @@
+# WAL archiving to pgBackRest, profiles M and L. Copy to
+# /etc/postgresql/17/main/conf.d/auth-api-pitr.conf next to auth-api.conf, then
+# restart PostgreSQL and create the stanza (docs/deploy/database/deployment.md,
+# section 4.7).
+archive_mode = on
+archive_command = 'pgbackrest --stanza=auth_api archive-push %p'
+archive_timeout = 60
+wal_level = replica
+max_wal_senders = 3
diff --git a/deploy/db/redis.auth-api.conf b/deploy/db/redis.auth-api.conf
new file mode 100644
index 0000000..5a0e46e
--- /dev/null
+++ b/deploy/db/redis.auth-api.conf
@@ -0,0 +1,31 @@
+# Redis settings for auth-api on the DB VPS, profile M. Append an include to the
+# packaged configuration, then restart Redis:
+# echo 'include /etc/redis/auth-api.conf' | sudo tee -a /etc/redis/redis.conf
+# Other profiles: maxmemory 256mb (S), 4gb (L).
+
+bind 10.0.0.2
+protected-mode yes
+port 6379
+
+# Access control: the default user is disabled and the API authenticates as
+# auth_api, which cannot run administrative or dangerous commands (FLUSHALL,
+# CONFIG, KEYS, DEBUG, MONITOR...). Users are defined in the ACL file, with
+# password hashes only (docs/deploy/database/deployment.md, section 3).
+aclfile /etc/redis/users.acl
+
+# Memory. Nothing is ever evicted: attempt budgets, revocation lists and
+# second-factor challenges must not vanish to make room, since evicting a budget
+# resets it. When memory is full, writes fail, the API answers 503, and the
+# memory alert has fired long before.
+maxmemory 1gb
+maxmemory-policy noeviction
+
+# Persistence: revoked access tokens and attempt budgets survive a restart.
+appendonly yes
+appendfsync everysec
+aof-use-rdb-preamble yes
+
+# Connections.
+tcp-keepalive 60
+timeout 0
+maxclients 1024
diff --git a/deploy/db/sysctl-auth-api.conf b/deploy/db/sysctl-auth-api.conf
new file mode 100644
index 0000000..7b0358e
--- /dev/null
+++ b/deploy/db/sysctl-auth-api.conf
@@ -0,0 +1,9 @@
+# Kernel settings for the DB VPS (PostgreSQL and Redis). Copy to
+# /etc/sysctl.d/90-auth-api.conf, then run `sudo sysctl --system`.
+
+# Redis forks to rewrite its append-only file: without overcommit, the fork can
+# fail on a busy host and persistence stops.
+vm.overcommit_memory = 1
+
+# Keep the databases in memory; swap only to avoid an out-of-memory kill.
+vm.swappiness = 1
diff --git a/deploy/monitoring/alertmanager.yml b/deploy/monitoring/alertmanager.yml
new file mode 100644
index 0000000..a7879cb
--- /dev/null
+++ b/deploy/monitoring/alertmanager.yml
@@ -0,0 +1,46 @@
+# Alertmanager of the monitoring host. Replace the SMTP relay, the addresses and
+# the webhook with your own; test the route with:
+# amtool --alertmanager.url=http://127.0.0.1:9093 alert add Test severity=warning
+
+global:
+ smtp_smarthost: smtp.example.com:587
+ smtp_from: alerts@example.com
+ smtp_auth_username: alerts@example.com
+ smtp_auth_password_file: /etc/alertmanager/smtp_password
+ smtp_require_tls: true
+
+route:
+ receiver: operators
+ group_by: [alertname, vps]
+ group_wait: 30s
+ group_interval: 5m
+ repeat_interval: 4h
+ routes:
+ - matchers: [severity="critical"]
+ receiver: operators-urgent
+ repeat_interval: 1h
+ # The dead man's switch goes to a service that pages when it stops arriving.
+ - matchers: [alertname="Watchdog"]
+ receiver: deadmans-switch
+ repeat_interval: 5m
+
+receivers:
+ - name: operators
+ email_configs:
+ - to: ops@example.com
+ - name: operators-urgent
+ email_configs:
+ - to: ops@example.com
+ webhook_configs:
+ - url: https://hooks.example.com/auth-api-urgent
+ - name: deadmans-switch
+ webhook_configs:
+ - url: https://deadmans-switch.example.com/ping/auth-api
+
+# While the public probe fails, the alerts it explains stay quiet.
+inhibit_rules:
+ - source_matchers: [alertname="AuthApiPublicProbeFailing"]
+ target_matchers: [alertname=~"AuthApiHigh5xxRate|AuthApiSlowTokenPaths"]
+ - source_matchers: [alertname="HostDown"]
+ target_matchers: [severity="warning"]
+ equal: [vps]
diff --git a/deploy/monitoring/blackbox.yml b/deploy/monitoring/blackbox.yml
new file mode 100644
index 0000000..42db9b6
--- /dev/null
+++ b/deploy/monitoring/blackbox.yml
@@ -0,0 +1,14 @@
+# Blackbox exporter modules of the monitoring host.
+modules:
+ # GET /ready over HTTPS: a valid certificate, TLS 1.2 or later, a 200.
+ https_ready:
+ prober: http
+ timeout: 10s
+ http:
+ method: GET
+ valid_status_codes: [200]
+ fail_if_not_ssl: true
+ preferred_ip_protocol: ip4
+ tls_config:
+ insecure_skip_verify: false
+ min_version: TLS12
diff --git a/deploy/monitoring/docker-compose.monitoring.yml b/deploy/monitoring/docker-compose.monitoring.yml
new file mode 100644
index 0000000..2cee873
--- /dev/null
+++ b/deploy/monitoring/docker-compose.monitoring.yml
@@ -0,0 +1,73 @@
+# Monitoring host: Prometheus, Alertmanager and the external blackbox probe.
+#
+# Runs on a host other than the API and DB VPS (a monitoring stack on the API VPS
+# goes down with what it watches). The host joins the WireGuard network as
+# 10.0.0.3 and scrapes the exporters there; the blackbox exporter probes the
+# public HTTPS endpoint from outside. See docs/deploy/guides/monitoring.md.
+#
+# docker compose -f docker-compose.monitoring.yml up -d
+
+services:
+ prometheus:
+ image: prom/prometheus:v3.5.0
+ restart: unless-stopped
+ command:
+ - --config.file=/etc/prometheus/prometheus.yml
+ - --storage.tsdb.path=/prometheus
+ - --storage.tsdb.retention.time=30d
+ - --web.listen-address=127.0.0.1:9090
+ network_mode: host
+ volumes:
+ - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
+ - ./rules:/etc/prometheus/rules:ro
+ - prometheus_data:/prometheus
+ read_only: true
+ cap_drop: [ALL]
+ security_opt: [no-new-privileges:true]
+ logging: &logging
+ driver: json-file
+ options: { max-size: "10m", max-file: "3" }
+ deploy:
+ resources:
+ limits: { cpus: "1.0", memory: 1G }
+
+ alertmanager:
+ image: prom/alertmanager:v0.28.1
+ restart: unless-stopped
+ command:
+ - --config.file=/etc/alertmanager/alertmanager.yml
+ - --storage.path=/alertmanager
+ - --web.listen-address=127.0.0.1:9093
+ - --cluster.listen-address=
+ network_mode: host
+ volumes:
+ - ./alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
+ - alertmanager_data:/alertmanager
+ read_only: true
+ cap_drop: [ALL]
+ security_opt: [no-new-privileges:true]
+ logging: *logging
+ deploy:
+ resources:
+ limits: { cpus: "0.25", memory: 128M }
+
+ blackbox:
+ image: prom/blackbox-exporter:v0.27.0
+ restart: unless-stopped
+ command:
+ - --config.file=/etc/blackbox/blackbox.yml
+ - --web.listen-address=127.0.0.1:9115
+ network_mode: host
+ volumes:
+ - ./blackbox.yml:/etc/blackbox/blackbox.yml:ro
+ read_only: true
+ cap_drop: [ALL]
+ security_opt: [no-new-privileges:true]
+ logging: *logging
+ deploy:
+ resources:
+ limits: { cpus: "0.25", memory: 64M }
+
+volumes:
+ prometheus_data:
+ alertmanager_data:
diff --git a/deploy/monitoring/prometheus.yml b/deploy/monitoring/prometheus.yml
new file mode 100644
index 0000000..bde95aa
--- /dev/null
+++ b/deploy/monitoring/prometheus.yml
@@ -0,0 +1,62 @@
+# Prometheus of the monitoring host (docker-compose.monitoring.yml).
+# Every internal target is a WireGuard address: exporters never listen on a
+# public interface. Replace api.example.com with the public name of the API.
+
+global:
+ scrape_interval: 15s
+ evaluation_interval: 15s
+
+rule_files:
+ - /etc/prometheus/rules/*.yml
+
+alerting:
+ alertmanagers:
+ - static_configs:
+ - targets: ["127.0.0.1:9093"]
+
+scrape_configs:
+ # The API instances (metrics listener of docker-compose.api.yml). Each one
+ # also publishes its container's memory, limit and CPU throttling.
+ - job_name: auth-api
+ static_configs:
+ # Profile L: add 10.0.0.1:9467 and 10.0.0.1:9468.
+ - targets: ["10.0.0.1:9465", "10.0.0.1:9466"]
+ labels: { vps: api }
+
+ # The broker, through prometheus-nats-exporter (docker-compose.api.yml).
+ - job_name: nats
+ static_configs:
+ - targets: ["10.0.0.1:7777"]
+ labels: { vps: api }
+
+ - job_name: node
+ static_configs:
+ - targets: ["10.0.0.1:9100"]
+ labels: { vps: api }
+ - targets: ["10.0.0.2:9100"]
+ labels: { vps: db }
+
+ - job_name: postgres
+ static_configs:
+ - targets: ["10.0.0.2:9187"]
+ labels: { vps: db }
+
+ - job_name: redis
+ static_configs:
+ - targets: ["10.0.0.2:9121"]
+ labels: { vps: db }
+
+ # The public endpoint as a client sees it: DNS, TLS, nginx, an instance ready.
+ - job_name: blackbox-https
+ metrics_path: /probe
+ params:
+ module: [https_ready]
+ static_configs:
+ - targets: ["https://api.example.com/ready"]
+ relabel_configs:
+ - source_labels: [__address__]
+ target_label: __param_target
+ - source_labels: [__param_target]
+ target_label: instance
+ - target_label: __address__
+ replacement: 127.0.0.1:9115
diff --git a/deploy/monitoring/rules/infrastructure.test.yml b/deploy/monitoring/rules/infrastructure.test.yml
new file mode 100644
index 0000000..ba241aa
--- /dev/null
+++ b/deploy/monitoring/rules/infrastructure.test.yml
@@ -0,0 +1,200 @@
+# promtool test rules infrastructure.test.yml
+rule_files:
+ - infrastructure.yml
+
+evaluation_interval: 1m
+
+tests:
+ - interval: 1m
+ input_series:
+ - series: 'probe_success{job="blackbox-https",instance="https://api.example.com/ready"}'
+ values: "1 1 0 0 0 0"
+ - series: 'probe_ssl_earliest_cert_expiry{job="blackbox-https",instance="https://api.example.com/ready"}'
+ values: "86400x90"
+ - series: 'up{job="node",vps="db",instance="10.0.0.2:9100"}'
+ values: "1 0 0 0 0"
+ - series: 'node_filesystem_avail_bytes{fstype="ext4",mountpoint="/",vps="api",instance="10.0.0.1:9100"}'
+ values: "10x20"
+ - series: 'node_filesystem_size_bytes{fstype="ext4",mountpoint="/",vps="api",instance="10.0.0.1:9100"}'
+ values: "100x20"
+ alert_rule_test:
+ - eval_time: 1m
+ alertname: AuthApiPublicProbeFailing
+ exp_alerts: []
+ - eval_time: 5m
+ alertname: AuthApiPublicProbeFailing
+ exp_alerts:
+ - exp_labels: { severity: critical, job: blackbox-https, instance: "https://api.example.com/ready" }
+ exp_annotations:
+ summary: "https /ready fails from outside"
+ description: "DNS, the certificate, nginx or every API instance: check `curl -v https:///ready`, then nginx and `docker compose ps` on the API VPS."
+ - eval_time: 70m
+ alertname: TlsCertificateExpiresSoon
+ exp_alerts:
+ - exp_labels: { severity: warning, job: blackbox-https, instance: "https://api.example.com/ready" }
+ exp_annotations:
+ summary: "the public certificate expires in less than 14 days"
+ description: "certbot renewal is failing: `sudo certbot renew --dry-run` on the API VPS."
+ - eval_time: 4m
+ alertname: HostDown
+ exp_alerts:
+ - exp_labels: { severity: critical, job: node, vps: db, instance: "10.0.0.2:9100" }
+ exp_annotations: { summary: "db VPS unreachable over WireGuard" }
+ - eval_time: 15m
+ alertname: DiskSpaceLow
+ exp_alerts:
+ - exp_labels: { severity: warning, fstype: ext4, mountpoint: /, vps: api, instance: "10.0.0.1:9100" }
+ exp_annotations: { summary: "less than 15% free on / (api VPS)" }
+
+ - interval: 1m
+ input_series:
+ - series: 'auth_process_start_time_seconds{instance="10.0.0.1:9465"}'
+ values: "1 2 3 4 5 6"
+ - series: 'auth_container_memory_working_set_bytes{instance="10.0.0.1:9466"}'
+ values: "500000000x15"
+ - series: 'auth_container_memory_limit_bytes{instance="10.0.0.1:9466"}'
+ values: "536870912x15"
+ - series: 'auth_container_cpu_periods_total{instance="10.0.0.1:9465"}'
+ values: "0+100x25"
+ - series: 'auth_container_cpu_throttled_periods_total{instance="10.0.0.1:9465"}'
+ values: "0+50x25"
+ alert_rule_test:
+ - eval_time: 5m
+ alertname: ContainerRestarting
+ exp_alerts:
+ - exp_labels: { severity: warning, instance: "10.0.0.1:9465" }
+ exp_annotations:
+ summary: "10.0.0.1:9465 restarted more than 3 times in 15 minutes"
+ description: "Check `docker compose logs` on the API VPS: a crash loop, or an out-of-memory kill."
+ - eval_time: 12m
+ alertname: ContainerMemoryNearLimit
+ exp_alerts:
+ - exp_labels: { severity: warning, instance: "10.0.0.1:9466" }
+ exp_annotations:
+ summary: "10.0.0.1:9466 uses more than 90% of its memory limit"
+ description: "An out-of-memory kill is close: move to the next profile or look for a leak."
+ - eval_time: 22m
+ alertname: ContainerCpuThrottled
+ exp_alerts:
+ - exp_labels: { severity: warning, instance: "10.0.0.1:9465" }
+ exp_annotations:
+ summary: "10.0.0.1:9465 is throttled in more than 25% of CPU periods"
+ description: "The instance needs more CPU than its profile gives it."
+
+ - interval: 1m
+ input_series:
+ - series: 'pg_up{job="postgres"}'
+ values: "1 0 0 0"
+ - series: 'pg_stat_activity_count{state="active"}'
+ values: "50x10"
+ - series: 'pg_settings_max_connections'
+ values: "60x10"
+ - series: 'auth_db_pool_connections{state="in_use",instance="10.0.0.1:9465"}'
+ values: "12x10"
+ - series: 'auth_db_pool_connections{state="max",instance="10.0.0.1:9465"}'
+ values: "12x10"
+ - series: 'redis_up{job="redis"}'
+ values: "0x5"
+ - series: 'redis_memory_used_bytes{job="redis"}'
+ values: "900x10"
+ - series: 'redis_memory_max_bytes{job="redis"}'
+ values: "1000x10"
+ - series: 'redis_errors_total{job="redis",err="OOM"}'
+ values: "0 0 3 3"
+ - series: 'auth_redis_errors_total{instance="10.0.0.1:9465",operation="budget"}'
+ values: "0+60x10"
+ - series: 'auth_redis_pool_waiting{instance="10.0.0.1:9466"}'
+ values: "2x10"
+ - series: 'up{job="nats",instance="10.0.0.1:7777"}'
+ values: "0x5"
+ - series: 'auth_outbox_oldest_pending_age_seconds{instance="10.0.0.1:9465"}'
+ values: "0 400x10"
+ alert_rule_test:
+ - eval_time: 3m
+ alertname: PostgresDown
+ exp_alerts:
+ - exp_labels: { severity: critical, job: postgres }
+ exp_annotations: { summary: "PostgreSQL does not answer its exporter" }
+ - eval_time: 6m
+ alertname: PostgresConnectionsHigh
+ exp_alerts:
+ - exp_labels: { severity: warning }
+ exp_annotations: { summary: "PostgreSQL uses more than 80% of max_connections" }
+ - eval_time: 6m
+ alertname: AuthApiDbPoolSaturated
+ exp_alerts:
+ - exp_labels: { severity: warning, instance: "10.0.0.1:9465" }
+ exp_annotations: { summary: "10.0.0.1:9465 uses every database connection of its pool" }
+ - eval_time: 2m
+ alertname: RedisDown
+ exp_alerts:
+ - exp_labels: { severity: critical, job: redis }
+ exp_annotations: { summary: "Redis does not answer its exporter; the API answers 503" }
+ - eval_time: 6m
+ alertname: RedisMemoryHigh
+ exp_alerts:
+ - exp_labels: { severity: warning, job: redis }
+ exp_annotations:
+ summary: "Redis uses more than 80% of maxmemory"
+ description: "With noeviction, writes fail once it is full: raise maxmemory (profile) now."
+ - eval_time: 3m
+ alertname: RedisRejectingWrites
+ exp_alerts:
+ - exp_labels: { severity: critical, job: redis, err: OOM }
+ exp_annotations: { summary: "Redis refuses writes: maxmemory reached" }
+ - eval_time: 8m
+ alertname: AuthApiRedisErrors
+ exp_alerts:
+ - exp_labels: { severity: warning, instance: "10.0.0.1:9465" }
+ exp_annotations: { summary: "10.0.0.1:9465 fails Redis operations" }
+ - eval_time: 6m
+ alertname: AuthApiRedisPoolWaiting
+ exp_alerts:
+ - exp_labels: { severity: warning, instance: "10.0.0.1:9466" }
+ exp_annotations: { summary: "10.0.0.1:9466 waits for Redis connections" }
+ - eval_time: 3m
+ alertname: NatsDown
+ exp_alerts:
+ - exp_labels: { severity: critical, job: nats, instance: "10.0.0.1:7777" }
+ exp_annotations: { summary: "the NATS broker or its exporter is down; domain events wait in the outbox" }
+ - eval_time: 7m
+ alertname: AuthApiEventsStalled
+ exp_alerts:
+ - exp_labels: { severity: warning }
+ exp_annotations: { summary: "domain events have waited more than 5 minutes to reach NATS" }
+
+ - interval: 1m
+ input_series:
+ - series: 'auth_notifications_failed_total{instance="10.0.0.1:9465",task="verification_email"}'
+ values: "0+1x20"
+ - series: 'auth_notifications_dropped_total{instance="10.0.0.1:9466",task="password_reset_email"}'
+ values: "0 0 4 4"
+ - series: 'auth_cleanup_failures_total{job="auth-api",instance="10.0.0.1:9465"}'
+ values: "0 1 1"
+ - series: 'auth_webhook_deliveries_total{instance="10.0.0.1:9465",outcome="failed"}'
+ values: "0 0 1 1"
+ alert_rule_test:
+ - eval_time: 15m
+ alertname: AuthApiNotificationsFailing
+ exp_alerts:
+ - exp_labels: { severity: warning, instance: "10.0.0.1:9465" }
+ exp_annotations:
+ summary: "10.0.0.1:9465 fails to send e-mails"
+ description: "Check the SMTP relay and credentials; verification and reset e-mails are not reaching users."
+ - eval_time: 3m
+ alertname: AuthApiNotificationsDropped
+ exp_alerts:
+ - exp_labels: { severity: critical, instance: "10.0.0.1:9466" }
+ exp_annotations: { summary: "10.0.0.1:9466 dropped e-mails: 1 000 were already pending" }
+ - eval_time: 2m
+ alertname: AuthApiCleanupFailing
+ exp_alerts:
+ - exp_labels: { severity: warning, job: auth-api, instance: "10.0.0.1:9465" }
+ exp_annotations: { summary: "a retention job failed on 10.0.0.1:9465" }
+ - eval_time: 3m
+ alertname: AuthApiWebhooksFailing
+ exp_alerts:
+ - exp_labels: { severity: warning }
+ exp_annotations:
+ summary: "webhook deliveries were given up after every retry"
+ description: "An endpoint stayed unreachable or refused deliveries for hours: see GET /admin/webhooks/{id}/deliveries, fix it, then retry."
diff --git a/deploy/monitoring/rules/infrastructure.yml b/deploy/monitoring/rules/infrastructure.yml
new file mode 100644
index 0000000..168396c
--- /dev/null
+++ b/deploy/monitoring/rules/infrastructure.yml
@@ -0,0 +1,206 @@
+# Infrastructure alerts: the public endpoint, the hosts, the containers and the
+# dependencies of the API. The application's own alerts live in auth-api.yml.
+# Unit tests: promtool test rules infrastructure.test.yml
+
+groups:
+ - name: auth-api-edge
+ rules:
+ - alert: Watchdog
+ # Always firing: Alertmanager forwards it to a dead man's switch, which
+ # pages when it stops arriving (Prometheus or Alertmanager is down).
+ expr: vector(1)
+ labels:
+ severity: none
+ annotations:
+ summary: "monitoring pipeline alive"
+
+ - alert: AuthApiPublicProbeFailing
+ expr: probe_success{job="blackbox-https"} == 0
+ for: 2m
+ labels:
+ severity: critical
+ annotations:
+ summary: "https /ready fails from outside"
+ description: >-
+ DNS, the certificate, nginx or every API instance: check
+ `curl -v https:///ready`, then nginx and `docker compose ps` on
+ the API VPS.
+
+ - alert: TlsCertificateExpiresSoon
+ expr: probe_ssl_earliest_cert_expiry{job="blackbox-https"} - time() < 14 * 86400
+ for: 1h
+ labels:
+ severity: warning
+ annotations:
+ summary: "the public certificate expires in less than 14 days"
+ description: "certbot renewal is failing: `sudo certbot renew --dry-run` on the API VPS."
+
+ - name: hosts
+ rules:
+ - alert: HostDown
+ expr: up{job="node"} == 0
+ for: 2m
+ labels:
+ severity: critical
+ annotations:
+ summary: "{{ $labels.vps }} VPS unreachable over WireGuard"
+
+ - alert: DiskSpaceLow
+ expr: >-
+ node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}
+ / node_filesystem_size_bytes{fstype!~"tmpfs|overlay"} < 0.15
+ for: 10m
+ labels:
+ severity: warning
+ annotations:
+ summary: "less than 15% free on {{ $labels.mountpoint }} ({{ $labels.vps }} VPS)"
+
+ - name: containers
+ # Each API instance reads its own cgroup and publishes it: this works with
+ # any container runtime and needs no privileged exporter on the host.
+ rules:
+ - alert: ContainerRestarting
+ expr: changes(auth_process_start_time_seconds[15m]) > 3
+ labels:
+ severity: warning
+ annotations:
+ summary: "{{ $labels.instance }} restarted more than 3 times in 15 minutes"
+ description: "Check `docker compose logs` on the API VPS: a crash loop, or an out-of-memory kill."
+
+ - alert: ContainerMemoryNearLimit
+ expr: >-
+ auth_container_memory_working_set_bytes
+ / (auth_container_memory_limit_bytes > 0) > 0.9
+ for: 10m
+ labels:
+ severity: warning
+ annotations:
+ summary: "{{ $labels.instance }} uses more than 90% of its memory limit"
+ description: "An out-of-memory kill is close: move to the next profile or look for a leak."
+
+ - alert: ContainerCpuThrottled
+ expr: >-
+ rate(auth_container_cpu_throttled_periods_total[5m])
+ / rate(auth_container_cpu_periods_total[5m]) > 0.25
+ for: 15m
+ labels:
+ severity: warning
+ annotations:
+ summary: "{{ $labels.instance }} is throttled in more than 25% of CPU periods"
+ description: "The instance needs more CPU than its profile gives it."
+
+ - name: dependencies
+ rules:
+ - alert: PostgresDown
+ expr: pg_up == 0
+ for: 1m
+ labels:
+ severity: critical
+ annotations:
+ summary: "PostgreSQL does not answer its exporter"
+
+ - alert: PostgresConnectionsHigh
+ expr: sum(pg_stat_activity_count) / max(pg_settings_max_connections) > 0.8
+ for: 5m
+ labels:
+ severity: warning
+ annotations:
+ summary: "PostgreSQL uses more than 80% of max_connections"
+
+ - alert: AuthApiDbPoolSaturated
+ expr: >-
+ auth_db_pool_connections{state="in_use"}
+ >= ignoring(state) auth_db_pool_connections{state="max"}
+ for: 5m
+ labels:
+ severity: warning
+ annotations:
+ summary: "{{ $labels.instance }} uses every database connection of its pool"
+
+ - alert: RedisDown
+ expr: redis_up == 0
+ for: 1m
+ labels:
+ severity: critical
+ annotations:
+ summary: "Redis does not answer its exporter; the API answers 503"
+
+ - alert: RedisMemoryHigh
+ expr: redis_memory_used_bytes / (redis_memory_max_bytes > 0) > 0.8
+ for: 5m
+ labels:
+ severity: warning
+ annotations:
+ summary: "Redis uses more than 80% of maxmemory"
+ description: "With noeviction, writes fail once it is full: raise maxmemory (profile) now."
+
+ - alert: RedisRejectingWrites
+ expr: increase(redis_errors_total{err="OOM"}[5m]) > 0
+ labels:
+ severity: critical
+ annotations:
+ summary: "Redis refuses writes: maxmemory reached"
+
+ - alert: AuthApiRedisErrors
+ expr: sum by (instance) (rate(auth_redis_errors_total[5m])) > 0.1
+ for: 5m
+ labels:
+ severity: warning
+ annotations:
+ summary: "{{ $labels.instance }} fails Redis operations"
+
+ - alert: AuthApiRedisPoolWaiting
+ expr: auth_redis_pool_waiting > 0
+ for: 5m
+ labels:
+ severity: warning
+ annotations:
+ summary: "{{ $labels.instance }} waits for Redis connections"
+
+ - alert: NatsDown
+ expr: up{job="nats"} == 0
+ for: 2m
+ labels:
+ severity: critical
+ annotations:
+ summary: "the NATS broker or its exporter is down; domain events wait in the outbox"
+
+ - alert: AuthApiEventsStalled
+ expr: max(auth_outbox_oldest_pending_age_seconds) > 300
+ for: 5m
+ labels:
+ severity: warning
+ annotations:
+ summary: "domain events have waited more than 5 minutes to reach NATS"
+
+ - name: background
+ rules:
+ - alert: AuthApiNotificationsFailing
+ expr: sum by (instance) (increase(auth_notifications_failed_total[15m])) > 5
+ labels:
+ severity: warning
+ annotations:
+ summary: "{{ $labels.instance }} fails to send e-mails"
+ description: "Check the SMTP relay and credentials; verification and reset e-mails are not reaching users."
+
+ - alert: AuthApiNotificationsDropped
+ expr: sum by (instance) (increase(auth_notifications_dropped_total[15m])) > 0
+ labels:
+ severity: critical
+ annotations:
+ summary: "{{ $labels.instance }} dropped e-mails: 1 000 were already pending"
+
+ - alert: AuthApiWebhooksFailing
+ expr: sum(increase(auth_webhook_deliveries_total{outcome="failed"}[1h])) > 0
+ labels:
+ severity: warning
+ annotations:
+ summary: "webhook deliveries were given up after every retry"
+ description: "An endpoint stayed unreachable or refused deliveries for hours: see GET /admin/webhooks/{id}/deliveries, fix it, then retry."
+
+ - alert: AuthApiCleanupFailing
+ expr: sum by (job, instance) (increase(auth_cleanup_failures_total[2h])) > 0
+ labels:
+ severity: warning
+ annotations:
+ summary: "a retention job failed on {{ $labels.instance }}"
diff --git a/deploy/profiles/l.env b/deploy/profiles/l.env
new file mode 100644
index 0000000..27311ff
--- /dev/null
+++ b/deploy/profiles/l.env
@@ -0,0 +1,12 @@
+# Profile L: up to 5 million accounts, 150 sign-ins per second at peak.
+# Four instances: add docker-compose.api.l.yml. API VPS: 20 vCPU, 8 GB RAM.
+# Sizing rules: docs/deploy/guides/operations.md, section 9.
+API_CPUS=4
+API_MEMORY=512M
+API_MEMORY_RESERVATION=256M
+ARGON2_MAX_CONCURRENCY=4
+DB_MAX_CONNECTIONS=16
+REDIS_POOL_SIZE=16
+NATS_CPUS=1
+NATS_MEMORY=256M
+NATS_GOMEMLIMIT=200MiB
diff --git a/deploy/profiles/m.env b/deploy/profiles/m.env
new file mode 100644
index 0000000..01130ce
--- /dev/null
+++ b/deploy/profiles/m.env
@@ -0,0 +1,11 @@
+# Profile M: up to 1 million accounts, 56 sign-ins per second at peak.
+# API VPS: 8 vCPU, 4 GB RAM. Sizing rules: docs/deploy/guides/operations.md, section 9.
+API_CPUS=3
+API_MEMORY=512M
+API_MEMORY_RESERVATION=256M
+ARGON2_MAX_CONCURRENCY=3
+DB_MAX_CONNECTIONS=12
+REDIS_POOL_SIZE=12
+NATS_CPUS=0.5
+NATS_MEMORY=192M
+NATS_GOMEMLIMIT=150MiB
diff --git a/deploy/profiles/s.env b/deploy/profiles/s.env
new file mode 100644
index 0000000..2317208
--- /dev/null
+++ b/deploy/profiles/s.env
@@ -0,0 +1,11 @@
+# Profile S: up to 100 000 accounts, 10 sign-ins per second at peak.
+# API VPS: 3 vCPU, 2 GB RAM. Sizing rules: docs/deploy/guides/operations.md, section 9.
+API_CPUS=1
+API_MEMORY=384M
+API_MEMORY_RESERVATION=192M
+ARGON2_MAX_CONCURRENCY=1
+DB_MAX_CONNECTIONS=8
+REDIS_POOL_SIZE=8
+NATS_CPUS=0.25
+NATS_MEMORY=128M
+NATS_GOMEMLIMIT=100MiB
diff --git a/deploy/profiles/xl.env b/deploy/profiles/xl.env
new file mode 100644
index 0000000..b5a67c4
--- /dev/null
+++ b/deploy/profiles/xl.env
@@ -0,0 +1,16 @@
+# Profile XL: up to 20 million accounts, 450 sign-ins per second at peak.
+# Three API hosts, each running profile L's four instances (docker-compose.api.l.yml),
+# behind the load balancers of docs/deploy/guides/high-availability.md.
+# Per host: 20 vCPU, 8 GB RAM. PostgreSQL: a primary and two replicas behind HAProxy,
+# lag-tolerant reads on DATABASE_READ_URL.
+# Extrapolated from the measured per-CPU rate, not validated by `make sizing`:
+# docs/deploy/guides/operations.md, section 9.
+API_CPUS=4
+API_MEMORY=512M
+API_MEMORY_RESERVATION=256M
+ARGON2_MAX_CONCURRENCY=4
+DB_MAX_CONNECTIONS=12
+REDIS_POOL_SIZE=16
+NATS_CPUS=1
+NATS_MEMORY=256M
+NATS_GOMEMLIMIT=200MiB
diff --git a/docker-compose.api.l.yml b/docker-compose.api.l.yml
new file mode 100644
index 0000000..dab508b
--- /dev/null
+++ b/docker-compose.api.l.yml
@@ -0,0 +1,20 @@
+# Profile L: two more API instances. Add this file after docker-compose.api.yml
+# (`-f docker-compose.api.yml -f docker-compose.api.l.yml`) and list
+# 127.0.0.1:3003 and 127.0.0.1:3004 in the nginx upstream.
+
+services:
+ api-c:
+ extends:
+ file: docker-compose.api.yml
+ service: api-a
+ ports: !override
+ - "127.0.0.1:3003:3000"
+ - "${METRICS_BIND_ADDRESS:-10.0.0.1}:9467:9464"
+
+ api-d:
+ extends:
+ file: docker-compose.api.yml
+ service: api-a
+ ports: !override
+ - "127.0.0.1:3004:3000"
+ - "${METRICS_BIND_ADDRESS:-10.0.0.1}:9468:9464"
diff --git a/docker-compose.api.yml b/docker-compose.api.yml
index b98c9fc..db6646f 100644
--- a/docker-compose.api.yml
+++ b/docker-compose.api.yml
@@ -1,85 +1,187 @@
+# Production stack of the API VPS: two API instances behind the host's nginx
+# (upstream 127.0.0.1:3001 and 127.0.0.1:3002) and the NATS event broker.
+#
+# Sizing comes from a profile passed with --env-file (deploy/profiles/s.env,
+# m.env or l.env): CPU and memory per instance, Argon2 concurrency, pool sizes,
+# broker limits. Secrets are exported from pass before running (see
+# docs/deploy/guides/update.md). scripts/rolling-update.sh replaces the
+# instances one at a time, so an update never stops the service.
+
+x-api: &api
+ # Built by `make release` and loaded on the server.
+ image: auth-api:${AUTH_API_VERSION:?set AUTH_API_VERSION to the release being deployed}
+ env_file: config.prod.env
+ environment:
+ # Secrets, exported from pass. `:?` stops compose when one is missing.
+ DATABASE_URL: ${DATABASE_URL:?export DATABASE_URL}
+ REDIS_URL: ${REDIS_URL:?export REDIS_URL}
+ JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY:?export JWT_PRIVATE_KEY}
+ JWT_PUBLIC_KEY: ${JWT_PUBLIC_KEY:?export JWT_PUBLIC_KEY}
+ # Only during a key rotation; empty means unset.
+ JWT_PREVIOUS_PUBLIC_KEY: ${JWT_PREVIOUS_PUBLIC_KEY:-}
+ JWT_NEXT_PUBLIC_KEY: ${JWT_NEXT_PUBLIC_KEY:-}
+ ENCRYPTION_KEY: ${ENCRYPTION_KEY:?export ENCRYPTION_KEY}
+ PREVIOUS_ENCRYPTION_KEY: ${PREVIOUS_ENCRYPTION_KEY:-}
+ SMTP_USERNAME: ${SMTP_USERNAME:?export SMTP_USERNAME}
+ SMTP_PASSWORD: ${SMTP_PASSWORD:?export SMTP_PASSWORD}
+ CAPTCHA_SECRET: ${CAPTCHA_SECRET:?export CAPTCHA_SECRET}
+ # nats://@nats:4222, the token of nats-auth.conf.
+ NATS_URL: ${NATS_URL:?export NATS_URL}
+ # Sizing, from the profile.
+ ARGON2_MAX_CONCURRENCY: ${ARGON2_MAX_CONCURRENCY:?pass a profile with --env-file}
+ DB_MAX_CONNECTIONS: ${DB_MAX_CONNECTIONS:?pass a profile with --env-file}
+ REDIS_POOL_SIZE: ${REDIS_POOL_SIZE:?pass a profile with --env-file}
+ restart: unless-stopped
+ # The app writes nothing to disk (logs go to stdout, templates are read-only),
+ # needs no Linux capability and must not gain privileges.
+ read_only: true
+ tmpfs:
+ - /tmp
+ cap_drop:
+ - ALL
+ security_opt:
+ - no-new-privileges:true
+ ulimits:
+ nofile:
+ soft: 65536
+ hard: 65536
+ # Longer than the shutdown drain (32 s of requests, 5 s of background tasks,
+ # 2 s of NATS flush): Docker never kills a draining instance.
+ stop_grace_period: 40s
+ logging:
+ driver: json-file
+ options:
+ max-size: "10m"
+ max-file: "5"
+ deploy:
+ resources:
+ # Memory: 64 MiB per concurrent Argon2 hash plus 256 MiB for the rest
+ # (docs/deploy/guides/operations.md, section 9).
+ limits:
+ cpus: "${API_CPUS:?pass a profile with --env-file}"
+ memory: ${API_MEMORY:?pass a profile with --env-file}
+ pids: 256
+ reservations:
+ memory: ${API_MEMORY_RESERVATION:?pass a profile with --env-file}
+ healthcheck:
+ # The binary calls its own /live: the distroless image has no curl.
+ test: ["CMD", "./auth-api", "--healthcheck"]
+ interval: 10s
+ timeout: 5s
+ retries: 3
+ start_period: 20s
+ depends_on:
+ nats:
+ condition: service_healthy
+ networks:
+ - auth-api
+
services:
- api:
- image: ghcr.io/siir3x/auth-api:latest
- env_file: config.prod.env
- environment:
- # Secrets - exported from pass before running
- - DATABASE_URL
- - REDIS_URL
- - JWT_PRIVATE_KEY
- - JWT_PUBLIC_KEY
- - JWT_PREVIOUS_PUBLIC_KEY
- - ENCRYPTION_KEY
- - PREVIOUS_ENCRYPTION_KEY
- - SMTP_USERNAME
- - SMTP_PASSWORD
- - CAPTCHA_SECRET
- # NATS URL embedding the auth token (nats://@nats:4222); the
- # broker below requires the same token.
- - NATS_URL
+ api-a:
+ <<: *api
+ ports:
+ # The API on loopback, reached through nginx (which sets
+ # X-Forwarded-For). Metrics on the WireGuard address, scraped by the
+ # monitoring host (METRICS_BIND_ADDRESS, 10.0.0.1 by default).
+ - "127.0.0.1:3001:3000"
+ - "${METRICS_BIND_ADDRESS:-10.0.0.1}:9465:9464"
+
+ api-b:
+ <<: *api
ports:
- # Bind to loopback only: the API must be reached through the reverse
- # proxy (which sets X-Forwarded-For from a trusted CIDR), never directly.
- - "127.0.0.1:3000:3000"
- # Prometheus metrics: loopback only, scraped by the host's Prometheus.
- # Never route this through nginx.
- - "127.0.0.1:9464:9464"
+ - "127.0.0.1:3002:3000"
+ - "${METRICS_BIND_ADDRESS:-10.0.0.1}:9466:9464"
+
+ # Domain-event broker. No host port: only the API instances reach it, over the
+ # compose network, and the token is required on top of that isolation.
+ nats:
+ image: nats:2-alpine@sha256:ad7a43eb7e3337c3c38ce5d784d1461791f95f730f252d2b25eee699752a0ca3
restart: unless-stopped
- # Container hardening: the app writes nothing to disk (logs go to stdout,
- # templates are read-only), needs no Linux capabilities, and must not
- # gain privileges via setuid binaries.
- read_only: true
- tmpfs:
- - /tmp
+ # Limits and JetStream live in nats.conf; the token in a file mounted as a
+ # secret, so it appears neither in the process arguments nor in
+ # `docker inspect`.
+ command: ["--config", "/etc/nats/nats.conf"]
+ environment:
+ GOMEMLIMIT: ${NATS_GOMEMLIMIT:?pass a profile with --env-file}
+ volumes:
+ # JetStream keeps the durable user events (`user.deleted`) on a named
+ # volume: a tmpfs would lose unconsumed erasure events on restart.
+ - nats_data:/data
+ - ./nats.conf:/etc/nats/nats.conf:ro
+ secrets:
+ - source: nats_auth
+ target: /etc/nats/auth.conf
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
- # Resource ceilings so a leak or a flood cannot take the whole VPS down
- # with it. Memory is sized for Argon2's worst case: ARGON2_MAX_CONCURRENCY
- # x ARGON2_MEMORY_KIB (4 x 64 MiB = 256 MiB) plus the app's baseline.
+ stop_grace_period: 30s
+ logging:
+ driver: json-file
+ options:
+ max-size: "10m"
+ max-file: "3"
deploy:
resources:
limits:
- cpus: "2.0"
- memory: 768M
+ cpus: "${NATS_CPUS:?pass a profile with --env-file}"
+ memory: ${NATS_MEMORY:?pass a profile with --env-file}
healthcheck:
- # Use the binary's built-in --healthcheck flag (added to avoid bundling
- # curl/wget in the slim runtime image). It hits 127.0.0.1:$SERVER_PORT/health
- # and exits 0 on 2xx, 1 otherwise.
- test: ["CMD", "./auth-api", "--healthcheck"]
- interval: 30s
- timeout: 10s
- retries: 3
- start_period: 10s
- depends_on:
- nats:
- condition: service_healthy
+ test: ["CMD", "wget", "--spider", "-q", "http://localhost:8222/healthz"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+ networks:
+ - auth-api
- # Domain-event broker. The API connects to it at startup (fail-fast), so it
- # ships in the same compose file. No host ports: only the api container
- # needs to reach it, over the internal compose network. Token auth is
- # defence in depth on top of the network isolation; the API embeds the same
- # token in NATS_URL.
- nats:
- image: nats:2-alpine
+ # NATS metrics for the monitoring host: connections, JetStream storage.
+ nats-exporter:
+ image: natsio/prometheus-nats-exporter:0.17.3@sha256:26c826662ac8424597cc9bdf89ea5b606eb66e3c11db9b1215c27d2076bbb01b
restart: unless-stopped
- # Overriding the command bypasses the image's config file, so monitoring
- # (which the healthcheck probes) must be re-enabled explicitly.
- command: ["--http_port", "8222", "--auth", "${NATS_AUTH_TOKEN:?set from pass}"]
- tmpfs:
- - /data
+ command: ["-varz", "-jsz=all", "http://nats:8222"]
+ ports:
+ - "${METRICS_BIND_ADDRESS:-10.0.0.1}:7777:7777"
+ read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
+ logging:
+ driver: json-file
+ options:
+ max-size: "10m"
+ max-file: "3"
deploy:
resources:
limits:
- cpus: "0.5"
- memory: 256M
- healthcheck:
- test: ["CMD", "wget", "--spider", "-q", "http://localhost:8222/healthz"]
- interval: 5s
- timeout: 5s
- retries: 10
+ cpus: "0.1"
+ memory: 32M
+ depends_on:
+ nats:
+ condition: service_healthy
+ networks:
+ - auth-api
+
+secrets:
+ nats_auth:
+ # authorization { token: "..." }, written from pass, mode 0600
+ # (docs/deploy/api/secrets.md).
+ file: ./nats-auth.conf
+
+volumes:
+ nats_data:
+
+# A fixed subnet makes the trusted proxy address predictable. The host's nginx
+# reaches the published loopback ports through Docker's port proxy, so the
+# application sees the bridge gateway (172.30.0.1) as its peer - not
+# 127.0.0.1. TRUSTED_PROXY_CIDRS in config.prod.env must name that gateway,
+# or X-Forwarded-For is ignored and every client shares one rate-limit bucket.
+# Any process on the host can send a forged X-Forwarded-For through those ports:
+# keep shell access to the API VPS to the operators.
+networks:
+ auth-api:
+ driver: bridge
+ ipam:
+ config:
+ - subnet: 172.30.0.0/24
+ gateway: 172.30.0.1
diff --git a/docker-compose.db.yml b/docker-compose.db.yml
deleted file mode 100644
index 2bec144..0000000
--- a/docker-compose.db.yml
+++ /dev/null
@@ -1,11 +0,0 @@
-services:
- appsmith:
- image: appsmith/appsmith-ce
- ports:
- - "127.0.0.1:8080:80"
- volumes:
- - appsmith_data:/appsmith-stacks
- restart: unless-stopped
-
-volumes:
- appsmith_data:
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index 506b105..0e2dfc5 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -1,3 +1,5 @@
+# Development stack. Every port is published on loopback only: the database,
+# cache and broker run with default credentials and must not face the network.
services:
api:
build:
@@ -5,7 +7,7 @@ services:
dockerfile: Dockerfile.dev
env_file: .env.dev
ports:
- - "3000:3000"
+ - "127.0.0.1:3000:3000"
depends_on:
postgres:
condition: service_healthy
@@ -15,23 +17,21 @@ services:
condition: service_healthy
restart: unless-stopped
networks:
- - gateway-net
- auth-net
- cache-net
- mail-net
- broker-net
postgres:
- image: postgres:17-alpine
+ image: postgres:17-alpine@sha256:18cfe3ef5e6815560c98237d6216d1e5119702fb0f3894c8785dd58b8bbe5d73
environment:
POSTGRES_DB: auth_api
POSTGRES_USER: auth_api
POSTGRES_PASSWORD: auth_api
ports:
- - "5432:5432"
+ - "127.0.0.1:5432:5432"
volumes:
- postgres_dev_data:/var/lib/postgresql/data
- - ./docker/init:/docker-entrypoint-initdb.d
healthcheck:
test: ["CMD-SHELL", "pg_isready -U auth_api -d auth_api"]
interval: 5s
@@ -40,12 +40,11 @@ services:
restart: unless-stopped
networks:
- auth-net
- - admin-net
redis:
- image: redis:7-alpine
+ image: redis:7-alpine@sha256:ff02b58f971e7d7d156a1267e283fcbbeee91773b6aa36c49dac28ecfe28eadf
ports:
- - "6379:6379"
+ - "127.0.0.1:6379:6379"
volumes:
- redis_dev_data:/data
healthcheck:
@@ -58,19 +57,23 @@ services:
- cache-net
mailpit:
- image: axllent/mailpit:latest
+ image: axllent/mailpit:v1.21@sha256:81370195cd4a0eab9604d17c2617a7525b0486f9365555253b6c5376c6350f1a
ports:
- - "1025:1025" # SMTP
- - "8025:8025" # Web UI
+ - "127.0.0.1:1025:1025" # SMTP
+ - "127.0.0.1:8025:8025" # Web UI
restart: unless-stopped
networks:
- mail-net
nats:
- image: nats:2-alpine
+ image: nats:2-alpine@sha256:ad7a43eb7e3337c3c38ce5d784d1461791f95f730f252d2b25eee699752a0ca3
+ # JetStream stores the user events the outbox relay publishes. Overriding
+ # the command drops the image's config file,
+ # so monitoring (probed by the healthcheck) is re-enabled explicitly.
+ command: ["--jetstream", "--store_dir", "/data", "--http_port", "8222"]
ports:
- - "4222:4222" # client
- - "8222:8222" # monitoring
+ - "127.0.0.1:4222:4222" # client
+ - "127.0.0.1:8222:8222" # monitoring
tmpfs:
- /data
healthcheck:
@@ -82,37 +85,18 @@ services:
networks:
- broker-net
- appsmith:
- image: appsmith/appsmith-ce
- profiles: [admin]
- ports:
- - "8080:80"
- volumes:
- - appsmith_data:/appsmith-stacks
- depends_on:
- postgres:
- condition: service_healthy
- restart: unless-stopped
- networks:
- - admin-net
-
volumes:
postgres_dev_data:
redis_dev_data:
- appsmith_data:
# All networks are compose-managed: the standalone deployment model (nginx in
# front, NATS in this file) needs no externally provisioned networks.
networks:
- gateway-net:
- driver: bridge
auth-net:
driver: bridge
cache-net:
driver: bridge
mail-net:
driver: bridge
- admin-net:
- driver: bridge
broker-net:
driver: bridge
diff --git a/docker-compose.test.yml b/docker-compose.test.yml
index 23bb1a3..403479b 100644
--- a/docker-compose.test.yml
+++ b/docker-compose.test.yml
@@ -1,12 +1,12 @@
services:
postgres:
- image: postgres:17-alpine
+ image: postgres:17-alpine@sha256:18cfe3ef5e6815560c98237d6216d1e5119702fb0f3894c8785dd58b8bbe5d73
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- - "5433:5432"
+ - "127.0.0.1:5433:5432"
tmpfs:
- /var/lib/postgresql/data
healthcheck:
@@ -18,9 +18,9 @@ services:
- test-auth-net
redis:
- image: redis:7-alpine
+ image: redis:7-alpine@sha256:ff02b58f971e7d7d156a1267e283fcbbeee91773b6aa36c49dac28ecfe28eadf
ports:
- - "6380:6379"
+ - "127.0.0.1:6380:6379"
tmpfs:
- /data
healthcheck:
@@ -32,10 +32,10 @@ services:
- test-cache-net
mailpit:
- image: axllent/mailpit:v1.21
+ image: axllent/mailpit:v1.21@sha256:81370195cd4a0eab9604d17c2617a7525b0486f9365555253b6c5376c6350f1a
ports:
- - "1026:1025"
- - "8026:8025"
+ - "127.0.0.1:1026:1025"
+ - "127.0.0.1:8026:8025"
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8025/api/v1/info"]
interval: 5s
@@ -45,10 +45,16 @@ services:
- test-mail-net
nats:
- image: nats:2-alpine
+ image: nats:2-alpine@sha256:ad7a43eb7e3337c3c38ce5d784d1461791f95f730f252d2b25eee699752a0ca3
+ # JetStream stores the user events the outbox relay publishes. Overriding
+ # the command drops the image's config file,
+ # so monitoring (probed by the healthcheck) is re-enabled explicitly.
+ # Authenticated like production: every suite connects with the token of
+ # TEST_NATS_URL, so a client that drops it fails the whole gate.
+ command: ["--jetstream", "--store_dir", "/data", "--http_port", "8222", "--auth", "auth-api-test-token"]
ports:
- - "4224:4222"
- - "8224:8222"
+ - "127.0.0.1:4224:4222"
+ - "127.0.0.1:8224:8222"
tmpfs:
- /data
healthcheck:
diff --git a/docker/init/appsmith.sql b/docker/init/appsmith.sql
deleted file mode 100644
index 959bbf5..0000000
--- a/docker/init/appsmith.sql
+++ /dev/null
@@ -1,14 +0,0 @@
--- Creates the Appsmith read-only user for the admin panel.
--- Runs automatically at first PostgreSQL container startup.
-
-CREATE USER appsmith WITH PASSWORD 'appsmith';
-
-\connect auth_api
-
-GRANT CONNECT ON DATABASE auth_api TO appsmith;
-GRANT USAGE ON SCHEMA public TO appsmith;
-GRANT SELECT ON ALL TABLES IN SCHEMA public TO appsmith;
-
--- Applies to tables created later by migrations
-ALTER DEFAULT PRIVILEGES FOR ROLE auth_api IN SCHEMA public
- GRANT SELECT ON TABLES TO appsmith;
diff --git a/docs/assets/auth-api-banner.svg b/docs/assets/auth-api-banner.svg
index 50800a2..e57a454 100644
--- a/docs/assets/auth-api-banner.svg
+++ b/docs/assets/auth-api-banner.svg
@@ -23,5 +23,5 @@
Authentication & authorization for Rust services
JWT | 2FA | RBAC | risk-based login
+ font-size="17" fill="#f97316">JWT | 2FA | OAuth PKCE | device flow
diff --git a/docs/deploy/README.md b/docs/deploy/README.md
index 1203854..5d5ef53 100644
--- a/docs/deploy/README.md
+++ b/docs/deploy/README.md
@@ -5,7 +5,7 @@ Follow the steps in this order for a complete deployment.
## Initial Deployment
1. [Secrets](api/secrets.md) - Insert all secrets into `pass` on the API VPS
-2. [Database Deployment](database/deployment.md) - Set up PostgreSQL, Redis and Appsmith on the DB VPS
+2. [Database Deployment](database/deployment.md) - Set up PostgreSQL and Redis on the DB VPS
3. [API Deployment](api/deployment.md) - Deploy the API (the NATS broker ships in the same compose file)
4. [Nginx](api/nginx.md) - Reverse proxy configuration on the API VPS
@@ -15,4 +15,6 @@ Follow the steps in this order for a complete deployment.
## Operations
+- [Monitoring](guides/monitoring.md) - Prometheus, Alertmanager and the external probe on a separate host, exporters and alerts
+- [High Availability](guides/high-availability.md) - Surviving the loss of any host: load balancers, Patroni, Redis Sentinel, a NATS cluster, and failover drills
- [Operations Runbook](guides/operations.md) - Key rotations, backup/restore, Redis outage response, manual interventions, metrics
diff --git a/docs/deploy/api/deployment.md b/docs/deploy/api/deployment.md
index 1b7b0e2..e2aa068 100644
--- a/docs/deploy/api/deployment.md
+++ b/docs/deploy/api/deployment.md
@@ -4,92 +4,119 @@ Previous: [Database Deployment](../database/deployment.md) | [Index](../README.m
## Overview
-The API is distributed as a Docker image published to GitHub Container Registry (GHCR). The production server only needs Docker - no Rust, no source code.
+The API runs from a release bundle built with `make release` (see
+[Creating a Release](../../dev/guides/release.md)). The server needs Docker and
+`sqlx-cli` for migrations - no source code and no registry.
```
-git tag v1.2.3
+trusted machine: make ci && make release VERSION=X.Y.Z
|
-GitHub Actions builds the image
+scp dist/auth-api-X.Y.Z -> API VPS
|
-Image pushed to ghcr.io/siir3x/auth-api:latest
- |
-VPS: docker compose pull && docker compose up -d
+API VPS: verify checksums, docker load, migrate, docker compose up -d
```
-## Prerequisites
-
The NATS event broker ships in `docker-compose.api.yml` and starts with the
-API - no external infrastructure is required. Nginx runs directly on this VPS
-as the reverse proxy (see [Nginx](nginx.md)); the API and its metrics endpoint
-are published on loopback only and are reachable exclusively through it.
+API. Nginx runs on the host as the reverse proxy (see [Nginx](nginx.md)); the
+API and its metrics listener are published on loopback only.
## 1. Initial Setup
### 1.1 Open the firewall
-**On the API VPS** - allow WireGuard (database tunnel) and HTTP/HTTPS for
-the local Nginx, which terminates TLS on this VPS:
+**On the API VPS** - HTTP and HTTPS for nginx. The WireGuard tunnel to the DB
+VPS is opened from this side, so no WireGuard port is needed here:
```bash
-sudo ufw allow 51820/udp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
```
---
-### 1.2 Install Docker
+### 1.2 Install Docker and sqlx-cli
```bash
curl -fsSL https://get.docker.com | sh
```
----
-
-### 1.3 Authenticate to GHCR
-
-A GitHub Personal Access Token with `read:packages` scope is required.
+Build `sqlx` on a machine with Rust and copy the binary to the server:
```bash
-echo "" | docker login ghcr.io -u SIIR3X --password-stdin
+cargo install sqlx-cli --no-default-features --features rustls,postgres --locked
+scp ~/.cargo/bin/sqlx api-vps:/usr/local/bin/sqlx
```
---
-### 1.4 Fetch the deployment files
-
-Only two files are needed on the VPS - no need to clone the full repository.
+### 1.3 Copy and verify the bundle
```bash
-mkdir -p /srv/auth-api && cd /srv/auth-api
-
-curl -O https://raw.githubusercontent.com/SIIR3X/auth-api/main/docker-compose.api.yml
-curl -O https://raw.githubusercontent.com/SIIR3X/auth-api/main/config.prod.env
+# On the trusted machine
+scp -r dist/auth-api-X.Y.Z api-vps:/srv/auth-api/releases/
+
+# On the API VPS, once: the public release key, never taken from a bundle
+echo "release $(cat auth-api-release.pub)" > /srv/auth-api/allowed_signers
+
+# On the API VPS, for every bundle
+cd /srv/auth-api/releases/auth-api-X.Y.Z
+ssh-keygen -Y verify -f /srv/auth-api/allowed_signers -I release \
+ -n auth-api-release -s SHA256SUMS.sig < SHA256SUMS
+sha256sum -c SHA256SUMS
+gunzip -c auth-api-X.Y.Z.image.tar.gz | docker load
+test "$(docker image inspect --format '{{.Id}}' auth-api:X.Y.Z)" = "$(cat IMAGE_ID)" \
+ && echo "image matches the signed bundle"
```
+The signature proves `SHA256SUMS` was written by the release key; the checksums
+prove every file matches it, and `IMAGE_ID` that the loaded image is the one
+that was scanned. Stop at the first command that fails.
+
---
-### 1.5 Edit non-sensitive configuration
+### 1.4 Configure
-Open `config.prod.env` and fill in the values specific to your environment:
+Copy the deployment files next to each other and fill in the non-sensitive
+values:
```bash
+mkdir -p /srv/auth-api && cd /srv/auth-api
+cp releases/auth-api-X.Y.Z/docker-compose.api.yml releases/auth-api-X.Y.Z/config.prod.env \
+ releases/auth-api-X.Y.Z/nats.conf releases/auth-api-X.Y.Z/scripts/rolling-update.sh .
+cp releases/auth-api-X.Y.Z/deploy/profiles/m.env profile.env # s.env, m.env, l.env or xl.env
+# Profile L: also copy docker-compose.api.l.yml
nano config.prod.env
```
-Key values to update:
+Values to set:
+
+- `APP_PUBLIC_URL`, `FRONTEND_URL`, `CORS_ALLOWED_ORIGINS`, `JWT_AUDIENCE`
+- `DEVICE_AUTH_VERIFICATION_URI`
+- `SMTP_HOST`, `SMTP_FROM_ADDRESS`, `TOTP_ISSUER`
+- `ARGON2_*` for the server's memory and cores
+- `TRUSTED_PROXY_CIDRS` stays `172.30.0.1/32` (the compose network gateway, see [Nginx](nginx.md#trusted-proxy))
+
+The instance sizes (CPU, memory, Argon2 concurrency, pool sizes, broker limits)
+live in `profile.env`: pick the profile of the expected load from the
+[capacity planning](../guides/operations.md#9-capacity-planning), and adjust
+the copy rather than `config.prod.env`.
+
+---
+
+### 1.5 Run the migrations
-- `APP_PUBLIC_URL` - public URL of the API
-- `CORS_ALLOWED_ORIGINS` - frontend origin(s)
-- `SMTP_HOST`, `SMTP_FROM_ADDRESS` - email provider
-- `TOTP_ISSUER` - name shown in authenticator apps
-- `ARGON2_*` - tune for your server hardware
+```bash
+DATABASE_URL=$(pass prod/auth-api/database-url) \
+ sqlx migrate run --source /srv/auth-api/releases/auth-api-X.Y.Z/migrations
+```
---
-### 1.6 Export secrets and deploy
+### 1.6 Export secrets and start
```bash
+cd /srv/auth-api
+export AUTH_API_VERSION=X.Y.Z
export DATABASE_URL=$(pass prod/auth-api/database-url)
export REDIS_URL=$(pass prod/auth-api/redis-url)
export JWT_PRIVATE_KEY=$(pass prod/auth-api/jwt-private-key)
@@ -98,9 +125,29 @@ export ENCRYPTION_KEY=$(pass prod/auth-api/encryption-key)
export SMTP_USERNAME=$(pass prod/auth-api/smtp-username)
export SMTP_PASSWORD=$(pass prod/auth-api/smtp-password)
export CAPTCHA_SECRET=$(pass prod/auth-api/captcha-secret)
-# NATS runs in the same compose file, guarded by a token (see Secrets):
export NATS_URL=$(pass prod/auth-api/nats-url)
-export NATS_AUTH_TOKEN=$(pass prod/auth-api/nats-auth-token)
+# Owned by root: the broker runs without capabilities (see Secrets)
+sudo install -m 600 -o root -g root /dev/null nats-auth.conf
+printf 'authorization { token: "%s" }\n' "$(pass prod/auth-api/nats-auth-token)" \
+ | sudo tee nats-auth.conf > /dev/null
-docker compose -f docker-compose.api.yml up -d
+docker compose --env-file profile.env -f docker-compose.api.yml up -d --wait
+curl -fsS http://127.0.0.1:3001/ready && curl -fsS http://127.0.0.1:3002/ready
```
+
+Both instances must answer `/ready` before nginx is pointed at them.
+
+---
+
+### 1.7 Register the client applications
+
+Device and authorization code flows only serve registered clients. Register the
+application this instance owns as primary, then any other client:
+
+```bash
+docker compose --env-file profile.env -f docker-compose.api.yml run --rm --no-deps api-a \
+ ./auth-api --register-client web-app --name "Web app" --primary \
+ --redirect-uri https://app.example.com/callback
+```
+
+Options are listed in [Commands](../../dev/guides/commands.md#binary-commands).
diff --git a/docs/deploy/api/nginx.md b/docs/deploy/api/nginx.md
index bd46997..f549ae7 100644
--- a/docs/deploy/api/nginx.md
+++ b/docs/deploy/api/nginx.md
@@ -4,12 +4,17 @@ Previous: [API Deployment](deployment.md) | [Index](../README.md)
## Overview
-Nginx sits in front of the Docker container as a reverse proxy. It handles TLS termination, security headers, and rate limiting before requests reach the API.
+Nginx sits in front of the API instances as a reverse proxy. It handles TLS
+termination, security headers, rate limiting and the failover between
+instances before requests reach the API.
```
-Client -> Nginx (443) -> Docker container (127.0.0.1:3000)
+Client -> Nginx (443) -> api-a (127.0.0.1:3001)
+ -> api-b (127.0.0.1:3002)
```
+The configuration needs nginx 1.25.1 or later (`http2 on;`).
+
## 1. Install Nginx and Certbot
```bash
@@ -29,17 +34,33 @@ Certbot automatically renews certificates. Verify the renewal timer is active:
sudo systemctl status certbot.timer
```
+Reload nginx after each renewal, so it serves the new certificate:
+
+```bash
+echo 'deploy-hook = nginx -t && systemctl reload nginx' | sudo tee -a /etc/letsencrypt/cli.ini
+```
+
## 3. Deploy the configuration
Copy the config file from the repository and replace the placeholder domain:
```bash
-sudo cp /srv/auth-api/nginx/nginx.conf /etc/nginx/sites-available/auth-api
+sudo cp /srv/auth-api/releases/auth-api-X.Y.Z/nginx/nginx.conf /etc/nginx/sites-available/auth-api
sudo sed -i 's/api.example.com/your-actual-domain.com/g' /etc/nginx/sites-available/auth-api
sudo ln -s /etc/nginx/sites-available/auth-api /etc/nginx/sites-enabled/auth-api
sudo rm -f /etc/nginx/sites-enabled/default
```
+Raise the worker limits in the main configuration (`/etc/nginx/nginx.conf`,
+outside the site file: these settings belong to the main and `events`
+contexts):
+
+```bash
+sudo sed -i 's/^worker_processes .*/worker_processes auto;/' /etc/nginx/nginx.conf
+sudo sed -i '/^worker_processes/a worker_rlimit_nofile 65536;' /etc/nginx/nginx.conf
+sudo sed -i 's/worker_connections .*/worker_connections 4096;/' /etc/nginx/nginx.conf
+```
+
Test and reload:
```bash
@@ -54,7 +75,8 @@ sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
```
-Port 3000 must **not** be open - the API is only reachable through Nginx on loopback.
+Ports 3001 and 3002 must **not** be open: the instances are published on
+loopback and reached through nginx only.
## Configuration notes
@@ -64,11 +86,66 @@ Two zones mirror the API's own rate limiting as a first line of defense:
| Zone | Limit | Applied to |
|------|-------|------------|
-| `api_auth` | 20 req/min | `/auth/register`, `/auth/login`, `/auth/refresh`, password and 2FA routes |
-| `api_general` | 300 req/min | All other routes |
+| `api_auth` | 40 req/min | Credential-bearing routes: register, login, refresh, email verification, password reset, 2FA completion, device and authorization code token routes, re-authentication and email change |
+| `api_general` | 600 req/min | Every other route, logout, probes and the JWKS included |
+
+The route patterns are anchored on both ends, so `/auth/login-anything` is not
+a login route.
+
+The zones allow twice `RATE_LIMIT_RPM` and `RATE_LIMIT_AUTH_RPM` of
+`config.prod.env`: they only absorb floods, and a client over its limit gets the
+API's 429 with its `Retry-After`. Change both files together.
+
+### Instances and failover
-Adjust the values to match `RATE_LIMIT_RPM` and `RATE_LIMIT_AUTH_RPM` in `config.prod.env`.
+The upstream lists both instances. An instance that refuses connections or
+fails three times is skipped for 10 seconds, and a request that could not reach
+one is passed to the other. nginx never resends a `POST`, `PATCH` or `DELETE`
+that an instance already received, so a write is never applied twice. During
+[a rolling update](../guides/update.md) the instance being replaced stops
+accepting connections while it finishes its requests: new requests go to the
+other one without an error. Profile L adds `127.0.0.1:3003` and `127.0.0.1:3004`
+to the upstream.
+
+### Timeouts
+
+| Location | Proxy timeout | Why |
+|----------|--------------:|-----|
+| Credential routes and everything else | 35 s | Above the API's 30-second request timeout: a sign-in queued behind Argon2 during a storm completes instead of ending in a 504 the client retries |
+| `/live`, `/ready`, `/health` | 5 s | Probes; not logged |
+| `/.well-known/jwks.json` | 10 s | |
+
+### Logs
+
+`/var/log/nginx/auth-api.access.log` has one JSON line per request with its
+`request_id`, which nginx passes to the API as `X-Request-Id`: the API keeps it
+in every log line of that request. The nginx package's logrotate configuration
+already covers `/var/log/nginx/*.log`.
### Trusted proxy
-Since the API receives requests via Nginx, configure `TRUSTED_PROXY_CIDRS=127.0.0.1/32` in `config.prod.env` so the API resolves the real client IP from `X-Forwarded-For` instead of the loopback address.
+Nginx reaches the container through Docker's port proxy, so the address the API
+sees is the gateway of the compose network, not `127.0.0.1`.
+`docker-compose.api.yml` pins that network to `172.30.0.0/24`, and
+`config.prod.env` sets `TRUSTED_PROXY_CIDRS=172.30.0.1/32`. With any other
+value the API ignores `X-Forwarded-For` and every client shares one rate-limit
+bucket.
+
+Nginx overwrites `X-Forwarded-For` with the client address instead of appending
+to it, so a client cannot inject hops the API would trust.
+
+### Headers
+
+Nginx is the single source of the security headers: it hides the copies the API
+sets and adds its own, on every response including the ones it generates (429,
+502). The values match the API's, with `preload` added to HSTS.
+
+### Public keys
+
+`/.well-known/jwks.json` has an exact-match location placed before the
+hidden-file rule (`location ~ /\.`), which would otherwise deny it and leave
+resource servers unable to verify tokens.
+
+### OCSP stapling
+
+Not configured: Let's Encrypt stopped operating OCSP responders in 2025.
diff --git a/docs/deploy/api/secrets.md b/docs/deploy/api/secrets.md
index 99830d9..ec41716 100644
--- a/docs/deploy/api/secrets.md
+++ b/docs/deploy/api/secrets.md
@@ -81,7 +81,9 @@ pass insert prod/auth-api/encryption-key
### SMTP Username
-Authentication username for the SMTP server.
+Authentication username for the SMTP server. Required in production: the API
+refuses to start without it, because an empty username would send mail without
+STARTTLS or authentication.
```bash
pass insert prod/auth-api/smtp-username
@@ -91,7 +93,7 @@ pass insert prod/auth-api/smtp-username
### SMTP Password
-Authentication password for the SMTP server.
+Authentication password for the SMTP server. Required in production.
```bash
pass insert prod/auth-api/smtp-password
@@ -101,7 +103,10 @@ pass insert prod/auth-api/smtp-password
### CAPTCHA Secret
-hCaptcha secret key. Leave unset to disable CAPTCHA entirely.
+hCaptcha secret key. Required in production: the API refuses to start without
+it, since registration, sign-in and forgotten-password requests would lose their
+bot protection. `CAPTCHA_VERIFY_URL` must be HTTPS there too. Outside production,
+leaving it unset disables the check.
```bash
pass insert prod/auth-api/captcha-secret
@@ -123,14 +128,16 @@ pass insert prod/auth-api/nats-url
# nats://@nats:4222
```
----
-
-### GitHub Token
-
-Personal Access Token with `read:packages` scope. Used to authenticate against GHCR to pull the Docker image.
+The broker reads the same token from `/srv/auth-api/nats-auth.conf`, mounted
+as a compose secret so it shows neither in the broker's command line nor in
+`docker inspect`. Write it on the API VPS, owned by root and readable by root
+only, and again after every token change. Root must own it: the broker runs
+without Linux capabilities, so it cannot read a file owned by another user.
```bash
-pass insert prod/auth-api/github-token
+sudo install -m 600 -o root -g root /dev/null /srv/auth-api/nats-auth.conf
+printf 'authorization { token: "%s" }\n' "$(pass prod/auth-api/nats-auth-token)" \
+ | sudo tee /srv/auth-api/nats-auth.conf > /dev/null
```
## Verify
diff --git a/docs/deploy/database/deployment.md b/docs/deploy/database/deployment.md
index ac6250e..1c316af 100644
--- a/docs/deploy/database/deployment.md
+++ b/docs/deploy/database/deployment.md
@@ -138,24 +138,31 @@ GRANT ALL PRIVILEGES ON DATABASE auth_api TO auth_api;
---
-### 2.2 Allow connections on the VPN interface
+### 2.2 Configure PostgreSQL
-**On the DB VPS** - edit `/etc/postgresql//main/postgresql.conf`:
+**On the DB VPS** - install the settings shipped in the release bundle
+(`deploy/db/`). They listen on the VPN address only and size PostgreSQL for
+profile M; the comments give the values of the other profiles (see
+[capacity planning](../guides/operations.md#9-capacity-planning)):
-```conf
-listen_addresses = '10.0.0.2'
+```bash
+sudo cp deploy/db/postgresql.auth-api.conf /etc/postgresql/17/main/conf.d/auth-api.conf
```
-Edit `/etc/postgresql//main/pg_hba.conf` - allow the API VPS via its VPN IP only:
+Edit `/etc/postgresql/17/main/pg_hba.conf` - allow the API VPS via its VPN IP only:
```conf
host auth_api auth_api 10.0.0.1/32 scram-sha-256
```
-Restart PostgreSQL:
+Restart PostgreSQL, then give the role its session limits (statements and lock
+waits stop before the API's 30-second request timeout; a connection idle inside
+a transaction is closed):
```bash
sudo systemctl restart postgresql
+sudo -u postgres psql -d auth_api -c 'CREATE EXTENSION IF NOT EXISTS pg_stat_statements'
+sudo -u postgres psql -d auth_api -f deploy/db/auth-api-role.sql
```
---
@@ -182,89 +189,64 @@ psql "$(pass prod/auth-api/database-url)"
### 2.5 Run migrations
-Each release publishes a `migrations.tar.gz` asset on GitHub. The archive is fetched directly into `/dev/shm` (RAM) - nothing is written to disk.
-
-**On the API VPS** - install `sqlx-cli`:
+Migrations ship in every release bundle (see [Deploying a New Release](../guides/update.md)).
+**On the API VPS**, with `sqlx-cli` installed (see [API Deployment](../api/deployment.md#12-install-docker-and-sqlx-cli)):
```bash
-cargo install sqlx-cli --no-default-features --features rustls,postgres --locked
-```
-
-Fetch and run migrations:
-
-```bash
-curl -sL https://github.com/SIIR3X/auth-api/releases/latest/download/migrations.tar.gz \
- | tar -xz -C /dev/shm
-
-DATABASE_URL=$(pass prod/auth-api/database-url) \
- sqlx migrate run --source /dev/shm/migrations
-
-rm -rf /dev/shm/migrations
+DATABASE_URL="$(pass prod/auth-api/database-url)?options=-c%20statement_timeout%3D0" \
+ sqlx migrate run --source /srv/auth-api/releases/auth-api-X.Y.Z/migrations
```
-## 3. Appsmith
-
-**On the DB VPS** - install Docker:
-
-```bash
-curl -fsSL https://get.docker.com | sh
-```
+The `options` parameter lifts the role's 25-second statement timeout for the
+migration session only: a migration on a large table may run longer.
---
-### 3.1 Create the Appsmith user
+### 2.6 Size PostgreSQL's memory
-```bash
-sudo -u postgres psql -d auth_api
-```
-
-```sql
-CREATE USER appsmith WITH PASSWORD '';
-GRANT CONNECT ON DATABASE auth_api TO appsmith;
-GRANT USAGE ON SCHEMA public TO appsmith;
-GRANT SELECT ON ALL TABLES IN SCHEMA public TO appsmith;
-ALTER DEFAULT PRIVILEGES FOR ROLE auth_api IN SCHEMA public
- GRANT SELECT ON TABLES TO appsmith;
-\q
-```
+Reads barely notice the number of accounts. Writes do, once the indexes they
+update no longer fit in memory: at 1 million accounts the sign-in transaction
+lost 25 to 35 % of its throughput in the performance campaign. Give PostgreSQL
+enough memory to keep those indexes cached. 8 GB at 1 million accounts was
+validated under the load of profile M (`make sizing`, see section 9 of the
+[operations runbook](../guides/operations.md#9-capacity-planning)); 14 GB changed
+neither throughput nor latency:
----
+| Accounts | Database | Indexes updated by sign-ins and refreshes | RAM for PostgreSQL |
+|---------:|---------:|------------------------------------------:|-------------------:|
+| 100 000 | 1.3 GB | 0.6 GB | 2 GB |
+| 1 000 000 | 9.5 GB | 4 GB | 8 GB |
+| more | ~10 KB per account | ~4 KB per account | index size x 2 |
-### 3.2 Deploy Appsmith
+**On the DB VPS**, in `postgresql.conf`, for R GB of RAM dedicated to PostgreSQL:
-```bash
-mkdir -p /srv/auth-api && cd /srv/auth-api
-curl -O https://raw.githubusercontent.com/SIIR3X/auth-api/main/docker-compose.db.yml
-docker compose -f docker-compose.db.yml up -d
+```conf
+shared_buffers = GB
+effective_cache_size = GB
+random_page_cost = 1.1 # SSD
+max_wal_size = 4GB
```
-Appsmith is bound to `127.0.0.1:8080` - never exposed publicly.
+The two largest tables grow with retention, not with accounts alone:
+`login_attempts` keeps `CLEANUP_LOGIN_ATTEMPTS_RETENTION_DAYS` days (90) and the
+audit log `AUDIT_LOG_RETENTION_MONTHS` months (12). Shortening them shrinks
+the tables and their indexes in proportion.
----
-
-### 3.3 Access the panel
+Check whether reads are served from memory. `blks_hit` counts only PostgreSQL's
+own buffers: a page served by the kernel's page cache still counts as a read,
+so a database larger than `shared_buffers` keeps a hit ratio below 1 without
+touching the disk. The time of a read tells the two apart (`track_io_timing` is
+on in `postgresql.auth-api.conf`): a few microseconds from the page cache,
+around 100 microseconds or more from an SSD. Reads that take that long, many
+per second, mean PostgreSQL needs more memory:
-From your local machine, open an SSH tunnel:
-
-```powershell
-ssh -L 8080:127.0.0.1:8080 -p 2222 @
+```sql
+SELECT round(blks_hit::numeric / nullif(blks_hit + blks_read, 0), 4) AS buffer_hit_ratio,
+ round((blk_read_time * 1000 / nullif(blks_read, 0))::numeric, 1) AS microseconds_per_read
+FROM pg_stat_database WHERE datname = 'auth_api';
```
-Then open `http://localhost:8080`.
-
----
-
-### 3.4 Connect to the database
-
-In Appsmith: **Settings -> Datasources -> New datasource -> PostgreSQL**
-
-- Host: `localhost`
-- Port: `5432`
-- Database: `auth_api`
-- Username: `appsmith`
-- Password: the password set in 3.1
-
-## 4. Redis
+## 3. Redis
**On the DB VPS** - install Redis:
@@ -275,25 +257,36 @@ sudo apt install -y redis-server
---
-### 4.1 Configure authentication and binding
+### 3.1 Configure Redis
-**On the DB VPS** - inject the password from `pass` and bind to the VPN interface:
+The settings shipped in `deploy/db/redis.auth-api.conf` bind Redis to the VPN
+address, never evict a key (evicting an attempt budget would reset it), persist
+to an append-only file so revocations survive a restart, and read the users
+from an ACL file. Size `maxmemory` for the profile.
```bash
-REDIS_PASSWORD=$(pass prod/auth-api/redis-password)
-sudo sed -i "s/^# requirepass .*/requirepass ${REDIS_PASSWORD}/" /etc/redis/redis.conf
-sudo sed -i "s/^bind .*/bind 10.0.0.2/" /etc/redis/redis.conf
+sudo cp deploy/db/redis.auth-api.conf /etc/redis/auth-api.conf
+echo 'include /etc/redis/auth-api.conf' | sudo tee -a /etc/redis/redis.conf
```
-Restart Redis:
+Create the ACL file. The default user is disabled; the API's user can run
+every command but the administrative and dangerous ones (`FLUSHALL`, `CONFIG`,
+`KEYS`, `DEBUG`...), and only the SHA-256 of its password is stored:
```bash
-sudo systemctl restart redis
+REDIS_PASSWORD_SHA=$(pass prod/auth-api/redis-password | tr -d '\n' | sha256sum | cut -d' ' -f1)
+printf 'user default off\nuser auth_api on #%s ~* &* +@all -@dangerous -@admin\n' "$REDIS_PASSWORD_SHA" \
+ | sudo tee /etc/redis/users.acl > /dev/null
+sudo chown redis:redis /etc/redis/users.acl && sudo chmod 600 /etc/redis/users.acl
+sudo systemctl restart redis-server
```
+The API connects as that user: store `redis://auth_api:@10.0.0.2:6379`
+as `prod/auth-api/redis-url`.
+
---
-### 4.2 Open the firewall
+### 3.2 Open the firewall
**On the DB VPS:**
@@ -303,22 +296,47 @@ sudo ufw allow from 10.0.0.1 to any port 6379
---
-### 4.3 Verify connectivity
+### 3.3 Verify connectivity
**On the API VPS:**
```bash
-redis-cli -h 10.0.0.2 ping
+redis-cli -u "$(pass prod/auth-api/redis-url)" ping
+redis-cli -u "$(pass prod/auth-api/redis-url)" flushall # must fail: NOPERM
```
-## 5. Backups
+---
+
+### 3.4 Kernel settings
+
+**On the DB VPS** - overcommit for Redis's background rewrites, minimal
+swapping, and no transparent huge pages (latency spikes in both databases):
+
+```bash
+sudo cp deploy/db/sysctl-auth-api.conf /etc/sysctl.d/90-auth-api.conf
+sudo sysctl --system
+sudo cp deploy/db/disable-thp.service /etc/systemd/system/
+sudo systemctl enable --now disable-thp
+```
+
+## 4. Backups
+
+Two mechanisms, complementary:
+
+| | Nightly encrypted dump (`backup-db.sh`) | pgBackRest with WAL archiving |
+|---|---|---|
+| Profiles | Every profile | M and L |
+| Recovery point | The last night: up to 24 hours lost | Any moment of the last two weeks |
+| Restore | `restore-db.sh`, into a fresh database | `pgbackrest restore --type=time` |
-Backups are encrypted with [age](https://github.com/FiloSottile/age) before touching disk.
-The private key never lives on the DB VPS - only the public key is needed to encrypt.
+Keep the nightly dump even with pgBackRest: it is an independent copy, readable
+with standard tools. Dumps are encrypted with
+[age](https://github.com/FiloSottile/age) before touching disk, and the private
+key never lives on the DB VPS - only the public key is needed to encrypt.
---
-### 5.1 Generate a key pair
+### 4.1 Generate a key pair
Run this **on a secure machine** (your laptop, a password manager export, etc.) - not the DB VPS.
@@ -354,7 +372,7 @@ Store `backup.key` somewhere safe and offline (e.g. alongside your other secrets
---
-### 5.2 Install age on the DB VPS
+### 4.2 Install age on the DB VPS
```bash
sudo apt update
@@ -363,37 +381,47 @@ sudo apt install -y age
---
-### 5.3 Deploy the backup script
+### 4.3 Deploy the backup script
-**On the DB VPS** - fetch the script from the repository, then set the public key:
+**On the DB VPS** - install the script from the release bundle and its
+configuration (the public key of step 4.1, and optionally an rclone remote for
+the offsite copy):
```bash
sudo mkdir -p /opt/auth-api
-curl -sL https://raw.githubusercontent.com/SIIR3X/auth-api/main/scripts/backup-db.sh \
- | sudo tee /opt/auth-api/backup-db.sh > /dev/null
-sudo chmod 700 /opt/auth-api/backup-db.sh
-sudo chown root:root /opt/auth-api/backup-db.sh
-```
+sudo install -m 700 -o root -g root scripts/backup-db.sh /opt/auth-api/backup-db.sh
-Edit the script and replace `AGE_PUBLIC_KEY` with the public key from step 5.1:
+sudo install -d -m 700 /etc/auth-api
+printf 'AGE_PUBLIC_KEY=%s\nOFFSITE_REMOTE=%s\n' 'age1...' 'b2:auth-backups' \
+ | sudo tee /etc/auth-api/backup.env > /dev/null
+sudo chmod 600 /etc/auth-api/backup.env
-```bash
-sudo nano /opt/auth-api/backup-db.sh
-# AGE_PUBLIC_KEY="age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+# Read by node_exporter (see the monitoring guide)
+sudo install -d -m 755 /var/lib/node_exporter/textfile
```
+The script refuses to run with the placeholder key. Without `OFFSITE_REMOTE`,
+backups live on this VPS only and die with it.
+
---
-### 5.4 Test the script
+### 4.4 Test the script
```bash
sudo /opt/auth-api/backup-db.sh
ls -lh /var/backups/auth-api/
+cat /var/lib/node_exporter/textfile/auth_backup.prom
```
+The metrics file is written only after a complete run, offsite copy included.
+A failed run exits with an error, leaves no partial file and keeps the previous
+metrics: after 48 hours without a complete backup, `AuthBackupMissing` fires.
+`AuthBackupShrunk` warns when a backup is less than half the size of those of
+the previous week.
+
---
-### 5.5 Schedule via cron
+### 4.5 Schedule via cron
```bash
sudo crontab -e
@@ -405,17 +433,74 @@ Add:
0 2 * * * /opt/auth-api/backup-db.sh >> /var/log/auth-api-backup.log 2>&1
```
-Backups run nightly at 2:00 AM and are retained for 7 days.
+Backups run nightly at 2:00 AM and are retained for 7 days (`RETAIN_DAYS`).
+
+---
+
+### 4.6 Restore a backup
+
+Always through `restore-db.sh`, connected as `auth_api`, into a **fresh**
+database: a bare `| psql` does not stop at the first error. The script restores
+in a single transaction, so a failure leaves the target untouched.
+
+```bash
+sudo -u postgres psql -c "CREATE DATABASE auth_api_restore OWNER auth_api"
+scripts/restore-db.sh -i backup.key -f auth_api_YYYYMMDD_HHMMSS.sql.gz.age \
+ -d "postgres://auth_api:@10.0.0.2/auth_api_restore"
+```
+
+Check the restored data, then point `DATABASE_URL` at it (or rename the
+databases while the API is stopped). `--force` restores over an existing
+database after emptying it.
+
+Every quarter, prove the backups themselves: restore last night's backup with
+the offline key into a scratch database and compare it with production.
+`scripts/backup-drill.sh` proves the mechanism on throwaway containers
+(non-superuser restore, refused overwrite, `--force`, every table compared).
---
-### 5.6 Restore a backup
+### 4.7 Point-in-time recovery with pgBackRest (profiles M and L)
+
+WAL is archived continuously to an encrypted pgBackRest repository, with a full
+backup every week and a differential one every day; two full backups are kept,
+so any moment of about the last two weeks can be restored.
+
+**On the DB VPS** - install and configure:
+
+```bash
+sudo apt install -y pgbackrest
+sudo install -d -o postgres -g postgres -m 750 /var/lib/pgbackrest /var/spool/pgbackrest /var/log/pgbackrest
+sudo install -o postgres -g postgres -m 640 deploy/db/pgbackrest.conf /etc/pgbackrest/pgbackrest.conf
+sudo sed -i "s|^repo1-cipher-pass=.*|repo1-cipher-pass=$(pass prod/auth-api/pgbackrest-cipher-pass)|" \
+ /etc/pgbackrest/pgbackrest.conf
+sudo cp deploy/db/postgresql.pitr.conf /etc/postgresql/17/main/conf.d/auth-api-pitr.conf
+sudo systemctl restart postgresql
+
+sudo -u postgres pgbackrest --stanza=auth_api stanza-create
+sudo -u postgres pgbackrest --stanza=auth_api check
+sudo -u postgres pgbackrest --stanza=auth_api --type=full backup
+```
+
+The repository is unreadable without the cipher passphrase: keep it in `pass`
+(`prod/auth-api/pgbackrest-cipher-pass`) and offline. For an offsite copy, add a
+second repository as the comment in `pgbackrest.conf` shows.
+
+Schedule the backups (`sudo crontab -u postgres -e`):
+
+```
+0 1 * * 0 pgbackrest --stanza=auth_api --type=full backup
+0 1 * * 1-6 pgbackrest --stanza=auth_api --type=diff backup
+```
-On any machine that has the private key and `psql` available:
+**Restore to a point in time** - first into another directory, to inspect the
+result without touching production:
```bash
-age --decrypt -i backup.key auth_api_YYYYMMDD_HHMMSS.sql.gz.age \
- | gunzip \
- | psql "postgres://auth_api:@/auth_api"
+sudo -u postgres install -d -m 700 /var/lib/postgresql/17/restore
+sudo -u postgres pgbackrest --stanza=auth_api --pg1-path=/var/lib/postgresql/17/restore \
+ --type=time "--target=2026-09-15 14:30:00+02" --target-action=promote restore
```
+Once satisfied, stop PostgreSQL and restore in place with `--delta` (only the
+files that differ are rewritten), then start it again.
diff --git a/docs/deploy/guides/high-availability.md b/docs/deploy/guides/high-availability.md
new file mode 100644
index 0000000..8b87ae3
--- /dev/null
+++ b/docs/deploy/guides/high-availability.md
@@ -0,0 +1,240 @@
+# High Availability
+
+[Index](../README.md)
+
+The reference deployment (one API VPS, one database VPS) survives the loss of
+an instance, not of a host. This guide takes it to a self-hosted deployment that
+survives the loss of any single host, with what each component needs and what
+auth-api does while a failover runs. No managed service is assumed.
+
+## 1. Targets
+
+| Failure | Service | Data |
+|---------|---------|------|
+| An API instance or host | No interruption: the load balancer stops sending traffic within 10 s | Nothing lost |
+| The PostgreSQL primary | Sign-ins and writes answer `503` for the failover, 10 to 30 s | Nothing committed is lost (synchronous replication) |
+| The Redis primary | Rate-limited and budgeted routes answer `503` for the failover, 5 to 15 s | Short-lived state written in the last second may be lost (see section 5) |
+| A NATS server | No interruption | Nothing lost: events wait in the outbox, the stream has three copies |
+| A load balancer | No interruption beyond the address move, about 3 s | - |
+| The whole site | Restore elsewhere from backups (operations runbook, section 3) | Up to the last archived WAL segment |
+
+## 2. Layout
+
+```text
+ clients
+ |
+ VIP (keepalived, VRRP)
+ / \
+ lb-1 (nginx) lb-2 (nginx)
+ \ /
+ +------+--------------------+------+
+ | |
+ api-host-1 (api-a, api-b) api-host-2 (api-c, api-d)
+ | |
+ +---------+--------------+---------+
+ | |
+ HAProxy on each API host: 5432 -> PostgreSQL primary, 6379 -> Redis primary
+ | |
+ db-1, db-2, db-3: PostgreSQL + Patroni + etcd, Redis + Sentinel
+ nats-1, nats-2, nats-3: NATS cluster with JetStream
+```
+
+Three database hosts give etcd, Patroni and Sentinel the quorum they need to
+elect a new primary without a split brain. NATS runs on three hosts of its own,
+or beside the database services when load allows.
+
+## 3. API instances
+
+Instances share nothing but the database, Redis and NATS: any instance serves
+any request, and jobs that must run once coordinate through PostgreSQL advisory
+locks (the event relay, retention jobs) or row leases (webhook deliveries).
+
+- Run at least two instances on each of two hosts (section 12 of the operations
+ runbook), with **identical configuration**: the same JWT keys,
+ `ENCRYPTION_KEY`, `APP_PUBLIC_URL` and client registrations. A token signed by
+ one instance is verified by every other.
+- The load balancers check `GET /ready` (database, Redis and NATS answer) and
+ remove an instance that fails it; the container runtime checks `GET /live`
+ and restarts a stuck process. Never use `/ready` for restarts: a database
+ failover would restart every instance at once.
+- `TRUSTED_PROXY_CIDRS` lists both load balancers, or client addresses (rate
+ limits, audit log) become the balancer's.
+- Rolling updates (`rolling-update.sh`) replace one instance at a time; run it
+ host by host.
+
+nginx on each load balancer, with keepalived moving the public address:
+
+```nginx
+upstream auth_api {
+ server 10.0.1.11:3001 max_fails=2 fail_timeout=10s;
+ server 10.0.1.11:3002 max_fails=2 fail_timeout=10s;
+ server 10.0.1.12:3001 max_fails=2 fail_timeout=10s;
+ server 10.0.1.12:3002 max_fails=2 fail_timeout=10s;
+ keepalive 64;
+}
+```
+
+```text
+# /etc/keepalived/keepalived.conf on lb-1 (priority 100; lb-2: BACKUP, 90)
+vrrp_script nginx_alive { script "/usr/bin/pgrep nginx" interval 2 }
+vrrp_instance public {
+ state MASTER
+ interface eth0
+ virtual_router_id 51
+ priority 100
+ virtual_ipaddress { 203.0.113.10/24 }
+ track_script { nginx_alive }
+}
+```
+
+## 4. PostgreSQL
+
+Patroni manages three PostgreSQL nodes and elects the primary through etcd.
+auth-api connects to one address, HAProxy, which forwards to whichever node
+Patroni reports as primary.
+
+```yaml
+# patroni.yml (excerpt, one per node)
+scope: auth-api
+bootstrap:
+ dcs:
+ ttl: 30
+ loop_wait: 10
+ maximum_lag_on_failover: 0
+ synchronous_mode: true
+ postgresql:
+ parameters:
+ synchronous_commit: "on"
+ max_connections: 200
+```
+
+```text
+# haproxy.cfg on each API host
+listen postgres
+ bind 127.0.0.1:5432
+ option httpchk GET /primary
+ http-check expect status 200
+ default-server inter 2s fall 2 rise 2 on-marked-down shutdown-sessions
+ server db-1 10.0.2.11:5432 check port 8008
+ server db-2 10.0.2.12:5432 check port 8008
+ server db-3 10.0.2.13:5432 check port 8008
+```
+
+- `synchronous_mode` makes a commit wait for a replica: a failover never loses a
+ committed sign-in, revocation or event. The price is a few milliseconds per
+ write, measured by `make bench-http`.
+- `on-marked-down shutdown-sessions` cuts the connections to a demoted primary,
+ so the pools reconnect to the new one instead of writing to a read-only node.
+- pgBackRest (`deploy/db/pgbackrest.conf`) backs up from a replica; point every
+ node's `archive_command` at the same repository.
+- Keep the settings of `deploy/db/postgresql.auth-api.conf` on every node.
+
+**During a failover** a request that needs the database waits up to
+`DB_ACQUIRE_TIMEOUT_SECS` for a connection, then answers `503`. That includes
+authenticated requests whose session state is not in Redis, where it is cached
+five seconds: expect most traffic to see `503` for the length of the failover,
+and clients to retry. A transaction cut
+by the failover rolls back whole: the outbox guarantees an event exists if and
+only if its change committed.
+
+## 5. Redis
+
+Three Redis nodes with Sentinel; HAProxy on each API host sends connections to
+the node that reports itself primary.
+
+```text
+# sentinel.conf on each database host
+sentinel monitor auth-api 10.0.2.11 6379 2
+sentinel down-after-milliseconds auth-api 5000
+sentinel failover-timeout auth-api 30000
+
+# haproxy.cfg on each API host
+listen redis
+ bind 127.0.0.1:6379
+ option tcp-check
+ tcp-check send AUTH\ ${REDIS_PASSWORD}\r\n
+ tcp-check expect string +OK
+ tcp-check send info\ replication\r\n
+ tcp-check expect string role:master
+ tcp-check send QUIT\r\n
+ tcp-check expect string +OK
+ default-server inter 1s fall 2 rise 2 on-marked-down shutdown-sessions
+ server redis-1 10.0.2.11:6379 check
+ server redis-2 10.0.2.12:6379 check
+ server redis-3 10.0.2.13:6379 check
+```
+
+Redis holds only short-lived state, and auth-api fails closed without it: rate
+limits, attempt budgets and the token revocation check answer `503` rather than
+letting unchecked traffic through (`RATE_LIMIT_FAIL_OPEN` stays `false`). Its
+replication is asynchronous, so a failover can lose the last second of writes.
+What that means:
+
+- a budget or rate-limit counter restarts lower: a brute-force attempt gains at
+ most the writes of that second, and the database-backed lockout still counts
+ every failed password;
+- a pre-authentication, email-change, device or authorization request started in
+ that second must be started again;
+- revoked sessions stay revoked: session revocations are written to PostgreSQL,
+ and Redis only caches them. The one revocation held in Redis alone is that of
+ a single access token through `POST /oauth/revoke` (and the access token of a
+ logout, whose session is revoked in PostgreSQL anyway): lost in that second,
+ such a token works again until it expires, within `JWT_ACCESS_EXPIRY_SECS`.
+
+Set `min-replicas-to-write 1` and `min-replicas-max-lag 10` so a primary cut off
+from its replicas stops accepting writes instead of diverging.
+
+## 6. NATS
+
+A three-node cluster with JetStream, and three copies of the event stream:
+
+```text
+# nats.conf on each NATS host (server_name differs)
+server_name: nats-1
+jetstream { store_dir: /data }
+cluster {
+ name: auth-api
+ listen: 0.0.0.0:6222
+ routes: [nats://10.0.3.11:6222, nats://10.0.3.12:6222, nats://10.0.3.13:6222]
+}
+```
+
+```env
+NATS_URL=nats://@10.0.3.11:4222,nats://@10.0.3.12:4222,nats://@10.0.3.13:4222
+NATS_STREAM_REPLICAS=3
+```
+
+The client follows the cluster when a server leaves. Events are written to the
+PostgreSQL outbox with their change and published afterwards: while no quorum
+of the stream is available, they wait (`AuthApiEventsStalled` fires after five
+minutes) and go out in order when it returns.
+
+## 7. Configuration checklist
+
+- [ ] Same configuration and secrets on every instance.
+- [ ] `DATABASE_URL` and `REDIS_URL` point at the local HAProxy.
+- [ ] `DB_MAX_CONNECTIONS` times every instance, plus 10 per PostgreSQL node for
+ replication and administration, stays under `max_connections`.
+- [ ] `NATS_URL` lists every NATS server; `NATS_STREAM_REPLICAS=3`.
+- [ ] `TRUSTED_PROXY_CIDRS` lists both load balancers.
+- [ ] Load balancers check `/ready`; containers check `/live`.
+- [ ] Prometheus scrapes every instance, exporter and node; the alerts of the
+ monitoring guide are installed for all of them.
+- [ ] Backups run from a replica and are restored once a quarter.
+
+## 8. Failover drills
+
+Run each drill under the load of `make soak` and watch the dashboards.
+
+| Drill | How | Expected |
+|-------|-----|----------|
+| API host lost | `systemctl stop docker` on api-host-1 | No error once nginx marks the instances down; `AuthApiDown` for those targets |
+| PostgreSQL primary lost | `patronictl failover` or power off the primary | `503` on writes for the failover, then recovery without restart; no committed row missing |
+| Redis primary lost | `redis-cli -p 26379 sentinel failover auth-api` | `503` on budgeted routes for a few seconds, then recovery |
+| NATS server lost | Stop one NATS server | No error; `auth_outbox_pending` stays near zero |
+| Load balancer lost | Stop nginx on the active balancer | The address moves to the other within seconds |
+
+The simulation suite (`tests/simulation/`) reproduces these outages against a
+single instance with fault proxies: a dependency that stops answering,
+refuses connections or slows down. Run `make test` after changing anything the
+failover behaviour depends on.
diff --git a/docs/deploy/guides/monitoring.md b/docs/deploy/guides/monitoring.md
new file mode 100644
index 0000000..c6a07b2
--- /dev/null
+++ b/docs/deploy/guides/monitoring.md
@@ -0,0 +1,176 @@
+# Monitoring
+
+[Index](../README.md)
+
+## Overview
+
+Monitoring runs on a third host: a Prometheus on the API VPS would go down with
+what it watches. That host joins the WireGuard network and scrapes exporters
+that listen on VPN addresses only; it also probes the public HTTPS endpoint
+from outside, as a client would.
+
+```
+Monitoring host (10.0.0.3) -- WireGuard -- API VPS (10.0.0.1): API instances, NATS exporter, node_exporter
+ -- WireGuard -- DB VPS (10.0.0.2): postgres_exporter, redis_exporter, node_exporter
+ -- HTTPS ----- https://api.example.com/ready (blackbox probe)
+```
+
+| Target | Address | Exporter |
+|--------|---------|----------|
+| API instances and their containers | `10.0.0.1:9465`, `10.0.0.1:9466` | the API's metrics listener, which also publishes its container's memory, memory limit, CPU throttling and start time, read from its own cgroup |
+| NATS | `10.0.0.1:7777` | `prometheus-nats-exporter`, in `docker-compose.api.yml` |
+| Hosts | `10.0.0.1:9100`, `10.0.0.2:9100` | node_exporter (textfile collector on the DB VPS: backup metrics) |
+| PostgreSQL | `10.0.0.2:9187` | postgres_exporter |
+| Redis | `10.0.0.2:9121` | redis_exporter |
+| Public endpoint | `https://api.example.com/ready` | blackbox exporter, on the monitoring host |
+
+The files live in `deploy/monitoring/` of the release bundle.
+
+## 1. Add the monitoring host to WireGuard
+
+**On the monitoring host** - generate keys as in the
+[database guide](../database/deployment.md#11-generate-keys) and create
+`/etc/wireguard/wg10.conf`:
+
+```ini
+[Interface]
+Address = 10.0.0.3/24
+PrivateKey =
+
+[Peer]
+PublicKey =
+Endpoint = :51820
+AllowedIPs = 10.0.0.2/32
+PersistentKeepalive = 25
+
+[Peer]
+PublicKey =
+Endpoint = :51821
+AllowedIPs = 10.0.0.1/32
+PersistentKeepalive = 25
+```
+
+**On the DB VPS** - add a `[Peer]` for `10.0.0.3/32` to `wg10.conf`. **On the
+API VPS** - give `wg10.conf` a `ListenPort = 51821`, add the same peer, and
+open that port to the monitoring host's public address only:
+
+```bash
+sudo ufw allow from to any port 51821 proto udp
+```
+
+Restart WireGuard on the three hosts (`sudo systemctl restart wg-quick@wg10`)
+and check `ping 10.0.0.1` and `ping 10.0.0.2` from the monitoring host.
+
+## 2. Exporters on the API VPS
+
+**node_exporter:**
+
+```bash
+sudo apt install -y prometheus-node-exporter
+echo 'ARGS="--web.listen-address=10.0.0.1:9100"' | sudo tee /etc/default/prometheus-node-exporter
+sudo systemctl restart prometheus-node-exporter
+```
+
+No container exporter is needed: each API instance reads its own cgroup and
+publishes its memory against its limit, its CPU throttling and its start time.
+(cAdvisor 0.52 does not see the containers of Docker 29 with the containerd
+image store, and would need a privileged container.)
+
+**API instances and NATS** - `docker-compose.api.yml` publishes the metrics
+listeners and the NATS exporter on `METRICS_BIND_ADDRESS`, `10.0.0.1` by
+default, and nothing else.
+
+**Firewall:**
+
+```bash
+sudo ufw allow from 10.0.0.3 to any port 9100,9465,9466,7777 proto tcp
+```
+
+## 3. Exporters on the DB VPS
+
+**node_exporter**, with the textfile collector that reads the backup metrics
+written by `backup-db.sh`:
+
+```bash
+sudo apt install -y prometheus-node-exporter
+sudo mkdir -p /var/lib/node_exporter/textfile
+echo 'ARGS="--web.listen-address=10.0.0.2:9100 --collector.textfile.directory=/var/lib/node_exporter/textfile"' \
+ | sudo tee /etc/default/prometheus-node-exporter
+sudo systemctl restart prometheus-node-exporter
+```
+
+**postgres_exporter** - a role limited to the monitoring views:
+
+```bash
+sudo -u postgres psql -c "CREATE ROLE exporter LOGIN PASSWORD '$(pass prod/monitoring/postgres-exporter)' IN ROLE pg_monitor"
+echo "host postgres exporter 127.0.0.1/32 scram-sha-256" | sudo tee -a /etc/postgresql/17/main/pg_hba.conf
+sudo systemctl reload postgresql
+docker run -d --name postgres-exporter --restart unless-stopped --network host \
+ -e DATA_SOURCE_NAME="postgresql://exporter:$(pass prod/monitoring/postgres-exporter)@127.0.0.1:5432/postgres?sslmode=disable" \
+ quay.io/prometheuscommunity/postgres-exporter:v0.17.1 --web.listen-address=10.0.0.2:9187
+```
+
+**redis_exporter** - an ACL user restricted to the read-only commands the
+exporter needs. Append it to `/etc/redis/users.acl`, then reload the ACL:
+
+```bash
+EXPORTER_SHA=$(pass prod/monitoring/redis-exporter | tr -d '\n' | sha256sum | cut -d' ' -f1)
+printf 'user exporter on #%s ~* &* -@all +ping +info +config|get +client|list +slowlog|get +latency|latest +select +memory|usage +dbsize +cluster|info\n' "$EXPORTER_SHA" \
+ | sudo tee -a /etc/redis/users.acl > /dev/null
+redis-cli -u "$(pass prod/auth-api/redis-url)" ACL LOAD 2>/dev/null || sudo systemctl restart redis-server
+docker run -d --name redis-exporter --restart unless-stopped --network host \
+ -e REDIS_ADDR=redis://10.0.0.2:6379 -e REDIS_USER=exporter \
+ -e REDIS_PASSWORD="$(pass prod/monitoring/redis-exporter)" \
+ oliver006/redis_exporter:v1.74.0 --web.listen-address=10.0.0.2:9121
+```
+
+**Firewall:**
+
+```bash
+sudo ufw allow from 10.0.0.3 to any port 9100,9187,9121 proto tcp
+```
+
+## 4. The monitoring host
+
+```bash
+mkdir -p /srv/monitoring/rules && cd /srv/monitoring
+cp releases/auth-api-X.Y.Z/deploy/monitoring/{docker-compose.monitoring.yml,prometheus.yml,blackbox.yml,alertmanager.yml} .
+cp releases/auth-api-X.Y.Z/deploy/monitoring/rules/infrastructure.yml rules/
+cp releases/auth-api-X.Y.Z/docs/deploy/guides/prometheus-alerts.yml rules/auth-api.yml
+sed -i 's/api.example.com/your-actual-domain.com/' prometheus.yml
+```
+
+Edit `alertmanager.yml` (SMTP relay, addresses, webhooks) and store the SMTP
+password in `alertmanager/smtp_password`, then start:
+
+```bash
+docker compose -f docker-compose.monitoring.yml up -d
+```
+
+Prometheus and Alertmanager listen on `127.0.0.1` of the monitoring host: reach
+them through an SSH tunnel (`ssh -L 9090:127.0.0.1:9090 monitoring-host`).
+
+## 5. Alerts
+
+| File | Covers |
+|------|--------|
+| `rules/auth-api.yml` | The API: instances down, 5xx ratio, Argon2 saturation, latency, backups missing or shrunk |
+| `rules/infrastructure.yml` | The public probe and certificate, hosts and disks, containers (restarts, memory, CPU throttling), PostgreSQL, Redis and NATS, pools, dropped events and e-mails, retention jobs, the dead man's switch |
+
+Every infrastructure alert has a scenario in `rules/infrastructure.test.yml`:
+
+```bash
+docker run --rm --entrypoint promtool -w /m/rules -v "$PWD/deploy/monitoring:/m:ro" \
+ prom/prometheus:v3.5.0 test rules infrastructure.test.yml
+```
+
+`Watchdog` always fires. Point its receiver at a dead man's switch service
+(healthchecks.io, Better Stack, ...) that pages when the ping stops: that is
+the only alert that still works when Prometheus or Alertmanager is down.
+
+## 6. Check the pipeline
+
+- `http://127.0.0.1:9090/targets` (through the tunnel): every target `UP`.
+- `ALERTS{alertname="Watchdog"}` is firing and the dead man's switch receives it.
+- Stop one API instance for three minutes: `AuthApiDown` fires for it and
+ nothing else, since nginx serves from the other one.
diff --git a/docs/deploy/guides/operations.md b/docs/deploy/guides/operations.md
index a07ff7a..5f20814 100644
--- a/docs/deploy/guides/operations.md
+++ b/docs/deploy/guides/operations.md
@@ -9,9 +9,12 @@ where it runs (API VPS or DB VPS).
## 1. JWT Signing Key Rotation (ES256)
Access tokens are signed with the private key and verified against the JWKS
-served at `/.well-known/jwks.json`. Rotation is zero-downtime because the API
-accepts tokens signed with the previous key for as long as
-`JWT_PREVIOUS_PUBLIC_KEY` is set.
+served at `/.well-known/jwks.json`, which resource servers may cache for 5
+minutes (`Cache-Control: max-age=300`). The rotation takes three redeploys so
+that no valid token is ever refused, by the API or by a resource server: a key
+is published before anything is signed with it, and kept until every token it
+signed has expired. The API picks the verification key named by a token's
+`kid`.
**On a secure machine** - generate the new key pair:
@@ -23,26 +26,34 @@ openssl ec -in jwt-private-new.pem -pubout -out jwt-public-new.pem
**On the API VPS:**
-1. Store the new keys and keep the old public key as "previous":
+1. **Publish the next key.**
```bash
- pass show prod/auth-api/jwt-public-key > /dev/shm/jwt-public-old.pem
- pass insert -m prod/auth-api/jwt-private-key < jwt-private-new.pem
- pass insert -m prod/auth-api/jwt-public-key < jwt-public-new.pem
- pass insert -m prod/auth-api/jwt-previous-public-key < /dev/shm/jwt-public-old.pem
- rm /dev/shm/jwt-public-old.pem
+ pass insert -m prod/auth-api/jwt-next-private-key < jwt-private-new.pem
+ pass insert -m prod/auth-api/jwt-next-public-key < jwt-public-new.pem
+ export JWT_NEXT_PUBLIC_KEY=$(pass show prod/auth-api/jwt-next-public-key)
```
-2. Redeploy with `JWT_PREVIOUS_PUBLIC_KEY` exported (see
- [Deploying a New Release](update.md)). The JWKS now lists both `kid`s;
- tokens signed with either key are accepted.
+ Redeploy ([Deploying a New Release](update.md#4-start-the-new-version)).
+ The JWKS lists both `kid`s; nothing is signed with the new key yet. **Wait at
+ least 5 minutes**, or the longest JWKS cache of your resource servers.
-3. **Wait at least `JWT_ACCESS_EXPIRY_SECS` (default 15 min) plus the JWKS
- cache window (5 min)** so every token signed with the old key has expired
- and downstream services (core-api, billing-api) have refreshed their JWKS.
+2. **Sign with the next key.**
-4. Remove `prod/auth-api/jwt-previous-public-key` from `pass`, unset the
- variable, and redeploy. Verify the JWKS lists a single key:
+ ```bash
+ pass show prod/auth-api/jwt-public-key | pass insert -m -f prod/auth-api/jwt-previous-public-key
+ pass show prod/auth-api/jwt-next-private-key | pass insert -m -f prod/auth-api/jwt-private-key
+ pass show prod/auth-api/jwt-next-public-key | pass insert -m -f prod/auth-api/jwt-public-key
+ pass rm prod/auth-api/jwt-next-private-key prod/auth-api/jwt-next-public-key
+ unset JWT_NEXT_PUBLIC_KEY
+ ```
+
+ Redeploy. New tokens carry the new `kid`; tokens signed with the old key
+ still verify through `JWT_PREVIOUS_PUBLIC_KEY`. **Wait at least
+ `JWT_ACCESS_EXPIRY_SECS`** (15 minutes by default).
+
+3. **Retire the old key.** `pass rm prod/auth-api/jwt-previous-public-key`,
+ then redeploy. Verify that the JWKS lists a single key:
```bash
curl -s https://api.example.com/.well-known/jwks.json | jq '.keys | length'
@@ -52,33 +63,41 @@ Refresh tokens are opaque (not JWT) and are unaffected by this rotation.
## 2. TOTP Encryption Key Rotation (AES-256-GCM)
-TOTP secrets are encrypted at rest with `ENCRYPTION_KEY`. The binary ships a
-one-off re-encryption command.
+TOTP secrets are encrypted at rest. Each ciphertext names its key
+(`v1:{key id}:...`) and the service reads with `ENCRYPTION_KEY` and, when set,
+`PREVIOUS_ENCRYPTION_KEY`. A rotation needs no downtime and can be interrupted
+and resumed.
**On the API VPS:**
-```bash
-# 1. Generate the new key
-openssl rand -base64 32 # -> becomes the new ENCRYPTION_KEY
+1. Store both keys before anything else, so no key ever lives only in a shell:
-# 2. Run the rotation with BOTH keys set (one-off container)
-export PREVIOUS_ENCRYPTION_KEY=$(pass prod/auth-api/encryption-key)
-export ENCRYPTION_KEY=
-docker compose -f docker-compose.api.yml run --rm api ./auth-api --rotate-totp-keys
-```
+ ```bash
+ pass show prod/auth-api/encryption-key | pass insert -m prod/auth-api/previous-encryption-key
+ openssl rand -base64 32 | pass insert -m -f prod/auth-api/encryption-key
+ ```
-The command logs `rotated` / `failed` counts and exits non-zero if any secret
-failed (in which case nothing is lost: re-run after fixing the cause). Then:
+2. Redeploy with the exports of the [update guide](update.md#4-start-the-new-version):
+ they read `ENCRYPTION_KEY` (the new key) and `PREVIOUS_ENCRYPTION_KEY` (the
+ old one) from `pass`. Existing secrets stay readable; new ones are written
+ under the new key.
+3. Re-encrypt the stored secrets:
-```bash
-# 3. Persist the new key, drop the previous one, redeploy
-pass insert prod/auth-api/encryption-key # paste the new key
-unset PREVIOUS_ENCRYPTION_KEY
-docker compose -f docker-compose.api.yml up -d
-```
+ ```bash
+ docker compose -f docker-compose.api.yml run --rm api ./auth-api --rotate-totp-keys
+ ```
-Never delete the old key from `pass` history until a user with TOTP enabled
-has successfully logged in after the rotation.
+ The command also re-encrypts webhook signing secrets. It logs `rotated`,
+ `skipped` and `failed`, exits non-zero when a
+ secret failed, and is recorded in the audit log as `encryption_key_rotated`.
+ Run it until it reports `rotated=0 failed=0`: secrets already under the new
+ key are skipped, and a secret changed during the run is left as the service
+ wrote it.
+4. Remove the previous key and redeploy:
+ `pass rm prod/auth-api/previous-encryption-key`, then the update guide's
+ exports again (the previous key is now unset).
+
+Keep the old key in `pass` history until the run reported no failures.
## 3. Backup and Restore
@@ -106,9 +125,9 @@ switch the API over).
**Drills** - two levels:
-- **Mechanism (automated):** the `backup-drill` GitHub workflow runs
- `scripts/backup-drill.sh` monthly: seed -> backup -> restore into a fresh
- Postgres -> verify row counts. It validates the pipeline, not your data.
+- **Mechanism (monthly):** run `scripts/backup-drill.sh` (it needs Docker):
+ seed -> backup -> restore into a fresh Postgres -> verify row counts. It
+ validates the pipeline, not your data.
- **Data (manual, quarterly):** decrypt a real production backup with the
offline key and restore it into a scratch database. This is the only test
that proves the actual backups are usable. Log the date and outcome below.
@@ -126,19 +145,37 @@ deliberate.
| Subsystem | Behaviour without Redis |
|-----------|------------------------|
| Rate limiting (prod) | **Fail-closed: 503** on all routes (`RATE_LIMIT_FAIL_OPEN=false` enforced in prod) |
-| JTI blocklist (logout revocation) | **Fail-closed: 503** on authenticated routes - revocation cannot be proven |
+| Token checks (logout blocklist and session cache, one Redis read) | **Fail-closed: 503** on authenticated routes - revocation cannot be proven |
| Refresh-token blocklist | Falls back to the DB `sessions.revoked_at` check (durable source of truth) |
-| Session validity cache | Falls back to a direct DB query per request (slower, correct) |
| TOTP replay guard | Redis is only a fast-path: the `used_totp_codes` table remains authoritative (**fail-closed**, no replay window) |
| Pre-auth (2FA challenge) tokens | Stored in Redis: in-flight 2FA logins fail; users retry after recovery |
| CAPTCHA / lockout counters | Various counters degrade fail-open; account lockout (DB-based) still works |
-**Response:** restart/restore Redis, then verify `curl -f localhost:3000/health`
-and watch `auth_logins_total` on the metrics endpoint resume. No application
+**Response:** restart/restore Redis, then verify `curl -f 127.0.0.1:3001/ready`
+and `curl -f 127.0.0.1:3002/ready` on the API VPS and watch `auth_logins_total` on the metrics endpoint resume. No application
restart is needed - pools reconnect automatically.
+**Redis full.** Redis runs with `maxmemory-policy noeviction`: evicting a
+budget or blocklist key would silently reset an attempt budget or forget a
+revoked token. When `maxmemory` is reached, Redis refuses writes and the API
+answers 503 as during an outage (`RedisRejectingWrites`, `AuthApiRedisErrors`,
+warned earlier by `RedisMemoryHigh`). Raise the limit on the DB VPS
+(`CONFIG SET maxmemory 2gb`, then the same value in the Redis configuration);
+never switch to an evicting policy. The measured footprint is in section 9.
+
## 5. Manual Interventions
+Day-to-day interventions go through the administration API (`/admin/users`:
+unlock, suspend, sign out everywhere, forced reset, deletion; see
+[routes](../../dev/api/routes.md#administration)). Appoint the first
+administrator on the API VPS, then have them enroll a second factor:
+
+```bash
+docker compose -f docker-compose.api.yml run --rm api ./auth-api --grant-role admin --user admin@example.com
+```
+
+The SQL below remains for when the API itself is unavailable.
+
**On the DB VPS** (`sudo -u postgres psql auth_api`):
Unlock an account locked out by failed logins:
@@ -165,57 +202,266 @@ UPDATE users SET status = 'suspended' WHERE email = 'user@example.com';
## 6. Metrics
Prometheus metrics are exposed on an internal listener
-(`127.0.0.1:9464/metrics` on the API VPS - loopback only, never behind
-nginx). Key series:
+(`10.0.0.1:9465/metrics` and `10.0.0.1:9466/metrics` on the API VPS - WireGuard
+only, never behind nginx). Key series:
-- `auth_logins_total{outcome=...}` - success / invalid_credentials / locked / blocked / two_factor_required
+- `auth_logins_total{outcome=...}` - success / invalid_credentials / locked / two_factor_required
- `auth_lockouts_total`, `auth_session_replays_total`, `auth_2fa_failures_total{method=...}`
- `argon2_queue_available_permits` - **0 while login latency climbs = login storm**; capacity is `ARGON2_MAX_CONCURRENCY` (defaults to CPU cores)
- `axum_http_requests_duration_seconds` - per-route latency histograms
+- `auth_db_pool_connections{state=max|open|idle|in_use}`, `auth_redis_pool_connections{state=max|open|available}`, `auth_redis_pool_waiting` - pool saturation, refreshed every 10 s; `in_use` at `max` with requests timing out = pool too small or a slow query
+- `auth_redis_errors_total{operation=budget|rate_limit|token_state}` - Redis failures, each refused with a 503 (fail closed)
+- `auth_outbox_pending`, `auth_outbox_oldest_pending_age_seconds` - domain events recorded but not yet stored by JetStream; `auth_events_published_total`, `auth_events_publish_failures_total{reason=error|timeout|stream}` - relay publications and failed attempts (each retried)
+- `auth_notifications_pending`, `auth_notifications_failed_total{task}`, `auth_notifications_dropped_total{task}` - e-mails in flight, failed after retries, dropped past 1 000 pending
+- `auth_background_tasks` - notifications and cache invalidations still running (drained for 5 s at shutdown)
+- `auth_cleanup_deleted_rows_total{job}`, `auth_cleanup_failures_total{job}` - retention jobs
+- `auth_webhook_deliveries_pending`, `auth_webhook_deliveries_total{outcome=delivered|retry|failed}` - webhook deliveries waiting, and attempts; `failed` means given up after 12 attempts (`AuthApiWebhooksFailing`)
+- `auth_pwned_password_checks_total{outcome=clean|compromised|unavailable}` - breached-password checks; a rise of `unavailable` means the range API is unreachable (passwords are then accepted unless `PWNED_PASSWORDS_FAIL_OPEN=false`)
+- `auth_container_memory_working_set_bytes`, `auth_container_memory_limit_bytes` (0 without a limit), `auth_container_cpu_periods_total`, `auth_container_cpu_throttled_periods_total`, `auth_process_start_time_seconds` - the instance's own container, read from its cgroup every 10 s; the container alerts of the [monitoring guide](monitoring.md) use them
+
+Prometheus runs on a separate monitoring host and scrapes each instance over
+WireGuard (`10.0.0.1:9465` and `9466`).
+
+**Alert rules:** [`prometheus-alerts.yml`](prometheus-alerts.yml) (API down, 5xx
+ratio, Argon2 saturation, p95 latency, missing backups) and
+`deploy/monitoring/rules/infrastructure.yml` (probe, hosts, containers,
+dependencies, pools); installation in the [monitoring guide](monitoring.md).
+
+### Traces
+
+With `OTEL_EXPORTER_OTLP_ENDPOINT` set, each instance exports OpenTelemetry
+traces over OTLP/HTTP (`{endpoint}/v1/traces`) to any collector: the
+OpenTelemetry Collector, Grafana Alloy, Tempo or Jaeger accept it. Each request
+is a server span named after its route template (`POST /oauth/token`), with
+`http.request.method`, `http.route` and `http.response.status_code`; a request
+carrying a W3C `traceparent` continues the caller's trace, so a gateway or a
+client application sees auth-api inside its own traces. Paths, query strings,
+headers and bodies are never recorded.
+
+`OTEL_TRACES_SAMPLER_ARG` (default `0.1`) keeps one new trace in ten; a sampled
+`traceparent` is always followed. Spans are batched in memory and flushed at
+shutdown; an unreachable collector drops spans and never slows requests.
+
+A minimal collector on the monitoring host, forwarding to Tempo:
+
+```yaml
+receivers:
+ otlp:
+ protocols:
+ http:
+ endpoint: 10.0.0.3:4318
+exporters:
+ otlp/tempo:
+ endpoint: tempo:4317
+ tls:
+ insecure: true
+service:
+ pipelines:
+ traces:
+ receivers: [otlp]
+ exporters: [otlp/tempo]
+```
-Scrape config (host Prometheus): `static_configs: [{targets: ['127.0.0.1:9464']}]`.
-
-**Alert rules:** [`prometheus-alerts.yml`](prometheus-alerts.yml) ships ready
-to install (API down, 5xx ratio, Argon2 saturation, p95 latency, missing
-backups). Copy it into `/etc/prometheus/rules/` on the API VPS - installation
-notes are in the file header.
-
-## 7. Release Verification (cosign)
+## 7. Release Bundle Verification
-Every published image is signed keyless (GitHub OIDC) and carries SBOM +
-provenance attestations. Before deploying a new tag, verify the signature:
+`make release` writes a `SHA256SUMS` file into the bundle. Record its own
+checksum when the bundle is built, then verify on the server before loading
+anything:
```bash
-cosign verify \
- --certificate-identity-regexp 'github.com/SIIR3X/auth-api' \
- --certificate-oidc-issuer https://token.actions.githubusercontent.com \
- ghcr.io/siir3x/auth-api:latest
+cd /srv/auth-api/releases/auth-api-X.Y.Z
+sha256sum SHA256SUMS # must match the value recorded at build time
+sha256sum -c SHA256SUMS # every file of the bundle
```
-A failed verification means the image was not produced by this repository's
-`docker-publish` workflow - do not deploy it.
-
## 8. Measured Capacity
-End-to-end HTTP load benchmark (`scripts/bench-http.sh`, concurrency 8), run
-against Postgres + Redis + NATS. Argon2 at production parameters (64 MiB, 3
-iterations) dominates every credential path - this is by design.
+End-to-end HTTP benchmark (`make bench-http`, 16 workers x 64 iterations)
+against PostgreSQL, Redis and NATS. Argon2 at production parameters dominates
+every credential path, by design.
-| Path | p50 | p99 | Notes |
+| Path | p50 | p95 | Notes |
|------|-----|-----|-------|
-| Login (success) | ~195 ms | ~210 ms | Argon2 verify-bound (~33 logins/s/worker) |
-| Login (wrong password) | ~1100 ms | ~1110 ms | Deliberate backoff on failure |
-| Register | ~195 ms | ~210 ms | Argon2 hash-bound |
-| Change password | ~380 ms | ~410 ms | Two Argon2 ops (verify + hash) |
-| Refresh token | ~8 ms | ~15 ms | No Argon2 |
-| Get profile (authed) | ~3 ms | ~4 ms | JWT + Redis session cache |
-| TOTP / email 2FA complete | ~200 ms | ~220 ms | Argon2 on the pre-auth login step |
-
-**Reading:** credential endpoints are intentionally slow (Argon2 is the cost of
-offline-crack resistance); everything token- or session-based is single-digit
-milliseconds. Login throughput scales linearly with `ARGON2_MAX_CONCURRENCY`
-and CPU cores. Watch `argon2_queue_available_permits`: sustained 0 means logins
-are queueing - scale cores or raise the concurrency bound.
-
-_Baseline recorded 2026-07-04 on the development machine; re-run per environment
-before capacity planning._
+| Login (success) | ~319 ms | ~332 ms | Argon2 verify |
+| Login (wrong password) | ~1082 ms | ~1088 ms | Deliberate backoff on failure |
+| Register | ~321 ms | ~337 ms | Argon2 hash |
+| Change password | ~653 ms | ~678 ms | Argon2 verify and hash |
+| TOTP / email 2FA completion | ~330 ms | ~347 ms | Includes the password step |
+| Refresh token | ~2.8 ms | ~5.2 ms | No Argon2 |
+| Get profile (authenticated) | ~0.7 ms | ~1.7 ms | JWT and one Redis read |
+| List sessions | ~0.8 ms | ~1.6 ms | |
+
+**Reading:** credential endpoints are slow on purpose (Argon2 is what makes a
+leaked hash expensive to crack); token and session paths stay around a
+millisecond. Login throughput scales with `ARGON2_MAX_CONCURRENCY` and CPU
+cores. Watch `argon2_queue_available_permits`: a sustained 0 means logins are
+queueing.
+
+_Recorded 2026-09-15 on the development machine. Re-run on 2026-09-16 for 2.0.0,
+the same machine under more background load measured refresh at 5.2 ms and
+authenticated reads at 1.2 to 1.5 ms, and the commit before the 2.0 work at
+6.1 ms and 1.4 ms: the differences come from the machine, not from the code;
+credential paths were unchanged. Re-run per environment before
+capacity planning._
+
+## 9. Capacity Planning
+
+Measured in [the performance campaign](../../perf/performance-report.md)
+(3 API cores, PostgreSQL on 3 cores, 1 million accounts):
+
+| Traffic | Measured capacity | Limiting factor |
+|---------|------------------:|-----------------|
+| Sign-ins and registrations | 33 per second | Argon2id, about 90 ms of CPU per hash |
+| Refreshes | 5 000 per second | PostgreSQL write transaction |
+| Authenticated reads | 11 000 to 12 600 per second | API CPU |
+
+**Size the API on sign-ins**: they saturate first, by two orders of magnitude.
+
+| Setting | Rule |
+|---------|------|
+| API cores | Peak sign-ins per second / 11, rounded up, plus one of headroom, spread over at least two instances |
+| `ARGON2_MAX_CONCURRENCY` | The CPU limit of the instance |
+| Memory per instance | 64 MiB x Argon2 concurrency + 256 MiB, rounded up to a multiple of 128 MiB; reservation half of it |
+| `DB_MAX_CONNECTIONS`, `REDIS_POOL_SIZE` | 4 x the cores of the instance, at least 8. The sum over every instance, plus 10, stays under PostgreSQL's `max_connections` |
+| PostgreSQL memory | Twice the indexes that sign-ins and refreshes update, about 4 KB per account (see [Database Deployment](../database/deployment.md#26-size-postgresqls-memory)) |
+
+Validated with `make sizing` ([perf/README.md](../../../perf/README.md#sizing-validation))
+on 2026-09-15: the production image under each profile's CPU quota and memory
+limit, PostgreSQL and Redis with the settings of `deploy/db/`, 1 million
+accounts.
+
+| Sign-ins under the CPU quota | 1 CPU (S) | 2 CPUs | 3 CPUs (M) | 4 CPUs (L) |
+|---|---:|---:|---:|---:|
+| Sign-ins per second | 13.7 | 25.0 | 36.1 | 45.0 |
+| Per CPU | 13.7 | 12.5 | 12.0 | 11.2 |
+| Peak memory with 64 sign-ins at once | 78 / 384 MiB | 141 / 512 MiB | 209 / 512 MiB | 274 / 512 MiB |
+
+No error and no out-of-memory kill, including with 64 sign-ins at once. With the
+mixed traffic of profile M at 1 million accounts (291 requests per second):
+
+- **Redis** used 9 MiB for 50 000 keys, almost all of them rate-limit buckets,
+ one per client address for two minutes: Redis grows with distinct client
+ addresses, not with accounts. At about 190 bytes a key, `maxmemory 1gb`
+ holds some 5 million addresses within two minutes.
+- **NATS** used 11 of its 192 MiB.
+- **PostgreSQL** (3 CPUs, 8 GB, the settings of `deploy/db/`): at most 4 of the
+ 12 connections of an instance in use, no wait for a Redis connection. The
+ 9.5 GB database does not fit in memory: the buffer hit ratio stayed between
+ 0.94 (cold) and 0.98 (warm), with 300 to 1 100 disk reads a second, since the
+ test reads accounts uniformly across the whole database. With 14 GB the
+ throughput and latency were the same: 8 GB is enough for this traffic.
+- **One-hour soak**: 326 requests per second, no error, no restart, working set
+ 202 MiB at the start and at the end.
+- **Restore** of the 8.8 GiB database (2.4 GiB compressed dump): 134 s to dump,
+ 131 s to restore in one transaction.
+
+These rules give the profiles shipped in `deploy/profiles/`, passed to compose
+with `--env-file`:
+
+| | S | M | L | XL |
+|---|---|---|---|---|
+| Accounts | up to 100 000 | up to 1 million | up to 5 million | up to 20 million |
+| Peak sign-ins per second | 10 | 56 | 150 | 450 |
+| API instances x CPU | 2 x 1 | 2 x 3 | 4 x 4 (`docker-compose.api.l.yml`) | 12 x 4: three hosts of profile L |
+| Memory per instance (limit / reservation) | 384 / 192 MiB | 512 / 256 MiB | 512 / 256 MiB | 512 / 256 MiB |
+| `DB_MAX_CONNECTIONS` per instance | 8 | 12 | 16 | 12, and 12 on the replica |
+| NATS (CPU / memory) | 0.25 / 128 MiB | 0.5 / 192 MiB | 1 / 256 MiB | 1 / 256 MiB on each of three servers |
+| API hosts (vCPU / RAM) | 1 x 3 / 2 GB | 1 x 8 / 4 GB | 1 x 20 / 8 GB | 3 x 20 / 8 GB |
+| Database hosts (vCPU / RAM), PostgreSQL memory | 1 x 2 / 4 GB, 2 GB | 1 x 4 / 16 GB, 8 GB | 1 x 8 / 48 GB, 32 GB | 3 x 16 / 192 GB, 128 GB |
+
+The API VPS keeps about 600 MiB beside the containers for nginx and the
+system. Profiles S to L were validated by `make sizing`; XL is extrapolated from
+the measured rate of about 11 sign-ins per CPU and has not been measured.
+
+**Beyond profile L** PostgreSQL becomes the limit:
+
+- run it highly available (the [high availability guide](high-availability.md)),
+ which XL assumes;
+- set `DATABASE_READ_URL` to an HAProxy port routing to the replicas (falling
+ back to the primary): security histories, the admin audit log, account search
+ and webhook delivery lists read there, a few seconds behind at most;
+ everything a user just changed is read from the primary;
+- 12 instances with 12 connections each need `max_connections` of 200 on the
+ primary and on each replica: keep `DB_MAX_CONNECTIONS` at 12 or put PgBouncer
+ (transaction pooling) in front of PostgreSQL;
+- shorten `AUDIT_LOG_RETENTION_MONTHS` and `CLEANUP_LOGIN_ATTEMPTS_RETENTION_DAYS`
+ if the audit and sign-in tables outgrow memory.
+
+Run `make sizing` again after changing the Argon2 parameters, the profiles or
+a hot path, and watch the capacity alerts on the target machine.
+
+Each instance logs its Argon2 budget at startup (`argon2: at most N concurrent
+hashes`) and warns when its memory limit is below it.
+
+**Alerts**: `AuthApiArgon2Saturated` fires when no Argon2 slot was free for
+5 minutes, `AuthApiArgon2SaturatedLong` after 15 (see
+[`prometheus-alerts.yml`](prometheus-alerts.yml)). Either means sign-ins are
+queueing: each waits for the ones ahead of it (8 seconds at 256 concurrent
+sign-ins on 3 cores). Move to the next profile, or add an instance.
+
+## 10. Bulk Imports
+
+`login_attempts` is purged through a BRIN index on `attempted_at`. BRIN is
+selective only while rows sit on disk in time order, which is the case for rows
+the API writes. After loading attempts in another order (a migration from
+another system, a reordered restore), check **on the DB VPS**:
+
+```sql
+SELECT correlation FROM pg_stats
+WHERE tablename = 'login_attempts' AND attname = 'attempted_at';
+```
+
+Close to 1: nothing to do. Close to 0: every purge batch reads the whole table
+(1.1 s at 10 million rows, whatever it deletes). Rewrite the table in time order
+during a maintenance window - `CLUSTER` locks the table while it runs:
+
+```sql
+CREATE INDEX CONCURRENTLY tmp_login_attempts_order ON login_attempts (attempted_at);
+CLUSTER login_attempts USING tmp_login_attempts_order;
+DROP INDEX tmp_login_attempts_order;
+ANALYZE login_attempts;
+```
+
+## 11. NATS and SMTP Outages
+
+Neither stops the service. Docker checks `/live`, which does not depend on
+them, so it never restarts the instances because of them.
+
+| Path | Without NATS |
+|------|--------------|
+| Every event, `user.deleted` included | Recorded with its change in `event_outbox`; the relay publishes it once the broker is back, in order. The request succeeds; the backlog shows in `auth_outbox_pending` (`AuthApiEventsStalled` past 5 minutes) |
+| `/ready` | 503 with `"nats": "down"` (`NatsDown` alerts) |
+| Instance start | Starts and connects in the background; only a wrong token stops the start |
+
+**Response:** restart the broker (`docker compose -f docker-compose.api.yml
+restart nats`); the instances reconnect by themselves and the relay publishes
+the recorded events, oldest first. Nothing is lost. To see what waits:
+`SELECT subject, attempts, last_error FROM event_outbox WHERE published_at IS NULL ORDER BY seq;`
+
+**SMTP relay down.** E-mails are sent in the background, never inside the
+request: each attempt has a 10-second timeout and is retried after 2 then
+8 seconds, unless the relay refused it permanently. A notification that still
+fails is counted in `auth_notifications_failed_total{task}`
+(`AuthApiNotificationsFailing`). Past 1 000 notifications in flight, new ones
+are dropped and counted in `auth_notifications_dropped_total{task}`
+(`AuthApiNotificationsDropped`). Verification, password reset and e-mail codes
+sent meanwhile are lost: users request a new reset or code once the relay is
+back. There is no route to resend a verification e-mail yet.
+
+## 12. Adding an Instance
+
+Profile L shows the pattern (`docker-compose.api.l.yml`). For each new instance:
+
+1. A service extending `x-api` in an overlay file, with the next loopback port
+ (`127.0.0.1:3003:3000`) and metrics port (`${METRICS_BIND_ADDRESS:-10.0.0.1}:9467:9464`).
+2. Its port in the nginx upstream (`server 127.0.0.1:3003 max_fails=3 fail_timeout=10s;`),
+ then `sudo nginx -t && sudo systemctl reload nginx`.
+3. Its metrics target in `prometheus.yml` on the monitoring host, and the port
+ in the API VPS firewall rule for `10.0.0.3`.
+4. Check the connection budget: `DB_MAX_CONNECTIONS` times the instances, plus
+ 10, stays under PostgreSQL's `max_connections` (section 9).
+5. Its `service:port` in the `INSTANCES` list of `rolling-update.sh`, which
+ otherwise replaces only `api-a` and `api-b`, plus `api-c` and `api-d` when
+ `docker-compose.api.l.yml` is present.
+6. Start it with `docker compose ... up -d --wait `; it takes traffic
+ as soon as nginx is reloaded.
diff --git a/docs/deploy/guides/prometheus-alerts.yml b/docs/deploy/guides/prometheus-alerts.yml
index 25872e5..0ad3381 100644
--- a/docs/deploy/guides/prometheus-alerts.yml
+++ b/docs/deploy/guides/prometheus-alerts.yml
@@ -1,12 +1,9 @@
# Prometheus alerting rules for the auth API.
#
-# Install on the API VPS host Prometheus:
-# sudo cp prometheus-alerts.yml /etc/prometheus/rules/auth-api.yml
-# # then in prometheus.yml: rule_files: ["/etc/prometheus/rules/*.yml"]
-# sudo systemctl reload prometheus
+# Installed on the monitoring host as rules/auth-api.yml, next to
+# deploy/monitoring/rules/infrastructure.yml: see docs/deploy/guides/monitoring.md.
#
-# Thresholds are starting points for a single-instance deployment; tune them
-# against real traffic.
+# Thresholds are starting points; tune them against real traffic.
groups:
- name: auth-api
@@ -19,7 +16,7 @@ groups:
annotations:
summary: "auth-api metrics endpoint is unreachable"
description: >-
- Prometheus cannot scrape 127.0.0.1:9464 for 2 minutes. Check
+ Prometheus cannot scrape {{ $labels.instance }} for 2 minutes. Check
`docker compose -f docker-compose.api.yml ps` and container logs.
Every downstream service depends on this API for JWT verification.
@@ -38,18 +35,32 @@ groups:
usual suspects - see the operations runbook.
- alert: AuthApiArgon2Saturated
- # Sustained zero available permits means logins/registrations are
- # queueing behind the Argon2 concurrency bound (see "Measured
- # Capacity" in the runbook): scale cores or raise the bound.
- expr: argon2_queue_available_permits == 0
- for: 5m
+ # Not a single free Argon2 slot for 5 minutes: sign-ins and
+ # registrations are queueing. Measured capacity is about 11-13
+ # sign-ins per second per API core (docs/perf/performance-report.md);
+ # beyond it, latency grows with the queue (8 s at 256 concurrent
+ # sign-ins on 3 cores). Add an instance or cores.
+ expr: max_over_time(argon2_queue_available_permits[5m]) == 0
+ for: 1m
labels:
severity: warning
annotations:
- summary: "Argon2 concurrency bound saturated for 5 minutes"
+ summary: "Argon2 saturated: sign-ins are queueing"
description: >-
- Credential operations are queueing. Sustained saturation degrades
- login latency far beyond the deliberate Argon2 cost.
+ Every Argon2 slot stayed busy for 5 minutes. Each queued sign-in
+ waits for the ones ahead of it; scale out the API (each core adds
+ about 12 sign-ins per second and needs ARGON2_MEMORY_KIB of RAM).
+
+ - alert: AuthApiArgon2SaturatedLong
+ expr: max_over_time(argon2_queue_available_permits[15m]) == 0
+ for: 1m
+ labels:
+ severity: critical
+ annotations:
+ summary: "Argon2 saturated for 15 minutes"
+ description: >-
+ Sign-in latency is now measured in seconds for every user. Scale
+ out immediately; see "Capacity Planning" in the operations runbook.
- alert: AuthApiSlowTokenPaths
# Non-credential paths (refresh, profile) are single-digit ms; a slow
@@ -67,16 +78,35 @@ groups:
WireGuard link to the DB VPS.
- alert: AuthBackupMissing
- # Requires node_exporter with the textfile collector on the DB VPS;
- # backup-db.sh's cron wrapper should write the completion timestamp:
- # echo "auth_backup_last_success_timestamp $(date +%s)" \
- # > /var/lib/node_exporter/textfile/auth_backup.prom
- expr: time() - auth_backup_last_success_timestamp > 86400 * 2
+ # node_exporter's textfile collector on the DB VPS reads the metrics that
+ # backup-db.sh writes after a complete backup only, offsite copy
+ # included (--collector.textfile.directory=/var/lib/node_exporter/textfile).
+ # absent(): a backup that never reported is as missing as a stale one.
+ expr: >-
+ (time() - auth_backup_last_success_timestamp > 86400 * 2)
+ or absent(auth_backup_last_success_timestamp)
for: 1h
labels:
severity: critical
annotations:
summary: "no successful auth DB backup for 48h"
description: >-
- The nightly encrypted backup has not completed for two days.
- Check cron and /var/log/auth-api-backup.log on the DB VPS.
+ The nightly encrypted backup, offsite copy included, has not
+ completed for two days or has never reported. Check cron and
+ /var/log/auth-api-backup.log on the DB VPS, and that node_exporter
+ reads /var/lib/node_exporter/textfile.
+
+ - alert: AuthBackupShrunk
+ # A backup far smaller than those of the previous week usually means
+ # a dump of the wrong database or a truncated one.
+ expr: >-
+ auth_backup_last_size_bytes
+ < 0.5 * max_over_time(auth_backup_last_size_bytes[8d] offset 1d)
+ for: 1h
+ labels:
+ severity: warning
+ annotations:
+ summary: "the last auth DB backup is less than half the size of the previous ones"
+ description: >-
+ Compare the last files in /var/backups/auth-api on the DB VPS and
+ restore the last one into a scratch database before trusting it.
diff --git a/docs/deploy/guides/update.md b/docs/deploy/guides/update.md
index ca29f31..640ef6c 100644
--- a/docs/deploy/guides/update.md
+++ b/docs/deploy/guides/update.md
@@ -4,50 +4,91 @@
## Overview
-Each release may include:
-- A new Docker image (always)
-- New migrations (check the release notes)
+Read the release's entry in `CHANGELOG.md` first: it lists breaking changes and
+the configuration to add. Migrations always run before the new image starts;
+they are written to be compatible with the previous version still running.
-Always run migrations before restarting the container. If there are no new migrations, skip directly to step 2.
+## 1. Copy and verify the bundle
-## 1. Run Migrations (if any)
+```bash
+# On the trusted machine
+scp -r dist/auth-api-X.Y.Z api-vps:/srv/auth-api/releases/
+
+# On the API VPS
+cd /srv/auth-api/releases/auth-api-X.Y.Z
+ssh-keygen -Y verify -f /srv/auth-api/allowed_signers -I release \
+ -n auth-api-release -s SHA256SUMS.sig < SHA256SUMS
+sha256sum -c SHA256SUMS
+gunzip -c auth-api-X.Y.Z.image.tar.gz | docker load
+test "$(docker image inspect --format '{{.Id}}' auth-api:X.Y.Z)" = "$(cat IMAGE_ID)" \
+ && echo "image matches the signed bundle"
+```
-Check the release notes on GitHub to confirm whether the release includes new migrations.
+The signature proves `SHA256SUMS` was written by the release key; the checksums
+prove every file matches it, and `IMAGE_ID` that the loaded image is the one
+that was scanned. Stop at the first command that fails.
-**On the API VPS** - fetch and run migrations from the release asset:
+## 2. Update the deployment files
-```bash
-curl -sL https://github.com/SIIR3X/auth-api/releases/latest/download/migrations.tar.gz \
- | tar -xz -C /dev/shm
+Compare the bundle's deployment files with the ones in `/srv/auth-api`, carry
+over new or changed settings into `config.prod.env` and `profile.env`, and
+install the files you do not edit:
-DATABASE_URL=$(pass prod/auth-api/database-url) \
- sqlx migrate run --source /dev/shm/migrations
-
-rm -rf /dev/shm/migrations
+```bash
+cd /srv/auth-api
+R=releases/auth-api-X.Y.Z
+diff config.prod.env $R/config.prod.env
+diff profile.env $R/deploy/profiles/m.env # the profile this server uses
+for f in docker-compose.api.yml nats.conf; do diff "$f" "$R/$f"; done
+cp $R/docker-compose.api.yml $R/nats.conf $R/scripts/rolling-update.sh .
+# Profile L only:
+cp $R/docker-compose.api.l.yml .
```
-The archive is extracted directly into `/dev/shm` (RAM) - nothing is written to disk.
+A change to `nats.conf` or to the broker's service restarts the broker during
+the update; the instances reconnect, and events published meanwhile are dropped
+(see [the operations runbook](operations.md#11-nats-and-smtp-outages)).
-## 2. Deploy the New Image
+## 3. Run the migrations
-**On the API VPS:**
+```bash
+DATABASE_URL=$(pass prod/auth-api/database-url) \
+ sqlx migrate run --source /srv/auth-api/releases/auth-api-X.Y.Z/migrations
+```
+
+## 4. Start the new version
```bash
cd /srv/auth-api
-
+export AUTH_API_VERSION=X.Y.Z
export DATABASE_URL=$(pass prod/auth-api/database-url)
export REDIS_URL=$(pass prod/auth-api/redis-url)
export JWT_PRIVATE_KEY=$(pass prod/auth-api/jwt-private-key)
export JWT_PUBLIC_KEY=$(pass prod/auth-api/jwt-public-key)
export ENCRYPTION_KEY=$(pass prod/auth-api/encryption-key)
+# Only while a key rotation is in progress (see the operations runbook); unset
+# otherwise. An empty value is treated as unset.
+export JWT_PREVIOUS_PUBLIC_KEY=$(pass prod/auth-api/jwt-previous-public-key 2>/dev/null)
+export JWT_NEXT_PUBLIC_KEY=$(pass prod/auth-api/jwt-next-public-key 2>/dev/null)
+export PREVIOUS_ENCRYPTION_KEY=$(pass prod/auth-api/previous-encryption-key 2>/dev/null)
export SMTP_USERNAME=$(pass prod/auth-api/smtp-username)
export SMTP_PASSWORD=$(pass prod/auth-api/smtp-password)
export CAPTCHA_SECRET=$(pass prod/auth-api/captcha-secret)
export NATS_URL=$(pass prod/auth-api/nats-url)
-export NATS_AUTH_TOKEN=$(pass prod/auth-api/nats-auth-token)
-docker compose -f docker-compose.api.yml pull
-docker compose -f docker-compose.api.yml up -d
+./rolling-update.sh
```
-`up -d` recreates the container only if the image has changed. Downtime is a few seconds.
+`rolling-update.sh` recreates the instances one at a time and moves on only
+once the new one is healthy and answers `/ready`. The instance being replaced
+finishes its requests (up to 32 seconds) while nginx sends new ones to the
+other: the update causes no downtime. If the new version does not become
+ready, the script stops and the remaining instances keep serving the previous
+version; roll back as below.
+
+## Rolling back
+
+Run the rolling update again with the previous tag (`AUTH_API_VERSION= ./rolling-update.sh`). Migrations
+are not rolled back: each one is written so the previous version keeps working
+with the new schema. The changelog calls out a release after which rolling back
+is not possible.
diff --git a/docs/dev/README.md b/docs/dev/README.md
index 024a5d6..4a37a26 100644
--- a/docs/dev/README.md
+++ b/docs/dev/README.md
@@ -4,14 +4,24 @@
- [Prerequisites](guides/prerequisites.md) - Tools required to work on this project
- [Commands](guides/commands.md) - All available `make` commands
-- [Workflows](guides/workflows.md) - GitHub Actions workflows
-- [Release](guides/release.md) - How to create a new release
+- [Quality gate](guides/quality-gate.md) - `make ci`: what it checks and when to run it
+- [Release](guides/release.md) - Building a release bundle
+- [Versioning](guides/versioning.md) - What the version number promises, deprecation, upgrades and support
- [Configuration](guides/configuration.md) - Environment files, secrets, and variables reference
+- [Integration](guides/integration.md) - Choosing a flow, verifying tokens in resource servers, following account changes
+- [Webhooks](guides/webhooks.md) - Receiving signed account events over HTTPS
## API
- [Routes](api/routes.md) - All routes with authentication and rate limit requirements
+- [OpenAPI](api/openapi.yaml) - Machine-readable contract, generated from the code
## Database
- [Schema](database/schema.md) - All tables with their columns and types
+
+## Security
+
+- [Security model](security-model.md) - What is protected, against whom, and how
+- [Threat model](threat-model.md) - Trust boundaries, threats by STRIDE, what stops them and what is left
+- [Personal data](privacy.md) - What is stored about people, for how long, and what deletion removes
diff --git a/docs/dev/api/openapi.yaml b/docs/dev/api/openapi.yaml
new file mode 100644
index 0000000..98c5c39
--- /dev/null
+++ b/docs/dev/api/openapi.yaml
@@ -0,0 +1,7569 @@
+openapi: 3.1.0
+info:
+ title: Auth API
+ description: 'Authentication API: accounts, sessions, second factors and client applications. Issues ES256 access tokens and rotating refresh tokens, and publishes the JWKS resource servers verify them with.'
+ license:
+ name: MIT
+ version: 2.0.0
+servers:
+- url: http://localhost:3000
+ description: Local development
+paths:
+ /.well-known/jwks.json:
+ get:
+ tags:
+ - discovery
+ operationId: jwks
+ responses:
+ '200':
+ description: JSON Web Key Set of the current and previous signing keys
+ content:
+ application/json:
+ schema:
+ type: object
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ /.well-known/oauth-authorization-server:
+ get:
+ tags:
+ - oauth
+ operationId: metadata
+ responses:
+ '200':
+ description: Authorization server metadata (RFC 8414)
+ content:
+ application/json:
+ schema:
+ type: object
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ /.well-known/openid-configuration:
+ get:
+ tags:
+ - oauth
+ operationId: openid_configuration
+ responses:
+ '200':
+ description: OpenID Provider metadata (OpenID Connect Discovery 1.0)
+ content:
+ application/json:
+ schema:
+ type: object
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ /admin/audit:
+ get:
+ tags:
+ - admin
+ operationId: list
+ parameters:
+ - name: user_id
+ in: query
+ description: Only this account's entries
+ required: false
+ schema:
+ type: string
+ format: uuid
+ - name: action
+ in: query
+ description: Only this action
+ required: false
+ schema:
+ type: string
+ - name: limit
+ in: query
+ description: Entries per page, 1-200 (default 50)
+ required: false
+ schema:
+ type: integer
+ format: int64
+ - name: cursor
+ in: query
+ description: next_cursor of the previous page
+ required: false
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Audit entries, newest first
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/AdminAuditPage'
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Missing `audit:read`, or no second factor enrolled
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid cursor or account id
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /admin/clients:
+ get:
+ tags:
+ - admin
+ operationId: list
+ responses:
+ '200':
+ description: Every registered client
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/ClientResponse'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Missing `clients:manage`, or no second factor enrolled
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /admin/clients/{client_id}:
+ put:
+ tags:
+ - admin
+ operationId: save
+ parameters:
+ - name: client_id
+ in: path
+ description: Client id, 1 to 100 of [A-Za-z0-9._-]
+ required: true
+ schema:
+ type: string
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/SaveClientRequest'
+ required: true
+ responses:
+ '200':
+ description: Client updated
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ClientResponse'
+ '201':
+ description: Client registered
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ClientResponse'
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Missing `clients:manage`, no second factor, or re-authentication required
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '409':
+ description: '`primary_client_exists`'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid settings or unknown scope
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ delete:
+ tags:
+ - admin
+ operationId: delete
+ parameters:
+ - name: client_id
+ in: path
+ description: Client id
+ required: true
+ schema:
+ type: string
+ responses:
+ '204':
+ description: Client removed and its sessions revoked
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Missing `clients:manage`, or no second factor enrolled
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '404':
+ description: No such client
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /admin/clients/{client_id}/secret:
+ post:
+ tags:
+ - admin
+ operationId: rotate_secret
+ parameters:
+ - name: client_id
+ in: path
+ description: Client id
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: A new secret; the client is confidential from now on
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ClientSecretResponse'
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Missing `clients:manage`, no second factor, or re-authentication required
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '404':
+ description: No such client
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ delete:
+ tags:
+ - admin
+ operationId: remove_secret
+ parameters:
+ - name: client_id
+ in: path
+ description: Client id
+ required: true
+ schema:
+ type: string
+ responses:
+ '204':
+ description: Secret removed; the client is public
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Missing `clients:manage`, or no second factor enrolled
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '404':
+ description: No such client
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /admin/permissions:
+ get:
+ tags:
+ - admin
+ operationId: permissions
+ responses:
+ '200':
+ description: Every permission a role can grant
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/PermissionResponse'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Missing `roles:manage`, or no second factor enrolled
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /admin/roles:
+ get:
+ tags:
+ - admin
+ operationId: list
+ responses:
+ '200':
+ description: Every role with its permissions
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/RoleResponse'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Missing `roles:manage`, or no second factor enrolled
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ post:
+ tags:
+ - admin
+ operationId: create
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CreateRoleRequest'
+ required: true
+ responses:
+ '201':
+ description: Role created
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RoleResponse'
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Missing `roles:manage`, no second factor, or re-authentication required
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '409':
+ description: '`role_exists`'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid name or unknown permission
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /admin/roles/{name}:
+ delete:
+ tags:
+ - admin
+ operationId: delete
+ parameters:
+ - name: name
+ in: path
+ description: Role name
+ required: true
+ schema:
+ type: string
+ responses:
+ '204':
+ description: Role deleted and taken back from every account
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Missing `roles:manage`, or no second factor enrolled
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '404':
+ description: No such role
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '409':
+ description: '`default_role`, or `last_administrator`'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /admin/roles/{name}/permissions:
+ put:
+ tags:
+ - admin
+ operationId: set_permissions
+ parameters:
+ - name: name
+ in: path
+ description: Role name
+ required: true
+ schema:
+ type: string
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RolePermissionsRequest'
+ required: true
+ responses:
+ '200':
+ description: The role now grants exactly these permissions
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RoleResponse'
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Missing `roles:manage`, no second factor, or re-authentication required
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '404':
+ description: No such role
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '409':
+ description: '`last_administrator`: nobody would keep `roles:manage`'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Unknown permission
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /admin/users:
+ get:
+ tags:
+ - admin
+ operationId: search
+ parameters:
+ - name: query
+ in: query
+ description: Start of the address or username
+ required: false
+ schema:
+ type: string
+ - name: status
+ in: query
+ description: active, inactive, suspended or pending_verification
+ required: false
+ schema:
+ type: string
+ - name: limit
+ in: query
+ description: Accounts per page, 1-200 (default 50)
+ required: false
+ schema:
+ type: integer
+ format: int64
+ - name: cursor
+ in: query
+ description: next_cursor of the previous page
+ required: false
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Accounts, newest first
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/AdminUserPage'
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Missing `users:read`, or no second factor enrolled
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid status or cursor
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /admin/users/{id}:
+ get:
+ tags:
+ - admin
+ operationId: detail
+ parameters:
+ - name: id
+ in: path
+ description: Account id
+ required: true
+ schema:
+ type: string
+ format: uuid
+ responses:
+ '200':
+ description: The account
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/AdminUserDetail'
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Missing `users:read`, or no second factor enrolled
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '404':
+ description: No such account
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ delete:
+ tags:
+ - admin
+ operationId: delete
+ parameters:
+ - name: id
+ in: path
+ description: Account id
+ required: true
+ schema:
+ type: string
+ format: uuid
+ requestBody:
+ content:
+ application/json:
+ schema:
+ oneOf:
+ - type: 'null'
+ - $ref: '#/components/schemas/CurrentPasswordRequest'
+ responses:
+ '204':
+ description: Account deleted and `user.deleted` announced
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token, or wrong password
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Missing `users:manage`, no second factor, the administrator's own account, or re-authentication required
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '404':
+ description: No such account
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /admin/users/{id}/password-reset:
+ post:
+ tags:
+ - admin
+ operationId: force_password_reset
+ parameters:
+ - name: id
+ in: path
+ description: Account id
+ required: true
+ schema:
+ type: string
+ format: uuid
+ responses:
+ '204':
+ description: Signed out everywhere and a reset link mailed to the owner
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Missing `users:manage`, no second factor enrolled, or the administrator's own account
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '404':
+ description: No such account
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /admin/users/{id}/reactivate:
+ post:
+ tags:
+ - admin
+ operationId: reactivate
+ parameters:
+ - name: id
+ in: path
+ description: Account id
+ required: true
+ schema:
+ type: string
+ format: uuid
+ responses:
+ '204':
+ description: Active again, or already active
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Missing `users:manage`, or no second factor enrolled
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '404':
+ description: No such account
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /admin/users/{id}/roles:
+ post:
+ tags:
+ - admin
+ operationId: assign
+ parameters:
+ - name: id
+ in: path
+ description: Account id
+ required: true
+ schema:
+ type: string
+ format: uuid
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/AssignRoleRequest'
+ required: true
+ responses:
+ '204':
+ description: Role granted, or already held
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Missing `roles:manage`, no second factor, or re-authentication required
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '404':
+ description: No such account or role
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /admin/users/{id}/roles/{name}:
+ delete:
+ tags:
+ - admin
+ operationId: unassign
+ parameters:
+ - name: id
+ in: path
+ description: Account id
+ required: true
+ schema:
+ type: string
+ format: uuid
+ - name: name
+ in: path
+ description: Role name
+ required: true
+ schema:
+ type: string
+ responses:
+ '204':
+ description: Role taken back, or not held
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Missing `roles:manage`, or no second factor enrolled
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '404':
+ description: No such role
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '409':
+ description: '`last_administrator`: nobody would keep `roles:manage`'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /admin/users/{id}/sessions:
+ delete:
+ tags:
+ - admin
+ operationId: revoke_sessions
+ parameters:
+ - name: id
+ in: path
+ description: Account id
+ required: true
+ schema:
+ type: string
+ format: uuid
+ responses:
+ '200':
+ description: Signed out everywhere
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RevokedSessionsResponse'
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Missing `users:manage`, no second factor enrolled, or the administrator's own account
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '404':
+ description: No such account
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /admin/users/{id}/suspend:
+ post:
+ tags:
+ - admin
+ operationId: suspend
+ parameters:
+ - name: id
+ in: path
+ description: Account id
+ required: true
+ schema:
+ type: string
+ format: uuid
+ responses:
+ '204':
+ description: Suspended and signed out everywhere, or already suspended
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Missing `users:manage`, no second factor enrolled, or the administrator's own account
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '404':
+ description: No such account
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: The account was never verified
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /admin/users/{id}/unlock:
+ post:
+ tags:
+ - admin
+ operationId: unlock
+ parameters:
+ - name: id
+ in: path
+ description: Account id
+ required: true
+ schema:
+ type: string
+ format: uuid
+ responses:
+ '204':
+ description: Lockouts ended and past failures forgiven
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Missing `users:manage`, or no second factor enrolled
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '404':
+ description: No such account
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /admin/webhooks:
+ get:
+ tags:
+ - admin
+ operationId: list
+ responses:
+ '200':
+ description: Every webhook endpoint
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/WebhookResponse'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Missing `webhooks:manage`, or no second factor enrolled
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ post:
+ tags:
+ - admin
+ operationId: create
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/WebhookRequest'
+ required: true
+ responses:
+ '201':
+ description: Endpoint registered; its signing secret is in this response only
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CreatedWebhookResponse'
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Missing `webhooks:manage`, or no second factor enrolled
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid URL or unknown event
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /admin/webhooks/{id}:
+ put:
+ tags:
+ - admin
+ operationId: update
+ parameters:
+ - name: id
+ in: path
+ description: Webhook id
+ required: true
+ schema:
+ type: string
+ format: uuid
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/WebhookRequest'
+ required: true
+ responses:
+ '200':
+ description: Endpoint updated; the secret is unchanged
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/WebhookResponse'
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Missing `webhooks:manage`, or no second factor enrolled
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '404':
+ description: No such webhook
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid URL or unknown event
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ delete:
+ tags:
+ - admin
+ operationId: delete
+ parameters:
+ - name: id
+ in: path
+ description: Webhook id
+ required: true
+ schema:
+ type: string
+ format: uuid
+ responses:
+ '204':
+ description: Endpoint and its pending deliveries removed
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Missing `webhooks:manage`, or no second factor enrolled
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '404':
+ description: No such webhook
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /admin/webhooks/{id}/deliveries:
+ get:
+ tags:
+ - admin
+ operationId: deliveries
+ parameters:
+ - name: id
+ in: path
+ description: Webhook id
+ required: true
+ schema:
+ type: string
+ format: uuid
+ responses:
+ '200':
+ description: The latest 100 deliveries, newest first
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/WebhookDeliveryResponse'
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Missing `webhooks:manage`, or no second factor enrolled
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /admin/webhooks/{id}/deliveries/{delivery_id}/retry:
+ post:
+ tags:
+ - admin
+ operationId: retry
+ parameters:
+ - name: id
+ in: path
+ description: Webhook id
+ required: true
+ schema:
+ type: string
+ format: uuid
+ - name: delivery_id
+ in: path
+ description: Delivery id
+ required: true
+ schema:
+ type: string
+ format: uuid
+ responses:
+ '204':
+ description: Delivery queued again with a fresh attempt budget
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Missing `webhooks:manage`, or no second factor enrolled
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '404':
+ description: No such delivery for this webhook
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /admin/webhooks/{id}/secret:
+ post:
+ tags:
+ - admin
+ operationId: rotate_secret
+ parameters:
+ - name: id
+ in: path
+ description: Webhook id
+ required: true
+ schema:
+ type: string
+ format: uuid
+ responses:
+ '200':
+ description: A new signing secret
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/WebhookSecretResponse'
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Missing `webhooks:manage`, or no second factor enrolled
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '404':
+ description: No such webhook
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /auth/external/complete:
+ post:
+ tags:
+ - external-identities
+ operationId: complete_sign_in
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ExternalCompleteRequest'
+ required: true
+ responses:
+ '200':
+ description: Tokens, or the account's two-factor challenge
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/LoginResponse'
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Unknown, used or foreign code
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Account locked, suspended or inactive
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '409':
+ description: '`external_identity_not_linked`'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: The provider could not identify the person
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ /auth/external/providers:
+ get:
+ tags:
+ - external-identities
+ operationId: providers
+ responses:
+ '200':
+ description: Configured identity providers
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/IdentityProviderResponse'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ /auth/external/{provider}/callback:
+ get:
+ tags:
+ - external-identities
+ operationId: callback
+ parameters:
+ - name: provider
+ in: path
+ description: Provider name
+ required: true
+ schema:
+ type: string
+ responses:
+ '303':
+ description: To `EXTERNAL_LOGIN_URI` with `code`, or `error` when the request matches no pending sign-in
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '404':
+ description: No such provider
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ /auth/external/{provider}/start:
+ post:
+ tags:
+ - external-identities
+ operationId: start_sign_in
+ parameters:
+ - name: provider
+ in: path
+ description: Provider name
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Where to send the browser to sign in
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ExternalStartResponse'
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '404':
+ description: No such provider
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: The provider's metadata is unavailable
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ /auth/forgot-password:
+ post:
+ tags:
+ - auth
+ operationId: forgot_password
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ForgotPasswordRequest'
+ required: true
+ responses:
+ '200':
+ description: Accepted; identical whether or not the account exists
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ /auth/login:
+ post:
+ tags:
+ - auth
+ operationId: login
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/LoginRequest'
+ required: true
+ responses:
+ '200':
+ description: Tokens, or a two-factor challenge
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/LoginResponse'
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Invalid credentials
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Account locked, suspended or not verified
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ /auth/logout:
+ post:
+ tags:
+ - auth
+ operationId: logout
+ responses:
+ '204':
+ description: Session ended
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /auth/magic-link:
+ post:
+ tags:
+ - auth
+ operationId: request_magic_link
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/MagicLinkRequest'
+ required: true
+ responses:
+ '200':
+ description: Accepted; identical whether or not an account can sign in with this address
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '404':
+ description: Sign-in links are not enabled on this deployment
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ /auth/magic-link/complete:
+ post:
+ tags:
+ - auth
+ operationId: complete_magic_link
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CompleteMagicLinkRequest'
+ required: true
+ responses:
+ '200':
+ description: Tokens, or the account's two-factor challenge
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/LoginResponse'
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Invalid, used or expired link
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Account locked, suspended or inactive
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '404':
+ description: Sign-in links are not enabled on this deployment
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ /auth/passkeys/options:
+ post:
+ tags:
+ - passkeys
+ operationId: authentication_options
+ responses:
+ '200':
+ description: '`PublicKeyCredentialRequestOptions` (JSON form) for `navigator.credentials.get()`; valid five minutes, used once'
+ content:
+ application/json:
+ schema:
+ type: object
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ /auth/passkeys/sign-in:
+ post:
+ tags:
+ - passkeys
+ operationId: sign_in
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PasskeySignInRequest'
+ required: true
+ responses:
+ '200':
+ description: 'Signed in: a passkey with user verification needs no second factor'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PasskeySignInResponse'
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: '`invalid_credentials`: the assertion does not verify'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Account locked, suspended or inactive
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ /auth/personal-access-tokens/exchange:
+ post:
+ tags:
+ - auth
+ operationId: exchange
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PersonalAccessTokenExchangeRequest'
+ required: true
+ responses:
+ '200':
+ description: A short-lived access token carrying the token's scopes
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PersonalAccessTokenExchangeResponse'
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Unknown, revoked or expired token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Account locked, suspended or inactive
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ /auth/refresh:
+ post:
+ tags:
+ - auth
+ operationId: refresh
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RefreshRequest'
+ required: true
+ responses:
+ '200':
+ description: Tokens issued
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/TokensResponse'
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Invalid, expired or replayed refresh token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Account suspended or inactive
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ /auth/register:
+ post:
+ tags:
+ - auth
+ operationId: register
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RegisterRequest'
+ required: true
+ responses:
+ '202':
+ description: Accepted; identical whether or not the address is taken
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RegistrationAccepted'
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '409':
+ description: Username already taken (`username_taken`); a taken address is not revealed
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ /auth/reset-password:
+ post:
+ tags:
+ - auth
+ operationId: reset_password
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ResetPasswordRequest'
+ required: true
+ responses:
+ '200':
+ description: Password replaced; every session revoked
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Invalid or expired token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ /auth/two-factor/complete:
+ post:
+ tags:
+ - auth
+ operationId: complete_two_factor
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CompleteTwoFactorRequest'
+ required: true
+ responses:
+ '200':
+ description: Tokens issued
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/TokensResponse'
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Invalid code or pre-auth token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Account suspended or locked since the challenge
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ /auth/two-factor/email/complete:
+ post:
+ tags:
+ - auth
+ operationId: complete_email_two_factor
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CompleteEmailTwoFactorRequest'
+ required: true
+ responses:
+ '200':
+ description: Tokens issued
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/TokensResponse'
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Invalid code or pre-auth token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Account suspended or locked since the challenge
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ /auth/two-factor/email/resend:
+ post:
+ tags:
+ - auth
+ operationId: resend_email_two_factor
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ResendEmailTwoFactorRequest'
+ required: true
+ responses:
+ '204':
+ description: Code sent if the challenge is an email challenge
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Invalid pre-auth token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ /auth/two-factor/recovery:
+ post:
+ tags:
+ - auth
+ operationId: recovery_login
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RecoveryLoginRequest'
+ required: true
+ responses:
+ '200':
+ description: Tokens issued
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/TokensResponse'
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Invalid recovery code or pre-auth token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Account suspended or locked since the challenge
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ /auth/verify-email:
+ post:
+ tags:
+ - auth
+ operationId: verify_email
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/VerifyEmailRequest'
+ required: true
+ responses:
+ '200':
+ description: Address verified
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Invalid or expired token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ /auth/verify-email/resend:
+ post:
+ tags:
+ - auth
+ operationId: resend_verification
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ResendVerificationRequest'
+ required: true
+ responses:
+ '200':
+ description: Accepted; identical whether the address is unknown, pending or already verified
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ /health:
+ get:
+ tags:
+ - discovery
+ operationId: health
+ responses:
+ '200':
+ description: Serving
+ content:
+ text/plain:
+ schema:
+ type: string
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ /live:
+ get:
+ tags:
+ - discovery
+ summary: |-
+ Liveness: answers as long as the process serves HTTP. Restarting the
+ container cannot fix a dependency, so this never checks one.
+ operationId: live
+ responses:
+ '200':
+ description: The process serves requests; dependencies are not checked
+ content:
+ text/plain:
+ schema:
+ type: string
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ /oauth/authorization-requests/{id}:
+ get:
+ tags:
+ - oauth
+ operationId: describe_request
+ parameters:
+ - name: id
+ in: path
+ description: '`request_id` given to the consent page'
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: What the consent page shows
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/AuthorizationRequestResponse'
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '404':
+ description: Unknown, decided or expired request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /oauth/authorization-requests/{id}/approve:
+ post:
+ tags:
+ - oauth
+ operationId: approve_request
+ parameters:
+ - name: id
+ in: path
+ description: '`request_id` given to the consent page'
+ required: true
+ schema:
+ type: string
+ requestBody:
+ content:
+ application/json:
+ schema:
+ oneOf:
+ - type: 'null'
+ - $ref: '#/components/schemas/ApproveAuthorizationRequest'
+ responses:
+ '200':
+ description: Approved; send the browser to `redirect_to`
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/AuthorizationDecisionResponse'
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token, or wrong password
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Re-authentication required, or account unusable
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '404':
+ description: Unknown, decided or expired request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /oauth/authorization-requests/{id}/deny:
+ post:
+ tags:
+ - oauth
+ operationId: deny_request
+ parameters:
+ - name: id
+ in: path
+ description: '`request_id` given to the consent page'
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Denied; send the browser to `redirect_to`
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/AuthorizationDecisionResponse'
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '404':
+ description: Unknown, decided or expired request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /oauth/authorize:
+ get:
+ tags:
+ - oauth
+ operationId: authorize
+ parameters:
+ - name: response_type
+ in: query
+ description: '`code`'
+ required: true
+ schema:
+ type: string
+ - name: client_id
+ in: query
+ description: Registered client
+ required: true
+ schema:
+ type: string
+ - name: redirect_uri
+ in: query
+ description: A registered redirect URI; optional when the client has exactly one
+ required: false
+ schema:
+ type: string
+ - name: code_challenge
+ in: query
+ description: S256 PKCE challenge
+ required: true
+ schema:
+ type: string
+ - name: code_challenge_method
+ in: query
+ description: '`S256`'
+ required: true
+ schema:
+ type: string
+ - name: scope
+ in: query
+ description: Space-separated permissions; omitted, the client's registered scopes
+ required: false
+ schema:
+ type: string
+ - name: state
+ in: query
+ description: Echoed back, at most 512 bytes
+ required: false
+ schema:
+ type: string
+ - name: nonce
+ in: query
+ description: 'OpenID Connect: echoed in the ID token'
+ required: false
+ schema:
+ type: string
+ responses:
+ '303':
+ description: To the consent page (`OAUTH_CONSENT_URI?request_id=...`), or back to the client with `error`
+ '400':
+ description: 'Unknown client or unregistered redirect URI: never redirected'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/OAuthErrorBody'
+ '401':
+ description: Unknown client
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/OAuthErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ /oauth/device/verify:
+ post:
+ tags:
+ - oauth
+ operationId: verify_device
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DeviceVerifyRequest'
+ required: true
+ responses:
+ '200':
+ description: Decision recorded
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '404':
+ description: Unknown or expired code
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '409':
+ description: Already decided
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /oauth/device/{user_code}:
+ get:
+ tags:
+ - oauth
+ operationId: describe_device
+ parameters:
+ - name: user_code
+ in: path
+ description: Code shown on the device, XXXX-9999
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: What the user is about to approve
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DevicePreview'
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '404':
+ description: Unknown or expired code
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '409':
+ description: Already decided
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /oauth/device_authorization:
+ post:
+ tags:
+ - oauth
+ operationId: device_authorization
+ requestBody:
+ content:
+ application/x-www-form-urlencoded:
+ schema:
+ $ref: '#/components/schemas/DeviceAuthorizationRequest'
+ required: true
+ responses:
+ '200':
+ description: Flow started (RFC 8628 section 3.2)
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DeviceInitResponse'
+ '400':
+ description: '`invalid_request` or `invalid_scope`'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/OAuthErrorBody'
+ '401':
+ description: '`invalid_client`'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/OAuthErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ /oauth/introspect:
+ post:
+ tags:
+ - oauth
+ operationId: introspect
+ requestBody:
+ content:
+ application/x-www-form-urlencoded:
+ schema:
+ $ref: '#/components/schemas/TokenOperationRequest'
+ required: true
+ responses:
+ '200':
+ description: 'RFC 7662: `{ "active": false }` for anything unknown, expired or revoked'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Introspection'
+ '400':
+ description: '`invalid_request` or `unauthorized_client` (a public client)'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/OAuthErrorBody'
+ '401':
+ description: '`invalid_client`'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/OAuthErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ /oauth/revoke:
+ post:
+ tags:
+ - oauth
+ operationId: revoke
+ requestBody:
+ content:
+ application/x-www-form-urlencoded:
+ schema:
+ $ref: '#/components/schemas/TokenOperationRequest'
+ required: true
+ responses:
+ '200':
+ description: 'RFC 7009: the token no longer works, if it was issued to this client; the same answer otherwise'
+ '400':
+ description: '`invalid_request`'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/OAuthErrorBody'
+ '401':
+ description: '`invalid_client`'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/OAuthErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ /oauth/token:
+ post:
+ tags:
+ - oauth
+ operationId: token
+ requestBody:
+ content:
+ application/x-www-form-urlencoded:
+ schema:
+ $ref: '#/components/schemas/OAuthTokenRequest'
+ required: true
+ responses:
+ '200':
+ description: Tokens (RFC 6749 section 5.1)
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/OAuthTokenResponse'
+ '400':
+ description: '`invalid_request`, `invalid_grant`, `unsupported_grant_type`, `authorization_pending`, `slow_down`, `expired_token`, `access_denied`'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/OAuthErrorBody'
+ '401':
+ description: '`invalid_client`'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/OAuthErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ /oauth/userinfo:
+ get:
+ tags:
+ - oauth
+ operationId: userinfo
+ responses:
+ '200':
+ description: Claims about the user released by the session's scopes (OIDC Core 5.3)
+ content:
+ application/json:
+ schema:
+ type: object
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: The session was not granted `openid`
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /ready:
+ get:
+ tags:
+ - discovery
+ summary: |-
+ Readiness: whether this instance can serve traffic now. The reverse proxy
+ and the rolling update send traffic only to a ready instance.
+ operationId: ready
+ responses:
+ '200':
+ description: Every dependency answered
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ReadyResponse'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency did not answer
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ReadyResponse'
+ /users/me:
+ get:
+ tags:
+ - account
+ operationId: me
+ responses:
+ '200':
+ description: The caller's profile
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/UserResponse'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ delete:
+ tags:
+ - account
+ operationId: delete_account
+ requestBody:
+ content:
+ application/json:
+ schema:
+ oneOf:
+ - type: 'null'
+ - $ref: '#/components/schemas/DeleteAccountRequest'
+ responses:
+ '204':
+ description: Account deleted
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Recent re-authentication required
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /users/me/audit:
+ get:
+ tags:
+ - account
+ summary: GET /users/me/audit - newest first.
+ operationId: list
+ parameters:
+ - name: limit
+ in: query
+ description: Entries per page, 1-200 (default 50)
+ required: false
+ schema:
+ type: integer
+ format: int64
+ - name: cursor
+ in: query
+ description: next_cursor of the previous page
+ required: false
+ schema:
+ type: string
+ responses:
+ '200':
+ description: The caller's security history, newest first
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/AuditPageResponse'
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid cursor
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /users/me/email/confirm:
+ post:
+ tags:
+ - email-change
+ operationId: confirm_new_email
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ConfirmNewEmailRequest'
+ required: true
+ responses:
+ '204':
+ description: Address changed; other sessions revoked
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '409':
+ description: Address taken
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /users/me/email/start:
+ post:
+ tags:
+ - email-change
+ operationId: start_email_change
+ requestBody:
+ content:
+ application/json:
+ schema:
+ oneOf:
+ - type: 'null'
+ - $ref: '#/components/schemas/CurrentPasswordRequest'
+ responses:
+ '200':
+ description: Code sent to the current address
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/FlowTokenResponse'
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Recent re-authentication required
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /users/me/email/submit:
+ post:
+ tags:
+ - email-change
+ operationId: submit_new_email
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/SubmitNewEmailRequest'
+ required: true
+ responses:
+ '204':
+ description: Code sent to the new address
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '409':
+ description: Address taken
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /users/me/email/verify-current:
+ post:
+ tags:
+ - email-change
+ operationId: verify_current_email
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/VerifyCurrentEmailRequest'
+ required: true
+ responses:
+ '204':
+ description: Current address confirmed
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /users/me/export:
+ get:
+ tags:
+ - account
+ operationId: export_data
+ responses:
+ '200':
+ description: 'Everything stored about the account, as a JSON download: profile, roles, sessions, second factors (no secrets), devices, sign-in attempts and security history'
+ content:
+ application/json:
+ schema:
+ type: object
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Recent re-authentication required
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /users/me/external-identities:
+ get:
+ tags:
+ - external-identities
+ operationId: list
+ responses:
+ '200':
+ description: Identities linked to the account
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/ExternalIdentityResponse'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /users/me/external-identities/complete:
+ post:
+ tags:
+ - external-identities
+ operationId: complete_link
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ExternalCompleteRequest'
+ required: true
+ responses:
+ '201':
+ description: Identity linked
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ExternalIdentityResponse'
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token, or an unknown, used or foreign code
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '409':
+ description: '`external_identity_already_linked`'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: The provider could not identify the person
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /users/me/external-identities/{id}:
+ delete:
+ tags:
+ - external-identities
+ operationId: unlink
+ parameters:
+ - name: id
+ in: path
+ description: Identity id
+ required: true
+ schema:
+ type: string
+ format: uuid
+ requestBody:
+ content:
+ application/json:
+ schema:
+ oneOf:
+ - type: 'null'
+ - $ref: '#/components/schemas/CurrentPasswordRequest'
+ responses:
+ '204':
+ description: Identity unlinked
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Recent re-authentication required
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '404':
+ description: No such identity on this account
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /users/me/external-identities/{provider}/start:
+ post:
+ tags:
+ - external-identities
+ operationId: start_link
+ parameters:
+ - name: provider
+ in: path
+ description: Provider name
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Where to send the browser to link the identity
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ExternalStartResponse'
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Recent re-authentication required
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '404':
+ description: No such provider
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /users/me/locale:
+ patch:
+ tags:
+ - account
+ operationId: change_locale
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ChangeLocaleRequest'
+ required: true
+ responses:
+ '204':
+ description: Locale changed
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /users/me/passkeys:
+ get:
+ tags:
+ - passkeys
+ operationId: list
+ responses:
+ '200':
+ description: The account's passkeys
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/PasskeyResponse'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ post:
+ tags:
+ - passkeys
+ operationId: register
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RegisterPasskeyRequest'
+ required: true
+ responses:
+ '201':
+ description: Passkey registered
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RegisteredPasskeyResponse'
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '409':
+ description: '`passkey_already_registered`'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: No pending registration, or the response does not verify
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /users/me/passkeys/options:
+ post:
+ tags:
+ - passkeys
+ operationId: registration_options
+ responses:
+ '200':
+ description: '`PublicKeyCredentialCreationOptions` (JSON form) for `navigator.credentials.create()`; valid five minutes'
+ content:
+ application/json:
+ schema:
+ type: object
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Recent re-authentication required
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '409':
+ description: '`too_many_passkeys`'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /users/me/passkeys/{id}:
+ delete:
+ tags:
+ - passkeys
+ operationId: remove
+ parameters:
+ - name: id
+ in: path
+ description: Passkey id
+ required: true
+ schema:
+ type: string
+ format: uuid
+ requestBody:
+ content:
+ application/json:
+ schema:
+ oneOf:
+ - type: 'null'
+ - $ref: '#/components/schemas/CurrentPasswordRequest'
+ responses:
+ '204':
+ description: Passkey removed
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Recent re-authentication required
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '404':
+ description: No such passkey on this account
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /users/me/password:
+ patch:
+ tags:
+ - account
+ operationId: change_password
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ChangePasswordRequest'
+ required: true
+ responses:
+ '204':
+ description: Password changed; every other session revoked, and the current one too unless `keep_current_session`
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Recent re-authentication required
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /users/me/reauth:
+ post:
+ tags:
+ - account
+ operationId: reauthenticate
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ReauthenticateRequest'
+ required: true
+ responses:
+ '204':
+ description: Re-authenticated for SENSITIVE_ACTION_REAUTH_SECS
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Wrong password or invalid token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: 'Too many wrong passwords: `account_locked` until the window ends'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /users/me/sessions:
+ get:
+ tags:
+ - sessions
+ operationId: list
+ responses:
+ '200':
+ description: Active sessions
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/SessionResponse'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ delete:
+ tags:
+ - sessions
+ operationId: revoke_all
+ requestBody:
+ content:
+ application/json:
+ schema:
+ oneOf:
+ - type: 'null'
+ - $ref: '#/components/schemas/RevokeAllRequest'
+ responses:
+ '204':
+ description: Every other session revoked, and the current one too unless `keep_current_session`
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Recent re-authentication required
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /users/me/sessions/{id}:
+ delete:
+ tags:
+ - sessions
+ operationId: revoke
+ parameters:
+ - name: id
+ in: path
+ description: Method or session id
+ required: true
+ schema:
+ type: string
+ format: uuid
+ requestBody:
+ content:
+ application/json:
+ schema:
+ oneOf:
+ - type: 'null'
+ - $ref: '#/components/schemas/RevokeAllRequest'
+ responses:
+ '204':
+ description: Session revoked
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Recent re-authentication required
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '404':
+ description: No such session
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /users/me/tokens:
+ get:
+ tags:
+ - account
+ operationId: list
+ responses:
+ '200':
+ description: Personal access tokens that still work, newest first
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/PersonalAccessTokenResponse'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ post:
+ tags:
+ - account
+ operationId: create
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CreatePersonalAccessTokenRequest'
+ required: true
+ responses:
+ '201':
+ description: Token created; its secret is in this response only
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CreatedPersonalAccessTokenResponse'
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Recent re-authentication required
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '409':
+ description: '`too_many_tokens`'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid name or lifetime, or a scope the account does not hold
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /users/me/tokens/{id}:
+ delete:
+ tags:
+ - account
+ operationId: revoke
+ parameters:
+ - name: id
+ in: path
+ description: Token id
+ required: true
+ schema:
+ type: string
+ format: uuid
+ responses:
+ '204':
+ description: Token revoked
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '404':
+ description: No such token for this account
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /users/me/two-factor:
+ get:
+ tags:
+ - two-factor
+ summary: GET /users/me/two-factor
+ description: |-
+ What the account has set up. The disable routes need a method id that only
+ the setup call returned: without this, a refreshed page could not turn its
+ own second factor off.
+ operationId: list
+ responses:
+ '200':
+ description: Configured methods and remaining recovery codes
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/TwoFactorOverviewResponse'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /users/me/two-factor/email/send:
+ post:
+ tags:
+ - two-factor
+ operationId: send_email_otp_code
+ responses:
+ '204':
+ description: Code sent
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /users/me/two-factor/email/setup:
+ post:
+ tags:
+ - two-factor
+ operationId: setup_email_otp
+ requestBody:
+ content:
+ application/json:
+ schema:
+ oneOf:
+ - type: 'null'
+ - $ref: '#/components/schemas/SetupTwoFactorRequest'
+ responses:
+ '200':
+ description: Method created; first code sent
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/EmailOtpSetupResponse'
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Recent re-authentication required
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '409':
+ description: Already enabled
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /users/me/two-factor/email/{id}:
+ delete:
+ tags:
+ - two-factor
+ operationId: disable_email_otp
+ parameters:
+ - name: id
+ in: path
+ description: Method or session id
+ required: true
+ schema:
+ type: string
+ format: uuid
+ requestBody:
+ content:
+ application/json:
+ schema:
+ oneOf:
+ - type: 'null'
+ - $ref: '#/components/schemas/DisableEmailOtpRequest'
+ responses:
+ '204':
+ description: Method removed
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Recent re-authentication required
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '404':
+ description: No such method
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /users/me/two-factor/email/{id}/verify:
+ post:
+ tags:
+ - two-factor
+ operationId: verify_email_otp_setup
+ parameters:
+ - name: id
+ in: path
+ description: Method or session id
+ required: true
+ schema:
+ type: string
+ format: uuid
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/VerifyEmailOtpSetupRequest'
+ required: true
+ responses:
+ '200':
+ description: Method enabled; recovery codes shown once
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RecoveryCodesResponse'
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '404':
+ description: No such method
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /users/me/two-factor/recovery-codes:
+ post:
+ tags:
+ - two-factor
+ operationId: regenerate_recovery_codes
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RegenerateRecoveryCodesRequest'
+ required: true
+ responses:
+ '200':
+ description: New codes, shown once
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RecoveryCodesResponse'
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Recent re-authentication required
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /users/me/two-factor/recovery-codes/use:
+ post:
+ tags:
+ - two-factor
+ operationId: use_recovery_code
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/UseRecoveryCodeRequest'
+ required: true
+ responses:
+ '204':
+ description: Code consumed
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /users/me/two-factor/totp/setup:
+ post:
+ tags:
+ - two-factor
+ operationId: setup_totp
+ requestBody:
+ content:
+ application/json:
+ schema:
+ oneOf:
+ - type: 'null'
+ - $ref: '#/components/schemas/SetupTwoFactorRequest'
+ responses:
+ '200':
+ description: Secret provisioned, shown once
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/TotpSetupResponse'
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Recent re-authentication required
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '409':
+ description: Already enabled
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /users/me/two-factor/totp/{id}:
+ delete:
+ tags:
+ - two-factor
+ operationId: disable_totp
+ parameters:
+ - name: id
+ in: path
+ description: Method or session id
+ required: true
+ schema:
+ type: string
+ format: uuid
+ requestBody:
+ content:
+ application/json:
+ schema:
+ oneOf:
+ - type: 'null'
+ - $ref: '#/components/schemas/DisableTotpRequest'
+ responses:
+ '204':
+ description: Method removed
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Recent re-authentication required
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '404':
+ description: No such method
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /users/me/two-factor/totp/{id}/verify:
+ post:
+ tags:
+ - two-factor
+ operationId: verify_totp_setup
+ parameters:
+ - name: id
+ in: path
+ description: Method or session id
+ required: true
+ schema:
+ type: string
+ format: uuid
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/VerifyTotpSetupRequest'
+ required: true
+ responses:
+ '200':
+ description: Method enabled; recovery codes shown once
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RecoveryCodesResponse'
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '404':
+ description: No such method
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '409':
+ description: Already verified
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+ /users/me/username:
+ patch:
+ tags:
+ - account
+ operationId: change_username
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ChangeUsernameRequest'
+ required: true
+ responses:
+ '204':
+ description: Username changed
+ '400':
+ description: Malformed request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '401':
+ description: Missing, invalid or revoked access token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '403':
+ description: Recent re-authentication required
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '409':
+ description: Username taken
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '413':
+ description: Body larger than 64 KB
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '415':
+ description: Body is not JSON
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '422':
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '429':
+ description: Rate limited; see Retry-After
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ '503':
+ description: A dependency is unavailable or the request timed out
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorBody'
+ security:
+ - bearer: []
+components:
+ schemas:
+ AdminAuditEntry:
+ allOf:
+ - $ref: '#/components/schemas/AuditEntryResponse'
+ - type: object
+ properties:
+ user_id:
+ type:
+ - string
+ - 'null'
+ format: uuid
+ description: Absent once the account is deleted.
+ AdminAuditPage:
+ type: object
+ required:
+ - entries
+ properties:
+ entries:
+ type: array
+ items:
+ $ref: '#/components/schemas/AdminAuditEntry'
+ next_cursor:
+ type:
+ - string
+ - 'null'
+ description: Pass back as `cursor` to read the next page; absent on the last one.
+ AdminUserDetail:
+ allOf:
+ - $ref: '#/components/schemas/AdminUserSummary'
+ - type: object
+ required:
+ - roles
+ - two_factor_methods
+ - active_sessions
+ properties:
+ active_sessions:
+ type: integer
+ minimum: 0
+ roles:
+ type: array
+ items:
+ type: string
+ two_factor_methods:
+ type: integer
+ description: Verified second factors.
+ minimum: 0
+ AdminUserPage:
+ type: object
+ required:
+ - users
+ properties:
+ next_cursor:
+ type:
+ - string
+ - 'null'
+ description: Pass back as `cursor` to read the next page; absent on the last one.
+ users:
+ type: array
+ items:
+ $ref: '#/components/schemas/AdminUserSummary'
+ AdminUserSummary:
+ type: object
+ required:
+ - id
+ - username
+ - email
+ - status
+ - preferred_locale
+ - created_at
+ properties:
+ created_at:
+ type: integer
+ format: int64
+ email:
+ type: string
+ email_verified_at:
+ type:
+ - integer
+ - 'null'
+ format: int64
+ id:
+ type: string
+ format: uuid
+ last_login_at:
+ type:
+ - integer
+ - 'null'
+ format: int64
+ locked_until:
+ type:
+ - integer
+ - 'null'
+ format: int64
+ description: Present while a sign-in lockout lasts.
+ preferred_locale:
+ type: string
+ status:
+ type: string
+ username:
+ type: string
+ ApproveAuthorizationRequest:
+ type: object
+ properties:
+ current_password:
+ type:
+ - string
+ - 'null'
+ AssignRoleRequest:
+ type: object
+ required:
+ - role
+ properties:
+ role:
+ type: string
+ AuditEntryResponse:
+ type: object
+ required:
+ - id
+ - created_at
+ - action
+ - metadata
+ properties:
+ action:
+ type: string
+ description: 'Stable snake_case name: `login`, `password_changed`, ...'
+ created_at:
+ type: integer
+ format: int64
+ description: Unix timestamp (seconds), like every other timestamp of this API.
+ id:
+ type: string
+ format: uuid
+ ip_address:
+ type:
+ - string
+ - 'null'
+ metadata:
+ type: object
+ request_id:
+ type:
+ - string
+ - 'null'
+ format: uuid
+ AuditPageResponse:
+ type: object
+ required:
+ - entries
+ properties:
+ entries:
+ type: array
+ items:
+ $ref: '#/components/schemas/AuditEntryResponse'
+ next_cursor:
+ type:
+ - string
+ - 'null'
+ description: Pass back as `cursor` to read the next page; absent on the last one.
+ AuthorizationDecisionResponse:
+ type: object
+ required:
+ - redirect_to
+ properties:
+ redirect_to:
+ type: string
+ description: 'Where to send the browser: the client''s redirect URI with the response.'
+ AuthorizationRequestResponse:
+ type: object
+ required:
+ - client_id
+ - client_name
+ - redirect_uri
+ - scopes
+ - unrestricted
+ - unavailable_scopes
+ - sessions_used
+ - reauthentication_required
+ properties:
+ client_id:
+ type: string
+ client_name:
+ type: string
+ reauthentication_required:
+ type: boolean
+ description: Approving needs `current_password` (or a recent `POST /users/me/reauth`).
+ redirect_uri:
+ type: string
+ scopes:
+ type: array
+ items:
+ type: string
+ description: Permissions the approval would grant.
+ sessions_allowed:
+ type:
+ - integer
+ - 'null'
+ format: int64
+ sessions_used:
+ type: integer
+ format: int64
+ unavailable_scopes:
+ type: array
+ items:
+ type: string
+ description: Requested scopes the user does not hold.
+ unrestricted:
+ type: boolean
+ description: |-
+ The request names no scope and the client has none: the session would
+ carry every permission of the user.
+ ChangeLocaleRequest:
+ type: object
+ required:
+ - locale
+ properties:
+ locale:
+ type: string
+ ChangePasswordRequest:
+ type: object
+ required:
+ - new_password
+ properties:
+ current_password:
+ type:
+ - string
+ - 'null'
+ keep_current_session:
+ type: boolean
+ description: |-
+ Keep the session making the request signed in; every other session is
+ revoked either way. Default: false.
+ new_password:
+ type: string
+ ChangeUsernameRequest:
+ type: object
+ required:
+ - username
+ properties:
+ current_password:
+ type:
+ - string
+ - 'null'
+ username:
+ type: string
+ ClientResponse:
+ type: object
+ required:
+ - client_id
+ - display_name
+ - is_primary
+ - scopes
+ - redirect_uris
+ - allows_loopback_redirect
+ - default_max_sessions
+ - confidential
+ - allows_client_credentials
+ - created_at
+ properties:
+ allows_client_credentials:
+ type: boolean
+ description: May obtain tokens for itself with the client credentials grant.
+ allows_loopback_redirect:
+ type: boolean
+ client_id:
+ type: string
+ confidential:
+ type: boolean
+ description: Authenticates with a secret at the token endpoint.
+ created_at:
+ type: integer
+ format: int64
+ default_max_sessions:
+ type: integer
+ format: int32
+ display_name:
+ type: string
+ is_primary:
+ type: boolean
+ redirect_uris:
+ type: array
+ items:
+ type: string
+ scopes:
+ type: array
+ items:
+ type: string
+ description: Permissions its tokens may carry; empty means every permission of the user.
+ ClientSecretResponse:
+ type: object
+ required:
+ - client_secret
+ properties:
+ client_secret:
+ type: string
+ description: The client secret (`aacs_...`), shown once.
+ CompleteEmailTwoFactorRequest:
+ type: object
+ required:
+ - pre_auth_token
+ - code
+ properties:
+ code:
+ type: string
+ pre_auth_token:
+ type: string
+ CompleteMagicLinkRequest:
+ type: object
+ required:
+ - token
+ properties:
+ device_name:
+ type:
+ - string
+ - 'null'
+ remember_me:
+ type:
+ - boolean
+ - 'null'
+ description: When true, issues a long-lived refresh token.
+ token:
+ type: string
+ description: The token from the link's fragment.
+ CompleteTwoFactorRequest:
+ type: object
+ required:
+ - pre_auth_token
+ - code
+ properties:
+ code:
+ type: string
+ pre_auth_token:
+ type: string
+ ConfirmNewEmailRequest:
+ type: object
+ required:
+ - flow_token
+ - code
+ properties:
+ code:
+ type: string
+ flow_token:
+ type: string
+ CreatePersonalAccessTokenRequest:
+ type: object
+ required:
+ - name
+ properties:
+ expires_in_days:
+ type:
+ - integer
+ - 'null'
+ format: int64
+ description: 1 to 365 (default 90).
+ name:
+ type: string
+ scopes:
+ type: array
+ items:
+ type: string
+ description: |-
+ Permissions the token's access tokens carry; each must be held by the
+ account. Empty: none.
+ CreateRoleRequest:
+ type: object
+ required:
+ - name
+ properties:
+ description:
+ type:
+ - string
+ - 'null'
+ name:
+ type: string
+ permissions:
+ type: array
+ items:
+ type: string
+ CreatedPersonalAccessTokenResponse:
+ allOf:
+ - $ref: '#/components/schemas/PersonalAccessTokenResponse'
+ - type: object
+ required:
+ - secret
+ properties:
+ secret:
+ type: string
+ description: The secret, shown once.
+ CreatedWebhookResponse:
+ allOf:
+ - $ref: '#/components/schemas/WebhookResponse'
+ - type: object
+ required:
+ - secret
+ properties:
+ secret:
+ type: string
+ description: Signing secret (`whsec_...`), shown once.
+ CredentialResponse_AssertionResponse:
+ type: object
+ description: A credential as the browser returns it (`PublicKeyCredential.toJSON()`).
+ required:
+ - rawId
+ - response
+ properties:
+ rawId:
+ type: string
+ description: base64url credential id.
+ response:
+ type: object
+ required:
+ - clientDataJSON
+ - authenticatorData
+ - signature
+ properties:
+ authenticatorData:
+ type: string
+ clientDataJSON:
+ type: string
+ signature:
+ type: string
+ userHandle:
+ type:
+ - string
+ - 'null'
+ CredentialResponse_AttestationResponse:
+ type: object
+ description: A credential as the browser returns it (`PublicKeyCredential.toJSON()`).
+ required:
+ - rawId
+ - response
+ properties:
+ rawId:
+ type: string
+ description: base64url credential id.
+ response:
+ type: object
+ required:
+ - clientDataJSON
+ - attestationObject
+ properties:
+ attestationObject:
+ type: string
+ clientDataJSON:
+ type: string
+ CurrentPasswordRequest:
+ type: object
+ description: |-
+ Optional body for sensitive actions that accept the current password in
+ place of a recent re-authentication.
+ properties:
+ current_password:
+ type:
+ - string
+ - 'null'
+ DeleteAccountRequest:
+ type: object
+ properties:
+ current_password:
+ type:
+ - string
+ - 'null'
+ DeviceAuthorizationRequest:
+ type: object
+ description: Form fields of `POST /oauth/device_authorization`, for the document.
+ properties:
+ client_id:
+ type:
+ - string
+ - 'null'
+ client_secret:
+ type:
+ - string
+ - 'null'
+ scope:
+ type:
+ - string
+ - 'null'
+ description: Space-separated permissions; omitted, the client's registered scopes.
+ DeviceInitResponse:
+ type: object
+ description: RFC 8628 section 3.2.
+ required:
+ - device_code
+ - user_code
+ - verification_uri
+ - verification_uri_complete
+ - expires_in
+ - interval
+ properties:
+ device_code:
+ type: string
+ expires_in:
+ type: integer
+ format: int64
+ minimum: 0
+ interval:
+ type: integer
+ format: int64
+ minimum: 0
+ user_code:
+ type: string
+ verification_uri:
+ type: string
+ verification_uri_complete:
+ type: string
+ description: '`verification_uri` with the user code, for a QR code or a link.'
+ DevicePreview:
+ type: object
+ description: |-
+ What the signed-in user is shown before approving a device. Nothing here is
+ secret from the holder of the code; it is what lets them notice a code being
+ claimed by an unexpected client or from an unexpected place.
+ required:
+ - user_code
+ - created_at
+ properties:
+ client_id:
+ type:
+ - string
+ - 'null'
+ client_name:
+ type:
+ - string
+ - 'null'
+ created_at:
+ type: integer
+ format: int64
+ requested_from_ip:
+ type:
+ - string
+ - 'null'
+ user_agent:
+ type:
+ - string
+ - 'null'
+ user_code:
+ type: string
+ DeviceVerifyRequest:
+ type: object
+ required:
+ - user_code
+ properties:
+ approve:
+ type: boolean
+ user_code:
+ type: string
+ DisableEmailOtpRequest:
+ type: object
+ properties:
+ current_password:
+ type:
+ - string
+ - 'null'
+ DisableTotpRequest:
+ type: object
+ properties:
+ current_password:
+ type:
+ - string
+ - 'null'
+ EmailOtpSetupResponse:
+ type: object
+ required:
+ - method_id
+ properties:
+ method_id:
+ type: string
+ format: uuid
+ ErrorBody:
+ type: object
+ description: Body of every error response.
+ required:
+ - code
+ - message
+ properties:
+ code:
+ type: string
+ description: 'Stable and machine-readable: `invalid_credentials`, `reauthentication_required`, ...'
+ message:
+ type: string
+ description: Human-readable explanation; may change between versions.
+ ExternalCompleteRequest:
+ type: object
+ required:
+ - code
+ - binding
+ properties:
+ binding:
+ type: string
+ code:
+ type: string
+ description: '`code` given to `EXTERNAL_LOGIN_URI` after the provider.'
+ device_name:
+ type:
+ - string
+ - 'null'
+ remember_me:
+ type:
+ - boolean
+ - 'null'
+ ExternalIdentityResponse:
+ type: object
+ required:
+ - id
+ - provider
+ - created_at
+ properties:
+ created_at:
+ type: integer
+ format: int64
+ id:
+ type: string
+ format: uuid
+ last_used_at:
+ type:
+ - integer
+ - 'null'
+ format: int64
+ provider:
+ type: string
+ ExternalStartResponse:
+ type: object
+ required:
+ - authorization_url
+ - binding
+ properties:
+ authorization_url:
+ type: string
+ description: Send the browser there.
+ binding:
+ type: string
+ description: Keep in the browser (session storage) and present at completion.
+ FlowTokenResponse:
+ type: object
+ required:
+ - flow_token
+ properties:
+ flow_token:
+ type: string
+ ForgotPasswordRequest:
+ type: object
+ required:
+ - email
+ properties:
+ captcha_token:
+ type:
+ - string
+ - 'null'
+ email:
+ type: string
+ IdentityProviderResponse:
+ type: object
+ required:
+ - name
+ - display_name
+ properties:
+ display_name:
+ type: string
+ name:
+ type: string
+ Introspection:
+ type: object
+ description: What introspection says about a token. `None` fields are left out.
+ required:
+ - active
+ properties:
+ active:
+ type: boolean
+ aud:
+ type:
+ - array
+ - 'null'
+ items:
+ type: string
+ client_id:
+ type:
+ - string
+ - 'null'
+ exp:
+ type:
+ - integer
+ - 'null'
+ format: int64
+ iat:
+ type:
+ - integer
+ - 'null'
+ format: int64
+ iss:
+ type:
+ - string
+ - 'null'
+ jti:
+ type:
+ - string
+ - 'null'
+ format: uuid
+ scope:
+ type:
+ - string
+ - 'null'
+ sub:
+ type:
+ - string
+ - 'null'
+ format: uuid
+ token_type:
+ type:
+ - string
+ - 'null'
+ description: '`access_token`, `refresh_token` or `personal_access_token`.'
+ LoginRequest:
+ type: object
+ required:
+ - identifier
+ - password
+ properties:
+ captcha_token:
+ type:
+ - string
+ - 'null'
+ device_name:
+ type:
+ - string
+ - 'null'
+ identifier:
+ type: string
+ password:
+ type: string
+ remember_me:
+ type:
+ - boolean
+ - 'null'
+ description: |-
+ When true, issues a long-lived refresh token (30 days).
+ When false or omitted, issues a short-lived token (24 h).
+ LoginResponse:
+ oneOf:
+ - type: object
+ required:
+ - access_token
+ - refresh_token
+ properties:
+ access_token:
+ type: string
+ refresh_token:
+ type: string
+ - type: object
+ required:
+ - two_factor_required
+ - two_factor_method
+ - pre_auth_token
+ properties:
+ pre_auth_token:
+ type: string
+ two_factor_method:
+ type: string
+ description: '"totp" or "email"'
+ two_factor_required:
+ type: boolean
+ MagicLinkRequest:
+ type: object
+ required:
+ - email
+ properties:
+ captcha_token:
+ type:
+ - string
+ - 'null'
+ email:
+ type: string
+ OAuthErrorBody:
+ type: object
+ description: RFC 6749 section 5.2.
+ required:
+ - error
+ properties:
+ error:
+ type: string
+ description: '`invalid_request`, `invalid_client`, `invalid_grant`, ...'
+ error_description:
+ type:
+ - string
+ - 'null'
+ OAuthTokenRequest:
+ type: object
+ description: Form fields of `POST /oauth/token`, for the document.
+ required:
+ - grant_type
+ properties:
+ client_id:
+ type:
+ - string
+ - 'null'
+ client_secret:
+ type:
+ - string
+ - 'null'
+ description: '`client_secret_post`; or use `Authorization: Basic`.'
+ code:
+ type:
+ - string
+ - 'null'
+ code_verifier:
+ type:
+ - string
+ - 'null'
+ device_code:
+ type:
+ - string
+ - 'null'
+ device_name:
+ type:
+ - string
+ - 'null'
+ description: Label of the new session in the account's session list.
+ grant_type:
+ type: string
+ description: |-
+ `authorization_code`, `refresh_token`, `client_credentials` or
+ `urn:ietf:params:oauth:grant-type:device_code`.
+ redirect_uri:
+ type:
+ - string
+ - 'null'
+ refresh_token:
+ type:
+ - string
+ - 'null'
+ scope:
+ type:
+ - string
+ - 'null'
+ description: Space-separated scopes, for `client_credentials`.
+ OAuthTokenResponse:
+ type: object
+ description: RFC 6749 section 5.1.
+ required:
+ - access_token
+ - token_type
+ - expires_in
+ properties:
+ access_token:
+ type: string
+ expires_in:
+ type: integer
+ format: int64
+ description: Seconds.
+ minimum: 0
+ id_token:
+ type:
+ - string
+ - 'null'
+ description: OpenID Connect ID token, when the `openid` scope was granted.
+ refresh_token:
+ type:
+ - string
+ - 'null'
+ description: Absent for the client credentials grant.
+ scope:
+ type:
+ - string
+ - 'null'
+ description: Space-separated scopes the token carries; absent when unrestricted.
+ token_type:
+ type: string
+ description: Always `Bearer`.
+ PasskeyResponse:
+ type: object
+ required:
+ - id
+ - name
+ - algorithm
+ - backup_eligible
+ - backed_up
+ - created_at
+ properties:
+ algorithm:
+ type: integer
+ format: int32
+ description: 'COSE algorithm: -7 (ES256), -8 (EdDSA) or -257 (RS256).'
+ backed_up:
+ type: boolean
+ backup_eligible:
+ type: boolean
+ description: The passkey can be synced to other devices.
+ created_at:
+ type: integer
+ format: int64
+ id:
+ type: string
+ format: uuid
+ last_used_at:
+ type:
+ - integer
+ - 'null'
+ format: int64
+ name:
+ type: string
+ PasskeySignInRequest:
+ type: object
+ required:
+ - credential
+ properties:
+ credential:
+ $ref: '#/components/schemas/CredentialResponse_AssertionResponse'
+ description: The `PublicKeyCredential` returned by `navigator.credentials.get()`.
+ device_name:
+ type:
+ - string
+ - 'null'
+ remember_me:
+ type:
+ - boolean
+ - 'null'
+ PasskeySignInResponse:
+ type: object
+ required:
+ - access_token
+ - refresh_token
+ properties:
+ access_token:
+ type: string
+ refresh_token:
+ type: string
+ PermissionResponse:
+ type: object
+ required:
+ - name
+ properties:
+ description:
+ type:
+ - string
+ - 'null'
+ name:
+ type: string
+ description: '`resource:action`.'
+ PersonalAccessTokenExchangeRequest:
+ type: object
+ required:
+ - token
+ properties:
+ token:
+ type: string
+ description: A personal access token (`aapat_...`).
+ PersonalAccessTokenExchangeResponse:
+ type: object
+ required:
+ - access_token
+ - token_type
+ - expires_in
+ properties:
+ access_token:
+ type: string
+ expires_in:
+ type: integer
+ format: int64
+ description: Seconds.
+ minimum: 0
+ token_type:
+ type: string
+ description: Always `Bearer`.
+ PersonalAccessTokenResponse:
+ type: object
+ required:
+ - id
+ - name
+ - scopes
+ - created_at
+ - expires_at
+ properties:
+ created_at:
+ type: integer
+ format: int64
+ expires_at:
+ type: integer
+ format: int64
+ id:
+ type: string
+ format: uuid
+ last_used_at:
+ type:
+ - integer
+ - 'null'
+ format: int64
+ name:
+ type: string
+ scopes:
+ type: array
+ items:
+ type: string
+ ReadyResponse:
+ type: object
+ description: What `/ready` found for each dependency.
+ required:
+ - status
+ - database
+ - redis
+ - nats
+ properties:
+ database:
+ type: string
+ description: '`up` or `down`.'
+ nats:
+ type: string
+ redis:
+ type: string
+ status:
+ type: string
+ description: '`ready` when every dependency answered, `unavailable` otherwise.'
+ ReauthenticateRequest:
+ type: object
+ required:
+ - current_password
+ properties:
+ current_password:
+ type: string
+ RecoveryCodesResponse:
+ type: object
+ required:
+ - recovery_codes
+ properties:
+ recovery_codes:
+ type: array
+ items:
+ type: string
+ description: Plaintext recovery codes shown once. The user must store them securely.
+ RecoveryLoginRequest:
+ type: object
+ required:
+ - pre_auth_token
+ - recovery_code
+ properties:
+ pre_auth_token:
+ type: string
+ recovery_code:
+ type: string
+ RefreshRequest:
+ type: object
+ required:
+ - refresh_token
+ properties:
+ refresh_token:
+ type: string
+ RegenerateRecoveryCodesRequest:
+ type: object
+ properties:
+ current_password:
+ type:
+ - string
+ - 'null'
+ RegisterPasskeyRequest:
+ type: object
+ required:
+ - name
+ - credential
+ properties:
+ credential:
+ $ref: '#/components/schemas/CredentialResponse_AttestationResponse'
+ description: The `PublicKeyCredential` returned by `navigator.credentials.create()`.
+ name:
+ type: string
+ description: Label shown in the passkey list, such as "iPhone".
+ RegisterRequest:
+ type: object
+ required:
+ - username
+ - email
+ - password
+ properties:
+ captcha_token:
+ type:
+ - string
+ - 'null'
+ description: hCaptcha token from the frontend widget. Required when CAPTCHA_SECRET is configured.
+ email:
+ type: string
+ locale:
+ type:
+ - string
+ - 'null'
+ password:
+ type: string
+ username:
+ type: string
+ RegisteredPasskeyResponse:
+ type: object
+ required:
+ - passkey
+ properties:
+ passkey:
+ $ref: '#/components/schemas/PasskeyResponse'
+ recovery_codes:
+ type:
+ - array
+ - 'null'
+ items:
+ type: string
+ description: Shown once, when the account had no recovery code left.
+ RegistrationAccepted:
+ type: object
+ description: |-
+ Registration answer. Identical whether the address was free or already had
+ an account, so it cannot be used to enumerate accounts.
+ required:
+ - status
+ - message
+ properties:
+ message:
+ type: string
+ status:
+ type: string
+ ResendEmailTwoFactorRequest:
+ type: object
+ required:
+ - pre_auth_token
+ properties:
+ pre_auth_token:
+ type: string
+ ResendVerificationRequest:
+ type: object
+ required:
+ - email
+ properties:
+ captcha_token:
+ type:
+ - string
+ - 'null'
+ email:
+ type: string
+ ResetPasswordRequest:
+ type: object
+ required:
+ - token
+ - new_password
+ properties:
+ new_password:
+ type: string
+ token:
+ type: string
+ RevokeAllRequest:
+ type: object
+ properties:
+ current_password:
+ type:
+ - string
+ - 'null'
+ keep_current_session:
+ type: boolean
+ description: 'Keep the session making the request signed in. Default: false.'
+ RevokedSessionsResponse:
+ type: object
+ required:
+ - revoked
+ properties:
+ revoked:
+ type: integer
+ format: int64
+ minimum: 0
+ RolePermissionsRequest:
+ type: object
+ required:
+ - permissions
+ properties:
+ permissions:
+ type: array
+ items:
+ type: string
+ RoleResponse:
+ type: object
+ required:
+ - name
+ - is_default
+ - permissions
+ - created_at
+ properties:
+ created_at:
+ type: integer
+ format: int64
+ description:
+ type:
+ - string
+ - 'null'
+ is_default:
+ type: boolean
+ description: Given to every new account.
+ name:
+ type: string
+ permissions:
+ type: array
+ items:
+ type: string
+ SaveClientRequest:
+ type: object
+ required:
+ - display_name
+ properties:
+ allows_client_credentials:
+ type:
+ - boolean
+ - 'null'
+ description: |-
+ Allow the client credentials grant; needs a secret and scopes. Omitted:
+ unchanged (false for a new client).
+ allows_loopback_redirect:
+ type: boolean
+ default_max_sessions:
+ type:
+ - integer
+ - 'null'
+ format: int32
+ description: 'Default: 5.'
+ display_name:
+ type: string
+ is_primary:
+ type: boolean
+ redirect_uris:
+ type: array
+ items:
+ type: string
+ scopes:
+ type: array
+ items:
+ type: string
+ SessionResponse:
+ type: object
+ required:
+ - id
+ - session_type
+ - last_used_at
+ - expires_at
+ - created_at
+ - is_current
+ properties:
+ client_id:
+ type:
+ - string
+ - 'null'
+ created_at:
+ type: integer
+ format: int64
+ device_name:
+ type:
+ - string
+ - 'null'
+ expires_at:
+ type: integer
+ format: int64
+ id:
+ type: string
+ format: uuid
+ ip_address:
+ type:
+ - string
+ - 'null'
+ is_current:
+ type: boolean
+ description: True when this is the session used to make the current request.
+ last_used_at:
+ type: integer
+ format: int64
+ session_type:
+ $ref: '#/components/schemas/SessionType'
+ user_agent:
+ type:
+ - string
+ - 'null'
+ SessionType:
+ type: string
+ enum:
+ - web
+ - device
+ - personal_access_token
+ SetupTwoFactorRequest:
+ type: object
+ description: |-
+ Body of the setup endpoints. Optional: a recent re-authentication
+ (`POST /users/me/reauth`) makes the password unnecessary.
+ properties:
+ current_password:
+ type:
+ - string
+ - 'null'
+ SubmitNewEmailRequest:
+ type: object
+ required:
+ - flow_token
+ - new_email
+ properties:
+ flow_token:
+ type: string
+ new_email:
+ type: string
+ TokenOperationRequest:
+ type: object
+ description: Form fields of `POST /oauth/introspect` and `POST /oauth/revoke`.
+ required:
+ - token
+ properties:
+ client_id:
+ type:
+ - string
+ - 'null'
+ client_secret:
+ type:
+ - string
+ - 'null'
+ token:
+ type: string
+ token_type_hint:
+ type:
+ - string
+ - 'null'
+ description: '`access_token` or `refresh_token`; the token''s shape decides anyway.'
+ TokensResponse:
+ type: object
+ required:
+ - access_token
+ - refresh_token
+ properties:
+ access_token:
+ type: string
+ refresh_token:
+ type: string
+ TotpSetupResponse:
+ type: object
+ required:
+ - method_id
+ - qr_uri
+ - base32_secret
+ properties:
+ base32_secret:
+ type: string
+ description: Base32 secret shown once so the user can manually enter it in their app.
+ method_id:
+ type: string
+ format: uuid
+ qr_uri:
+ type: string
+ TwoFactorMethodResponse:
+ type: object
+ required:
+ - id
+ - method_type
+ - is_verified
+ - is_primary
+ - created_at
+ properties:
+ created_at:
+ type: integer
+ format: int64
+ id:
+ type: string
+ format: uuid
+ is_primary:
+ type: boolean
+ is_verified:
+ type: boolean
+ last_used_at:
+ type:
+ - integer
+ - 'null'
+ format: int64
+ method_type:
+ type: string
+ description: '`totp` or `email`.'
+ TwoFactorOverviewResponse:
+ type: object
+ required:
+ - methods
+ - recovery_codes_remaining
+ properties:
+ methods:
+ type: array
+ items:
+ $ref: '#/components/schemas/TwoFactorMethodResponse'
+ recovery_codes_remaining:
+ type: integer
+ format: int64
+ description: |-
+ Unused and unexpired. Zero next to a verified method is worth showing:
+ losing the device would then lock the account.
+ UseRecoveryCodeRequest:
+ type: object
+ required:
+ - code
+ properties:
+ code:
+ type: string
+ UserResponse:
+ type: object
+ required:
+ - id
+ - username
+ - email
+ - status
+ - preferred_locale
+ - created_at
+ properties:
+ created_at:
+ type: integer
+ format: int64
+ email:
+ type: string
+ email_verified_at:
+ type:
+ - integer
+ - 'null'
+ format: int64
+ id:
+ type: string
+ format: uuid
+ last_login_at:
+ type:
+ - integer
+ - 'null'
+ format: int64
+ preferred_locale:
+ type: string
+ status:
+ type: string
+ username:
+ type: string
+ VerifyCurrentEmailRequest:
+ type: object
+ required:
+ - flow_token
+ - code
+ properties:
+ code:
+ type: string
+ flow_token:
+ type: string
+ VerifyEmailOtpSetupRequest:
+ type: object
+ required:
+ - code
+ properties:
+ code:
+ type: string
+ VerifyEmailRequest:
+ type: object
+ required:
+ - token
+ properties:
+ token:
+ type: string
+ VerifyTotpSetupRequest:
+ type: object
+ required:
+ - code
+ properties:
+ code:
+ type: string
+ WebhookDeliveryResponse:
+ type: object
+ required:
+ - id
+ - event_id
+ - event
+ - attempts
+ - created_at
+ properties:
+ attempts:
+ type: integer
+ format: int32
+ created_at:
+ type: integer
+ format: int64
+ delivered_at:
+ type:
+ - integer
+ - 'null'
+ format: int64
+ event:
+ type: string
+ event_id:
+ type: string
+ format: uuid
+ failed_at:
+ type:
+ - integer
+ - 'null'
+ format: int64
+ id:
+ type: string
+ format: uuid
+ last_error:
+ type:
+ - string
+ - 'null'
+ last_status:
+ type:
+ - integer
+ - 'null'
+ format: int32
+ next_attempt_at:
+ type:
+ - integer
+ - 'null'
+ format: int64
+ description: Next attempt, while neither delivered nor given up.
+ WebhookRequest:
+ type: object
+ required:
+ - url
+ - events
+ properties:
+ description:
+ type:
+ - string
+ - 'null'
+ enabled:
+ type:
+ - boolean
+ - 'null'
+ description: 'Default: true.'
+ events:
+ type: array
+ items:
+ type: string
+ description: Event names (`user.created`, `user.deleted`, ...) or `*`.
+ url:
+ type: string
+ description: HTTPS URL receiving the deliveries.
+ WebhookResponse:
+ type: object
+ required:
+ - id
+ - url
+ - events
+ - enabled
+ - created_at
+ - updated_at
+ properties:
+ created_at:
+ type: integer
+ format: int64
+ description:
+ type:
+ - string
+ - 'null'
+ enabled:
+ type: boolean
+ events:
+ type: array
+ items:
+ type: string
+ id:
+ type: string
+ format: uuid
+ updated_at:
+ type: integer
+ format: int64
+ url:
+ type: string
+ WebhookSecretResponse:
+ type: object
+ required:
+ - secret
+ properties:
+ secret:
+ type: string
+ description: The new signing secret, shown once; the previous one stops signing now.
+ securitySchemes:
+ bearer:
+ type: http
+ scheme: bearer
+ bearerFormat: JWT
+tags:
+- name: discovery
+ description: Health and public keys
+- name: auth
+ description: Registration, sign-in, tokens and two-factor challenges
+- name: oauth
+ description: 'OAuth 2.1: authorization code with PKCE, device authorization, token endpoint and metadata (RFC 6749, 7636, 8252, 8414, 8628)'
+- name: account
+ description: The caller's profile, security history and re-authentication
+- name: email-change
+ description: Changing the account's email address
+- name: sessions
+ description: Active sessions
+- name: two-factor
+ description: Second factors and recovery codes
+- name: external-identities
+ description: Signing in with Google, GitHub or an OpenID Connect provider, and linking them to an account
+- name: passkeys
+ description: 'Passkeys (WebAuthn): registering them and signing in with them'
+- name: admin
+ description: 'Administration: accounts, roles, client applications and the audit log. Requires the permission of each operation and a second factor'
diff --git a/docs/dev/api/routes.md b/docs/dev/api/routes.md
index cd5dbd8..2d38b9f 100644
--- a/docs/dev/api/routes.md
+++ b/docs/dev/api/routes.md
@@ -1,105 +1,345 @@
# API Routes
+The machine-readable contract is [`openapi.yaml`](openapi.yaml). This page is
+the overview.
+
## Legend
| Auth | Meaning |
|------|---------|
-| - | No authentication required |
-| JWT | Valid access token required |
+| - | No authentication |
+| JWT | Access token in `Authorization: Bearer` |
+| Admin | Access token carrying the named permission, still granted in the database, from an account with a second factor |
+| JWT + reauth | Access token, and a recent re-authentication: `POST /users/me/reauth` within `SENSITIVE_ACTION_REAUTH_SECS`, or `current_password` in the body. A fresh sign-in does not count |
| Rate limit | Meaning |
|------------|---------|
-| General | Shared bucket - `RATE_LIMIT_RPM` requests/min per IP |
-| Auth | Strict bucket - `RATE_LIMIT_AUTH_RPM` requests/min per IP |
+| General | `RATE_LIMIT_RPM` per client per minute |
+| Strict | Counts against the general budget **and** `RATE_LIMIT_AUTH_RPM` |
+
+Each request passes one limiter, which checks all of its buckets in a single
+Redis call; a refused request consumes nothing. A `429` carries `Retry-After`.
+
+Timestamps are Unix seconds. Errors are `{"code": "...", "message": "..."}`
+with a stable `code`.
-## Discovery & Health
+## Discovery
| Method | Route | Auth | Rate limit |
|--------|-------|------|------------|
-| GET | `/health` | - | General |
+| GET | `/health`, `/live` | - | None (liveness) |
+| GET | `/ready` | - | None (readiness: database, Redis, NATS) |
| GET | `/.well-known/jwks.json` | - | General |
-The JWKS endpoint publishes the ES256 public key(s) (current + previous during
-a rotation window) and is served with `cache-control: public, max-age=300` so
-downstream verifiers can cache it.
+The JWKS lists the current signing key, and the previous one during a
+rotation, with `cache-control: public, max-age=300`.
-Prometheus metrics (`GET /metrics`) are **not** served on the public port:
-they live on a separate internal listener (`METRICS_PORT`, default 9464),
-published on loopback only and never routed through the reverse proxy.
+Prometheus metrics are not on this listener: `METRICS_PORT` (default 9464),
+loopback only.
-## Authentication
+## Sign-in
| Method | Route | Auth | Rate limit |
|--------|-------|------|------------|
-| POST | `/auth/register` | - | Auth |
-| POST | `/auth/login` | - | Auth |
-| POST | `/auth/refresh` | - | Auth |
+| POST | `/auth/register` | - | Strict |
+| POST | `/auth/verify-email` | - | Strict |
+| POST | `/auth/verify-email/resend` | - | Strict |
+| POST | `/auth/login` | - | Strict |
+| POST | `/auth/two-factor/complete` | pre-auth token | Strict |
+| POST | `/auth/two-factor/email/complete` | pre-auth token | Strict |
+| POST | `/auth/two-factor/email/resend` | pre-auth token | Strict |
+| POST | `/auth/two-factor/recovery` | pre-auth token | Strict |
+| POST | `/auth/refresh` | refresh token | Strict |
| POST | `/auth/logout` | JWT | General |
-| POST | `/auth/verify-email` | - | Auth |
-| POST | `/auth/forgot-password` | - | Auth |
-| POST | `/auth/reset-password` | - | Auth |
-| POST | `/auth/two-factor/complete` | - | Auth |
-| POST | `/auth/two-factor/recovery` | - | Auth |
-| POST | `/auth/two-factor/email/complete` | - | Auth |
-| POST | `/auth/two-factor/email/resend` | - | Auth |
+| POST | `/auth/magic-link` | - | Strict |
+| POST | `/auth/magic-link/complete` | sign-in link | Strict |
+| POST | `/auth/personal-access-tokens/exchange` | personal access token | Strict |
+| POST | `/auth/forgot-password` | - | Strict |
+| POST | `/auth/reset-password` | - | Strict |
+
+- `register` answers `202` the same way whether or not the address is taken;
+ the owner of a taken address gets an email instead.
+- `login` answers tokens, or `{ "two_factor_required": ..., "pre_auth_token", "method" }`.
+ Each pre-auth token is bound to the method it was issued for.
+- `refresh` rotates the refresh token. Presenting a rotated token again revokes
+ the whole session family, except within 2 seconds of the rotation (two tabs,
+ a retried request).
+- Logout stays outside the strict bucket so an exhausted budget never prevents
+ ending a session.
+- `magic-link` (when `MAGIC_LINK_ENABLED`) mails a sign-in link valid 15 minutes
+ and once, answering alike for every address; `magic-link/complete` answers
+ like `login`, including the two-factor challenge. A new link replaces the
+ previous one.
+
+## Client applications (OAuth 2.1)
-## Device authorization (RFC 8628)
+Standard endpoints: RFC 6749, 7636 (PKCE), 8252 (native apps), 8414 (metadata)
+and 8628 (device authorization). Token and device authorization requests are
+`application/x-www-form-urlencoded`; their errors are
+`{ "error", "error_description" }` with `Cache-Control: no-store`.
| Method | Route | Auth | Rate limit |
|--------|-------|------|------------|
-| POST | `/auth/device` | - | Auth |
-| POST | `/auth/device/token` | - | Auth |
-| POST | `/auth/device/verify` | JWT | Auth |
+| GET | `/.well-known/oauth-authorization-server` | - | General |
+| GET | `/.well-known/openid-configuration` | - | General |
+| GET | `/oauth/userinfo` | JWT of a session granted `openid` | Strict |
+| GET | `/oauth/authorize` | - | Strict |
+| GET | `/oauth/authorization-requests/{id}` | JWT | Strict |
+| POST | `/oauth/authorization-requests/{id}/approve` | JWT (+ reauth for non-primary clients) | Strict |
+| POST | `/oauth/authorization-requests/{id}/deny` | JWT | Strict |
+| POST | `/oauth/token` | client | Strict |
+| POST | `/oauth/device_authorization` | client | Strict |
+| POST | `/oauth/introspect` | confidential client | Strict |
+| POST | `/oauth/revoke` | client | Strict |
+| GET | `/oauth/device/{user_code}` | JWT | Strict |
+| POST | `/oauth/device/verify` | JWT | Strict |
+
+**Client authentication.** A public client sends `client_id`. A confidential
+client (one given a secret with `POST /admin/clients/{client_id}/secret`)
+authenticates with `Authorization: Basic` (`client_secret_basic`) or
+`client_id` and `client_secret` in the body (`client_secret_post`), never both;
+a failure answers `401 invalid_client`.
+
+**Authorization code.** `GET /oauth/authorize` takes `response_type=code`,
+`client_id`, `redirect_uri` (optional when the client has exactly one),
+`code_challenge` with `code_challenge_method=S256`, `scope` and `state`.
+
+- An unknown client or an unregistered redirect URI answers directly with an
+ error, never through the redirect.
+- Other errors (`unsupported_response_type`, `invalid_request`,
+ `invalid_scope`) go back to the redirect URI with `error` and `state`.
+- A valid request is stored for 10 minutes and the browser is sent (`303`) to
+ `OAUTH_CONSENT_URI?request_id=...`. The consent page reads it with
+ `GET /oauth/authorization-requests/{id}` (client, scopes, session limits,
+ whether a re-authentication is needed) and approves or denies it; the answer
+ holds `redirect_to`, the client redirect carrying `code` and `state`, or
+ `error=access_denied`. A request is decided once.
+- The redirect URI must be registered exactly, or be a loopback
+ `http://127.0.0.1:{port}/path` / `http://[::1]:{port}/path` for a registered
+ path when the client allows it. `localhost` is refused.
+- The client redeems the code at `POST /oauth/token` with
+ `grant_type=authorization_code`, `code`, `code_verifier` and `redirect_uri`.
+ A code is single use: a failed redemption burns it, and a replayed code
+ revokes the session it produced.
+
+**Scopes.** `scope` lists permissions. A client registered with scopes may ask
+for a subset of them; without `scope`, its registered scopes apply. Tokens carry
+the consented scopes the user holds, on issue and on every refresh, and no
+roles. The token response echoes `scope` when the session is restricted.
+
+**Refresh.** A client refreshes its sessions at `POST /oauth/token` with
+`grant_type=refresh_token`; `/auth/refresh` refuses them. The session must
+belong to the authenticated client.
-`/auth/device` starts the flow (returns `device_code` + `user_code`),
-`/auth/device/token` is polled by the device until approval, and
-`/auth/device/verify` is called by the already-authenticated user to approve
-or deny the `user_code`. Registered clients and per-user device session
-quotas are enforced (`registered_clients`, `user_client_quotas`).
+**Client credentials.** A confidential client registered with scopes and
+`allows_client_credentials` (`PUT /admin/clients/{client_id}`) posts
+`grant_type=client_credentials` and optional `scope` to `POST /oauth/token`. The
+token carries no user and no refresh token: `sub` is a UUID derived from the
+client id, `client_id` names the client, `sid` is nil, and `permissions` are the
+granted scopes. Account routes refuse it; resource servers accept it like any
+access token. Removing the client's secret or turning the grant off ends the
+tokens already issued, as introspection reports.
-## Profile
+**OpenID Connect.** The provider supports the authorization code flow:
+`GET /.well-known/openid-configuration`, the `openid`, `profile` and `email`
+scopes (any client may ask for them; they grant no permission), `nonce`, an
+`id_token` (ES256, `aud` the client, `at_hash`, `auth_time`, and the claims of
+the granted scopes) in token responses of sessions granted `openid`, refreshes
+included, and `GET /oauth/userinfo` for their access tokens. `profile` releases
+`preferred_username`, `locale` and `updated_at`; `email` releases `email` and
+`email_verified`. Not supported: implicit and hybrid flows, request objects,
+`prompt`, `max_age`, dynamic registration.
+
+**Introspection (RFC 7662).** A confidential client (a resource server)
+posts `token` and learns `active`, and for an active token its `token_type`
+(`access_token`, `refresh_token`, `personal_access_token`), `scope`,
+`client_id`, `sub`, `exp`, `iat` and, for access tokens, `iss`, `aud` and `jti`.
+Anything unknown, expired or revoked is `{ "active": false }`.
+
+**Revocation (RFC 7009).** A client posts one of its tokens. A refresh token
+ends its session and every access token of it; an access token stops working
+until it expires. Unknown tokens and tokens of other clients get the same `200`
+and are left alone.
+
+**Device authorization.** `POST /oauth/device_authorization` (`client_id`,
+`scope`) answers `device_code`, `user_code`, `verification_uri`,
+`verification_uri_complete`, `expires_in` and `interval`. The device polls
+`POST /oauth/token` with `grant_type=urn:ietf:params:oauth:grant-type:device_code`:
+`authorization_pending`, `slow_down` when polling faster than the interval,
+`access_denied`, `expired_token`, or tokens. The signed-in user previews the
+request (`GET /oauth/device/{user_code}`) and approves or denies it
+(`POST /oauth/device/verify`). An approval is collected once, by the client that
+started the flow; account status and the client's session limit are checked
+when tokens are issued (`invalid_grant` otherwise).
+
+## Account
| Method | Route | Auth | Rate limit |
|--------|-------|------|------------|
| GET | `/users/me` | JWT | General |
-| PATCH | `/users/me/username` | JWT | General |
-| PATCH | `/users/me/password` | JWT | General |
+| GET | `/users/me/audit` | JWT | General |
+| POST | `/users/me/reauth` | JWT | Strict |
+| GET | `/users/me/export` | JWT + reauth (recent only) | Strict |
+| GET | `/users/me/tokens` | JWT | General |
+| POST | `/users/me/tokens` | JWT + reauth (recent only) | General |
+| DELETE | `/users/me/tokens/{id}` | JWT | General |
+| PATCH | `/users/me/username` | JWT + reauth | General |
+| PATCH | `/users/me/password` | JWT + reauth | General |
| PATCH | `/users/me/locale` | JWT | General |
-| DELETE | `/users/me` | JWT | General |
-| POST | `/users/me/reauth` | JWT | Auth |
+| DELETE | `/users/me` | JWT + reauth | General |
+
+`/users/me/audit?limit=&cursor=` returns the caller's own security history,
+newest first: `{ "entries": [...], "next_cursor" }`. Pass `next_cursor` back as
+`cursor`; it is absent on the last page. `limit` is clamped to 1-200.
+
+Personal access tokens (`aapat_...`) are shown once, at creation. Their
+exchange returns `{ "access_token", "token_type": "Bearer", "expires_in" }`: an
+access token carrying the token's scopes (intersected with the account's
+current permissions) and no roles. A token lives in a session of type
+`personal_access_token`: revoking either ends both.
+
+`/users/me/export` downloads everything stored about the account as one JSON
+document (`account-data.json`): profile, roles, sessions, second factors,
+recovery code counts, known devices, client quotas, sign-in attempts and the
+security history. No password hash, secret or token digest is included. As a
+`GET` it takes no body: re-authenticate with `POST /users/me/reauth` first.
## Email change
| Method | Route | Auth | Rate limit |
|--------|-------|------|------------|
-| POST | `/users/me/email/start` | JWT | Auth |
-| POST | `/users/me/email/verify-current` | JWT | Auth |
-| POST | `/users/me/email/submit` | JWT | Auth |
-| POST | `/users/me/email/confirm` | JWT | Auth |
+| POST | `/users/me/email/start` | JWT + reauth | Strict |
+| POST | `/users/me/email/verify-current` | JWT | Strict |
+| POST | `/users/me/email/submit` | JWT | Strict |
+| POST | `/users/me/email/confirm` | JWT | Strict |
+
+A code is sent to the current address, then to the new one. Confirming revokes
+every other session and notifies the previous address.
## Sessions
| Method | Route | Auth | Rate limit |
|--------|-------|------|------------|
| GET | `/users/me/sessions` | JWT | General |
-| DELETE | `/users/me/sessions` | JWT | General |
-| DELETE | `/users/me/sessions/{id}` | JWT | General |
+| DELETE | `/users/me/sessions` | JWT + reauth | General |
+| DELETE | `/users/me/sessions/{id}` | JWT + reauth | General |
-## Two-factor - TOTP
+## External identities
| Method | Route | Auth | Rate limit |
|--------|-------|------|------------|
-| POST | `/users/me/two-factor/totp/setup` | JWT | General |
-| POST | `/users/me/two-factor/totp/{id}/verify` | JWT | General |
-| DELETE | `/users/me/two-factor/totp/{id}` | JWT | General |
-| POST | `/users/me/two-factor/recovery-codes` | JWT | General |
-| POST | `/users/me/two-factor/recovery-codes/use` | JWT | General |
+| GET | `/auth/external/providers` | - | Strict |
+| POST | `/auth/external/{provider}/start` | - | Strict |
+| GET | `/auth/external/{provider}/callback` | - | Strict |
+| POST | `/auth/external/complete` | outcome code + binding | Strict |
+| GET | `/users/me/external-identities` | JWT | General |
+| POST | `/users/me/external-identities/{provider}/start` | JWT + reauth (recent only) | General |
+| POST | `/users/me/external-identities/complete` | JWT | General |
+| DELETE | `/users/me/external-identities/{id}` | JWT + reauth | General |
-## Two-factor - Email OTP
+Providers (`IDENTITY_PROVIDERS`): Google, GitHub, or any OpenID Connect issuer.
+
+1. `start` answers `{ "authorization_url", "binding" }`. Keep `binding` in the
+ browser (session storage) and send the browser to `authorization_url`
+ (authorization code with PKCE; state and nonce).
+2. The provider sends the browser back to `/auth/external/{provider}/callback`,
+ which exchanges the code, verifies the ID token (signature from the
+ provider's JWKS, issuer, audience, nonce, expiry) or reads the GitHub user,
+ then redirects (`303`) to `EXTERNAL_LOGIN_URI?code=...`.
+3. The frontend sends `{ "code", "binding" }` to `/auth/external/complete` (a
+ sign-in: tokens or the account's two-factor challenge, like `login`) or to
+ `/users/me/external-identities/complete` (a link: `201`). An outcome is used
+ once, within two minutes, by the browser holding its binding.
+
+An identity signs in only once linked by the signed-in owner of the account:
+nothing is matched or created from an email address
+(`409 external_identity_not_linked`). One identity links to one account, and an
+account links one identity per provider
+(`409 external_identity_already_linked`).
+
+## Passkeys
+
+| Method | Route | Auth | Rate limit |
+|--------|-------|------|------------|
+| GET | `/users/me/passkeys` | JWT | General |
+| POST | `/users/me/passkeys/options` | JWT + reauth (recent only) | General |
+| POST | `/users/me/passkeys` | JWT | General |
+| DELETE | `/users/me/passkeys/{id}` | JWT + reauth | General |
+| POST | `/auth/passkeys/options` | - | Strict |
+| POST | `/auth/passkeys/sign-in` | passkey | Strict |
+
+Registration: `options` returns the `PublicKeyCredentialCreationOptions` (JSON
+form) to pass to `navigator.credentials.create()`, then `POST /users/me/passkeys`
+sends `{ "name", "credential" }` with the credential's `toJSON()`. Passkeys are
+discoverable, require user verification, and use ES256, EdDSA or RS256;
+attestation is not requested. The first passkey of an account without recovery
+codes returns ten, once.
+
+Sign-in: `POST /auth/passkeys/options` returns request options with no allowed
+credentials (the browser offers the user's passkeys), then
+`POST /auth/passkeys/sign-in` sends `{ "credential", "device_name", "remember_me" }`
+and gets `{ "access_token", "refresh_token" }`. A passkey with user verification
+is two factors: no second-factor challenge follows. A passkey also counts as the
+second factor `/admin` requires.
+
+## Two-factor
| Method | Route | Auth | Rate limit |
|--------|-------|------|------------|
-| POST | `/users/me/two-factor/email/setup` | JWT | General |
+| GET | `/users/me/two-factor` | JWT | General |
+| POST | `/users/me/two-factor/totp/setup` | JWT + reauth | General |
+| POST | `/users/me/two-factor/totp/{id}/verify` | JWT | General |
+| DELETE | `/users/me/two-factor/totp/{id}` | JWT + reauth | General |
+| POST | `/users/me/two-factor/email/setup` | JWT + reauth | General |
| POST | `/users/me/two-factor/email/send` | JWT | General |
| POST | `/users/me/two-factor/email/{id}/verify` | JWT | General |
-| DELETE | `/users/me/two-factor/email/{id}` | JWT | General |
+| DELETE | `/users/me/two-factor/email/{id}` | JWT + reauth | General |
+| POST | `/users/me/two-factor/recovery-codes` | JWT + reauth | General |
+| POST | `/users/me/two-factor/recovery-codes/use` | JWT | General |
+
+`GET /users/me/two-factor` lists the configured methods (with the ids the other
+routes need) and `recovery_codes_remaining`, the unused and unexpired codes.
+Recovery codes are shown once, when a method is first verified or when they
+are regenerated.
+
+## Administration
+
+| Method | Route | Auth | Rate limit |
+|--------|-------|------|------------|
+| GET | `/admin/users` | Admin `users:read` | General |
+| GET | `/admin/users/{id}` | Admin `users:read` | General |
+| POST | `/admin/users/{id}/suspend` | Admin `users:manage` | General |
+| POST | `/admin/users/{id}/reactivate` | Admin `users:manage` | General |
+| POST | `/admin/users/{id}/unlock` | Admin `users:manage` | General |
+| DELETE | `/admin/users/{id}/sessions` | Admin `users:manage` | General |
+| POST | `/admin/users/{id}/password-reset` | Admin `users:manage` | General |
+| DELETE | `/admin/users/{id}` | Admin `users:manage` + reauth | General |
+| POST | `/admin/users/{id}/roles` | Admin `roles:manage` + reauth | General |
+| DELETE | `/admin/users/{id}/roles/{name}` | Admin `roles:manage` | General |
+| GET | `/admin/permissions` | Admin `roles:manage` | General |
+| GET | `/admin/roles` | Admin `roles:manage` | General |
+| POST | `/admin/roles` | Admin `roles:manage` + reauth | General |
+| PUT | `/admin/roles/{name}/permissions` | Admin `roles:manage` + reauth | General |
+| DELETE | `/admin/roles/{name}` | Admin `roles:manage` | General |
+| GET | `/admin/clients` | Admin `clients:manage` | General |
+| PUT | `/admin/clients/{client_id}` | Admin `clients:manage` + reauth | General |
+| DELETE | `/admin/clients/{client_id}` | Admin `clients:manage` | General |
+| POST | `/admin/clients/{client_id}/secret` | Admin `clients:manage` + reauth | General |
+| DELETE | `/admin/clients/{client_id}/secret` | Admin `clients:manage` | General |
+| GET | `/admin/audit` | Admin `audit:read` | General |
+
+`GET /admin/users` takes `query` (start of the address or username), `status`,
+`limit` and `cursor`, and pages newest first. Administrators cannot suspend,
+sign out, reset or delete their own account here; they use `/users/me`.
+
+A change to roles that would leave no account with `roles:manage` answers
+`409 last_administrator`; the default role cannot be deleted
+(`409 default_role`). Access tokens carry the permissions of their issuance
+until refreshed; `/admin` routes read them from the database on every request.
+Webhooks (`webhooks:manage`): `GET`/`POST /admin/webhooks`,
+`PUT`/`DELETE /admin/webhooks/{id}`, `POST /admin/webhooks/{id}/secret`,
+`GET /admin/webhooks/{id}/deliveries` and
+`POST /admin/webhooks/{id}/deliveries/{delivery_id}/retry`. See the
+[webhook guide](../guides/webhooks.md).
+
+`GET /admin/audit` takes `user_id`, `action`, `limit` and `cursor`.
diff --git a/docs/dev/database/schema.md b/docs/dev/database/schema.md
index c67cfa2..13bbaea 100644
--- a/docs/dev/database/schema.md
+++ b/docs/dev/database/schema.md
@@ -1,207 +1,225 @@
# Database Schema
-## users
+Migrations live in `migrations/` and are never edited once released: every
+change is a new file. Tokens, codes and refresh tokens are stored as SHA-256
+digests (32 bytes), never in clear.
-Core account table.
+## Accounts
+
+### users
| Column | Type | Nullable | Description |
|--------|------|----------|-------------|
| `id` | UUID | No | Primary key |
-| `created_at` | TIMESTAMPTZ | No | Account creation date |
-| `updated_at` | TIMESTAMPTZ | No | Last update (auto-set by trigger) |
-| `email_verified_at` | TIMESTAMPTZ | Yes | When the email was verified |
-| `last_login_at` | TIMESTAMPTZ | Yes | Last successful login |
-| `locked_until` | TIMESTAMPTZ | Yes | Lockout expiry after failed attempts |
-| `status` | user_status | No | `active`, `inactive`, `suspended`, `pending_verification` |
-| `preferred_locale` | VARCHAR(10) | No | Locale code (e.g. `en`, `fr_FR`) |
-| `username` | VARCHAR(50) | No | Unique, alphanumeric + underscore, 3-50 chars |
+| `created_at` | TIMESTAMPTZ | No | |
+| `updated_at` | TIMESTAMPTZ | No | Set by trigger |
+| `email_verified_at` | TIMESTAMPTZ | Yes | |
+| `last_login_at` | TIMESTAMPTZ | Yes | Last completed sign-in |
+| `locked_until` | TIMESTAMPTZ | Yes | Lockout expiry after repeated wrong passwords |
+| `lockout_cleared_at` | TIMESTAMPTZ | Yes | Last unlock by an administrator; earlier failures no longer count toward a lockout |
+| `status` | user_status | No | `pending_verification`, `active`, `inactive`, `suspended` |
+| `preferred_locale` | VARCHAR(10) | No | `en`, `fr`, ... |
+| `username` | VARCHAR(50) | No | Unique, case-insensitive |
| `email` | CITEXT | No | Unique, case-insensitive |
-| `password_hash` | TEXT | No | Argon2id hash |
+| `password_hash` | TEXT | No | Argon2id |
-### roles
+### roles, permissions, role_permissions, user_roles
-Application roles for RBAC.
+Role-based access control. A token carries the names of the user's roles and of
+the permissions those roles grant (`permissions.name` is generated as
+`resource:action`). Exactly one role is `is_default` and is assigned at
+registration. `user_roles.granted_by` records who granted a role. The `admin`
+role grants the administrative permissions (`users:read`, `users:manage`,
+`roles:manage`, `clients:manage`, `audit:read`, `webhooks:manage`).
-| Column | Type | Nullable | Description |
-|--------|------|----------|-------------|
-| `id` | UUID | No | Primary key |
-| `created_at` | TIMESTAMPTZ | No | |
-| `is_default` | BOOLEAN | No | Automatically assigned on registration (only one allowed) |
-| `name` | VARCHAR(50) | No | Unique role name |
-| `description` | TEXT | Yes | |
+## Sessions and tokens
-## permissions
+### sessions
-Permission catalog for RBAC.
+One row per refresh token. Rotation creates a new row in the same family and
+marks the old one rotated.
| Column | Type | Nullable | Description |
|--------|------|----------|-------------|
-| `id` | UUID | No | Primary key |
-| `created_at` | TIMESTAMPTZ | No | |
-| `resource` | VARCHAR(50) | No | Resource name (e.g. `users`) |
-| `action` | VARCHAR(50) | No | Action name (e.g. `read`) |
-| `name` | TEXT | No | Generated: `resource:action` |
-| `description` | TEXT | Yes | |
+| `id` | UUID | No | Primary key; `sid` claim of access tokens |
+| `user_id` | UUID | No | FK -> users |
+| `session_family_id` | UUID | No | Rotations of one sign-in |
+| `family_created_at` | TIMESTAMPTZ | No | Start of the sign-in; the absolute lifetime counts from it |
+| `token_hash` | BYTEA | No | Refresh token digest, unique |
+| `created_at`, `last_used_at`, `expires_at` | TIMESTAMPTZ | No | |
+| `revoked_at` | TIMESTAMPTZ | Yes | |
+| `rotated_at` | TIMESTAMPTZ | Yes | |
+| `replaced_by_session_id` | UUID | Yes | FK -> sessions, the successor |
+| `compromised_at`, `compromise_reason` | | Yes | Set when a replay is detected |
+| `session_type` | session_type | No | `web`, `device` or `personal_access_token` |
+| `client_id` | VARCHAR(100) | Yes | Registered client the session was issued to |
+| `scopes` | TEXT[] | Yes | Permissions consented for that client; `NULL` is unrestricted |
+| `ip_address`, `user_agent`, `device_name` | | Yes | |
+| `remember_me` | BOOLEAN | No | |
-## role_permissions
+### known_devices
-Pivot table - roles to permissions.
+Devices an account signed in from, for new-device alerts: `user_id`, a SHA-256
+`fingerprint` of the browser and operating system families (no version, no
+address), `first_seen_at`, `last_seen_at`. Forgotten after
+`CLEANUP_KNOWN_DEVICE_DAYS` unused.
-| Column | Type | Nullable | Description |
-|--------|------|----------|-------------|
-| `role_id` | UUID | No | FK -> roles |
-| `permission_id` | UUID | No | FK -> permissions |
+### email_verification_tokens, password_reset_tokens, magic_link_tokens
-## user_roles
+Single-use tokens (`token_hash`, `expires_at`, `used_at`). At most one active
+token per user. `magic_link_tokens` hold sign-in links (15 minutes).
-Pivot table - users to roles.
+### webhook_endpoints, webhook_deliveries
-| Column | Type | Nullable | Description |
-|--------|------|----------|-------------|
-| `user_id` | UUID | No | FK -> users |
-| `role_id` | UUID | No | FK -> roles |
-| `granted_by` | UUID | Yes | FK -> users (actor who granted the role) |
-| `granted_at` | TIMESTAMPTZ | No | |
+`webhook_endpoints`: `url`, `description`, `events` (names or `*`), `secret`
+(encrypted with the keyring), `enabled`. `webhook_deliveries`: one row per
+endpoint and event (`event_id`, `event_name`, `payload`, `occurred_at`),
+recorded with the event; `attempts`, `next_attempt_at`, `delivered_at` or
+`failed_at`, `last_status`, `last_error`.
-## sessions
+### external_identities
-Persistent refresh token sessions with rotation and compromise detection.
+Identities at external providers linked to an account: `provider`, `subject`
+(unique per provider), `last_used_at`; one per provider per account.
-| Column | Type | Nullable | Description |
-|--------|------|----------|-------------|
-| `id` | UUID | No | Primary key |
-| `user_id` | UUID | No | FK -> users |
-| `session_family_id` | UUID | No | Groups related sessions for family revocation |
-| `token_hash` | BYTEA | No | SHA-256 of the refresh token (32 bytes) |
-| `created_at` | TIMESTAMPTZ | No | |
-| `last_used_at` | TIMESTAMPTZ | No | |
-| `expires_at` | TIMESTAMPTZ | No | |
-| `revoked_at` | TIMESTAMPTZ | Yes | Set when session is terminated |
-| `rotated_at` | TIMESTAMPTZ | Yes | Set when token was rotated |
-| `compromised_at` | TIMESTAMPTZ | Yes | Set on replay detection |
-| `compromise_reason` | session_compromise_reason | Yes | `refresh_token_reuse`, `manual_security_action`, `credentials_rotated` |
-| `replaced_by_session_id` | UUID | Yes | FK -> sessions (successor after rotation) |
-| `ip_address` | INET | Yes | |
-| `user_agent` | TEXT | Yes | |
-| `device_name` | VARCHAR(100) | Yes | |
-| `remember_me` | BOOLEAN | No | |
+### passkeys
-## two_factor_methods
+WebAuthn credentials: `credential_id` (unique), `public_key` (COSE),
+`algorithm`, `sign_count`, `aaguid`, `name`, `backup_eligible`, `backed_up`,
+`last_used_at`.
-Second-factor registry per user.
+### personal_access_tokens
-| Column | Type | Nullable | Description |
-|--------|------|----------|-------------|
-| `id` | UUID | No | Primary key |
-| `user_id` | UUID | No | FK -> users |
-| `method_type` | two_factor_type | No | `totp` or `email` |
-| `is_primary` | BOOLEAN | No | Only one primary method allowed per user |
-| `is_verified` | BOOLEAN | No | Must be true before a method can be primary |
-| `totp_secret` | TEXT | Yes | AES-256-GCM encrypted TOTP secret (only for `totp`) |
-| `created_at` | TIMESTAMPTZ | No | |
-| `updated_at` | TIMESTAMPTZ | No | |
-| `last_used_at` | TIMESTAMPTZ | Yes | |
+Tokens an account creates for its scripts: `name`, `token_hash` (SHA-256 of the
+random part), `scopes`, `expires_at`, `last_used_at`, and `session_id`, the
+session of type `personal_access_token` whose revocation ends the token.
-## email_2fa_codes
+### authorization_codes
-Short-lived OTP codes sent by email during a 2FA challenge.
+Authorization code flow with PKCE.
| Column | Type | Nullable | Description |
|--------|------|----------|-------------|
| `id` | UUID | No | Primary key |
+| `code_hash` | BYTEA | No | Unique |
| `user_id` | UUID | No | FK -> users |
-| `code_hash` | BYTEA | No | Hashed OTP code |
-| `created_at` | TIMESTAMPTZ | No | |
-| `expires_at` | TIMESTAMPTZ | No | |
-| `used_at` | TIMESTAMPTZ | Yes | Set when code is consumed |
+| `client_id` | VARCHAR(100) | No | FK -> registered_clients |
+| `redirect_uri` | TEXT | No | Compared exactly at redemption |
+| `code_challenge` | TEXT | No | S256 challenge, 43 characters |
+| `code_challenge_method` | VARCHAR(10) | No | Always `S256` |
+| `scopes` | TEXT[] | Yes | Consent frozen at approval |
+| `expires_at` | TIMESTAMPTZ | No | One minute after issue |
+| `consumed_at` | TIMESTAMPTZ | Yes | Set atomically at redemption |
+| `session_id` | UUID | Yes | FK -> sessions, revoked if the code is replayed |
-## email_verification_tokens
+## Client applications
-One-time tokens for email verification and email change flows.
+### registered_clients
| Column | Type | Nullable | Description |
|--------|------|----------|-------------|
-| `id` | UUID | No | Primary key |
-| `user_id` | UUID | No | FK -> users |
-| `token_hash` | BYTEA | No | SHA-256 of the token (32 bytes) |
-| `target_email` | CITEXT | No | The email address being verified |
+| `client_id` | VARCHAR(100) | No | Primary key |
+| `display_name` | VARCHAR(200) | No | Shown on consent screens |
+| `is_primary` | BOOLEAN | No | The application this instance owns; at most one |
+| `scopes` | TEXT[] | No | Permissions its tokens may carry; empty is unrestricted |
+| `redirect_uris` | TEXT[] | No | Exact redirect URIs |
+| `allows_loopback_redirect` | BOOLEAN | No | Accept loopback redirects on any port for a registered path |
+| `default_max_sessions` | SMALLINT | No | Concurrent sessions per user without a quota row (the primary client is unlimited) |
| `created_at` | TIMESTAMPTZ | No | |
-| `expires_at` | TIMESTAMPTZ | No | |
-| `used_at` | TIMESTAMPTZ | Yes | Set when token is consumed |
-| `request_ip` | INET | Yes | |
-| `request_user_agent` | TEXT | Yes | |
-## password_reset_tokens
+Managed with `auth-api --register-client` or `/admin/clients`.
+`client_secret_hash` (SHA-256 of the secret) is set for a confidential client;
+`allows_client_credentials` requires it and at least one scope.
-One-time tokens for the forgot-password flow.
+### user_client_quotas
-| Column | Type | Nullable | Description |
-|--------|------|----------|-------------|
-| `id` | UUID | No | Primary key |
-| `user_id` | UUID | No | FK -> users |
-| `token_hash` | BYTEA | No | SHA-256 of the token (32 bytes) |
-| `created_at` | TIMESTAMPTZ | No | |
-| `expires_at` | TIMESTAMPTZ | No | |
-| `used_at` | TIMESTAMPTZ | Yes | Set when token is consumed |
-| `request_ip` | INET | Yes | |
-| `request_user_agent` | TEXT | Yes | |
+Per-user override of a client's session limit: `user_id`, `client_id`,
+`max_sessions` (> 0), unique per user and client.
-## recovery_codes
+## Second factors
-Hashed backup codes used when the primary 2FA method is unavailable.
+### two_factor_methods
| Column | Type | Nullable | Description |
|--------|------|----------|-------------|
| `id` | UUID | No | Primary key |
| `user_id` | UUID | No | FK -> users |
-| `code_hash` | BYTEA | No | SHA-256 of the code (32 bytes) |
-| `code_position` | SMALLINT | No | Position in the set (1-20) |
-| `created_at` | TIMESTAMPTZ | No | |
-| `expires_at` | TIMESTAMPTZ | Yes | Optional expiry |
-| `used_at` | TIMESTAMPTZ | Yes | Set when code is consumed |
+| `method_type` | two_factor_type | No | `totp` or `email`, at most one of each per user |
+| `is_verified` | BOOLEAN | No | |
+| `is_primary` | BOOLEAN | No | At most one per user |
+| `totp_secret` | TEXT | Yes | Encrypted: `v1:{key id}:{base64(nonce, ciphertext)}`, or bare base64 for values written before versioning |
+| `created_at`, `updated_at`, `last_used_at` | TIMESTAMPTZ | | |
+
+### used_totp_codes
+
+Replay guard: `(user_id, code_hash)` primary key, `used_at`. A TOTP code is
+accepted once within its validity window.
-## login_attempts
+### email_2fa_codes
-Operational ledger of authentication attempts for lockout and risk scoring.
+Codes sent during an email challenge: `code_hash`, `expires_at`, `used_at`.
+
+### recovery_codes
+
+`code_hash`, `code_position`, optional `expires_at`, `used_at`. Deleted with the
+last second factor.
+
+## Security records
+
+### login_attempts
+
+Ledger feeding brute-force limits and lockout.
| Column | Type | Nullable | Description |
|--------|------|----------|-------------|
| `id` | UUID | No | Primary key |
-| `user_id` | UUID | Yes | FK -> users (null if identifier not found) |
+| `user_id` | UUID | Yes | FK -> users, null for an unknown identifier |
| `attempted_at` | TIMESTAMPTZ | No | |
-| `attempted_identifier` | CITEXT | No | Username or email submitted |
+| `attempted_identifier` | CITEXT | No | |
| `was_successful` | BOOLEAN | No | |
-| `failure_reason` | login_failure_reason | Yes | `invalid_password`, `two_factor_failed`, `rate_limited`, etc. |
+| `failure_reason` | login_failure_reason | Yes | Only `invalid_password` counts toward a lockout |
| `request_ip` | INET | Yes | |
-| `request_user_agent` | TEXT | Yes | |
+| `request_user_agent` | TEXT | Yes | Kept for failures only |
-## audit_log
+### audit_log
-Append-only security event log, partitioned by month.
+Append-only, partitioned by month: a trigger refuses deletes and every update
+except detaching a deleted user (`user_id` to NULL) and forgetting or coarsening
+a client address.
| Column | Type | Nullable | Description |
|--------|------|----------|-------------|
-| `id` | UUID | No | Part of composite PK |
-| `created_at` | TIMESTAMPTZ | No | Part of composite PK (partition key) |
+| `id` | UUID | No | Primary key with `created_at` |
+| `created_at` | TIMESTAMPTZ | No | Partition key |
| `user_id` | UUID | Yes | FK -> users |
-| `request_id` | UUID | Yes | Correlates with the HTTP request |
-| `action` | audit_action | No | `login`, `logout`, `password_changed`, `session_revoked`, etc. |
-| `ip_address` | INET | Yes | |
-| `metadata` | JSONB | No | Action-specific details |
+| `request_id` | UUID | Yes | `x-request-id` of the request |
+| `action` | audit_action | No | `login`, `password_changed`, `session_replay_detected`, `encryption_key_rotated`, ... |
+| `ip_address` | INET | Yes | Only the network (/24, /48) after `AUDIT_IP_RETENTION_DAYS`; removed when the account is deleted |
+| `metadata` | JSONB | No | Action details, without personal data such as addresses |
+
+## Events
-## login_locations
+### event_outbox
-Behavioral history used for login risk scoring.
+Domain events waiting for, or already delivered to, NATS JetStream. A service
+inserts the event in the transaction of the change it announces; the relay
+publishes pending rows in `seq` order and marks them published.
| Column | Type | Nullable | Description |
|--------|------|----------|-------------|
-| `id` | UUID | No | Primary key |
-| `user_id` | UUID | No | FK -> users |
-| `country` | TEXT | No | |
-| `city` | TEXT | No | |
-| `user_agent` | TEXT | No | |
-| `ip_address` | INET | No | Most recent IP for this location |
-| `latitude` | DOUBLE PRECISION | Yes | |
-| `longitude` | DOUBLE PRECISION | Yes | |
-| `first_seen` | TIMESTAMPTZ | No | |
-| `last_seen` | TIMESTAMPTZ | No | Updated on each login from the same location |
+| `seq` | BIGINT | No | Primary key, publication order |
+| `id` | UUID | No | Unique; the JetStream message id and the payload's `event_id` |
+| `subject` | TEXT | No | `events.auth.user.*` |
+| `payload` | JSONB | No | Event fields; `occurred_at` is added from `created_at` when published |
+| `created_at` | TIMESTAMPTZ | No | |
+| `published_at` | TIMESTAMPTZ | Yes | Set once JetStream stored the event |
+| `attempts`, `next_attempt_at`, `last_error` | | | Failed attempts and the next retry |
+
+Published rows are kept seven days (`cleanup_published_events`).
+
+## Retention
+
+`cleanup_*` SQL functions take a grace interval and a batch size; the
+application's cleanup task calls them in batches under an advisory lock, and
+calls `rotate_audit_log_partitions(retention_months)` on every run
+(`0` keeps every partition). See [Configuration](../guides/configuration.md#retention).
diff --git a/docs/dev/guides/commands.md b/docs/dev/guides/commands.md
index a18e2b9..ab5f834 100644
--- a/docs/dev/guides/commands.md
+++ b/docs/dev/guides/commands.md
@@ -6,65 +6,105 @@ All commands are available via `make`. Run `make help` to list them.
| Command | Description |
|---------|-------------|
-| `make dev` | Build and start the full development stack (API, PostgreSQL, Redis, Mailpit) |
-| `make dev-detach` | Same as `make dev` but runs in the background |
+| `make dev` | Build and start the development stack (API, PostgreSQL, Redis, NATS, Mailpit) |
+| `make dev-detach` | Same as `make dev`, in the background |
| `make dev-stop` | Stop the development stack |
-| `make dev-reset` | Stop the development stack and delete all volumes (resets the database) |
-| `make dev-logs` | Stream logs from all development services |
-| `make dev-admin` | Start the development stack with the Appsmith admin panel (`http://localhost:8080`) |
-| `make dev-admin-stop` | Stop the development stack including Appsmith |
+| `make dev-reset` | Stop the development stack and delete its volumes (resets the database) |
+| `make dev-logs` | Stream logs from every development service |
-## Code Quality
+Every port of the development stack is published on `127.0.0.1` only.
+
+## Quality gate
| Command | Description |
|---------|-------------|
-| `make fmt` | Format the code with `rustfmt` |
+| `make ci` | The full gate, run before every merge: `quality`, every suite with the nextest `ci` profile, then the fuzz corpus replay |
+| `make quality` | `fmt-check`, `clippy` and `deny` |
+| `make fmt` | Format the code |
| `make fmt-check` | Check formatting without modifying files |
-| `make clippy` | Run the Clippy linter (warnings treated as errors) |
-| `make deny` | Enforce dependency policy and security audit via `cargo-deny` |
-| `make quality` | Run all of the above checks in sequence |
+| `make clippy` | Clippy on every target and feature of the workspace, warnings as errors |
+| `make deny` | Dependency policy and security advisories (`cargo-deny`) |
## Tests
| Command | Description |
|---------|-------------|
-| `make test` | Start test infrastructure, run all tests via `cargo-nextest`, then stop infrastructure |
-| `make test-verbose` | Same as `make test` with full output (`--no-capture`) |
-| `make test-infra-up` | Start PostgreSQL and Redis for tests (ports 5433 / 6380) |
-| `make test-infra-down` | Stop the test infrastructure |
-| `make coverage` | Run tests with coverage report (HTML + JSON in `reports/coverage/`) |
+| `make test` | Start the test infrastructure, run every suite, stop the infrastructure |
+| `make test-local` | Run every suite against infrastructure already running |
+| `make test-unit` | Unit tests of the service and of the harness, no infrastructure |
+| `make test-integration` | API end to end, repositories, services, schema and migrations |
+| `make test-security` | Security suite, then the fuzz corpus replay |
+| `make test-sim` | Simulation suite, long scenarios included |
+| `make test-verbose` | `make test` with test output shown |
+| `make test-infra-up` / `make test-infra-down` | Start / stop PostgreSQL (5433), Redis (6380), NATS (4224) and Mailpit (1026) |
+| `make fuzz` | Every fuzz target for `FUZZ_SECS` seconds (default 60; nightly toolchain and `cargo-fuzz`) |
+| `make mutants` | Mutation testing of the domain, crypto, token, client address and configuration code (`cargo-mutants`, unit tests); report in `reports/mutants.out/` |
+| `make coverage` | Coverage of every suite (`cargo-llvm-cov`), HTML report in `reports/coverage/` |
+| `make js-test` | Tests of the npm packages in `clients/js` (Node.js 20 or later, nothing to install) |
+
+Each test runs in its own process against its own database, cloned from a
+migrated template. The nextest configuration (`.config/nextest.toml`) kills a
+test hung for two minutes (simulations get longer) and never retries a failure.
+See the [testing guide](testing.md) for where a test belongs and what the
+harness provides.
## Benchmarks
| Command | Description |
|---------|-------------|
-| `make bench` | Run Criterion benchmarks (CPU only - no infrastructure required) |
-| `make bench-http` | Run HTTP integration benchmarks (requires infrastructure) |
-| `make bench-sql` | Run SQL integration benchmarks (requires infrastructure) |
+| `make bench` | Criterion micro-benchmarks (JWT, TOTP, Argon2), no infrastructure |
+| `make bench-http` | End-to-end HTTP scenarios against a real server, PostgreSQL and Redis |
+| `make bench-sql` | Query benchmarks |
-## Build
+`bench-http` reads `BENCH_HTTP_CONCURRENCY` (default 8) and
+`BENCH_HTTP_ITERATIONS` (default 16) and writes a report to `reports/bench/`.
+
+## Build and images
| Command | Description |
|---------|-------------|
-| `make build` | Compile the project in release mode |
-| `make docker-build` | Build the production Docker image (`auth-api:local`) |
-| `make docker-build-dev` | Build the development Docker image (`auth-api:dev`) |
+| `make build` | Release build |
+| `make docker-build` | Production image |
+| `make docker-build-dev` | Development image (runs migrations at start) |
+| `make docker-lint` | Hadolint on the Dockerfile |
+| `make docker-scan` / `make docker-scan-dev` | Trivy CVE scan of an image |
+| `make docker-scan-secrets` | Trivy secret scan of the production image |
+| `make docker-check` | Lint and both scans |
-## Docker Security
+## Maintenance
| Command | Description |
|---------|-------------|
-| `make docker-lint` | Lint the `Dockerfile` with Hadolint |
-| `make docker-scan` | Scan the production image for CVEs with Trivy |
-| `make docker-scan-dev` | Scan the development image for CVEs with Trivy |
-| `make docker-scan-secrets` | Scan the production image for leaked secrets with Trivy |
-| `make docker-check` | Run lint + CVE scan + secret scan in sequence |
+| `make clean` | Remove `target/` |
+| `make clean-reports` | Remove benchmark and coverage reports |
+| `make clean-all` | Remove build artifacts, reports and local images |
+| `make docker-clean` | Remove local project images |
+
+## Binary commands
-## Utilities
+The `auth-api` binary also runs one-off operational commands instead of the
+server. In production, run them in a one-off container:
+`docker compose -f docker-compose.api.yml run --rm api ./auth-api `.
| Command | Description |
|---------|-------------|
-| `make clean` | Remove compilation artifacts (`target/`) |
-| `make clean-reports` | Remove generated reports (`reports/bench/manual-*`, `reports/coverage/`) |
-| `make clean-all` | Remove all artifacts, reports and local Docker images |
-| `make docker-clean` | Remove local project Docker images |
+| `--healthcheck` | Call the local `/live` and exit 0 or 1 (the image's health check) |
+| `--grant-role --user ` | Grant a role to an account, audited; appoints the first administrator (`--grant-role admin`) |
+| `--register-client --name [options]` | Create or update a registered client (needs only `DATABASE_URL`) |
+| `--rotate-totp-keys` | Re-encrypt TOTP secrets and webhook signing secrets under `ENCRYPTION_KEY` (see the [operations runbook](../../deploy/guides/operations.md)) |
+
+`--register-client` options:
+
+| Option | Description |
+|--------|-------------|
+| `--primary` | The application this instance owns: used when a device flow names no client, exempt from session limits and from re-authentication on consent |
+| `--scopes a:b,c:d` | Permissions its tokens may carry; omitted, tokens carry every permission of the user |
+| `--redirect-uri ` | Exact redirect URI for the authorization code flow; repeatable |
+| `--loopback-redirect` | Also accept `http://127.0.0.1` / `http://[::1]` on any port for a registered loopback path (native apps) |
+| `--max-sessions ` | Concurrent sessions per user when no quota row overrides it |
+
+```bash
+auth-api --register-client desktop-app --name "Desktop app" \
+ --redirect-uri http://127.0.0.1/callback --loopback-redirect \
+ --scopes profile:read --max-sessions 3
+```
diff --git a/docs/dev/guides/configuration.md b/docs/dev/guides/configuration.md
index 38943c6..86fd088 100644
--- a/docs/dev/guides/configuration.md
+++ b/docs/dev/guides/configuration.md
@@ -1,177 +1,252 @@
# Configuration Reference
+Every setting is an environment variable, read once at startup. A variable
+that is present but does not parse is an error, never a silent fallback to its
+default (`LOCKOUT_THRESHOLD=1O` refuses to start). A blank value counts as
+unset.
+
+In `APP_ENV=production` the configuration is validated before the server
+accepts traffic; the checks are listed under [Production checks](#production-checks).
+
## Environment files
| File | Committed | Used in |
|------|-----------|---------|
-| `.env.dev` | Yes | Development (`make dev`) |
-| `config.prod.env` | Yes | Production (non-sensitive values only) |
-
-Sensitive production values are never stored in files - they are exported from `pass` before deployment.
+| `.env.dev` | Yes | Development (`make dev`) - development keys only, refused in production |
+| `config.prod.env` | Yes | Production, non-sensitive values only |
-## Secrets (production only)
+Production secrets are never written to files: they are exported from `pass`
+before `docker compose` runs (see [Secrets](../../deploy/api/secrets.md)).
-These variables must be exported from `pass` on the VPS before running `docker compose`:
+## Secrets
| Variable | Description | Generate with |
|----------|-------------|---------------|
-| `DATABASE_URL` | PostgreSQL connection string including credentials | - |
-| `REDIS_URL` | Redis connection string including password | - |
-| `JWT_PRIVATE_KEY` | EC P-256 private key in PEM format (signs tokens) | `openssl ecparam -genkey -name prime256v1 -noout \| openssl pkcs8 -topk8 -nocrypt` |
-| `JWT_PUBLIC_KEY` | EC P-256 public key in PEM format (verifies tokens) | `openssl ec -pubout < private.pem` |
-| `JWT_PREVIOUS_PUBLIC_KEY` | Previous public key - set only during key rotation | - |
-| `ENCRYPTION_KEY` | AES-256-GCM key for TOTP secret encryption (base64, 32 bytes) | `openssl rand -base64 32` |
-| `PREVIOUS_ENCRYPTION_KEY` | Previous encryption key - set only during key rotation | - |
-| `SMTP_USERNAME` | SMTP authentication username | - |
-| `SMTP_PASSWORD` | SMTP authentication password | - |
-| `CAPTCHA_SECRET` | hCaptcha secret key | - |
-| `NATS_URL` | NATS server connection URL | - |
-
-## Non-sensitive variables
-
-These variables are committed in `config.prod.env` and can be adjusted without any security concern.
+| `DATABASE_URL` | PostgreSQL connection string | - |
+| `REDIS_URL` | Redis connection string, password included | - |
+| `JWT_PRIVATE_KEY` | EC P-256 private key, PEM (signs access tokens) | `openssl ecparam -genkey -name prime256v1 -noout \| openssl pkcs8 -topk8 -nocrypt` |
+| `JWT_PUBLIC_KEY` | Matching public key, PEM | `openssl ec -pubout < private.pem` |
+| `JWT_PREVIOUS_PUBLIC_KEY` | Previous public key, only during a signing key rotation | - |
+| `JWT_NEXT_PUBLIC_KEY` | Next public key, only while a signing key rotation is being announced | - |
+| `ENCRYPTION_KEY` | AES-256-GCM key for TOTP secrets at rest, base64 of 32 bytes | `openssl rand -base64 32` |
+| `PREVIOUS_ENCRYPTION_KEY` | Previous encryption key, only during a rotation | - |
+| `SMTP_USERNAME`, `SMTP_PASSWORD` | SMTP credentials | - |
+| `CAPTCHA_SECRET` | hCaptcha secret | - |
+| `NATS_URL` | NATS URL embedding the broker token (`nats://@nats:4222`) | - |
+| `NATS_AUTH_TOKEN` | Token the bundled broker requires (read by `docker-compose.api.yml`) | `openssl rand -hex 32` |
+
+## Variables
+
+"Required" variables have no default and stop the startup when missing.
### Server
-| Variable | Default (prod) | Description |
-|----------|----------------|-------------|
-| `APP_ENV` | `production` | Environment name |
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `APP_ENV` | required | `development`, `test` or `production`. Required so a typo cannot start a deployment with development relaxations |
| `SERVER_HOST` | `0.0.0.0` | Bind address |
| `SERVER_PORT` | `3000` | Bind port |
-| `APP_PUBLIC_URL` | - | Public-facing URL of the API |
-| `TRUSTED_PROXY_CIDRS` | - | Comma-separated CIDRs of trusted reverse proxies |
+| `APP_PUBLIC_URL` | `http://localhost:3000` | Public URL of this API: token issuer, audience, JWKS location |
+| `FRONTEND_URL` | `APP_PUBLIC_URL` | Web application whose pages emails link to (`/verify-email`, `/reset-password`) |
+| `TRUSTED_PROXY_CIDRS` | empty | Comma-separated CIDRs allowed to set `X-Forwarded-For` / `X-Real-IP`. Behind the bundled compose file this is the network gateway, `172.30.0.1/32` |
### Database
-| Variable | Default (prod) | Description |
-|----------|----------------|-------------|
-| `DB_MAX_CONNECTIONS` | `20` | Maximum PostgreSQL pool size |
-| `DB_MIN_CONNECTIONS` | `2` | Minimum PostgreSQL pool size |
-| `DB_ACQUIRE_TIMEOUT_SECS` | `30` | Timeout to acquire a connection |
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `DATABASE_URL` | required | PostgreSQL connection string |
+| `DB_MAX_CONNECTIONS` | `20` | Pool size |
+| `DB_MIN_CONNECTIONS` | `2` | Connections kept open |
+| `DB_ACQUIRE_TIMEOUT_SECS` | `5` | Wait for a pooled connection |
+| `DATABASE_READ_URL` | unset | Read replica for lag-tolerant reads (security histories, admin audit log and account search, webhook deliveries); same pool settings. Unset: the primary |
### Redis
-| Variable | Default (prod) | Description |
-|----------|----------------|-------------|
-| `REDIS_POOL_SIZE` | `10` | Redis connection pool size |
-| `REDIS_WAIT_TIMEOUT_MS` | `2000` | Max wait time to acquire a Redis connection |
-
-### JWT (ES256)
-
-Tokens are signed with ECDSA P-256 (ES256). The private key signs tokens; only the public key is needed to verify them. Other services can fetch the public key from `GET /.well-known/jwks.json`.
-
-| Variable | Default (prod) | Description |
-|----------|----------------|-------------|
-| `JWT_ACCESS_EXPIRY_SECS` | `900` | Access token lifetime (15 minutes) |
-| `JWT_REFRESH_EXPIRY_SECS` | `2592000` | Refresh token lifetime when "remember me" is on (30 days) |
-| `JWT_SHORT_SESSION_EXPIRY_SECS` | `86400` | Refresh token lifetime when "remember me" is off (24 hours) |
-| `JWT_MAX_SESSION_LIFETIME_SECS` | `7776000` | Absolute session lifetime cap regardless of refresh activity (90 days) |
-| `JWT_STRICT_SESSION_BINDING` | `false` | Bind refresh tokens to the login IP (breaks mobile roaming) |
-
-### Argon2id
-
-| Variable | Default (prod) | Description |
-|----------|----------------|-------------|
-| `ARGON2_MEMORY_KIB` | `65536` | Memory cost in KiB - tune for your hardware |
-| `ARGON2_ITERATIONS` | `3` | Iteration count |
-| `ARGON2_PARALLELISM` | `4` | Parallelism factor |
-
-### TOTP / 2FA
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `REDIS_URL` | required | Redis connection string |
+| `REDIS_POOL_SIZE` | `10` | Pool size |
+| `REDIS_WAIT_TIMEOUT_MS` | `2000` | Wait for a pooled connection before failing |
-| Variable | Default (prod) | Description |
-|----------|----------------|-------------|
-| `TOTP_ISSUER` | `MyApp` | Issuer name shown in authenticator apps |
-| `TOTP_SKEW` | `1` | Accepted time-step skew (+/-1 window) |
-| `RECOVERY_CODE_EXPIRY_DAYS` | `365` | Recovery code validity in days |
+### NATS
-### Rate limiting
+Domain events (`user.created`, `user.email_verified`, `user.email_changed`,
+`user.password_changed`, `user.sessions_revoked`, `user.suspended`,
+`user.reactivated`, `user.deleted`) are recorded in
+the `event_outbox` table with the change they announce, then published to NATS
+JetStream by a background relay. They carry the user id only, with `event_id`
+and `occurred_at`. The broker ships in the compose files.
-| Variable | Default (prod) | Description |
-|----------|----------------|-------------|
-| `RATE_LIMIT_RPM` | `300` | Max requests per minute per IP |
-| `RATE_LIMIT_AUTH_RPM` | `20` | Max auth requests per minute per IP |
-| `RATE_LIMIT_FAIL_OPEN` | `false` | Allow requests if Redis is unavailable - must be `false` in production |
-| `RATE_LIMIT_ALLOW_MISSING_IP` | `false` | Allow requests without a resolved IP - must be `false` in production |
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `NATS_URL` | `nats://nats:4222` | Broker URL, or comma-separated URLs of a cluster sharing their credentials |
+| `NATS_STREAM_REPLICAS` | `1` | Copies of the event stream a JetStream cluster keeps: 1, 3 or 5 |
-### Account lockout
+### Access tokens (ES256)
-| Variable | Default (prod) | Description |
-|----------|----------------|-------------|
-| `LOCKOUT_THRESHOLD` | `10` | Failed attempts before lockout |
-| `LOCKOUT_DURATION_SECS` | `1800` | Lockout duration in seconds (30 minutes) |
-| `SENSITIVE_ACTION_REAUTH_SECS` | `600` | Recent authentication window for sensitive actions |
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `JWT_PRIVATE_KEY` | required | Signing key, PEM (`\n` escapes accepted) |
+| `JWT_PUBLIC_KEY` | required | Verification key, PEM |
+| `JWT_PREVIOUS_PUBLIC_KEY` | unset | Still accepted and published in the JWKS during a rotation |
+| `JWT_NEXT_PUBLIC_KEY` | unset | Published in the JWKS and accepted before the signing key switches to it |
+| `JWT_AUDIENCE` | empty | Comma-separated audiences stamped in `aud`; `APP_PUBLIC_URL` is always added |
+| `JWT_ACCESS_EXPIRY_SECS` | `900` | Access token lifetime |
+| `JWT_REFRESH_EXPIRY_SECS` | `2592000` | Refresh token lifetime with "remember me" (30 days) |
+| `JWT_SHORT_SESSION_EXPIRY_SECS` | `86400` | Refresh token lifetime without "remember me" (24 hours) |
+| `JWT_MAX_SESSION_LIFETIME_SECS` | `7776000` | Absolute lifetime of a sign-in, whatever the refresh activity (90 days) |
+| `JWT_STRICT_SESSION_BINDING` | `false` | Refuse a refresh from another address than the sign-in |
+
+### Passwords and second factors
-### GeoIP & risk scoring
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `ARGON2_MEMORY_KIB` | `65536` | Argon2id memory cost |
+| `ARGON2_ITERATIONS` | `3` | Argon2id iterations |
+| `ARGON2_PARALLELISM` | `4` | Argon2id lanes |
+| `ARGON2_MAX_CONCURRENCY` | CPU cores | Hashes computed at once; the rest queue |
+| `ENCRYPTION_KEY` | required | Current key for TOTP secrets |
+| `PREVIOUS_ENCRYPTION_KEY` | unset | Previous key, readable during a rotation |
+| `TOTP_ISSUER` | `auth-api` | Issuer shown in authenticator apps |
+| `TOTP_SKEW` | `1` | Accepted 30-second steps before and after the current one |
+| `RECOVERY_CODE_EXPIRY_DAYS` | `365` | Recovery code lifetime; `0` never expires |
+
+### Abuse protection
-| Variable | Default (prod) | Description |
-|----------|----------------|-------------|
-| `GEOIP_DB_PATH` | - | Path to the MaxMind GeoLite2-City `.mmdb` file |
-| `GEOIP_REQUIRED` | `false` | Fail on startup if the GeoIP database is missing |
-| `RISK_ALERT_THRESHOLD` | `30` | Risk score above which an alert is triggered |
-| `RISK_CHALLENGE_THRESHOLD` | `60` | Risk score above which a challenge is required |
-| `RISK_BLOCK_THRESHOLD` | `80` | Risk score above which the request is blocked |
-| `RISK_HISTORY_DAYS` | `90` | Days of login history used for risk evaluation |
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `RATE_LIMIT_RPM` | `300` | Requests per minute per client, every route |
+| `RATE_LIMIT_AUTH_RPM` | `20` | Additional per-minute budget of credential-bearing routes |
+| `RATE_LIMIT_FAIL_OPEN` | `true` outside production | Serve requests when Redis is unreachable |
+| `RATE_LIMIT_ALLOW_MISSING_IP` | `true` outside production | Serve requests whose client address cannot be resolved |
+| `LOCKOUT_THRESHOLD` | `10` | Consecutive wrong passwords before a lockout |
+| `LOCKOUT_DURATION_SECS` | `1800` | Lockout duration |
+| `SENSITIVE_ACTION_REAUTH_SECS` | `600` | How long a re-authentication (`POST /users/me/reauth`) covers sensitive actions |
+| `MAGIC_LINK_ENABLED` | `false` | Offer sign-in links by email (`/auth/magic-link`): whoever reads the mailbox can sign in without the password, the second factor still applies. Off, the routes answer `404` |
+| `NEW_DEVICE_ALERTS_ENABLED` | `true` | E-mail the owner when an account signs in from a browser and system family it never used (devices are recorded either way) |
+| `CAPTCHA_SECRET` | unset | hCaptcha secret; unset disables the check, which production refuses |
+| `CAPTCHA_VERIFY_URL` | `https://hcaptcha.com/siteverify` | Verification endpoint |
+| `CAPTCHA_TIMEOUT_SECS` | `5` | Verification timeout |
+| `CAPTCHA_FAIL_OPEN` | `true` outside production | Accept the request when the provider cannot be reached |
+| `PWNED_PASSWORDS_ENABLED` | `true` | Refuse passwords found in known data breaches, at registration, change and reset (`422 password_compromised`) |
+| `PWNED_PASSWORDS_URL` | `https://api.pwnedpasswords.com` | Range API; production requires HTTPS. Only the first five characters of the password's SHA-1 are sent |
+| `PWNED_PASSWORDS_TIMEOUT_MS` | `1500` | Range query timeout |
+| `PWNED_PASSWORDS_FAIL_OPEN` | `true` | Accept the password when the range API cannot be reached; `false` answers 503 instead |
+
+IPv6 clients are limited per `/64`, the prefix a subscriber is usually given.
+The breached-password check needs outbound HTTPS to the range API.
-### SMTP
+### Mail
-| Variable | Default (prod) | Description |
-|----------|----------------|-------------|
-| `SMTP_HOST` | - | SMTP server hostname |
-| `SMTP_PORT` | `587` | SMTP server port |
-| `SMTP_FROM_NAME` | `MyApp` | Sender display name |
-| `SMTP_FROM_ADDRESS` | - | Sender email address |
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `SMTP_HOST` | required | SMTP server; empty skips sending (tests) |
+| `SMTP_PORT` | `587` | STARTTLS port |
+| `SMTP_USERNAME`, `SMTP_PASSWORD` | required | Credentials; an empty username sends without TLS or authentication (Mailpit), which production refuses |
+| `SMTP_FROM_NAME` | `auth-api` | Sender name |
+| `SMTP_FROM_ADDRESS` | required | Sender address |
+| `MAIL_TEMPLATES_DIR` | `templates` | Holds `emails/{locale}/{name}.html` and `{name}.subject` |
+| `MAIL_DEFAULT_LOCALE` | `en` | Locale used when the user's has no template |
-### Mail
+### Client applications
-| Variable | Default (prod) | Description |
-|----------|----------------|-------------|
-| `MAIL_TEMPLATES_DIR` | `templates` | Path to email templates directory |
-| `MAIL_DEFAULT_LOCALE` | `en` | Default locale for email templates |
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `DEVICE_AUTH_VERIFICATION_URI` | required | Page where a user enters a device code (RFC 8628 `verification_uri`) |
+| `DEVICE_AUTH_TTL_SECS` | `300` | Lifetime of a device code |
+| `DEVICE_AUTH_POLL_INTERVAL_SECS` | `5` | Minimum polling interval; faster polls get `slow_down` |
+| `OAUTH_CONSENT_URI` | `{FRONTEND_URL}/authorize` | Frontend page where a signed-in user approves an authorization request; `GET /oauth/authorize` sends the browser there with `request_id`. HTTPS in production |
+| `CORS_ALLOWED_ORIGINS` | `http://localhost:3000` | Comma-separated origins allowed to call the API from a browser |
+| `CORS_ALLOW_CREDENTIALS` | `true` | Allow credentialed cross-origin requests |
-### CAPTCHA
+Registered clients (device and authorization code flows) live in the database
+and are managed with `auth-api --register-client` (see [Commands](commands.md))
+or `PUT /admin/clients/{client_id}`.
-| Variable | Default (prod) | Description |
-|----------|----------------|-------------|
-| `CAPTCHA_VERIFY_URL` | hCaptcha URL | Verification endpoint |
-| `CAPTCHA_TIMEOUT_SECS` | `5` | Request timeout for CAPTCHA verification |
-| `CAPTCHA_FAIL_OPEN` | `false` | Allow requests if CAPTCHA provider is unavailable - must be `false` in production |
+### External identity providers
-### CORS
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `IDENTITY_PROVIDERS` | none | Comma-separated provider names (`google,github,corp`) |
+| `IDP_{NAME}_KIND` | the name | `google`, `github` or `oidc` |
+| `IDP_{NAME}_CLIENT_ID`, `IDP_{NAME}_CLIENT_SECRET` | required | Credentials of auth-api at the provider; register the redirect URI `{APP_PUBLIC_URL}/auth/external/{name}/callback` |
+| `IDP_{NAME}_ISSUER` | Google's for `google` | OpenID Connect issuer (required for `oidc`), discovered at `/.well-known/openid-configuration` |
+| `IDP_{NAME}_DISPLAY_NAME` | the name | Label for the sign-in button |
+| `IDP_{NAME}_SCOPES` | `openid` / `read:user` | Comma-separated scopes |
+| `IDP_{NAME}_AUTHORIZATION_URL`, `_TOKEN_URL`, `_USER_URL` | github.com | GitHub Enterprise endpoints |
+| `EXTERNAL_LOGIN_URI` | `{FRONTEND_URL}/external-login` | Frontend page receiving `?code=` after the provider |
-| Variable | Default (prod) | Description |
-|----------|----------------|-------------|
-| `CORS_ALLOWED_ORIGINS` | - | Comma-separated list of allowed origins |
-| `CORS_ALLOW_CREDENTIALS` | `true` | Allow credentials in cross-origin requests |
+In production the issuers, GitHub endpoints and `EXTERNAL_LOGIN_URI` must be
+HTTPS.
-### Audit log
+### Passkeys
-| Variable | Default (prod) | Description |
-|----------|----------------|-------------|
-| `AUDIT_LOG_RETENTION_MONTHS` | `12` | Retention period in months - `0` keeps forever |
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `WEBAUTHN_RP_ID` | host of `FRONTEND_URL` | Relying party id passkeys are bound to; changing it orphans every registered passkey |
+| `WEBAUTHN_RP_NAME` | `SMTP_FROM_NAME` | Name the authenticator shows |
+| `WEBAUTHN_ORIGINS` | origin of `FRONTEND_URL` | Comma-separated origins allowed to run ceremonies; in production HTTPS, on the relying party id or its subdomains |
-### Cleanup
+### Webhooks
-Expired-data cleanup runs nightly via pg_cron when available; otherwise the application background task enforces these settings.
+Endpoints are registered through `/admin/webhooks` (see the
+[webhook guide](webhooks.md)).
| Variable | Default | Description |
|----------|---------|-------------|
-| `CLEANUP_INTERVAL_SECS` | `3600` | Interval between application-side cleanup runs (fallback when pg_cron is unavailable) |
-| `CLEANUP_SESSIONS_GRACE_DAYS` | `7` | Grace period after session expiry/revocation before deletion |
-| `CLEANUP_TOKENS_GRACE_DAYS` | `1` | Grace period after token expiry before deletion (email 2FA, password reset, email verification) |
-| `CLEANUP_LOGIN_ATTEMPTS_RETENTION_DAYS` | `90` | Retention period for `login_attempts` records |
-| `CLEANUP_RECOVERY_CODES_GRACE_DAYS` | `7` | Grace period after recovery code expiry before deletion |
-
-### Logging
+| `WEBHOOK_TIMEOUT_MS` | `5000` | Timeout of one delivery |
+| `WEBHOOK_ALLOW_HTTP` | `true` outside production | Accept `http://` endpoints; refused in production |
+| `WEBHOOK_ALLOW_PRIVATE_NETWORKS` | `false` | Deliver to loopback, private and other internal addresses; refused in production |
-| Variable | Default (prod) | Description |
-|----------|----------------|-------------|
-| `LOG_LEVEL` | `info` | Log level (`error`, `warn`, `info`, `debug`, `trace`) |
-| `LOG_FORMAT` | `json` | Log format - `json` for production, `pretty` for development |
+### Retention
-### NATS
+The application is the only scheduler: every `CLEANUP_INTERVAL_SECS`, one
+instance (advisory lock) deletes expired rows in bounded batches and rotates
+the audit log partitions.
-The API publishes domain events (user created, email verified, etc.) to a NATS JetStream broker. The broker ships with the stack: `docker-compose.dev.yml` in development, `docker-compose.api.yml` in production.
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `CLEANUP_INTERVAL_SECS` | `3600` | Interval between runs |
+| `CLEANUP_SESSIONS_GRACE_DAYS` | `7` | Kept after expiry or revocation |
+| `CLEANUP_TOKENS_GRACE_DAYS` | `1` | Kept after expiry: email codes, verification and reset tokens |
+| `CLEANUP_LOGIN_ATTEMPTS_RETENTION_DAYS` | `90` | Login attempt ledger retention |
+| `CLEANUP_RECOVERY_CODES_GRACE_DAYS` | `7` | Kept after expiry |
+| `CLEANUP_WEBHOOK_DELIVERY_DAYS` | `7` | Delivered and given-up webhook deliveries are deleted after this many days |
+| `CLEANUP_KNOWN_DEVICE_DAYS` | `90` | Devices unused for this many days are forgotten; a later sign-in from one alerts again |
+| `CLEANUP_UNVERIFIED_ACCOUNT_DAYS` | `7` | Accounts whose address was never verified are deleted after this many days (audited, `user.deleted` published); `0` keeps them |
+| `AUDIT_LOG_RETENTION_MONTHS` | `12` | Monthly audit partitions kept; `0` keeps every partition |
+| `AUDIT_IP_RETENTION_DAYS` | `90` | Client addresses of older audit entries keep only their network (/24, /48); `0` keeps full addresses |
+
+Authorization codes are kept one hour past expiry (so a replay still finds the
+session it produced) and TOTP replay records 90 seconds; neither is configurable.
+
+### Observability
-| Variable | Default (prod) | Description |
-|----------|----------------|-------------|
-| `NATS_URL` | `nats://nats:4222` | NATS server URL |
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `LOG_LEVEL` | `info` | `error`, `warn`, `info`, `debug`, `trace` |
+| `LOG_FORMAT` | `pretty` | `json` in production |
+| `METRICS_ENABLED` | `true` | Serve Prometheus metrics on a separate listener |
+| `METRICS_PORT` | `9464` | Metrics listener port; publish on loopback only |
+| `OTEL_EXPORTER_OTLP_ENDPOINT` | unset | OTLP/HTTP collector base URL (`/v1/traces` is appended); unset, no trace is exported |
+| `OTEL_SERVICE_NAME` | `auth-api` | `service.name` of the traces |
+| `OTEL_TRACES_SAMPLER_ARG` | `0.1` | Share of new traces recorded, 0 to 1; a request with a sampled `traceparent` is always recorded |
+
+## Production checks
+
+With `APP_ENV=production` the service refuses to start when:
+
+- `APP_PUBLIC_URL`, `FRONTEND_URL`, `OAUTH_CONSENT_URI` or `CAPTCHA_VERIFY_URL` is not HTTPS;
+- `TRUSTED_PROXY_CIDRS` is empty (every client would share the proxy's address);
+- the JWT keys do not form a pair, or are the committed development pair;
+- `ENCRYPTION_KEY` is not 32 bytes, is a committed development key, or is an
+ arithmetic sequence;
+- `JWT_AUDIENCE` is empty or has a blank entry;
+- `CORS_ALLOWED_ORIGINS` contains `*` or a non-HTTPS origin;
+- `SMTP_USERNAME` or `CAPTCHA_SECRET` is empty;
+- `RATE_LIMIT_FAIL_OPEN`, `RATE_LIMIT_ALLOW_MISSING_IP` or `CAPTCHA_FAIL_OPEN`
+ is `true`, or `JWT_STRICT_SESSION_BINDING` is `false`;
+- `WEBHOOK_ALLOW_HTTP` or `WEBHOOK_ALLOW_PRIVATE_NETWORKS` is `true`;
+- `WEBAUTHN_ORIGINS` is empty, or lists an origin that is not HTTPS or not on
+ `WEBAUTHN_RP_ID`;
+- `SENSITIVE_ACTION_REAUTH_SECS` or `ARGON2_MAX_CONCURRENCY` is `0`.
diff --git a/docs/dev/guides/integration.md b/docs/dev/guides/integration.md
new file mode 100644
index 0000000..a39cbbf
--- /dev/null
+++ b/docs/dev/guides/integration.md
@@ -0,0 +1,198 @@
+# Integration Guide
+
+[Index](../README.md)
+
+How applications and services use auth-api: which flow to pick, how to verify
+its tokens, and how to follow account changes. The routes are listed in
+[routes](../api/routes.md); the contract is [openapi.yaml](../api/openapi.yaml).
+
+## 1. Pick a flow
+
+| You build | Use | Client registration |
+|-----------|-----|---------------------|
+| The frontend that ships with auth-api (sign-up, sign-in, account pages) | The first-party routes: `/auth/login`, `/auth/passkeys/*`, `/auth/magic-link`, `/auth/external/*`, `/auth/refresh` | None |
+| Another web application, or a single-page application | Authorization code with PKCE (`/oauth/authorize`, `/oauth/token`) | Public (SPA) or confidential (server-side) |
+| A desktop or command-line application with a browser | Authorization code with PKCE and a loopback redirect | Public, `allows_loopback_redirect` |
+| A TV, a device without a browser, a CLI over SSH | Device authorization (`/oauth/device_authorization`) | Public |
+| A service calling another service, with no user | Client credentials | Confidential, `allows_client_credentials`, with scopes |
+| A user's own scripts | Personal access tokens (`/users/me/tokens`) | None |
+| "Sign in with" for a third-party site | OpenID Connect: the code flow with `scope=openid` | Public or confidential |
+| An API receiving these tokens | Token verification (section 3) | Confidential only to introspect |
+
+Register clients with `PUT /admin/clients/{client_id}` (or
+`auth-api --register-client`): display name, redirect URIs, scopes (the
+permissions its tokens may carry, empty for all of the user's), session limit.
+`POST /admin/clients/{client_id}/secret` makes it confidential and returns its
+secret once.
+
+## 2. Flows
+
+### Authorization code with PKCE
+
+1. Generate a `code_verifier` (43 to 128 characters of `A-Z a-z 0-9 - . _ ~`),
+ its `code_challenge` (base64url of its SHA-256), a random `state`, and for
+ OpenID Connect a `nonce`.
+2. Send the browser to:
+
+ ```text
+ https://auth.example.com/oauth/authorize?response_type=code&client_id=invoices-web
+ &redirect_uri=https://invoices.example.com/callback&scope=invoices:read%20openid
+ &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256
+ &state=af0ifjsldkj&nonce=n-0S6_WzA2Mj
+ ```
+
+ auth-api's frontend signs the user in if needed, shows the consent screen,
+ and the browser comes back to the redirect URI with `code` and `state`, or
+ with `error`. Check that `state` is the one you sent.
+3. Exchange the code, from your server for a confidential client:
+
+ ```bash
+ curl -u invoices-web:$CLIENT_SECRET https://auth.example.com/oauth/token \
+ -d grant_type=authorization_code -d code=$CODE -d code_verifier=$VERIFIER \
+ -d redirect_uri=https://invoices.example.com/callback
+ ```
+
+ A public client sends `client_id` in the body instead of the credentials.
+ The answer holds `access_token` (15 minutes by default), `refresh_token`,
+ `expires_in`, `scope`, and `id_token` when `openid` was granted.
+4. Refresh before the access token expires:
+
+ ```bash
+ curl -u invoices-web:$CLIENT_SECRET https://auth.example.com/oauth/token \
+ -d grant_type=refresh_token -d refresh_token=$REFRESH_TOKEN
+ ```
+
+ Each refresh returns a new refresh token; keep only the latest. Presenting a
+ replaced one revokes the whole session (two requests within two seconds are
+ treated as the same client retrying).
+
+Native applications register a loopback redirect such as
+`http://127.0.0.1/callback` and listen on any free port: the redirect
+`http://127.0.0.1:53817/callback` is accepted.
+
+### Device authorization
+
+```bash
+curl https://auth.example.com/oauth/device_authorization -d client_id=tv-app -d scope=media:read
+# {"device_code":"...","user_code":"WDJB-4726","verification_uri":"https://auth.example.com/device",
+# "verification_uri_complete":"https://auth.example.com/device?user_code=WDJB-4726","expires_in":300,"interval":5}
+```
+
+Show `user_code` and `verification_uri` (or a QR code of
+`verification_uri_complete`), then poll every `interval` seconds:
+
+```bash
+curl https://auth.example.com/oauth/token -d client_id=tv-app \
+ -d grant_type=urn:ietf:params:oauth:grant-type:device_code -d device_code=$DEVICE_CODE
+```
+
+`authorization_pending`: keep polling. `slow_down`: add five seconds to the
+interval. `access_denied` or `expired_token`: stop. Tokens: done.
+
+### Client credentials
+
+```bash
+curl -u billing-worker:$CLIENT_SECRET https://auth.example.com/oauth/token \
+ -d grant_type=client_credentials -d scope=invoices:read
+```
+
+The token has no refresh token and no user: `client_id` names the client, `sub`
+is a UUID derived from it, `sid` is nil. Request a new one when it expires.
+
+### Personal access tokens
+
+A signed-in user creates one with `POST /users/me/tokens` (name, scopes, 1 to
+365 days) and copies the `aapat_...` secret, shown once. The script exchanges it
+for an access token when it needs one:
+
+```bash
+curl https://auth.example.com/auth/personal-access-tokens/exchange \
+ -H 'content-type: application/json' -d "{\"token\": \"$AAPAT\"}"
+```
+
+### OpenID Connect
+
+Add `openid` (and `profile`, `email` for the matching claims) to the code flow
+and a `nonce`. Verify the `id_token` like an access token (section 3) with the
+client id as audience, then check its `nonce` and, if you use the access token,
+its `at_hash`. `GET /oauth/userinfo` with the access token returns the same
+claims. Libraries configure themselves from
+`https://auth.example.com/.well-known/openid-configuration`.
+
+## 3. Verifying access tokens
+
+Every resource server:
+
+1. Reads `Authorization: Bearer `.
+2. Verifies the ES256 signature with the key of the token's `kid` from
+ `https://auth.example.com/.well-known/jwks.json`. Cache the key set; fetch it
+ again when a `kid` is unknown, at most once a minute. Accept `ES256` only.
+3. Checks `iss` equals auth-api's `APP_PUBLIC_URL`, `aud` contains the
+ service's own identifier (listed in auth-api's `JWT_AUDIENCE`), and `exp` and
+ `nbf` with a small leeway.
+4. Authorizes on `permissions` (`resource:action` names). `roles` are absent
+ from tokens restricted to scopes (a client registered with scopes, or a
+ request naming them): authorize on permissions.
+
+Ready-made: the Rust crate [`crates/verifier`](../../../crates/verifier/README.md)
+(with an axum extractor) and the npm package
+[`clients/js/verifier`](../../../clients/js/verifier/README.md) (with an Express
+middleware). Any JOSE library does the same with the rules above.
+
+Offline verification cannot see a token revoked before it expires (a logout, a
+password change, an administrator ending the sessions). Where that matters,
+register the service as a confidential client and call
+`POST /oauth/introspect` (both packages do it with a short cache), or keep
+`JWT_ACCESS_EXPIRY_SECS` short.
+
+A token's claims:
+
+| Claim | Meaning |
+|-------|---------|
+| `sub` | User id (UUID), or the client's derived id for client credentials |
+| `sid` | Session id; nil for client credentials |
+| `jti` | Token id, for revocation and introspection caches |
+| `iss`, `aud`, `iat`, `nbf`, `exp` | Standard |
+| `roles` | Role names; absent from scoped and client credentials tokens |
+| `permissions` | Permission names, intersected with the consented scopes |
+| `client_id` | For client credentials tokens |
+
+## 4. Following account changes
+
+Services keeping data about users must follow `user.deleted` at least, to erase
+it. Two channels carry the same events, recorded with the change that caused
+them and delivered at least once:
+
+- **NATS JetStream**: stream `AUTH_EVENTS`, subjects `events.auth.user.*`,
+ kept 30 days. Create a durable consumer and acknowledge after processing.
+- **Webhooks**: signed HTTPS calls, see the [webhook guide](webhooks.md).
+
+Each event is `{ "user_id", "event_id", "occurred_at" }` (plus `"event"` in
+webhooks): `user.created`, `user.email_verified`, `user.email_changed`,
+`user.password_changed`, `user.sessions_revoked`, `user.suspended`,
+`user.reactivated`, `user.deleted`. Events carry no personal data: read the
+account from the API when needed. Deduplicate on `event_id`; order with
+`occurred_at`.
+
+## 5. Errors
+
+- First-party and account routes answer `{ "code", "message" }` with a stable
+ `code` to branch on (`invalid_credentials`, `reauthentication_required`,
+ `two_factor_required`, ...).
+- `/oauth/token`, `/oauth/device_authorization`, `/oauth/introspect` and
+ `/oauth/revoke` answer RFC 6749 errors: `{ "error", "error_description" }`.
+- `429` carries `Retry-After`: wait that long. `503` means a dependency is
+ unavailable: retry with backoff.
+- Sensitive account actions need a recent re-authentication: on
+ `403 reauthentication_required`, ask for the password, call
+ `POST /users/me/reauth`, then repeat the request.
+
+## 6. Checklist
+
+- [ ] The service's audience is listed in auth-api's `JWT_AUDIENCE`.
+- [ ] Tokens are verified with `ES256` only, and `iss` and `aud` are checked.
+- [ ] Refresh tokens and client secrets are stored server-side, never in the
+ browser's local storage.
+- [ ] `state` (and `nonce` for OpenID Connect) is checked on every redirect.
+- [ ] The service erases a user's data on `user.deleted`.
+- [ ] Retries honour `Retry-After`, and `503` is retried with backoff.
diff --git a/docs/dev/guides/prerequisites.md b/docs/dev/guides/prerequisites.md
index 6564887..36240d7 100644
--- a/docs/dev/guides/prerequisites.md
+++ b/docs/dev/guides/prerequisites.md
@@ -89,13 +89,3 @@ sudo apt install pass
# Arch
sudo pacman -S pass
```
-
----
-
-### GHCR authentication
-
-A GitHub Personal Access Token (PAT) with `read:packages` scope is required to pull the Docker image from GitHub Container Registry.
-
-```bash
-echo "" | docker login ghcr.io -u --password-stdin
-```
diff --git a/docs/dev/guides/quality-gate.md b/docs/dev/guides/quality-gate.md
new file mode 100644
index 0000000..5530d1d
--- /dev/null
+++ b/docs/dev/guides/quality-gate.md
@@ -0,0 +1,99 @@
+# Quality Gate
+
+The gate runs on every pull request to `main` in GitHub Actions and locally
+with the same Makefile targets: nothing is merged or released without it
+passing.
+
+```bash
+make test-infra-up # once: PostgreSQL, Redis, NATS, Mailpit on loopback ports
+make ci
+```
+
+## What `make ci` runs
+
+| Step | Tool | Fails on |
+|------|------|----------|
+| `fmt-check` | `cargo fmt --check` | Any formatting difference |
+| `clippy` | `cargo clippy --workspace --all-targets --all-features -- -D warnings` | Any warning in the library, binaries, tests, benches, the test harness or the fuzzing entry points |
+| `deny` | `cargo deny check` | Advisories (RUSTSEC), disallowed licenses, banned or duplicated crates |
+| tests | `cargo nextest run --workspace --profile ci` | Any failing test of the unit, integration, security and simulation suites; every failure is reported in one run |
+| fuzz corpus | `cargo nextest run --test fuzz_corpus --features fuzzing` | A fuzz seed or recorded crash that breaks its property again |
+
+Every response a test receives is checked against `docs/dev/api/openapi.yaml`,
+and a unit test fails when that document differs from what the code generates.
+The security suite attacks every documented operation and fails when a control
+of the [security model](../security-model.md) loses its tests. The
+[testing guide](testing.md) describes the suites and the harness.
+
+JUnit results are written to `target/nextest/ci/junit.xml`.
+
+## Hosted CI
+
+`staging` is the draft branch: pushes to it run nothing. Pushes to `main` are
+refused; every change reaches it through a pull request, merged with a merge
+commit (not squash or rebase) so the back-merge can fast-forward `staging`.
+
+`.github/workflows/ci.yml` runs on pull requests to `main` and on `main` after a
+merge. Branch protection requires a single check, `ci-ok`: a job the change does
+not concern is skipped without blocking. A draft pull request fails `ci-ok`
+until it is marked ready for review.
+
+| Job | Runs when | Runs | Time |
+|-----|-----------|------|------|
+| `changes` | always | classifies the changed files | seconds |
+| `hygiene` | always | `CHECKS=hygiene scripts/infra-check.sh`: actionlint, repository secret scan | ~1 min |
+| `rust` | Rust, migrations, templates, `Cargo.*`, `openapi.yaml` or the test compose changed | `make fmt-check`, `make clippy`, `make deny`, `make test-infra-up`, `make ci-test` | ~10 min with the cache |
+| `infra` | Dockerfiles, compose, nginx, `deploy/`, scripts changed | `CHECKS=static scripts/infra-check.sh` | ~2 min |
+| `image` | a Dockerfile, `.dockerignore`, `Cargo.lock` or the toolchain changed | `CHECKS=image scripts/infra-check.sh`: build, size under 100 MiB, Trivy | ~15 min |
+| `ci-ok` | always | fails if a job failed or was cancelled | seconds |
+
+Only `main` writes the Rust cache; pull requests read it. A documentation-only
+pull request runs `changes` and `hygiene`. The repository-wide English guard is
+a Rust test: a documentation change that breaks it is caught by the next Rust
+pull request or the weekly run.
+
+`.github/workflows/scheduled.yml` opens an issue when a task fails and closes it
+when every task passes again; run any task by hand from the Actions tab.
+
+| When | Task |
+|------|------|
+| Mondays | `cargo deny check advisories`, image build and Trivy, `make coverage`, `make test-sim` |
+| The first of the month | `scripts/backup-drill.sh`, `make fuzz FUZZ_SECS=60` |
+
+`.github/workflows/backmerge.yml` fast-forwards `staging` to `main` after a
+merge, and leaves it alone when draft commits were pushed meanwhile.
+Dependabot opens grouped pull requests to `staging` once a month (Cargo,
+actions, Dockerfile and compose images).
+
+The toolchain is pinned in `rust-toolchain.toml`, locally and in the CI: bump it
+deliberately, since a new clippy brings new lints. The CI never builds, signs or
+publishes a release ([release guide](release.md)).
+
+To reproduce a job locally, run its command from the table; `make ci` is the
+`rust` job and `make infra-check` covers `hygiene`, `infra` and `image`.
+
+## Before a release
+
+In addition to `make ci`. `make release` runs `infra-check` and `stack-test`
+itself and stops if either fails.
+
+| Command | Checks |
+|---------|--------|
+| `make fuzz FUZZ_SECS=600` | Every fuzz target for ten minutes (nightly); a crash is fixed and its input added to `fuzz/regressions/` |
+| `make test-sim` | The simulation suite, long scenarios included |
+| `make mutants` | Mutation testing of the security-relevant pure code; every surviving mutant in `reports/mutants.out/missed.txt` is a fault no unit test notices, unless the testing guide lists it as accepted |
+| `make coverage` | Coverage of every suite; fails under 93 % of lines, 89 % of regions or 83 % of functions |
+| `make soak` | An hour of mixed traffic against one API process: no failed request, and resident memory growing less than 20 % between the first and last tenth of the run |
+| `make docker-check` | Hadolint on the Dockerfile, Trivy CVE and secret scans of the image |
+| `make infra-check` | The deployment files: `docker compose config` for every compose file and profile, Hadolint, `nginx -t`, promtool on the Prometheus configuration, alert rules and their unit tests, amtool, shellcheck on every script, Trivy on the image |
+| `make stack-test` | The production compose with profile M behind nginx in TLS: container limits, balancing, sign-in flow, failover, a rolling update under load with no failed request, the deletion event through the authenticated broker, a clean stop. Needs ports 80 and 443 free and outbound HTTPS to hcaptcha.com |
+| `make sizing` | After a change to the profiles, the Argon2 parameters or the hot paths: the profiles under real container quotas at 100 000 and 1 million accounts (hours, see [perf/README.md](../../../perf/README.md#sizing-validation)) |
+| `make bench-http` | Latency of every scenario; compare with the figures in the [operations runbook](../../deploy/guides/operations.md#8-measured-capacity) |
+
+## Regenerating the OpenAPI document
+
+```bash
+cargo run --quiet --bin openapi > docs/dev/api/openapi.yaml
+```
+
+Commit the result with the change that caused it.
diff --git a/docs/dev/guides/release.md b/docs/dev/guides/release.md
index c9fc6e5..afc7a41 100644
--- a/docs/dev/guides/release.md
+++ b/docs/dev/guides/release.md
@@ -1,44 +1,68 @@
# Creating a Release
-## Overview
+A release is a bundle built on a trusted machine and copied to the server: no
+registry. The hosted CI checks every change but never builds, signs or
+publishes a release: the release key stays on the trusted machine.
-A release is triggered by pushing a git tag matching `v*` on `main`. GitHub Actions then:
+## 0. Create the release key (once)
-- Builds the production Docker image and pushes it to GHCR
-- Creates a GitHub Release with a `migrations.tar.gz` asset
-
-## Steps
-
-### 1. Ensure staging is ready
-
-All changes must be on `staging` and the CI checks must pass before opening the PR.
+Bundles are signed with an SSH key kept on the trusted machine:
```bash
-git checkout staging
-git pull
+ssh-keygen -t ed25519 -f ~/.ssh/auth-api-release -C auth-api-release
```
-### 2. Open a pull request staging -> main
+Copy `~/.ssh/auth-api-release.pub` to each server out of band (see the
+[API deployment](../../deploy/api/deployment.md#13-copy-and-verify-the-bundle)):
+a bundle must never carry the key that verifies it.
+
+## 1. Pass the gate
-Open the PR on GitHub. The CI checks must pass before the merge is allowed. Merge using **"Create a merge commit"** - this keeps the merged `staging` tip as the merge commit's second parent, so `staging` stays an ancestor of `main`. The `backmerge.yml` workflow then fast-forwards `staging` back onto `main` automatically, keeping the two branches in sync.
+On the commit to release, with a clean working tree:
-> Do **not** use "Rebase and merge" or "Squash and merge": both rewrite the staging commits to new SHAs, which makes `staging` diverge from `main` and breaks the automatic back-merge (its `git merge --ff-only` would fail).
+```bash
+make docker-refresh-pins # base images: pick up security fixes, then commit
+make test-infra-up
+make ci
+make docker-check # hadolint on both Dockerfiles, Trivy fails on HIGH/CRITICAL
+```
-### 3. Create and push the tag
+## 2. Update the changelog and tag
-Must be done from `main` - the tag must point to a commit on `main` to trigger the workflow correctly.
+Move the `Unreleased` entries of [`CHANGELOG.md`](../../../CHANGELOG.md) under
+the new version, set `version` in `Cargo.toml`, commit, then tag:
```bash
-git checkout main
-git pull
-git tag v1.2.3
-git push --tags
+git tag -a v1.2.3 -m "auth-api 1.2.3"
```
-This triggers the `docker-publish` workflow. The image and the GitHub Release are created automatically.
+## 3. Build the bundle
-### 4. Verify
+```bash
+make release VERSION=1.2.3 RELEASE_SIGNING_KEY=~/.ssh/auth-api-release
+```
-- Check the workflow run on GitHub Actions
-- Confirm the image is available on GHCR: `ghcr.io/siir3x/auth-api:1.2.3` (the `v` prefix is stripped from image tags)
-- Confirm the GitHub Release includes the `migrations.tar.gz` asset
+The target refuses to run when the working tree has changes or untracked
+files, when `VERSION` differs from `version` in `Cargo.toml`, or when the tag
+`v1.2.3` does not point at `HEAD` (`ALLOW_UNTAGGED=1` builds a test bundle).
+The image is built from `git archive HEAD`, so nothing outside the commit can
+reach it, labelled with the version and commit, and scanned by Trivy: a
+HIGH or CRITICAL vulnerability with a fix stops the release.
+
+`dist/auth-api-1.2.3/` then holds:
+
+| File | Purpose |
+|------|---------|
+| `auth-api-1.2.3.image.tar.gz` | The production image (`auth-api:1.2.3`), distroless, non-root |
+| `IMAGE_ID` | Identifier of that image, checked after `docker load` |
+| `migrations/` | Every migration of this version |
+| `docker-compose.api.yml`, `config.prod.env` | Deployment files of this version |
+| `nginx/nginx.conf` | Reverse proxy configuration |
+| `scripts/backup-db.sh`, `scripts/restore-db.sh`, `scripts/backup-drill.sh` | Database backup, restore and drill (DB VPS) |
+| `docs/deploy/guides/prometheus-alerts.yml` | Alert rules for the monitoring host |
+| `SHA256SUMS` | Checksums of every file above |
+| `SHA256SUMS.sig` | Signature of `SHA256SUMS` by the release key |
+
+## 4. Deploy
+
+See [Deploying a New Release](../../deploy/guides/update.md).
diff --git a/docs/dev/guides/testing.md b/docs/dev/guides/testing.md
new file mode 100644
index 0000000..ab9aaca
--- /dev/null
+++ b/docs/dev/guides/testing.md
@@ -0,0 +1,241 @@
+# Testing
+
+Every behaviour of the service is pinned by a test that fails when the behaviour
+changes. This guide says where a test belongs, which tools the harness gives
+it, and what the gate enforces.
+
+## Suites
+
+| Suite | Where | Needs | Runs with | Covers |
+|-------|-------|-------|-----------|--------|
+| Unit | `src/**` (`#[cfg(test)]`), `crates/testkit` | nothing | `make test-unit` | Pure decisions: domain rules, validators, token and cipher handling, configuration checks |
+| Integration | `tests/integration/` | PostgreSQL, Redis, NATS | `make test-integration` | `api/`: every flow over HTTP; `repositories/`, `services/`: code against the real stores; `schema/`: constraints, triggers and SQL functions; `migrations/`: files, extensions, query plans |
+| Security | `tests/security/` | PostgreSQL, Redis, NATS | `make test-security` | Authorization matrix, forged tokens, rate limits, headers, logs, audit regressions, static guards, control catalog; fuzz corpus replay |
+| Simulation | `tests/simulation/` | PostgreSQL, Redis, NATS | `make test-sim` | The service while dependencies fail, under concurrency and over time |
+| Fuzzing | `fuzz/` | nightly toolchain | `make fuzz` | Parsers of untrusted input, under libFuzzer and sanitizers |
+
+`make ci` runs formatting, Clippy (every target, every feature), the dependency
+policy, every suite but the long simulations, and the fuzz corpus replay.
+
+### Where a new test goes
+
+- A decision that needs no I/O: a unit test next to the code. If the decision
+ hides inside an async service, extract it into a function taking plain
+ values (and `now`), then test that function.
+- An invariant over many inputs (a round trip, a bound, a rule restated): a
+ property test in a `mod properties` block next to the code.
+- Behaviour visible through the API: `tests/integration/api//`.
+- A SQL constraint, trigger or function: `tests/integration/schema/`.
+- An attack, or a control of the [security model](../security-model.md):
+ `tests/security/`, and cite the test in the control catalog.
+- A fixed bug: a test that fails before the fix, in the suite matching the
+ bug, named after the behaviour rather than the ticket.
+
+## The harness: `crates/testkit`
+
+### `TestApp`
+
+`TestApp::spawn().await` starts the real router on a random port with:
+
+- **its own database**, cloned from a template migrated once per migration set
+ (the template is rebuilt when a migration changes, under an advisory lock);
+- **a `TestClock`** as the application clock (`app.clock.advance(..)`);
+- **a `MailOutbox`** capturing every message (`app.mail.wait_for(address,
+ subject).await`, then `six_digit_code()` or `value_after("token=")`);
+- **a client address of its own**, forwarded through the trusted loopback
+ proxy, so per-address budgets never leak between tests;
+- **the OpenAPI contract** checked on every response (see below).
+
+`TestApp::spawn_with_config(|config| ..)` adjusts the configuration;
+`TestApp::builder().fault_proxies().spawn()` routes PostgreSQL, Redis and NATS
+through `FaultProxy`s (`app.dependencies`), which add latency, hang or refuse
+connections on demand. `spawn_with_mailpit()` keeps real SMTP for the tests of
+the transport itself.
+
+Tests never sleep to wait for time: advance the clock. Only what the service
+decides in Rust follows `TestClock` (token expiry, TOTP steps, lockout,
+lifetimes); SQL `NOW()` and Redis TTLs keep real time, so a test covering those
+ages the stored rows or deletes the key.
+
+### Databases without the API
+
+`TestDb::new().await` gives a migrated database (`db.pool`); `TestDb::empty()`
+an empty one. `testkit::sql` has row fixtures, `assert_constraint_error`,
+`explain_plan` (sequential scans disabled) and `pg_args!` for mixed binds.
+
+A database is dropped with its value. A test process killed before that leaves
+its database behind; the next process drops databases whose creating process no
+longer exists.
+
+### Forged credentials and logs
+
+`app.access_claims(user, session)` and `app.sign(&claims)` mint tokens as the
+API does; `testkit::tokens` forges the rest (foreign key, `alg: none`, HS256
+keyed with the public key). `LogCapture::install(filter)` records the logs of
+a test process, to assert what never reaches them.
+
+## The OpenAPI contract
+
+`docs/dev/api/openapi.yaml` is generated from the handlers
+(`cargo run --quiet --bin openapi`) and a unit test fails when it is stale or
+misses a route. Every `TestApp` also validates each response against it: the
+status must be documented for the operation and a JSON body must match its
+schema. A violation fails the test when its app is dropped.
+
+`TEST_CONTRACT=report` records violations in `target/contract-report/` instead,
+to survey a change; `TEST_CONTRACT=off` disables the check. Neither is used by
+the gate.
+
+## Security tests
+
+- **Authorization matrix** (`tests/security/authorization.rs`): every
+ documented operation outside a short `PUBLIC` list is called anonymously, with
+ malformed credentials and with nine forged tokens for a live session; each
+ must answer 401 before reading its input. The list of operations comes from
+ the document, so a new route is covered as soon as it exists.
+- **Control catalog**: each control of the security model lists the tests that
+ pin it; `tests/security/catalog.rs` fails when a cited test disappears.
+- **Static guards**: SQL built from strings, `unsafe`, disabled TLS
+ verification, prints and `unwrap()` on request paths are refused in the
+ service code; `migrations/SHA256SUMS` freezes released migrations. A new
+ migration is appended to it:
+ `sha256sum migrations/NNNN_name.sql | sed 's|migrations/||' >> migrations/SHA256SUMS`.
+- **Logs**: flows handling passwords, tokens and codes run with trace logging,
+ and none of those values may appear.
+
+## Property tests
+
+Examples pin the cases someone thought of; properties state what must hold for
+every input and let proptest search for the exception.
+
+- **Next to the code** (`mod properties` in `src/`): pure functions with an
+ invariant. Audit cursors round-trip over the whole timestamp range; every
+ address of an IPv6 bucket network maps to the same bucket; any secret
+ survives encryption and a key rotation, and a rewritten ciphertext no longer
+ needs the old key; the password policy is exactly its definition; a lockout
+ follows its threshold and never ends in the past; a device label is bounded,
+ free of control characters and stable.
+- **Against the database** (`tests/integration/api/auth/properties.rs`): inputs
+ generated around the validators' and the SQL constraints' boundaries go
+ through the real API. What the API accepts must be stored exactly as sent;
+ what the database would refuse must be refused first, as a `422`, never as a
+ `500`. These sample a few hundred inputs with a random seed and print the one
+ that fails.
+
+The fuzz targets complement them: fuzzing searches raw bytes for crashes and
+broken security properties, properties search typed inputs for broken rules.
+
+## Mutation testing
+
+Coverage says a line ran; mutation testing says a test would notice if that
+line were wrong. `make mutants` asks `cargo-mutants` to break the
+security-relevant pure code one change at a time (a `<` turned into `<=`, a
+function returning a default, a match arm deleted) and runs the unit tests
+against each broken copy. A mutant no test catches is a fault that would ship.
+
+Scope: `src/domain`, the crypto, token, TOTP, password, time and backoff
+utilities, the client address and error body middlewares, configuration
+validation and the audit cursor. The campaign runs serially (about 40 minutes)
+and writes `reports/mutants.out/`; `missed.txt` lists the survivors.
+
+`src/domain` also holds the decisions the services take, as plain functions of
+plain values: the refresh verdict and the per-request token state, the
+brute-force ceilings, consent and the claims a client token carries, device
+polling and the finality of a decision, the steps of an email change, the
+CAPTCHA and rate limiter verdicts, and the one-time token verdict. The services
+do the I/O and map each verdict to its error, so unit tests and mutants reach
+every branch without Redis, PostgreSQL or HTTP. Their own campaign, on
+2026-09-15, produced 96 mutants: 80 caught, 16 unviable, none missed.
+
+On 2026-09-15 the first campaign caught 196 of 246 viable mutants (79.7 %).
+The survivors pointed at untested behaviour: the encryption key validator
+masked by the production checks, the entropy floor, the 254-byte email bound,
+backoff delays, OTP and token generation, re-encryption, key ids, the audit
+pagination, the plain-text error codes. Tests now pin each of them. The final
+campaign caught 265 of 272; its one new survivor, `decrypt` refusing the
+28-byte ciphertext of an empty secret, is pinned too, which leaves **266 of
+272 (97.8 %)**. The remaining survivors are accepted, each for a stated reason:
+
+| Survivor | Why no unit test kills it |
+|----------|---------------------------|
+| `TrustedProxySource for AppState` returning no proxy | Covered by every integration test: without the loopback proxy, each `TestApp` would lose its own client address and the per-address budget tests would fail |
+| `record_argon2_permits` doing nothing | Sets a Prometheus gauge; no recorder is installed in unit tests |
+| `log_capacity` doing nothing | Writes a startup log line; its arithmetic lives in tested functions |
+| `cgroup_memory_limit_mib` returning a constant (3 mutants) | Reads the host's `/sys/fs/cgroup/memory.max`; parsing it is tested in `parse_memory_max` |
+
+A new survivor in this scope needs a test, or a line in this table.
+
+## Coverage
+
+`make coverage` runs every suite under `cargo-llvm-cov` (the binaries and the
+harness excluded), writes an HTML report to `reports/coverage/`, and fails
+under 93 % of lines, 89 % of regions or 83 % of functions.
+
+| | Before this work | After the test plan | Final, 2026-09-15 |
+|---|---:|---:|---:|
+| Lines | 90.5 % | 92.1 % | 93.9 % |
+| Regions | 85.4 % | 87.4 % | 90.1 % |
+| Functions | 78.5 % | 81.3 % | 83.8 % |
+
+By area (lines): domain 99.9 %, middleware 98.6 %, repositories 98.2 %,
+utilities 97.3 %, configuration 96.7 %, handlers 95.6 %, services 92.2 %.
+`state.rs` (68.2 %) is the weakest: its failures are tested, but building the
+production database pool and clients from nothing only happens at startup;
+`main.rs` is not measured by tests. The floors sit just under these figures, so
+a change that lowers coverage fails the target.
+
+## Simulations
+
+`tests/simulation/` runs the service through what production eventually
+throws at it:
+
+- **Dependency failures** (`dependencies.rs`): the app reaches PostgreSQL, Redis
+ and NATS through `FaultProxy`s. A test refuses connections, hangs them or
+ adds latency, checks what clients receive and what was (not) written through
+ a direct pool, then restores the dependency and checks the service recovers
+ without a restart. An outage must answer `503 service_unavailable`, never
+ `500`, and must leave no half-written state.
+- **Time** (`clock.rs`): session lifetimes, lockouts and token expiry, driven by
+ `app.clock.advance(..)` rather than sleeps.
+- **Redis without Redis** (`redis_outage.rs`): flows that must keep working, or
+ fail closed, when Redis is unreachable from the start.
+- **Account lifecycles** (`lifecycle.rs`): proptest generates sequences of
+ sign-ins, refreshes, sign-outs, password changes, sign-outs everywhere and
+ deletions for several accounts, played against the API. After every step, each
+ session a model knows must answer as the model says, the database must hold
+ exactly the live sessions the model counts, and the audit log must not shrink.
+ A failure prints the sequence.
+- **Timing** (`timing.rs`, long): wrong-password sign-ins and password recoveries
+ for existing and unknown accounts, interleaved in the same batches. The median
+ times of both sides must stay within 25 ms.
+
+Scenarios that take tens of seconds (a hung database bounded by the request
+timeout) are marked `#[ignore = "long: ..."]`: `make test-sim` runs them,
+`make ci` does not.
+
+These scenarios found two defects the rest of the suites could not see: a
+database outage answered `500` instead of `503`, and the Redis pool never
+replaced a connection that failed under traffic, so a Redis restart kept every
+authenticated request failing until traffic paused. The lifecycle model then
+found two operations documented as revoking the *other* sessions, a password
+change and signing out everywhere, when both revoke the current one too; the
+contract now says so.
+
+## Fuzzing
+
+Each target in `fuzz/fuzz_targets/` calls an entry point of `src/fuzzing.rs`,
+compiled only with the `fuzzing` feature. An entry point feeds raw bytes to a
+production parser and asserts a security property (a tampered token never
+verifies to other claims, an untrusted peer always is the client address, an
+accepted redirect is registered...).
+
+```bash
+make fuzz # every target, 60 s each (FUZZ_SECS=600 for longer)
+cargo +nightly fuzz run --fuzz-dir fuzz redirect_uri # one target, until stopped
+```
+
+Inputs worth keeping live in `fuzz/seeds//`. When the fuzzer finds a
+crash (`fuzz/artifacts/`), fix the code, then copy the input to
+`fuzz/regressions//` with a descriptive name: `tests/fuzz_corpus.rs`
+replays seeds and regressions on stable in `make ci`, together with random
+inputs from proptest.
diff --git a/docs/dev/guides/versioning.md b/docs/dev/guides/versioning.md
new file mode 100644
index 0000000..a29a08b
--- /dev/null
+++ b/docs/dev/guides/versioning.md
@@ -0,0 +1,78 @@
+# Versioning and Compatibility
+
+[Index](../README.md)
+
+auth-api follows [Semantic Versioning](https://semver.org/). The version in
+`Cargo.toml`, the image tag, the release bundle and the `CHANGELOG.md` heading
+are the same number.
+
+## 1. What the version covers
+
+The public contract is everything an integrator or operator relies on:
+
+| Surface | Defined by |
+|---------|------------|
+| HTTP API: routes, request and response fields, status codes, error `code`s and OAuth `error`s | `docs/dev/api/openapi.yaml`, generated from the code and enforced by the contract tests |
+| Tokens: access token and ID token claims, signing algorithm, JWKS | [Integration guide](integration.md), section 3 |
+| Discovery documents | `/.well-known/oauth-authorization-server`, `/.well-known/openid-configuration` |
+| Events: NATS subjects, stream name, payloads; webhook payloads, headers and signature | [Integration guide](integration.md), section 4; [webhook guide](webhooks.md) |
+| Configuration: environment variables, their defaults and production checks | [Configuration](configuration.md) |
+| Command line: `--register-client`, `--grant-role`, `--rotate-totp-keys`, `--healthcheck` | [Commands](commands.md) |
+| Operations: metric names and labels, shipped alert rules, compose files and profiles | Operations runbook, monitoring guide |
+
+Not covered: the database schema (read it through the API), log line formats,
+internal Rust modules, the test harness, and the exact wording of `message`
+and `error_description` fields.
+
+## 2. What each part of the number means
+
+**Major** (`2.0.0`): something integrators or operators rely on changes or
+disappears. Removing or renaming a route, a field, an error code, a claim, an
+event or a configuration variable; changing the meaning of an existing one;
+making an optional input required; changing a default so that the behaviour of
+an existing deployment changes; dropping a supported algorithm.
+
+**Minor** (`1.3.0`): something is added and existing uses keep working. New
+routes, optional request fields, response fields, events, error codes on new
+behaviour, configuration variables whose defaults keep the previous behaviour,
+additive migrations. Clients must ignore response fields and event fields they
+do not know.
+
+**Patch** (`1.3.1`): fixes and security fixes that change no contract. A
+security fix that must change a contract (refusing an input that was unsafe to
+accept) ships in a patch and is flagged under **Security** in the changelog.
+
+## 3. Deprecation
+
+A part of the contract to be removed is first deprecated in a minor release:
+
+1. The changelog lists it under **Deprecated**, with its replacement.
+2. A deprecated route is marked `deprecated` in the OpenAPI document and
+ answers with `Deprecation` (RFC 9745) and `Sunset` (RFC 8594) headers; a
+ deprecated configuration variable logs a warning at startup.
+3. It keeps working for at least 90 days and one further minor release, then
+ is removed in the next major release.
+
+## 4. Upgrades
+
+- Migrations run forward only and are frozen once released
+ (`migrations/SHA256SUMS`). Every release migrates from any earlier release
+ of the same major version, and from the last release of the previous one:
+ one release at a time is not needed.
+- A minor or patch upgrade is a rolling update: instances of the previous and
+ the new release run side by side during it. A release never needs a
+ migration that the previous release cannot run against, within a major
+ version.
+- Downgrading after migrations have run is not supported: restore the backup
+ taken before the upgrade (update guide).
+- Read the **Upgrading** section of the changelog before a major upgrade.
+
+## 5. Support
+
+| Release | Receives |
+|---------|----------|
+| The latest minor release | Fixes and security fixes |
+| The previous minor release | Security fixes for 90 days after the latest one |
+| Older releases | Nothing: upgrade |
+
+Security issues are reported as described in [SECURITY.md](../../../SECURITY.md).
diff --git a/docs/dev/guides/webhooks.md b/docs/dev/guides/webhooks.md
new file mode 100644
index 0000000..4780904
--- /dev/null
+++ b/docs/dev/guides/webhooks.md
@@ -0,0 +1,83 @@
+# Webhooks
+
+[Index](../README.md)
+
+auth-api can call your HTTPS endpoints when accounts change, as an alternative
+to consuming the NATS stream. An administrator with `webhooks:manage` registers
+each endpoint.
+
+## Registering an endpoint
+
+```bash
+curl -X POST https://auth.example.com/admin/webhooks \
+ -H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' \
+ -d '{"url": "https://crm.example.com/hooks/auth", "events": ["user.created", "user.deleted"]}'
+```
+
+The response holds `secret` (`whsec_...`): store it now, it is never shown
+again. `POST /admin/webhooks/{id}/secret` replaces it; the old one stops
+signing at once.
+
+Events: `user.created`, `user.email_verified`, `user.email_changed`,
+`user.password_changed`, `user.sessions_revoked`, `user.suspended`,
+`user.reactivated`, `user.deleted`, or `*` for all of them, including events
+added later.
+
+## What a delivery looks like
+
+```http
+POST /hooks/auth HTTP/1.1
+Content-Type: application/json
+webhook-id: 0192a7c4-5b1e-7c3a-9f0e-2d4c6b8a1e3f
+webhook-timestamp: 1789582527
+webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
+
+{"event":"user.deleted","user_id":"...","event_id":"0192a7c4-...","occurred_at":"2026-09-16T12:15:27.123456Z"}
+```
+
+Events carry the user id only; read anything else from the API. Answer with a
+2xx status within 5 seconds (`WEBHOOK_TIMEOUT_MS`). Redirects are not followed.
+
+## Verifying a delivery
+
+The signature follows [Standard Webhooks](https://www.standardwebhooks.com/):
+HMAC-SHA256, keyed with the base64-decoded part of the secret after `whsec_`,
+over `{webhook-id}.{webhook-timestamp}.{raw body}`, base64-encoded after `v1,`.
+
+```python
+import base64, hmac, hashlib, time
+
+def verify(secret: str, headers, body: bytes) -> bool:
+ key = base64.b64decode(secret.removeprefix("whsec_"))
+ signed = f"{headers['webhook-id']}.{headers['webhook-timestamp']}.".encode() + body
+ expected = "v1," + base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
+ fresh = abs(time.time() - int(headers["webhook-timestamp"])) < 300
+ return fresh and hmac.compare_digest(expected, headers["webhook-signature"])
+```
+
+Compute the signature over the raw body, before parsing it; reject timestamps
+more than five minutes away to refuse replays.
+
+## Delivery guarantees
+
+- **Only committed changes.** A delivery is recorded in the same transaction as
+ the change: an endpoint never hears of a change that rolled back.
+- **At least once.** A delivery can arrive twice (a timeout after your endpoint
+ processed it). Deduplicate on `event_id`.
+- **Unordered.** Deliveries are sent concurrently and retried independently.
+ Use `occurred_at` to order them.
+- **Retries.** A failure (non-2xx, timeout, unreachable) is retried after 30
+ seconds, doubling up to 6 hours, 12 attempts in all (about fourteen hours).
+ Then the delivery is given up and `AuthApiWebhooksFailing` fires.
+- **Inspection.** `GET /admin/webhooks/{id}/deliveries` lists the latest
+ deliveries with their attempts, status and error;
+ `POST /admin/webhooks/{id}/deliveries/{delivery_id}/retry` sends one again
+ with a fresh budget. A disabled endpoint (`"enabled": false`) records no new
+ delivery.
+
+## Network rules
+
+An endpoint whose host resolves to a loopback, private, link-local, shared,
+documentation, multicast or reserved address is never called, and the attempt
+fails with `blocked address`. Outside production, `WEBHOOK_ALLOW_HTTP` and
+`WEBHOOK_ALLOW_PRIVATE_NETWORKS` relax this for local testing.
diff --git a/docs/dev/guides/workflows.md b/docs/dev/guides/workflows.md
deleted file mode 100644
index 4288f01..0000000
--- a/docs/dev/guides/workflows.md
+++ /dev/null
@@ -1,88 +0,0 @@
-# GitHub Actions Workflows
-
-All workflows are located in `.github/workflows/`. They run automatically on
-pull requests targeting `main` or `staging` (Dependabot's grouped bumps), except the publish workflow (git tags) and
-the scheduled jobs.
-
-## code-quality.yml - Code Quality
-
-**Trigger:** pull request -> `main` or `staging`
-
-Three jobs running in parallel:
-
-| Job | Tool | What it checks |
-|-----|------|----------------|
-| fmt | `cargo fmt --check` | Code style compliance |
-| clippy | `cargo clippy` | Code correctness and best practices (warnings as errors) |
-| deny | `cargo deny` | License compliance, duplicate dependencies, banned crates, CVEs |
-
-`cargo-deny` is installed via `taiki-e/install-action` (pre-compiled binary, ~2s). It covers both dependency policy and security auditing - `cargo-audit` is not needed separately.
-
-Fails the PR if any job does not pass.
-
-## tests.yml - Tests & Coverage
-
-**Trigger:** pull request -> `main` or `staging`
-
-A single job with PostgreSQL 17, Redis 7, NATS and Mailpit as services.
-
-The suite runs under `cargo llvm-cov nextest` with an **80% line-coverage
-gate** (binary entrypoints and the bench harness are excluded); the lcov
-report is uploaded as an artifact.
-
-Each test gets an isolated PostgreSQL database cloned from a shared template - created once, dropped after the test. No test state leaks between runs.
-
-Fails the PR if tests fail or coverage drops below the gate.
-
-## docker-checks.yml - Docker Checks
-
-**Trigger:** pull request -> `main`
-
-```
-lint -> build -> scan
-```
-
-| Job | Tool | What it checks |
-|-----|------|----------------|
-| lint | Hadolint + port guard | Dockerfile best practices; compose ports stay loopback-only |
-| build | Docker Buildx | Image builds; **fails above the 200 MB size threshold** |
-| scan | Trivy (pinned) | CVEs in OS packages and Cargo dependencies (CRITICAL/HIGH, fixed only), leaked secrets |
-
-## security-audit.yml - Security Audit
-
-**Trigger:** weekly schedule, pull request -> `main` or `staging`, manual
-
-- **advisories** (scheduled only): `cargo deny check advisories` catches new
- RUSTSEC advisories between PRs.
-- **secrets**: Gitleaks scans the repository history (allowlist in
- `.gitleaks.toml` for the committed test keys and `.env.dev`).
-
-## docker-publish.yml - Publish Docker Image
-
-**Trigger:** git tag matching `v*`
-
-Builds the production image and pushes it to GitHub Container Registry (`ghcr.io/siir3x/auth-api`).
-
-Tags applied to the image:
-
-| Tag | Example | When |
-|-----|---------|------|
-| `latest` | `latest` | Always on tag push |
-| semver full | `1.2.3` | When tag is `v1.2.3` |
-| semver minor | `1.2` | When tag is `v1.2.3` |
-
-Builds for `linux/amd64`. Uses GitHub Actions cache to avoid recompiling
-unchanged dependencies. The image is cosign-signed (keyless) with SBOM +
-provenance attestations.
-
-See [Creating a Release](release.md) for the full release process.
-
-## Scheduled maintenance
-
-- **backup-drill.yml** (monthly): seeds a database, backs it up through the
- real `pg_dump | gzip | age` pipeline, restores it with `restore-db.sh` and
- verifies witness rows.
-- **backmerge.yml**: after a PR merges into `main`, fast-forwards `staging`
- back onto `main` so the branches never drift.
-- **Dependabot** (weekly): grouped cargo updates, GitHub Actions and Docker
- base-image bumps, each going through the full PR gauntlet.
diff --git a/docs/dev/privacy.md b/docs/dev/privacy.md
new file mode 100644
index 0000000..f6cde2b
--- /dev/null
+++ b/docs/dev/privacy.md
@@ -0,0 +1,75 @@
+# Personal Data
+
+[Index](README.md)
+
+What auth-api stores about people, why, for how long, and what happens when an
+account is deleted. Durations are the defaults; the variables are in the
+[configuration guide](guides/configuration.md). The operator of a deployment is
+the controller: this document is the technical basis of its record of
+processing, not a legal notice.
+
+## Register
+
+| Data | Where | Purpose | Kept | When the account is deleted |
+|------|-------|---------|------|-----------------------------|
+| Email address, username, password hash (Argon2id), locale, status, timestamps | `users` | Account and sign-in | Until the account is deleted | Deleted |
+| Accounts whose address was never verified | `users` | Letting the owner finish signing up | 7 days (`CLEANUP_UNVERIFIED_ACCOUNT_DAYS`), then deleted and announced like a deletion | - |
+| Sessions: client address, user agent, device name | `sessions` | Signed-in devices, revocation, replay detection | Until expiry or revocation, plus 7 days (`CLEANUP_SESSIONS_GRACE_DAYS`) | Deleted |
+| Devices signed in from: browser and system families, hashed | `known_devices` | Telling the owner about a sign-in from a new device | 90 days unused (`CLEANUP_KNOWN_DEVICE_DAYS`) | Deleted |
+| Sign-in attempts: identifier typed, client address, user agent of failures | `login_attempts` | Brute-force protection, lockout, security history | 90 days (`CLEANUP_LOGIN_ATTEMPTS_RETENTION_DAYS`) | Deleted, including failed attempts typed with the account's address or username before it existed |
+| Security history: action, request id, client address | `audit_log` | Investigating incidents, showing users their own history | 12 months (`AUDIT_LOG_RETENTION_MONTHS`); the client address keeps only its network (/24, /48) after 90 days (`AUDIT_IP_RETENTION_DAYS`) | Kept without identity: the account link and the client addresses are removed |
+| Linked external identities: provider and its subject identifier | `external_identities` | Signing in through Google, GitHub or another provider | Until unlinked | Deleted |
+| Passkeys: public key, credential id, authenticator model (AAGUID), name, counter, last use | `passkeys` | Signing in without a password | Until removed | Deleted |
+| Personal access tokens: name, scopes, digest, last use | `personal_access_tokens` | Letting the owner's scripts call the API | Until revoked or expired, then with their session | Deleted |
+| Second factors: TOTP secret (encrypted), recovery code hashes, email code hashes | `two_factor_methods`, `recovery_codes`, `email_2fa_codes`, `used_totp_codes` | Second factor | Codes until expiry plus a grace period; methods until removed | Deleted |
+| One-time tokens (hashes) and the address being verified | `email_verification_tokens`, `password_reset_tokens`, `magic_link_tokens` | Verification, reset, email change | 1 day after expiry (`CLEANUP_TOKENS_GRACE_DAYS`) | Deleted |
+| Consents to client applications, authorization codes | `user_client_quotas`, `authorization_codes` | Device and authorization code flows | Codes one hour after expiry | Deleted |
+| Domain events: the user id only | `event_outbox`, then NATS JetStream | Letting other services follow account changes, erasure included | Outbox: 7 days after delivery; JetStream: 30 days | `user.deleted` tells every consumer to erase its own data |
+| Webhook deliveries: the event (user id only), attempts, last status | `webhook_deliveries`, then the registered endpoints | Letting other systems follow account changes, erasure included | 7 days after delivery or giving up (`CLEANUP_WEBHOOK_DELIVERY_DAYS`); endpoints keep what they receive | `user.deleted` is delivered like the other events |
+| Counters and short-lived state: budgets per client address (/64 in IPv6) or per account, pre-authentication and email-change flows | Redis | Rate limiting, abuse budgets, multi-step flows | Minutes to hours (key expiry) | Expire on their own |
+| Emails sent (address, content) | The SMTP relay | Verification, reset, security notices | The relay's own retention | Outside auth-api |
+| Username, locale, email address and its verification, in ID tokens and UserInfo | Client applications granted `profile` or `email` | Signing the user in to them | Held by the client | Outside auth-api; `user.deleted` tells subscribed services |
+| First five characters of a new password's SHA-1 | Pwned Passwords range API | Refusing breached passwords (k-anonymity: the password and its full hash never leave) | Not stored by auth-api | - |
+
+The application logs record the route, status, latency and request id of each
+request, not the client address. nginx records client addresses in its access
+log, rotated by the system's logrotate. Database backups are encrypted and kept
+7 days on the server and 30 days offsite (`RETAIN_DAYS`, `OFFSITE_RETAIN_DAYS`);
+pgBackRest keeps the last two full backups and the WAL they need
+(`repo1-retention-full=2`). A deleted account disappears from backups when the
+last backup holding it expires.
+
+## Account deletion
+
+`DELETE /users/me`, after re-authentication, runs one transaction:
+
+1. an `account_deleted` audit entry, without identity in its metadata;
+2. a `user.deleted` event in the outbox;
+3. the client addresses of the account's audit entries are removed, and its
+ sign-in attempts deleted;
+4. the account row is deleted, and with it (foreign keys) its sessions, second
+ factors, tokens, codes and consents; the audit entries keep their action and
+ date, no longer linked to anyone.
+
+The event is published even if NATS is down at the time. Services consuming it
+erase their own data about that user id.
+
+## Exercising rights
+
+| Right | How |
+|-------|-----|
+| Access and portability | `GET /users/me/export`: one JSON document with everything listed above that auth-api stores about the account, secrets excepted; the other `GET /users/me/*` routes show each part |
+| Rectification | `PATCH /users/me/username`, `PATCH /users/me/locale`, the email change flow (`/users/me/email/*`) |
+| Erasure | `DELETE /users/me`; for an account the user cannot reach, an administrator deletes it (`DELETE /admin/users/{id}`) |
+| Restriction | An administrator suspends the account (`POST /admin/users/{id}/suspend`) |
+
+## Minimization choices
+
+- Events carry the user id only: an address or a username stored 30 days in a
+ broker every consumer reads would outlive changes and deletions.
+- The audit log never holds an address, a username or a token in its metadata.
+ Entries of administrative changes hold the administrator's id, which stays
+ after that administrator's account is deleted: it identifies no one once the
+ account is gone.
+- Client addresses of the audit log lose their host part after 90 days.
+- Failed sign-ins keep the user agent, successful ones do not.
diff --git a/docs/dev/security-model.md b/docs/dev/security-model.md
new file mode 100644
index 0000000..eee73d7
--- /dev/null
+++ b/docs/dev/security-model.md
@@ -0,0 +1,278 @@
+# Security Model
+
+What the service protects, against whom, and how. Each control below is pinned
+by tests (the control catalog at the end) so that a regression fails the build.
+The [threat model](threat-model.md) maps threats to these controls.
+
+## Assets and adversaries
+
+| Asset | Worst outcome |
+|-------|---------------|
+| Passwords | Offline cracking after a database leak |
+| Sessions and tokens | Account takeover without the password |
+| Second factors | Bypass by someone who holds the password |
+| Account existence | Enumeration for phishing or credential stuffing |
+| Security history, addresses | Personal data exposure |
+
+Adversaries considered: an anonymous attacker on the network, an attacker
+holding a leaked password, an attacker holding a stolen access or refresh
+token, a malicious client application, and a read-only database leak.
+An attacker with code execution on the host, or with the `ENCRYPTION_KEY` and
+the database together, is out of scope.
+
+## Credentials
+
+- **Argon2id**, 64 MiB and 3 iterations by default; hashes run on a bounded
+ pool (`ARGON2_MAX_CONCURRENCY`) so a login storm queues instead of exhausting
+ memory.
+- **No account oracle.** An unknown identifier still pays a full hash against a
+ decoy. A locked account answers the same whatever the password. Registration
+ answers `202` identically whether or not the address is taken (the owner is
+ emailed instead; a pending one gets its verification again). Forgot-password
+ and verification resends take a constant minimum time and answer identically.
+- **Addresses cannot be squatted.** A password reset proves ownership of the
+ address: it verifies a pending account with the password its owner chose, so
+ an account someone else registered with the address is taken back. Accounts
+ never verified are deleted after `CLEANUP_UNVERIFIED_ACCOUNT_DAYS`.
+- **Breached passwords are refused** at registration, change and reset, through
+ the Pwned Passwords range API: only the first five characters of the SHA-1
+ leave the service, answers are padded, and the check runs before the address
+ is looked up so it costs the same whether the address is taken.
+- **Lockout** after `LOCKOUT_THRESHOLD` consecutive wrong passwords. Failed
+ second factors never count: whoever fails a second factor already holds the
+ password, and letting them lock the account would let them shut the owner out.
+- **Brute force** is bounded per identifier and per address (database counters),
+ across identifiers from one address (HyperLogLog), and per submitted token.
+ Budgets are consumed atomically in Redis before the guarded check runs, so
+ parallel requests cannot all pass; they fail closed when Redis is down.
+
+## Sessions and tokens
+
+- **New devices** are announced: a sign-in from a browser and operating system
+ family the account never used e-mails its owner and is audited as
+ `new_device_login`. The first sign-in of an account and browser updates do not
+ alert.
+- **Access tokens** are ES256 JWTs (15 minutes) carrying `iss`, `aud`, `sid` and
+ `jti`. Every authenticated request checks, in one Redis round trip, that the
+ `jti` was not revoked by a logout and that the session is still active. A
+ Redis failure refuses the request: revocation cannot be proven.
+- **Refresh tokens** are opaque, stored as SHA-256 digests, and rotated on every
+ use. Presenting a rotated token again revokes the whole session family;
+ within 2 seconds of the rotation it is treated as a concurrent refresh from
+ the same client (two tabs) and refused without revocation.
+- **Absolute lifetime.** A sign-in ends after `JWT_MAX_SESSION_LIFETIME_SECS`
+ however often it is refreshed, and no rotation dates a session past that
+ moment. `JWT_STRICT_SESSION_BINDING` refuses a refresh
+ from another address.
+- **Sensitive actions require a recent re-authentication**: changing the
+ password, username or email, deleting the account, revoking sessions, and
+ adding or removing a second factor. A fresh sign-in does not count - a stolen
+ refresh token or an approved device must not change the credentials.
+
+## Second factors
+
+- A pre-auth token (5 minutes) is bound to the method it was issued for: a TOTP
+ challenge cannot be completed with an email code or vice versa.
+- TOTP codes are accepted once (durable replay table), with per-challenge and
+ per-account failure budgets. Confirming a new TOTP method consumes its code
+ in the same table, under its own budget. Email codes have their own budgets;
+ recovery codes share one per-account budget between the sign-in challenge and
+ the authenticated route.
+- A second factor answers an account status exactly as the password sign-in
+ does.
+- Adding or removing a method notifies the account's address. Removing the last
+ method deletes the recovery codes; removing a primary method promotes another.
+- TOTP secrets are encrypted with AES-256-GCM. Ciphertexts name their key, so
+ the key can be rotated without downtime and the rotation can be resumed.
+
+## External identities
+
+- An external identity signs in only after the signed-in owner of an account
+ linked it, with a recent re-authentication. No account is found or created
+ from an email address, verified or not: an attacker controlling an address at
+ a provider gains nothing.
+- The flow uses PKCE, `state` and `nonce`; the ID token's signature is checked
+ against the provider's published keys, and its issuer, audience, authorized
+ party, expiry and nonce against the flow.
+- The callback's outcome is redeemed once, within two minutes, only with the
+ binding secret the starting browser kept: a callback URL forwarded to a victim
+ cannot sign the victim in to the attacker's account.
+- The identity stands for the password: a second factor enrolled on the account
+ is still required.
+
+## Passkeys
+
+- Registration needs a recent re-authentication. A response is accepted only
+ for the challenge issued to the session, from an allowed origin, for this
+ relying party, with user presence and verification; attestation is not
+ requested nor trusted.
+- A sign-in challenge is used once, whatever the outcome. The assertion must
+ verify against the stored key, come from an allowed origin with user
+ verification, carry the credential's user handle, and make a kept signature
+ counter grow; a counter that does not grow refuses the sign-in (possible
+ clone). Every refusal answers `invalid_credentials` and counts against the
+ client address.
+
+## Client applications
+
+- Only registered clients can obtain sessions, through the standard OAuth 2.1
+ endpoints. A confidential client proves its secret at every token and device
+ authorization request; the secret (256 random bits) is stored as a digest and
+ compared in constant time.
+- **Authorization endpoint:** an unknown client or an unregistered redirect URI
+ is answered directly, never redirected; the stored request lives ten minutes
+ and is decided once. Consenting to a third-party client requires a
+ re-authentication, checked before the request is used up.
+- **Device flow (RFC 8628):** user codes are reserved atomically, polling is
+ paced, an approval is collected exactly once and only by the client that
+ started the flow, and account status and session limits are rechecked when
+ tokens are issued.
+- **Authorization code with PKCE:** S256 only, exact redirect URIs (loopback on
+ any port only for a registered path, never `localhost`), single-use codes
+ consumed atomically, a replayed code revokes its session.
+- **Scopes:** a request may narrow the client's registered scopes, never widen
+ them; a client's tokens carry only the consented permissions, re-derived from
+ the user's current permissions on every refresh, and no roles.
+- **Refresh:** a client's session is refreshed only by that client at the token
+ endpoint, with its authentication; the first-party route refuses it.
+- **OpenID Connect:** identity scopes grant no permission and release only
+ their own claims; the ID token is bound to the client (`aud`), the request
+ (`nonce`) and the access token (`at_hash`).
+- **Client credentials:** only a confidential client with scopes that has the
+ grant turned on obtains tokens for itself; they carry no user, so account
+ routes refuse them, and they stop being active when the grant is turned off.
+- **Introspection and revocation:** only confidential clients introspect, and
+ an inactive token reveals nothing but `active: false`. A client revokes its
+ own tokens only; any other token gets the same answer and is left alone.
+
+## Administration
+
+- `/admin` routes require an administrative permission in the token, a second
+ factor enrolled on the administrator's account, and the permission of the
+ action still granted in the database: revoking a role takes effect on the
+ next request, not when the token expires.
+- Administrators cannot suspend, sign out, reset or delete their own account
+ from `/admin`, and deleting an account needs their recent re-authentication.
+- Every change is audited on the account it changed, with the administrator's
+ id, so owners see it in their own history; changes to roles and clients are
+ audited in the administrator's own history.
+- Granting a role, changing what a role grants and saving a client need a
+ recent re-authentication. No change may leave the deployment without an
+ account holding `roles:manage`.
+
+## Webhooks
+
+- Deliveries are recorded in the transaction of the change, like events: an
+ endpoint never hears of a change that rolled back.
+- Each delivery is signed with the endpoint's secret (HMAC-SHA256 over id,
+ timestamp and body); secrets are encrypted with the keyring and shown once.
+- Before each delivery the host is resolved and every address checked: loopback,
+ private, link-local, shared, documentation, multicast and reserved ranges,
+ and IPv6 forms embedding them, are refused. The connection is pinned to the
+ checked address and redirects are not followed, so a DNS answer or a
+ redirect cannot turn a webhook against the internal network.
+
+## Data
+
+- Every token, code and refresh token is stored as a digest.
+- The audit log is append-only (enforced by a trigger) and holds no personal
+ data such as addresses in its metadata. Its client addresses keep only their
+ network after 90 days and are removed when the account is deleted, with its
+ sign-in attempts ([personal data](privacy.md)). Users read their own history
+ through `GET /users/me/audit`.
+- An email change is confirmed on both addresses, revokes the other sessions,
+ and notifies the previous address.
+- Account deletion records `user.deleted` in the event outbox in the same
+ transaction as the deletion, so downstream erasure cannot be lost and is
+ never announced for an account that still exists; the relay delivers it to
+ JetStream, waiting for the broker when it is down.
+
+## Network edge
+
+- Client addresses come from forwarding headers only when the direct peer is a
+ trusted proxy (`TRUSTED_PROXY_CIDRS`); IPv6 clients are limited per `/64`.
+- Rate limits: a sliding-window estimate per client, one script per request,
+ fail closed in production.
+- Request bodies are capped at 64 KB and handlers at 30 seconds. Responses carry
+ HSTS, CSP `default-src 'none'`, `nosniff`, `DENY` framing and `no-store`.
+- Logs record route templates, never raw paths carrying codes. Metrics are served
+ on a loopback-only listener.
+
+## Configuration
+
+Production refuses to start with a configuration that disables a control: HTTP
+public or frontend URLs, no trusted proxy, committed development keys, a
+wildcard CORS origin, fail-open rate limiting or CAPTCHA, and more. The full
+list is in [Configuration](guides/configuration.md#production-checks).
+
+## Known limits
+
+- An administrative revocation outside the API (a direct database update) takes
+ effect within the 5-second session cache.
+- Email codes are 6 digits; their strength is the attempt budgets and short
+ lifetime, not their entropy.
+- Outside production, rate limiting and CAPTCHA fail open by default.
+- Anyone holding both `ENCRYPTION_KEY` and a database dump can read TOTP secrets.
+- A logout racing a refresh of the same session, more than 2 seconds after
+ its rotation, reads as a replay: the family is revoked and a replay audited.
+ Kept on purpose, since the audit signal outweighs this rare race.
+- Webhook signing secrets, like TOTP secrets, are readable by anyone holding
+ `ENCRYPTION_KEY` and a database dump.
+- Passkey attestation is not verified: the account's re-authentication vouches
+ for a new passkey, not the authenticator's make.
+- An access token revoked through `POST /oauth/revoke` is remembered in Redis
+ only, until it expires; a Redis failover can forget it.
+- Resource servers verifying tokens offline accept a revoked access token until
+ it expires, unless they introspect.
+- A refresh does not check the account lockout. A lockout can be triggered by
+ anyone who knows the identifier; cutting the owner's live sessions would
+ turn it into a way to sign them out. Suspending the account does end them.
+
+## Control catalog
+
+Each control names the tests that pin it; `tests/security/catalog.rs` fails
+when a cited test no longer exists.
+
+| ID | Control | Tests |
+|----|---------|-------|
+| SEC-01 | Passwords are hashed with Argon2id, salted per hash | `hash_and_verify_correct_password`, `same_password_produces_different_hashes`, `async_hash_and_verify_match_sync_behavior` |
+| SEC-02 | No account oracle: unknown identifiers, locked accounts, taken addresses, forgotten passwords and verification resends answer alike | `locked_account_answers_the_same_whatever_the_password`, `registering_a_taken_email_looks_like_a_new_signup`, `forgot_password_takes_the_same_minimum_time_either_way`, `forgot_password_returns_200_for_unknown_email`, `resending_the_verification_looks_the_same_for_every_address`, `login_unknown_user` |
+| SEC-03 | Lockout after consecutive wrong passwords, never after failed second factors | `account_locked_after_threshold_failures`, `account_unlocked_after_lockout_expires`, `second_factor_failures_do_not_lock_the_account`, `a_zero_threshold_never_locks`, `validate_rejects_zero_lockout_threshold` |
+| SEC-04 | Attempt budgets are consumed atomically and fail closed | `concurrent_attempts_never_exceed_the_budget`, `unreachable_redis_fails_closed`, `refresh_rate_limited_after_20_invalid_tokens`, `forgot_password_is_capped_per_account`, `concurrent_reauthentication_guesses_never_exceed_the_budget`, `rotating_ipv6_addresses_within_a_64_does_not_reset_the_failure_budget` |
+| SEC-05 | Access tokens: ES256 only, issuer, audience and time claims checked, revocation checked on every request | `missing_or_malformed_credentials_are_refused`, `forged_tokens_for_a_live_session_are_refused`, `a_token_outlives_neither_its_expiry_nor_its_logout`, `time_claims_follow_the_supplied_clock`, `decode_rejects_non_es256_alg`, `a_rotation_keeps_every_published_key_valid`, `a_kid_does_not_lend_its_key_to_another_signature` |
+| SEC-06 | Every operation outside a short public list requires an access token | `the_public_list_matches_the_document`, `a_valid_token_passes_authentication_everywhere` |
+| SEC-07 | Refresh tokens rotate; a replay revokes the family, a concurrent refresh does not | `refresh_token_replay_is_rejected`, `refresh_token_theft_invalidates_entire_session_family`, `concurrent_refreshes_keep_the_family_alive`, `rotated_within_accepts_the_grace_boundary_only` |
+| SEC-08 | Absolute session lifetime and optional address binding | `session_lifetime_counts_from_the_first_sign_in`, `a_rotation_inherits_the_family_start`, `refresh_rejects_mismatched_ip_with_strict_binding`, `rotations_never_outlive_the_absolute_lifetime`, `a_rotated_session_is_never_dated_past_its_absolute_lifetime` |
+| SEC-09 | Sensitive actions need a recent re-authentication; signing in does not count | `signing_in_does_not_grant_sensitive_actions`, `revoke_session_requires_recent_reauth`, `delete_account_without_password_and_no_recent_reauth_rejected`, `a_device_session_cannot_change_the_password_without_reauthentication`, `enrolling_a_second_factor_requires_reauthentication` |
+| SEC-10 | A pre-auth token completes only the method it was issued for | `totp_challenge_cannot_be_completed_with_an_email_code`, `a_pre_auth_state_without_a_method_cannot_complete_with_a_recovery_code`, `seeds_and_regressions_hold` |
+| SEC-11 | Second-factor codes are single-use and budgeted per challenge and per account | `totp_replay_within_window_rejected`, `totp_replay_rejected_even_after_redis_key_loss`, `concurrent_totp_guesses_never_exceed_the_token_budget`, `account_budget_blocks_fresh_pre_auth_tokens`, `recovery_challenge_rate_limited_after_max_failures`, `email_2fa_lockout_after_max_failures`, `recovery_login_replay_rejected`, `a_code_confirming_a_new_method_cannot_complete_a_sign_in`, `confirming_a_new_method_has_an_attempt_budget`, `recovery_code_guesses_share_one_budget_across_routes`, `a_challenge_owns_its_state_and_every_failure_budget` |
+| SEC-12 | Changes to second factors are notified and keep a usable configuration | `removing_the_last_method_drops_recovery_codes`, `removing_the_primary_method_promotes_the_remaining_one`, `disable_totp_sends_two_factor_disabled_email` |
+| SEC-13 | TOTP secrets are encrypted with named keys; rotation is resumable | `keyring_writes_versioned_ciphertexts_it_can_read`, `keyring_refuses_a_key_it_does_not_hold`, `encrypt_produces_different_output_each_call`, `rotate_is_idempotent_when_run_twice`, `rotate_re_encrypts_totp_secret_with_new_key` |
+| SEC-14 | Only registered clients obtain sessions through client flows | `a_flow_needs_a_registered_client` |
+| SEC-15 | Device flow: user codes reserved atomically, polling paced, approval collected once, account and session limit rechecked under lock | `a_live_user_code_is_never_handed_out_twice`, `polling_faster_than_the_interval_is_slowed_down`, `an_approval_is_collected_exactly_once_under_concurrent_polls`, `a_suspended_account_cannot_collect_approved_tokens`, `a_non_primary_client_is_capped_without_a_quota_row`, `unknown_user_codes_are_rate_limited`, `concurrent_approvals_never_exceed_the_session_limit`, `a_second_factor_answers_an_inactive_account_like_the_password_sign_in` |
+| SEC-16 | Authorization code: S256 only, exact or loopback redirects, single use, replay revokes, third-party consent re-authenticates | `only_s256_challenges_are_accepted`, `only_registered_or_loopback_redirects_are_accepted`, `loopback_redirects_accept_any_port_on_a_registered_path`, `a_replayed_code_is_refused_and_revokes_its_session`, `a_wrong_verifier_burns_the_code`, `a_code_is_bound_to_its_client_and_redirect`, `a_third_party_client_requires_a_fresh_reauthentication`, `challenges_and_verifiers_follow_rfc_7636` |
+| SEC-17 | Client tokens carry only consented permissions, re-derived on refresh | `tokens_carry_only_the_consented_scopes_even_after_refresh`, `granted_is_an_intersection_unless_unrestricted` |
+| SEC-18 | Tokens and codes are stored as digests | `sessions_require_32_byte_hashes`, `email_verification_tokens_are_fixed_length` |
+| SEC-19 | The audit log is append-only and holds no personal data | `audit_log_is_append_only`, `audit_log_delete_blocked_by_trigger`, `account_deletion_leaves_no_identity_in_the_audit_log`, `an_email_change_keeps_the_status_and_audits_no_address`, `a_forged_cursor_is_refused_and_the_history_needs_a_session`, `audit_addresses_can_only_be_forgotten_or_coarsened`, `a_deleted_account_leaves_no_address_or_sign_in_attempt_behind`, `old_audit_addresses_keep_only_their_network` |
+| SEC-20 | An email change is confirmed on both addresses by the user who started it | `email_change_full_flow_success`, `email_change_steps_cannot_be_skipped`, `email_change_token_bound_to_initiating_user` |
+| SEC-21 | Account deletion and its `user.deleted` event commit together, and the event goes out once the broker is back | `account_deletion_publishes_user_deleted_through_jetstream` |
+| SEC-22 | Forwarding headers count only from trusted proxies; IPv6 clients share their /64 | `direct_peer_ignores_forwarded_headers`, `trusted_proxy_uses_forwarded_client_ip`, `ipv6_addresses_share_their_64`, `every_forwarded_line_counts_as_one_list`, `an_unreadable_hop_stops_the_walk_at_the_proxy`, `sql_budgets_group_addresses_like_redis_budgets` |
+| SEC-23 | Rate limits per client, failing closed in production | `auth_rate_limit_blocks_requests_exceeding_limit`, `auth_routes_fail_closed_when_rate_limiter_backend_is_down`, `a_refused_request_consumes_nothing`, `validate_rejects_production_config_with_rate_limit_fail_open` |
+| SEC-24 | Bounded bodies, security headers, CORS allowlist, one error format | `an_oversized_body_is_refused_before_the_handler`, `security_headers_present_on_200_response`, `security_headers_enable_hsts_for_https_production`, `cross_origin_access_is_limited_to_the_allowlist`, `plain_text_errors_become_error_bodies_with_their_headers`, `parser_details_do_not_leak` |
+| SEC-25 | Logs carry route templates and never a secret | `access_logs_carry_route_templates_not_codes`, `account_flows_never_log_their_secrets` |
+| SEC-26 | Production refuses a configuration that disables a control | `validate_accepts_hardened_production_config`, `validate_rejects_committed_dev_key_in_production`, `validate_rejects_wildcard_cors_in_production`, `validate_rejects_non_https_public_url_in_production`, `validate_rejects_zero_device_poll_interval`, `validate_rejects_poll_interval_not_below_device_ttl`, `validate_rejects_zero_session_lifetime` |
+| SEC-27 | Code hygiene: bound SQL parameters, no unsafe code, no panics on request paths, released migrations frozen | `sql_is_never_assembled_from_strings`, `there_is_no_unsafe_code`, `request_paths_never_unwrap`, `released_migrations_are_never_edited` |
+| SEC-28 | Every response matches the published OpenAPI contract | `schemas_are_enforced_through_references`, `undocumented_statuses_and_bodies_are_violations`, `documented_schemas_have_unique_names` |
+| SEC-29 | Passwords found in known data breaches are refused, and only a hash prefix leaves the service | `registration_refuses_a_breached_password`, `only_the_hash_prefix_leaves_the_service_with_padding_asked`, `a_breached_password_is_refused_on_change_and_on_reset`, `the_range_key_splits_the_uppercase_sha1` |
+| SEC-30 | A sign-in from a new device is announced to the owner | `a_sign_in_from_a_new_device_alerts_the_owner`, `the_first_sign_in_and_a_browser_update_raise_no_alert`, `versions_do_not_make_a_new_device` |
+| SEC-31 | Administration needs the permission in the token and in the database, and a second factor | `an_account_without_administrative_permission_is_refused`, `an_administrator_without_a_second_factor_is_refused`, `a_permission_revoked_in_the_database_stops_working_before_the_token_expires`, `each_action_requires_its_own_permission`, `an_administrator_cannot_suspend_their_own_account_or_a_pending_one`, `deleting_an_account_needs_a_recent_reauthentication_and_announces_it`, `granting_a_role_needs_a_recent_reauthentication`, `nobody_can_remove_the_last_way_to_manage_roles_or_the_default_role` |
+| SEC-32 | The data export needs a recent re-authentication and holds no secret and no other account | `the_export_holds_the_account_its_history_and_no_secret`, `exporting_needs_a_recent_reauthentication` |
+| SEC-33 | Sign-in links are single-use, short-lived, replaced by the next one, off by default, and never skip the second factor | `a_link_signs_in_once`, `a_new_link_replaces_the_previous_one_and_an_old_link_expires`, `a_second_factor_is_still_required`, `unknown_pending_and_suspended_addresses_answer_alike_and_get_nothing`, `links_are_capped_per_account_and_off_unless_enabled` |
+| SEC-34 | Personal access tokens are stored as digests, shown once, scoped to permissions the account holds, and end with their session or account | `a_token_is_exchanged_for_access_tokens_carrying_its_scopes_only`, `a_revoked_token_and_its_access_tokens_stop_working`, `tokens_expire_and_follow_the_account_status`, `creation_is_checked`, `scopes_are_limited_to_the_permissions_held` |
+| SEC-35 | Webhooks are signed, never reach internal addresses or follow redirects, and deliver exactly the committed events | `a_subscribed_endpoint_receives_signed_events`, `internal_addresses_are_never_called`, `internal_addresses_are_refused`, `only_plain_https_urls_are_registered`, `endpoints_are_checked_updated_rotated_and_removed`, `validate_rejects_production_webhooks_to_http_or_internal_addresses` |
+| SEC-36 | Confidential clients authenticate at every token request, and client sessions are refreshed only by their client | `a_confidential_client_must_authenticate_with_its_secret`, `a_public_client_has_no_secret_to_present`, `a_device_code_works_for_its_client_only`, `tokens_carry_only_the_consented_scopes_even_after_refresh`, `basic_credentials_are_form_decoded`, `a_request_asks_for_a_subset_of_the_client_scopes` |
+| SEC-37 | Introspection is reserved to confidential clients and says nothing of inactive tokens; revocation reaches only the requesting client's tokens | `a_resource_server_learns_what_a_token_is_worth`, `revoking_a_refresh_token_ends_its_session`, `revoking_an_access_token_ends_that_token_only`, `a_client_cannot_revoke_the_tokens_of_another` |
+| SEC-38 | The client credentials grant is limited to confidential, scoped clients that enable it, and its tokens never act as a user | `a_client_obtains_a_token_for_itself_with_its_scopes`, `the_grant_is_reserved_to_confidential_clients_that_enable_it`, `a_client_token_is_introspected_and_revoked`, `a_client_subject_is_stable_and_never_a_user_id` |
+| SEC-39 | ID tokens are bound to their client, nonce and access token, and identity scopes release only their claims | `an_openid_request_gets_an_id_token_bound_to_its_nonce_and_access_token`, `userinfo_releases_the_claims_of_the_granted_scopes`, `scopes_release_their_claims_only` |
+| SEC-40 | Passkeys: registration re-authenticated and verified, sign-in challenges single use, signatures verified, cloned counters refused | `a_registration_is_verified_before_it_is_stored`, `forged_replayed_or_cloned_assertions_are_refused`, `a_passkey_signs_in_without_password_or_second_factor`, `a_removed_passkey_no_longer_signs_in`, `assertions_verify_against_the_stored_key_only`, `client_data_answers_the_challenge_from_an_allowed_origin`, `validate_rejects_production_passkey_origins_outside_the_relying_party` |
+| SEC-41 | External identities sign in only once linked by the owner, bound to the starting browser, with verified ID tokens | `a_linked_identity_signs_in_and_an_unlinked_one_never_does`, `an_outcome_is_used_once_by_the_browser_that_started_it`, `an_id_token_that_does_not_verify_identifies_nobody`, `a_token_for_something_else_is_refused` |
diff --git a/docs/dev/threat-model.md b/docs/dev/threat-model.md
new file mode 100644
index 0000000..4596696
--- /dev/null
+++ b/docs/dev/threat-model.md
@@ -0,0 +1,140 @@
+# Threat Model
+
+[Index](README.md)
+
+What can go wrong, where, and what stops it. The controls are described in the
+[security model](security-model.md) and pinned by the tests of its control
+catalog (`SEC-nn` below); this document maps threats to them and records what
+is left. Review it when a trust boundary, a flow or a dependency changes, and
+at least once a year.
+
+## 1. System and trust boundaries
+
+```text
+ [Browser / app / device] [Resource servers] [Webhook endpoints]
+ | TB1 (internet, TLS) | TB1 ^ TB5 (internet)
+ v v |
+ [nginx: TLS, proxy] ------> [auth-api instances] ---------+
+ | | | | \
+ TB2 (private network, WireGuard) TB6 (internet)
+ | | | | [Identity providers, Pwned
+ [PostgreSQL] [Redis] [NATS] Passwords, SMTP relay]
+ |
+ TB3 [event consumers]
+ [Operators] -- TB4 (SSH, pass, CLI, /admin with a second factor) --> hosts, API
+```
+
+| Boundary | Crossed by | Trust |
+|----------|------------|-------|
+| TB1 | Every client request | None: every input is hostile |
+| TB2 | Queries, cache reads, event publishing | Authenticated services on a private network |
+| TB3 | Domain events | Consumers are trusted to process them, not to publish |
+| TB4 | Administration | Operators with host access; administrators through the API |
+| TB5 | Webhook deliveries | Endpoints are registered by administrators, but may be hostile or compromised |
+| TB6 | Identity providers, breach checks, mail | Trusted for their one purpose, answers verified where possible |
+
+## 2. Assets
+
+| Asset | Where | Worst outcome |
+|-------|-------|---------------|
+| Credentials: password hashes, TOTP secrets, passkey keys, recovery codes | PostgreSQL | Offline cracking, second factor bypass |
+| Sessions: refresh tokens, access tokens, personal access tokens, client secrets | Clients, digests in PostgreSQL | Account or client takeover |
+| Signing keys (`JWT_PRIVATE_KEY`), `ENCRYPTION_KEY`, webhook and client secrets | `pass` on the API host, encrypted columns | Forged tokens for every account |
+| Personal data: addresses, usernames, client addresses, histories | PostgreSQL, backups, logs, events | Exposure, profiling |
+| Availability of sign-in | The whole stack | Users locked out of every dependent service |
+
+## 3. Threats by STRIDE
+
+### Spoofing
+
+| Threat | Mitigation | Residual |
+|--------|------------|----------|
+| Credential stuffing and password guessing | Per-address and per-identifier budgets, lockout, backoff, CAPTCHA, breached password refusal (SEC-03, SEC-04, SEC-23, SEC-29) | A slow, distributed attack below every budget; watch `auth_logins_total{outcome="invalid_credentials"}` |
+| Account enumeration | Identical answers and timing for unknown and known identifiers (SEC-02) | Timing measured on one machine; network jitter helps, co-located attackers are out of scope |
+| Stolen access token | 15-minute lifetime, revocation checked per request, session binding option (SEC-05, SEC-08) | Resource servers verifying offline accept it until expiry unless they introspect |
+| Stolen refresh token | Rotation with replay detection revoking the family (SEC-07) | The thief wins if they refresh first and the owner never does again |
+| Forged tokens | ES256 only, `kid` pinned to its key, issuer and audience checked (SEC-05) | Theft of `JWT_PRIVATE_KEY`: rotate the key (runbook section 1) |
+| Second factor bypass | Pre-auth token bound to its method, single-use codes, budgets (SEC-10, SEC-11) | Email codes are as strong as the mailbox |
+| Phishing of sign-in links | Off by default, short-lived, single-use, never skip the second factor (SEC-33) | Enabled, the mailbox is a first factor |
+| Passkey cloning or forged assertions | Signature, origin, relying party, user verification, counters (SEC-40) | Attestation not verified: an authenticator's make is not trusted nor checked |
+| Login CSRF with an external identity | Browser binding secret on the outcome, `state`, `nonce` (SEC-41) | A compromised identity provider signs in whoever it vouches for, for linked accounts |
+| Account takeover through a provider's email | Identities never matched by email; linking needs the signed-in owner (SEC-41) | - |
+| Malicious OAuth client | Registered clients only, exact redirects, PKCE S256, consent re-authentication, scopes (SEC-14 to SEC-17, SEC-36) | A user consenting to a malicious registered client |
+| Client impersonation at the token endpoint | Confidential client secrets, client-bound refresh and device codes (SEC-36) | Public clients rely on PKCE and redirect registration |
+
+### Tampering
+
+| Threat | Mitigation | Residual |
+|--------|------------|----------|
+| Audit log alteration | Append-only trigger; only anonymization allowed (SEC-19) | A database superuser |
+| SQL injection | Bound parameters only, static guard (SEC-27) | - |
+| Tampered webhook delivery | HMAC signature over id, timestamp and body (SEC-35) | Endpoints that do not verify |
+| Tampered events on NATS | Publishing restricted by broker credentials | A consumer holding publishing credentials |
+| Migration drift | Released migrations checksummed (SEC-27) | - |
+
+### Repudiation
+
+| Threat | Mitigation | Residual |
+|--------|------------|----------|
+| "I did not do that" | Audit entries for every security-relevant action, with request id and coarsened address; administrative changes name the administrator (SEC-19, SEC-31) | Addresses keep only their network after 90 days by design |
+
+### Information disclosure
+
+| Threat | Mitigation | Residual |
+|--------|------------|----------|
+| Database leak | Argon2id hashes, digests of every token and code, encrypted TOTP and webhook secrets (SEC-01, SEC-13, SEC-18) | `ENCRYPTION_KEY` and a dump together reveal TOTP and webhook secrets |
+| Secrets in logs | Route templates only, redacted configuration (SEC-25) | Third-party log shipping configuration |
+| Personal data in events | User id only in events and webhooks | Consumers' own storage |
+| SSRF through webhooks | Address checks after resolution, pinned connection, no redirects (SEC-35) | DNS rebinding between check and connect is closed by pinning; an internal service on a public address |
+| Data export abuse | Recent re-authentication, own account only (SEC-32) | A session with a known password |
+| Introspection as an oracle | Confidential clients only; inactive tokens reveal nothing (SEC-37) | A compromised resource server secret |
+
+### Denial of service
+
+| Threat | Mitigation | Residual |
+|--------|------------|----------|
+| Login storms exhausting CPU or memory | Bounded Argon2 queue, per-route rate limits, body limit, request timeout (SEC-23, SEC-24) | Volumetric attacks belong to the network edge |
+| Redis outage | Fail closed on budgets and revocation checks, `503` (SEC-04, SEC-23) | Sign-in unavailable while Redis is |
+| Broker outage | Events wait in the outbox; nothing is refused (SEC-21) | Consumers learn late |
+| Mailbox flooding | Per-account and per-address budgets on every email (SEC-02, SEC-33) | - |
+| Lockout of a victim by guessing | Lockout ends on its own; sessions are not cut; administrators unlock (SEC-03, SEC-31) | Anyone knowing the identifier can delay a password sign-in; passkeys and external identities still work |
+| Webhook endpoint slowing deliveries | Timeouts, leases, bounded attempts | A slow endpoint delays its own deliveries |
+
+### Elevation of privilege
+
+| Threat | Mitigation | Residual |
+|--------|------------|----------|
+| Session escalation to sensitive actions | Recent re-authentication required; sign-in does not grant it (SEC-09) | - |
+| Scope widening by a client | Scopes frozen at consent, re-derived at refresh (SEC-17) | - |
+| Administrator account compromise | Second factor required, permission rechecked in the database, re-authentication for role grants, last administrator kept (SEC-31) | A compromised administrator with a second factor acts as one |
+| Client credentials used as a user | No session: account routes refuse them (SEC-38) | - |
+| Personal access token overreach | Scopes limited to the holder's permissions, no sensitive action without the password (SEC-34, SEC-09) | Non-sensitive account routes accept its tokens |
+
+## 4. Supply chain and operations
+
+| Threat | Mitigation |
+|--------|------------|
+| Vulnerable or malicious dependency | `cargo deny` (advisories, licenses, sources, bans) in CI; lockfile committed |
+| Tampered CI | Actions pinned by commit, read-only permissions, secret scanning |
+| Tampered release | Checksummed release bundle verified on the host (runbook section 7) |
+| Image vulnerabilities | Trivy scan of the image in CI |
+| Lost backups | Encrypted backups on and off site, restore drills (runbook section 3) |
+
+## 5. Accepted risks
+
+- An attacker with code execution on an API host reads the signing key and every
+ secret; host hardening and access control are the defence, not auth-api.
+- Access tokens remain valid for resource servers verifying offline until they
+ expire (15 minutes by default) after a revocation.
+- The first factor of an account is only as strong as its email address when
+ magic links are enabled, and as its identity provider when one is linked.
+- Passkey attestation is not verified.
+- Email one-time codes have 6 digits; their budgets and lifetime make them hold.
+
+## 6. Verification
+
+The control catalog test (`tests/security/catalog.rs`) fails when a cited test
+disappears. Fuzz targets cover every parser at the boundaries: redirect URIs,
+client addresses, cursors, breach-check answers, webhook URLs and WebAuthn data.
+An external penetration test is recommended before exposing a deployment to
+high-value accounts; it is outside what this repository can provide.
diff --git a/docs/perf/data.md b/docs/perf/data.md
new file mode 100644
index 0000000..66125b6
--- /dev/null
+++ b/docs/perf/data.md
@@ -0,0 +1,366 @@
+
+
+## Measurement environment
+
+| Item | Value |
+|---|---|
+| CPU | QEMU Virtual CPU version 2.5+ |
+| Cores | 8 |
+| Memory (GB) | 23.4 |
+| Kernel | 6.12.107+deb13-amd64 |
+| PostgreSQL | postgres (PostgreSQL) 17.11 |
+| Measured commit | 3ac984d |
+| CPU pinning | postgres=0-2;cache=3;api=4-6;load=7 |
+| API PostgreSQL pool | 32 |
+| Argon2id | m=65536 KiB, t=3, p=4 |
+| HTTP measurement (s) | 20 |
+| HTTP warm-up (s) | 5 |
+| SQL measurement (s) | 10 |
+| PostgreSQL settings | `effective_cache_size=12GB`, `fsync=on`, `full_page_writes=on`, `max_connections=200`, `max_wal_size=8GB`, `random_page_cost=1.1`, `server_version=17.11`, `shared_buffers=4GB`, `synchronous_commit=on`, `work_mem=16MB` |
+
+## Data volumes
+
+| Table | 10k: rows | 10k: table / index (MB) | 100k: rows | 100k: table / index (MB) | 1M: rows | 1M: table / index (MB) |
+|---|---:|---:|---:|---:|---:|---:|
+| `users` | 10 000 | 2.1 / 1.6 | 105 206 | 23 / 17 | 1 010 012 | 225 / 158 |
+| `sessions` | 50 000 | 12 / 12 | 1 016 407 | 218 / 294 | 6 040 136 | 1 384 / 1 506 |
+| `login_attempts` | 100 000 | 11 / 10 | 1 134 715 | 122 / 119 | 10 254 063 | 1 112 / 1 096 |
+| `audit_log` | 150 000 | 14 / 23 | 1 639 966 | 156 / 225 | 15 277 468 | 1 470 / 2 362 |
+| `two_factor_methods` | 2 500 | 0.3 / 0.6 | 25 000 | 3.3 / 5.4 | 250 000 | 33 / 51 |
+| `recovery_codes` | 20 000 | 2.3 / 4.5 | 200 000 | 23 / 45 | 2 000 000 | 226 / 443 |
+| **Whole database (MB)** | 109 | | 1 307 | | 10 544 | |
+| Seeding time (s) | 5 (+10 000 accounts) | | 38 (+90 000 accounts) | | 546 (+900 000 accounts) | |
+
+## HTTP API
+
+
+
+
+
+### Maximum throughput per scenario
+
+| Scenario | 10k: max req/s (clients) | 10k: p95 at that point (ms) | 100k: max req/s (clients) | 100k: p95 at that point (ms) | 1M: max req/s (clients) | 1M: p95 at that point (ms) |
+|---|---:|---:|---:|---:|---:|---:|
+| GET /users/me | 14 086 (16) | 1.6 | 13 096 (256) | 22.3 | 12 620 (256) | 23.0 |
+| GET /users/me/sessions | 12 722 (256) | 24.9 | 12 606 (256) | 23.0 | 12 315 (256) | 23.4 |
+| GET /users/me/audit | 12 306 (64) | 7.8 | 12 067 (256) | 24.1 | 11 745 (256) | 24.6 |
+| GET /users/me/two-factor | 11 719 (256) | 27.5 | 11 109 (256) | 26.9 | 11 247 (256) | 26.4 |
+| POST /auth/refresh | 5 139 (256) | 56.8 | 5 324 (64) | 15.3 | 5 055 (256) | 57.4 |
+| POST /auth/login | 38 (256) | 6 839 | 34 (4) | 162 | 33 (64) | 1 963 |
+| POST /auth/register | 38 (256) | 6 877 | 34 (64) | 1 886 | 33 (64) | 2 022 |
+| Mixed traffic | 356 (64) | 1 795 | 325 (64) | 1 924 | 322 (256) | 7 749 |
+
+### GET /users/me
+
+| Users | Clients | req/s | p50 (ms) | p95 (ms) | p99 (ms) | max (ms) | Errors | API CPU (cores) | PostgreSQL CPU (cores) | Redis+NATS CPU | PG commits/s | Hit ratio |
+|---:|---:|---:|---:|---:|---:|---:|---|---:|---:|---:|---:|---:|
+| 10k | 1 | 1 723 | 0.64 | 0.70 | 0.74 | 2.0 | 0 | 0.59 | 0.32 | 0.15 | 2 825 | 1.0000 |
+| 10k | 4 | 7 369 | 0.52 | 0.75 | 0.84 | 5.3 | 0 | 2.08 | 0.79 | 0.42 | 8 646 | 1.0000 |
+| 10k | 16 | 14 086 | 1.1 | 1.6 | 1.9 | 15.2 | 0 | 2.97 | 1.31 | 0.60 | 15 858 | 1.0000 |
+| 10k | 64 | 13 721 | 4.4 | 6.8 | 7.5 | 16.0 | 0 | 2.99 | 1.35 | 0.60 | 16 620 | 1.0000 |
+| 10k | 256 | 13 760 | 18.3 | 22.5 | 24.9 | 39.8 | 0 | 2.99 | 1.29 | 0.59 | 15 371 | 1.0000 |
+| 100k | 1 | 1 575 | 0.64 | 0.70 | 0.75 | 8.3 | 0 | 0.61 | 0.36 | 0.15 | 3 038 | 1.0000 |
+| 100k | 4 | 6 007 | 0.68 | 0.81 | 0.88 | 7.3 | 0 | 2.07 | 1.00 | 0.41 | 11 035 | 1.0000 |
+| 100k | 16 | 12 786 | 1.3 | 1.7 | 1.9 | 8.0 | 0 | 2.98 | 1.50 | 0.60 | 20 080 | 1.0000 |
+| 100k | 64 | 13 003 | 4.6 | 7.0 | 7.5 | 18.2 | 0 | 2.98 | 1.49 | 0.60 | 19 241 | 1.0000 |
+| 100k | 256 | 13 096 | 19.2 | 22.3 | 24.4 | 41.6 | 0 | 2.98 | 1.46 | 0.59 | 18 840 | 1.0000 |
+| 1M | 1 | 1 470 | 0.67 | 0.79 | 0.85 | 8.4 | 0 | 0.59 | 0.38 | 0.14 | 2 922 | 0.8160 |
+| 1M | 4 | 5 347 | 0.74 | 0.89 | 1.0 | 4.7 | 0 | 2.04 | 1.03 | 0.42 | 10 607 | 0.9930 |
+| 1M | 16 | 11 295 | 1.4 | 1.8 | 2.5 | 11.9 | 0 | 2.93 | 1.68 | 0.63 | 20 761 | 0.9980 |
+| 1M | 64 | 11 458 | 5.6 | 6.6 | 7.1 | 16.9 | 0 | 2.98 | 1.70 | 0.60 | 22 448 | 1.0000 |
+| 1M | 256 | 12 620 | 20.2 | 23.0 | 24.4 | 33.9 | 0 | 2.98 | 1.53 | 0.58 | 19 464 | 1.0000 |
+
+### GET /users/me/sessions
+
+| Users | Clients | req/s | p50 (ms) | p95 (ms) | p99 (ms) | max (ms) | Errors | API CPU (cores) | PostgreSQL CPU (cores) | Redis+NATS CPU | PG commits/s | Hit ratio |
+|---:|---:|---:|---:|---:|---:|---:|---|---:|---:|---:|---:|---:|
+| 10k | 1 | 1 654 | 0.66 | 0.73 | 0.78 | 6.2 | 0 | 0.60 | 0.31 | 0.15 | 2 705 | 1.0000 |
+| 10k | 4 | 6 844 | 0.56 | 0.81 | 0.90 | 6.7 | 0 | 2.10 | 0.81 | 0.41 | 8 128 | 1.0000 |
+| 10k | 16 | 12 538 | 1.2 | 1.8 | 2.0 | 8.5 | 0 | 2.98 | 1.23 | 0.55 | 14 558 | 1.0000 |
+| 10k | 64 | 12 443 | 4.8 | 7.8 | 8.6 | 13.5 | 0 | 2.98 | 1.28 | 0.56 | 14 859 | 1.0000 |
+| 10k | 256 | 12 722 | 19.6 | 24.9 | 29.5 | 41.5 | 0 | 2.99 | 1.23 | 0.54 | 14 355 | 1.0000 |
+| 100k | 1 | 1 552 | 0.65 | 0.71 | 0.77 | 8.9 | 0 | 0.62 | 0.34 | 0.14 | 3 012 | 1.0000 |
+| 100k | 4 | 5 879 | 0.69 | 0.83 | 0.93 | 6.3 | 0 | 2.08 | 0.96 | 0.41 | 10 823 | 1.0000 |
+| 100k | 16 | 12 212 | 1.3 | 1.8 | 1.9 | 8.4 | 0 | 2.98 | 1.43 | 0.58 | 19 273 | 1.0000 |
+| 100k | 64 | 12 279 | 5.6 | 7.1 | 7.6 | 13.5 | 0 | 2.98 | 1.44 | 0.59 | 19 209 | 1.0000 |
+| 100k | 256 | 12 606 | 20.0 | 23.0 | 24.1 | 34.2 | 0 | 2.98 | 1.40 | 0.57 | 18 163 | 1.0000 |
+| 1M | 1 | 1 454 | 0.67 | 0.80 | 0.87 | 7.0 | 0 | 0.59 | 0.38 | 0.15 | 2 914 | 0.9280 |
+| 1M | 4 | 5 428 | 0.72 | 0.87 | 1.2 | 8.8 | 0 | 2.04 | 1.01 | 0.41 | 10 731 | 0.9950 |
+| 1M | 16 | 11 465 | 1.4 | 1.8 | 1.9 | 8.5 | 0 | 2.97 | 1.57 | 0.60 | 21 113 | 0.9990 |
+| 1M | 64 | 11 329 | 5.7 | 6.6 | 7.0 | 19.0 | 0 | 2.99 | 1.61 | 0.59 | 22 170 | 1.0000 |
+| 1M | 256 | 12 315 | 20.8 | 23.4 | 24.9 | 37.3 | 0 | 2.98 | 1.44 | 0.57 | 19 002 | 1.0000 |
+
+### GET /users/me/audit
+
+| Users | Clients | req/s | p50 (ms) | p95 (ms) | p99 (ms) | max (ms) | Errors | API CPU (cores) | PostgreSQL CPU (cores) | Redis+NATS CPU | PG commits/s | Hit ratio |
+|---:|---:|---:|---:|---:|---:|---:|---|---:|---:|---:|---:|---:|
+| 10k | 1 | 1 520 | 0.71 | 0.79 | 0.86 | 7.4 | 0 | 0.58 | 0.41 | 0.13 | 2 502 | 1.0000 |
+| 10k | 4 | 6 218 | 0.61 | 0.88 | 1.0 | 8.8 | 0 | 1.98 | 0.99 | 0.38 | 7 508 | 1.0000 |
+| 10k | 16 | 12 191 | 1.3 | 1.9 | 2.2 | 12.3 | 0 | 2.94 | 1.60 | 0.55 | 14 334 | 1.0000 |
+| 10k | 64 | 12 306 | 4.9 | 7.8 | 8.8 | 15.7 | 0 | 2.98 | 1.63 | 0.55 | 14 724 | 1.0000 |
+| 10k | 256 | 11 913 | 20.8 | 27.5 | 32.2 | 57.2 | 0 | 2.99 | 1.62 | 0.54 | 13 471 | 1.0000 |
+| 100k | 1 | 1 429 | 0.71 | 0.77 | 0.84 | 6.1 | 0 | 0.57 | 0.39 | 0.13 | 2 778 | 1.0000 |
+| 100k | 4 | 5 384 | 0.75 | 0.90 | 0.98 | 10.9 | 0 | 2.00 | 1.09 | 0.39 | 9 924 | 1.0000 |
+| 100k | 16 | 11 524 | 1.4 | 1.9 | 2.4 | 10.3 | 0 | 2.95 | 1.77 | 0.58 | 18 229 | 1.0000 |
+| 100k | 64 | 11 577 | 5.9 | 7.2 | 7.7 | 16.4 | 0 | 2.98 | 1.82 | 0.56 | 19 373 | 1.0000 |
+| 100k | 256 | 12 067 | 20.8 | 24.1 | 25.1 | 31.1 | 0 | 2.99 | 1.73 | 0.55 | 17 407 | 1.0000 |
+| 1M | 1 | 1 197 | 0.77 | 1.2 | 1.5 | 4.9 | 0 | 0.49 | 0.41 | 0.12 | 2 384 | 0.8950 |
+| 1M | 4 | 5 002 | 0.79 | 0.95 | 1.1 | 6.1 | 0 | 1.96 | 1.16 | 0.39 | 9 913 | 0.9930 |
+| 1M | 16 | 10 835 | 1.5 | 1.9 | 2.1 | 7.8 | 0 | 2.97 | 1.88 | 0.58 | 19 916 | 0.9990 |
+| 1M | 64 | 10 789 | 6.0 | 7.0 | 7.5 | 19.6 | 0 | 2.98 | 1.93 | 0.57 | 21 154 | 1.0000 |
+| 1M | 256 | 11 745 | 21.6 | 24.6 | 26.7 | 59.7 | 0 | 2.98 | 1.81 | 0.55 | 18 100 | 1.0000 |
+
+### GET /users/me/two-factor
+
+| Users | Clients | req/s | p50 (ms) | p95 (ms) | p99 (ms) | max (ms) | Errors | API CPU (cores) | PostgreSQL CPU (cores) | Redis+NATS CPU | PG commits/s | Hit ratio |
+|---:|---:|---:|---:|---:|---:|---:|---|---:|---:|---:|---:|---:|
+| 10k | 1 | 1 701 | 0.65 | 0.70 | 0.74 | 4.1 | 0 | 0.69 | 0.43 | 0.16 | 4 500 | 1.0000 |
+| 10k | 4 | 7 077 | 0.54 | 0.78 | 0.89 | 7.1 | 0 | 2.37 | 1.21 | 0.43 | 15 429 | 1.0000 |
+| 10k | 16 | 11 710 | 1.3 | 2.0 | 2.2 | 8.4 | 0 | 2.98 | 1.70 | 0.54 | 25 892 | 1.0000 |
+| 10k | 64 | 11 579 | 5.0 | 8.7 | 9.5 | 18.8 | 0 | 2.99 | 1.74 | 0.56 | 25 863 | 1.0000 |
+| 10k | 256 | 11 719 | 21.2 | 27.5 | 38.5 | 46.3 | 0 | 2.99 | 1.70 | 0.55 | 24 795 | 1.0000 |
+| 100k | 1 | 1 548 | 0.66 | 0.71 | 0.75 | 5.8 | 0 | 0.68 | 0.44 | 0.15 | 4 546 | 1.0000 |
+| 100k | 4 | 5 835 | 0.70 | 0.83 | 0.94 | 7.5 | 0 | 2.31 | 1.28 | 0.41 | 16 580 | 1.0000 |
+| 100k | 16 | 11 067 | 1.5 | 2.0 | 2.2 | 8.8 | 0 | 2.98 | 1.78 | 0.55 | 28 648 | 1.0000 |
+| 100k | 64 | 10 587 | 6.7 | 7.8 | 8.3 | 15.7 | 0 | 2.99 | 1.86 | 0.57 | 28 540 | 1.0000 |
+| 100k | 256 | 11 109 | 22.1 | 26.9 | 28.3 | 36.2 | 0 | 2.99 | 1.78 | 0.54 | 27 224 | 1.0000 |
+| 1M | 1 | 1 487 | 0.67 | 0.74 | 0.81 | 6.9 | 0 | 0.68 | 0.44 | 0.15 | 4 466 | 0.9580 |
+| 1M | 4 | 5 520 | 0.72 | 0.85 | 0.94 | 7.6 | 0 | 2.28 | 1.30 | 0.41 | 16 492 | 0.9970 |
+| 1M | 16 | 10 578 | 1.5 | 1.9 | 2.1 | 9.3 | 0 | 2.97 | 1.89 | 0.57 | 30 031 | 0.9980 |
+| 1M | 64 | 10 193 | 6.4 | 7.2 | 7.6 | 16.4 | 0 | 2.98 | 1.93 | 0.56 | 30 199 | 1.0000 |
+| 1M | 256 | 11 247 | 22.5 | 26.4 | 27.8 | 34.3 | 0 | 2.98 | 1.81 | 0.53 | 28 515 | 1.0000 |
+
+### POST /auth/refresh
+
+| Users | Clients | req/s | p50 (ms) | p95 (ms) | p99 (ms) | max (ms) | Errors | API CPU (cores) | PostgreSQL CPU (cores) | Redis+NATS CPU | PG commits/s | Hit ratio |
+|---:|---:|---:|---:|---:|---:|---:|---|---:|---:|---:|---:|---:|
+| 10k | 1 | 365 | 2.5 | 3.6 | 3.8 | 21.2 | 0 | 0.25 | 0.32 | 0.04 | 1 471 | 1.0000 |
+| 10k | 4 | 1 151 | 3.4 | 4.7 | 4.9 | 22.3 | 0 | 0.77 | 0.85 | 0.13 | 4 624 | 1.0000 |
+| 10k | 16 | 3 510 | 4.5 | 6.0 | 6.8 | 28.0 | 0 | 1.66 | 1.91 | 0.22 | 14 054 | 1.0000 |
+| 10k | 64 | 5 012 | 12.4 | 16.4 | 28.9 | 41.7 | 0 | 2.23 | 2.53 | 0.32 | 20 107 | 1.0000 |
+| 10k | 256 | 5 139 | 49.4 | 56.8 | 70.0 | 92.2 | 0 | 2.25 | 2.53 | 0.34 | 20 486 | 1.0000 |
+| 100k | 1 | 359 | 2.5 | 3.6 | 3.9 | 13.3 | 0 | 0.24 | 0.32 | 0.04 | 1 443 | 1.0000 |
+| 100k | 4 | 916 | 4.6 | 5.8 | 7.8 | 19.8 | 0 | 0.59 | 0.70 | 0.10 | 3 614 | 1.0000 |
+| 100k | 16 | 3 564 | 4.5 | 5.9 | 6.7 | 16.6 | 0 | 1.67 | 1.90 | 0.23 | 14 275 | 1.0000 |
+| 100k | 64 | 5 324 | 11.8 | 15.3 | 17.7 | 35.3 | 0 | 2.28 | 2.53 | 0.33 | 21 327 | 1.0000 |
+| 100k | 256 | 5 316 | 48.0 | 53.5 | 58.0 | 99.9 | 0 | 2.28 | 2.52 | 0.33 | 21 302 | 1.0000 |
+| 1M | 1 | 345 | 2.7 | 3.8 | 4.0 | 13.1 | 0 | 0.23 | 0.34 | 0.04 | 1 380 | 0.9870 |
+| 1M | 4 | 1 078 | 3.6 | 4.9 | 5.5 | 14.3 | 0 | 0.70 | 0.88 | 0.12 | 4 323 | 0.9930 |
+| 1M | 16 | 3 238 | 4.8 | 7.2 | 8.9 | 21.2 | 0 | 1.54 | 1.90 | 0.22 | 12 897 | 0.9940 |
+| 1M | 64 | 4 876 | 12.8 | 17.7 | 20.4 | 48.8 | 0 | 2.13 | 2.46 | 0.31 | 19 629 | 0.9990 |
+| 1M | 256 | 5 055 | 50.4 | 57.4 | 61.5 | 76.5 | 0 | 2.22 | 2.60 | 0.32 | 20 294 | 1.0000 |
+
+### POST /auth/login
+
+| Users | Clients | req/s | p50 (ms) | p95 (ms) | p99 (ms) | max (ms) | Errors | API CPU (cores) | PostgreSQL CPU (cores) | Redis+NATS CPU | PG commits/s | Hit ratio |
+|---:|---:|---:|---:|---:|---:|---:|---|---:|---:|---:|---:|---:|
+| 10k | 1 | 15 | 68.6 | 72.9 | 75.8 | 81.1 | 0 | 0.95 | 0.06 | 0.01 | 87 | 1.0000 |
+| 10k | 4 | 37 | 106 | 141 | 149 | 153 | 0 | 2.94 | 0.09 | 0.01 | 220 | 1.0000 |
+| 10k | 16 | 38 | 424 | 464 | 478 | 480 | 0 | 2.99 | 0.09 | 0.01 | 230 | 1.0000 |
+| 10k | 64 | 38 | 1 708 | 1 777 | 1 795 | 1 800 | 0 | 2.99 | 0.07 | 0.01 | 225 | 1.0000 |
+| 10k | 256 | 38 | 6 809 | 6 839 | 6 839 | 6 839 | 0 | 2.99 | 0.07 | 0.01 | 228 | 1.0000 |
+| 100k | 1 | 13 | 75.8 | 78.1 | 82.1 | 83.9 | 0 | 0.96 | 0.05 | 0.01 | 79 | 1.0000 |
+| 100k | 4 | 34 | 111 | 162 | 170 | 176 | 0 | 2.92 | 0.10 | 0.01 | 200 | 1.0000 |
+| 100k | 16 | 34 | 473 | 507 | 528 | 537 | 0 | 2.98 | 0.08 | 0.01 | 202 | 1.0000 |
+| 100k | 64 | 34 | 1 905 | 1 943 | 1 953 | 1 953 | 0 | 2.98 | 0.07 | 0.01 | 203 | 1.0000 |
+| 100k | 256 | 34 | 7 672 | 7 672 | 7 672 | 7 674 | 0 | 2.98 | 0.07 | 0.01 | 202 | 1.0000 |
+| 1M | 1 | 13 | 79.7 | 83.8 | 88.9 | 96.8 | 0 | 0.95 | 0.06 | 0.01 | 75 | 0.8790 |
+| 1M | 4 | 33 | 121 | 158 | 170 | 179 | 0 | 2.93 | 0.09 | 0.01 | 198 | 0.9040 |
+| 1M | 16 | 33 | 483 | 517 | 538 | 552 | 0 | 2.98 | 0.08 | 0.01 | 199 | 0.9100 |
+| 1M | 64 | 33 | 1 924 | 1 963 | 1 982 | 1 996 | 0 | 2.98 | 0.09 | 0.02 | 200 | 0.9140 |
+| 1M | 256 | 33 | 7 749 | 7 905 | 7 924 | 7 924 | 0 | 2.98 | 0.10 | 0.01 | 198 | 0.9430 |
+
+### POST /auth/register
+
+| Users | Clients | req/s | p50 (ms) | p95 (ms) | p99 (ms) | max (ms) | Errors | API CPU (cores) | PostgreSQL CPU (cores) | Redis+NATS CPU | PG commits/s | Hit ratio |
+|---:|---:|---:|---:|---:|---:|---:|---|---:|---:|---:|---:|---:|
+| 10k | 1 | 14 | 72.9 | 76.6 | 78.9 | 80.0 | 0 | 0.91 | 0.08 | 0.01 | 97 | 1.0000 |
+| 10k | 4 | 38 | 104 | 134 | 141 | 154 | 0 | 2.95 | 0.09 | 0.01 | 262 | 1.0000 |
+| 10k | 16 | 37 | 424 | 473 | 492 | 501 | 0 | 2.97 | 0.11 | 0.02 | 262 | 1.0000 |
+| 10k | 64 | 37 | 1 708 | 1 813 | 1 864 | 1 864 | 0 | 2.97 | 0.10 | 0.02 | 263 | 1.0000 |
+| 10k | 256 | 38 | 6 877 | 6 877 | 6 877 | 6 879 | 0 | 2.97 | 0.09 | 0.01 | 261 | 1.0000 |
+| 100k | 1 | 12 | 81.3 | 88.0 | 93.4 | 97.3 | 0 | 0.91 | 0.07 | 0.01 | 86 | 1.0000 |
+| 100k | 4 | 34 | 116 | 148 | 157 | 166 | 0 | 2.93 | 0.09 | 0.01 | 235 | 1.0000 |
+| 100k | 16 | 34 | 468 | 492 | 507 | 510 | 0 | 2.97 | 0.09 | 0.01 | 241 | 1.0000 |
+| 100k | 64 | 34 | 1 868 | 1 886 | 1 904 | 1 904 | 0 | 2.96 | 0.09 | 0.01 | 242 | 1.0000 |
+| 100k | 256 | 34 | 7 446 | 7 490 | 7 490 | 7 490 | 0 | 2.97 | 0.10 | 0.01 | 242 | 1.0000 |
+| 1M | 1 | 12 | 82.9 | 91.6 | 98.2 | 99.4 | 0 | 0.92 | 0.07 | 0.01 | 85 | 0.9270 |
+| 1M | 4 | 32 | 126 | 158 | 166 | 173 | 0 | 2.95 | 0.10 | 0.01 | 221 | 0.9370 |
+| 1M | 16 | 32 | 492 | 549 | 560 | 566 | 0 | 2.96 | 0.11 | 0.02 | 225 | 0.9440 |
+| 1M | 64 | 33 | 1 963 | 2 022 | 2 022 | 2 025 | 0 | 2.96 | 0.11 | 0.01 | 230 | 0.9500 |
+| 1M | 256 | 32 | 8 063 | 8 468 | 8 468 | 8 468 | 0 | 2.97 | 0.10 | 0.01 | 223 | 0.9530 |
+
+### Mixed traffic
+
+| Users | Clients | req/s | p50 (ms) | p95 (ms) | p99 (ms) | max (ms) | Errors | API CPU (cores) | PostgreSQL CPU (cores) | Redis+NATS CPU | PG commits/s | Hit ratio |
+|---:|---:|---:|---:|---:|---:|---:|---|---:|---:|---:|---:|---:|
+| 10k | 1 | 111 | 2.5 | 69.3 | 74.3 | 80.6 | 0 | 0.83 | 0.12 | 0.02 | 380 | 1.0000 |
+| 10k | 4 | 311 | 3.4 | 95.3 | 120 | 164 | 0 | 2.71 | 0.25 | 0.04 | 1 096 | 1.0000 |
+| 10k | 16 | 344 | 3.6 | 424 | 464 | 518 | 0 | 2.98 | 0.26 | 0.04 | 1 195 | 1.0000 |
+| 10k | 64 | 356 | 3.0 | 1 795 | 1 849 | 1 910 | 0 | 2.99 | 0.24 | 0.04 | 1 214 | 1.0000 |
+| 10k | 256 | 344 | 3.0 | 7 227 | 7 285 | 7 285 | 0 | 2.99 | 0.25 | 0.03 | 1 173 | 1.0000 |
+| 100k | 1 | 110 | 2.5 | 70.7 | 74.3 | 79.5 | 0 | 0.83 | 0.11 | 0.02 | 375 | 1.0000 |
+| 100k | 4 | 295 | 3.2 | 103 | 126 | 166 | 0 | 2.75 | 0.23 | 0.04 | 1 026 | 1.0000 |
+| 100k | 16 | 318 | 2.8 | 464 | 492 | 548 | 0 | 2.98 | 0.23 | 0.04 | 1 085 | 1.0000 |
+| 100k | 64 | 325 | 2.8 | 1 924 | 1 963 | 1 987 | 0 | 2.98 | 0.21 | 0.03 | 1 134 | 1.0000 |
+| 100k | 256 | 320 | 2.8 | 7 826 | 7 826 | 7 882 | 0 | 2.98 | 0.21 | 0.03 | 1 108 | 1.0000 |
+| 1M | 1 | 98 | 2.6 | 78.1 | 85.4 | 93.7 | 0 | 0.83 | 0.11 | 0.02 | 337 | 0.9790 |
+| 1M | 4 | 309 | 2.8 | 101 | 127 | 159 | 0 | 2.82 | 0.26 | 0.04 | 1 071 | 0.9840 |
+| 1M | 16 | 315 | 3.0 | 468 | 502 | 544 | 0 | 2.98 | 0.25 | 0.04 | 1 088 | 0.9840 |
+| 1M | 64 | 317 | 3.0 | 1 963 | 2 022 | 2 093 | 0 | 2.98 | 0.26 | 0.04 | 1 110 | 0.9840 |
+| 1M | 256 | 322 | 3.1 | 7 749 | 7 826 | 7 850 | 0 | 2.98 | 0.24 | 0.04 | 1 105 | 0.9840 |
+
+Breakdown by operation at the highest volume and the highest concurrency:
+
+| Operation | req/s | p50 (ms) | p95 (ms) | p99 (ms) | Errors |
+|---|---:|---:|---:|---:|---|
+| audit | 18 | 1.0 | 6.5 | 8.6 | 0 |
+| login | 27 | 7 749 | 7 826 | 7 826 | 0 |
+| profile | 66 | 0.81 | 5.6 | 7.5 | 0 |
+| refresh | 156 | 4.1 | 10.7 | 14.4 | 0 |
+| register | 6 | 7 749 | 7 826 | 7 826 | 0 |
+| sessions | 31 | 0.87 | 5.7 | 7.3 | 0 |
+| two_factor | 18 | 0.84 | 5.3 | 7.5 | 0 |
+
+## Database
+
+
+
+
+
+### Application queries
+
+| Query | Users | 1 conn.: req/s | 1 conn.: p50 / p99 (ms) | 8 conn.: req/s | 8 conn.: p50 / p99 (ms) | 32 conn.: req/s | 32 conn.: p50 / p99 (ms) | PG CPU at 32 conn. | Disk reads/s |
+|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
+| User by email | 10k | 5 821 | 0.17 / 0.22 | 21 110 | 0.39 / 0.50 | 24 078 | 1.4 / 1.7 | 1.88 | 0 |
+| User by email | 100k | 5 774 | 0.17 / 0.23 | 21 252 | 0.39 / 0.50 | 24 036 | 1.4 / 1.7 | 1.91 | 0 |
+| User by email | 1M | 5 660 | 0.18 / 0.25 | 21 526 | 0.38 / 0.50 | 24 155 | 1.4 / 1.7 | 1.96 | 0 |
+| Session by refresh token | 10k | 6 080 | 0.16 / 0.23 | 19 280 | 0.43 / 0.53 | 20 583 | 1.6 / 2.0 | 1.60 | 0 |
+| Session by refresh token | 100k | 6 122 | 0.16 / 0.22 | 19 473 | 0.43 / 0.51 | 20 138 | 1.6 / 1.8 | 1.56 | 0 |
+| Session by refresh token | 1M | 5 141 | 0.17 / 0.35 | 19 823 | 0.42 / 0.52 | 20 723 | 1.6 / 1.7 | 1.62 | 155 |
+| Session validity | 10k | 6 309 | 0.16 / 0.22 | 20 363 | 0.41 / 0.49 | 21 132 | 1.6 / 1.8 | 1.58 | 0 |
+| Session validity | 100k | 6 331 | 0.16 / 0.22 | 20 508 | 0.41 / 0.49 | 21 242 | 1.6 / 1.8 | 1.61 | 0 |
+| Session validity | 1M | 5 605 | 0.16 / 0.30 | 20 654 | 0.40 / 0.48 | 21 886 | 1.5 / 1.7 | 1.66 | 6 |
+| Active sessions of an account | 10k | 6 086 | 0.16 / 0.23 | 19 115 | 0.44 / 0.53 | 19 574 | 1.7 / 1.9 | 1.53 | 0 |
+| Active sessions of an account | 100k | 5 972 | 0.17 / 0.23 | 18 851 | 0.44 / 0.53 | 19 706 | 1.7 / 1.9 | 1.58 | 0 |
+| Active sessions of an account | 1M | 5 583 | 0.17 / 0.35 | 19 379 | 0.43 / 0.53 | 20 356 | 1.6 / 1.8 | 1.63 | 88 |
+| Recent failures by identifier | 10k | 5 885 | 0.17 / 0.23 | 21 811 | 0.38 / 0.49 | 23 820 | 1.4 / 1.7 | 1.85 | 0 |
+| Recent failures by identifier | 100k | 5 755 | 0.17 / 0.23 | 21 993 | 0.37 / 0.52 | 24 627 | 1.4 / 1.6 | 1.90 | 0 |
+| Recent failures by identifier | 1M | 5 677 | 0.17 / 0.30 | 22 340 | 0.37 / 0.48 | 24 860 | 1.4 / 1.6 | 1.94 | 0 |
+| Recent failures by address | 10k | 6 102 | 0.16 / 0.23 | 20 428 | 0.40 / 0.53 | 21 504 | 1.6 / 1.9 | 1.66 | 0 |
+| Recent failures by address | 100k | 5 916 | 0.17 / 0.24 | 20 633 | 0.40 / 0.50 | 22 317 | 1.5 / 1.7 | 1.72 | 0 |
+| Recent failures by address | 1M | 6 127 | 0.16 / 0.22 | 20 772 | 0.40 / 0.49 | 21 272 | 1.6 / 1.7 | 1.65 | 0 |
+| Consecutive failures of an account | 10k | 5 746 | 0.17 / 0.24 | 22 279 | 0.37 / 0.49 | 24 100 | 1.4 / 1.6 | 1.87 | 0 |
+| Consecutive failures of an account | 100k | 5 763 | 0.17 / 0.24 | 22 042 | 0.37 / 0.49 | 25 883 | 1.3 / 1.6 | 1.98 | 0 |
+| Consecutive failures of an account | 1M | 4 634 | 0.19 / 0.42 | 22 523 | 0.36 / 0.50 | 25 703 | 1.3 / 1.6 | 2.03 | 366 |
+| Roles and permissions | 10k | 5 623 | 0.18 / 0.24 | 22 701 | 0.36 / 0.50 | 27 683 | 1.2 / 1.7 | 2.08 | 0 |
+| Roles and permissions | 100k | 5 579 | 0.18 / 0.25 | 22 900 | 0.36 / 0.51 | 27 752 | 1.2 / 1.6 | 2.12 | 0 |
+| Roles and permissions | 1M | 5 663 | 0.18 / 0.24 | 22 916 | 0.36 / 0.48 | 25 933 | 1.3 / 1.6 | 2.03 | 0 |
+| History page (50) | 10k | 4 936 | 0.20 / 0.28 | 18 180 | 0.45 / 0.62 | 19 681 | 1.7 / 2.0 | 1.92 | 0 |
+| History page (50) | 100k | 4 753 | 0.21 / 0.29 | 17 977 | 0.45 / 0.65 | 21 173 | 1.6 / 2.1 | 2.12 | 0 |
+| History page (50) | 1M | 2 692 | 0.32 / 1.2 | 18 268 | 0.45 / 0.64 | 22 450 | 1.5 / 2.0 | 2.26 | 89 |
+| Second factors and codes | 10k | 3 181 | 0.31 / 0.39 | 10 610 | 0.76 / 0.95 | 11 439 | 2.8 / 3.4 | 1.61 | 0 |
+| Second factors and codes | 100k | 3 124 | 0.31 / 0.46 | 10 426 | 0.77 / 0.93 | 11 255 | 2.8 / 3.4 | 1.58 | 0 |
+| Second factors and codes | 1M | 3 097 | 0.32 / 0.47 | 10 676 | 0.75 / 0.94 | 12 315 | 2.7 / 3.4 | 1.79 | 343 |
+| Sign-in writes (tx) | 10k | 500 | 1.7 / 3.5 | 2 850 | 3.1 / 4.4 | 6 422 | 4.9 / 8.2 | 2.31 | 0 |
+| Sign-in writes (tx) | 100k | 447 | 2.0 / 3.3 | 2 807 | 3.1 / 4.4 | 6 493 | 4.9 / 8.0 | 2.35 | 0 |
+| Sign-in writes (tx) | 1M | 338 | 2.8 / 4.4 | 1 863 | 4.2 / 7.5 | 4 805 | 6.8 / 11.0 | 2.67 | 11 420 |
+
+### Execution plans
+
+Median of 7 `EXPLAIN (ANALYZE, BUFFERS)` runs for an account in the middle of the range.
+
+| Query | 10k: ms (blocks read) | 100k: ms (blocks read) | 1M: ms (blocks read) | Plan at the highest volume |
+|---|---:|---:|---:|---|
+| `user_by_email` | 0.01 (0) | 0.02 (0) | 0.02 (0) | Index Scan using users_email_key |
+| `session_by_token` | 0.01 (0) | 0.01 (0) | 0.01 (0) | Index Scan using sessions_token_hash_key |
+| `session_validation` | 0.01 (0) | 0.01 (0) | 0.01 (0) | Index Scan using sessions_pkey |
+| `active_sessions` | 0.01 (0) | 0.01 (0) | 0.01 (0) | Index Scan using idx_sessions_user_active |
+| `failures_by_identifier` | 0.02 (0) | 0.02 (0) | 0.02 (0) | Aggregate → Limit → Index Only Scan using idx_login_attempts_failed_identifier_time |
+| `failures_by_ip` | 0.01 (0) | 0.01 (0) | 0.01 (0) | Aggregate → Limit → Index Only Scan using idx_login_attempts_failed_ip_time |
+| `consecutive_failures` | 0.03 (0) | 0.02 (0) | 0.03 (0) | Aggregate → Limit → Result → Limit → Index Scan using idx_login_attempts_user_time → Index Scan using idx_login_attempts_user_time |
+| `rbac` | 0.03 (0) | 0.03 (0) | 0.03 (0) | Result → Sort → Nested Loop → Index Only Scan using user_roles_pkey → Seq Scan on roles → Unique → Sort → Nested Loop → Nested Loop → Index Only Scan using user_roles_pkey → Seq Scan on role_permissions → Seq Scan on permissions |
+| `audit_page` | 0.03 (0) | 0.03 (0) | 0.04 (0) | Limit → Sort → Append → Index Scan using audit_log_2026_08_user_id_created_at_idx → Index Scan using audit_log_2026_09_user_id_created_at_idx → Seq Scan on audit_log_2026_10 → Seq Scan on audit_log_2026_11 → Seq Scan on audit_log_2026_12 → Seq Scan on audit_log_2027_01 → Seq Scan on audit_log_2027_02 → Seq Scan on audit_log_2027_03 → Seq Scan on audit_log_2027_04 → Seq Scan on audit_log_2027_05 → Seq Scan on audit_log_2027_06 → Seq Scan on audit_log_2027_07 → Seq Scan on audit_log_2027_08 → Seq Scan on audit_log_2027_09 → Seq Scan on audit_log_default |
+| `two_factor_methods` | 0.01 (0) | 0.01 (0) | 0.01 (0) | Index Scan using idx_2fa_user_created |
+| `recovery_codes_usable` | 0.01 (0) | 0.01 (0) | 0.01 (0) | Aggregate → Index Scan using idx_recovery_codes_user_active |
+
+### Largest indexes at 1M users
+
+| Index | Table | Size (MB) | Scans |
+|---|---|---:|---:|
+| `idx_login_attempts_user_time` | `login_attempts` | 519 | 2 763 696 |
+| `audit_log_2026_09_pkey` | `audit_log_2026_09` | 479 | 0 |
+| `audit_log_2026_09_user_id_created_at_idx` | `audit_log_2026_09` | 455 | 3 283 976 |
+| `sessions_token_hash_key` | `sessions` | 443 | 1 997 790 |
+| `audit_log_2026_09_request_id_created_at_idx` | `audit_log_2026_09` | 440 | 0 |
+| `login_attempts_pkey` | `login_attempts` | 403 | 0 |
+| `audit_log_2026_08_pkey` | `audit_log_2026_08` | 349 | 0 |
+| `idx_sessions_family_created` | `sessions` | 330 | 0 |
+| `audit_log_2026_08_user_id_created_at_idx` | `audit_log_2026_08` | 323 | 3 283 976 |
+| `audit_log_2026_08_request_id_created_at_idx` | `audit_log_2026_08` | 315 | 0 |
+| `sessions_pkey` | `sessions` | 245 | 7 195 629 |
+| `recovery_codes_code_hash_key` | `recovery_codes` | 147 | 0 |
+| `idx_sessions_family_active` | `sessions` | 118 | 0 |
+| `idx_sessions_user_active` | `sessions` | 118 | 3 428 971 |
+| `idx_login_attempts_failed_identifier_time` | `login_attempts` | 103 | 1 370 262 |
+
+### `pg_stat_statements` at 10k users (mixed, 64 virtual users)
+
+| Query | Calls | Mean (ms) | Share of SQL time | Blocks read |
+|---|---:|---:|---:|---:|
+| `INSERT INTO sessions (user_id, session_family_id, expires_at, ip_address, device_name, remember_me, token_hash` | 4 599 | 0.11 | 25.5% | 0 |
+| `UPDATE sessions SET revoked_at = NOW(), rotated_at = NOW(), replaced_by_session_id = $2 WHERE id = $1` | 4 599 | 0.08 | 18.9% | 0 |
+| `SELECT COALESCE(ARRAY( SELECT r.name::TEXT FROM user_roles ur JOIN roles r ON r.id = ur.role_id WHERE ur.user_` | 5 437 | 0.03 | 8.7% | 0 |
+| `SELECT * FROM users WHERE id = $1` | 6 518 | 0.02 | 7.4% | 0 |
+| `INSERT INTO sessions (user_id, session_family_id, expires_at, ip_address, device_name, remember_me, token_hash` | 838 | 0.17 | 6.9% | 0 |
+| `SELECT * FROM sessions WHERE id = $1 FOR UPDATE` | 4 599 | 0.02 | 4.8% | 0 |
+| `SELECT * FROM sessions WHERE token_hash = $1` | 4 599 | 0.02 | 4.2% | 0 |
+| `UPDATE users SET last_login_at = NOW(), locked_until = NULL WHERE id = $1` | 838 | 0.09 | 3.7% | 0 |
+| `INSERT INTO login_attempts (user_id, attempted_identifier, was_successful, failure_reason, request_ip, request` | 838 | 0.08 | 3.5% | 0 |
+| `INSERT INTO audit_log (user_id, request_id, action, ip_address, metadata) VALUES ($1, $2, $3, $4, $5)` | 1 001 | 0.07 | 3.4% | 0 |
+| `SELECT expires_at, revoked_at FROM sessions WHERE id = $1` | 3 389 | 0.01 | 2.4% | 0 |
+| `SELECT * FROM audit_log WHERE user_id = $1 ORDER BY created_at DESC, id DESC LIMIT $2` | 476 | 0.09 | 2.1% | 0 |
+
+### `pg_stat_statements` at 100k users (mixed, 64 virtual users)
+
+| Query | Calls | Mean (ms) | Share of SQL time | Blocks read |
+|---|---:|---:|---:|---:|
+| `INSERT INTO sessions (user_id, session_family_id, expires_at, ip_address, device_name, remember_me, token_hash` | 4 220 | 0.10 | 24.1% | 0 |
+| `UPDATE sessions SET revoked_at = NOW(), rotated_at = NOW(), replaced_by_session_id = $2 WHERE id = $1` | 4 220 | 0.08 | 17.9% | 0 |
+| `SELECT COALESCE(ARRAY( SELECT r.name::TEXT FROM user_roles ur JOIN roles r ON r.id = ur.role_id WHERE ur.user_` | 5 011 | 0.03 | 8.3% | 0 |
+| `INSERT INTO sessions (user_id, session_family_id, expires_at, ip_address, device_name, remember_me, token_hash` | 791 | 0.18 | 8.0% | 0 |
+| `SELECT * FROM users WHERE id = $1` | 5 989 | 0.02 | 7.3% | 0 |
+| `INSERT INTO login_attempts (user_id, attempted_identifier, was_successful, failure_reason, request_ip, request` | 791 | 0.12 | 5.0% | 0 |
+| `SELECT * FROM sessions WHERE id = $1 FOR UPDATE` | 4 220 | 0.02 | 4.5% | 0 |
+| `INSERT INTO audit_log (user_id, request_id, action, ip_address, metadata) VALUES ($1, $2, $3, $4, $5)` | 947 | 0.08 | 4.3% | 0 |
+| `SELECT * FROM sessions WHERE token_hash = $1` | 4 220 | 0.02 | 4.1% | 0 |
+| `UPDATE users SET last_login_at = NOW(), locked_until = NULL WHERE id = $1` | 791 | 0.08 | 3.3% | 0 |
+| `SELECT expires_at, revoked_at FROM sessions WHERE id = $1` | 3 446 | 0.01 | 2.7% | 0 |
+| `SELECT * FROM audit_log WHERE user_id = $1 ORDER BY created_at DESC, id DESC LIMIT $2` | 431 | 0.10 | 2.2% | 0 |
+
+### `pg_stat_statements` at 1M users (mixed, 64 virtual users)
+
+| Query | Calls | Mean (ms) | Share of SQL time | Blocks read |
+|---|---:|---:|---:|---:|
+| `INSERT INTO sessions (user_id, session_family_id, expires_at, ip_address, device_name, remember_me, token_hash` | 4 135 | 0.12 | 15.3% | 44 |
+| `INSERT INTO sessions (user_id, session_family_id, expires_at, ip_address, device_name, remember_me, token_hash` | 776 | 0.56 | 13.3% | 1 681 |
+| `UPDATE sessions SET revoked_at = NOW(), rotated_at = NOW(), replaced_by_session_id = $2 WHERE id = $1` | 4 135 | 0.09 | 11.5% | 32 |
+| `INSERT INTO audit_log (user_id, request_id, action, ip_address, metadata) VALUES ($1, $2, $3, $4, $5)` | 929 | 0.39 | 11.1% | 906 |
+| `INSERT INTO login_attempts (user_id, attempted_identifier, was_successful, failure_reason, request_ip, request` | 776 | 0.43 | 10.4% | 1 168 |
+| `SELECT COALESCE(ARRAY( SELECT r.name::TEXT FROM user_roles ur JOIN roles r ON r.id = ur.role_id WHERE ur.user_` | 4 911 | 0.04 | 6.1% | 148 |
+| `SELECT * FROM users WHERE id = $1` | 5 871 | 0.03 | 5.2% | 1 095 |
+| `SELECT COUNT(*) FROM ( SELECT 1 FROM login_attempts WHERE attempted_identifier = $1::citext AND was_successful` | 776 | 0.17 | 4.0% | 354 |
+| `SELECT id, last_used_at, expires_at, created_at, ip_address, device_name, user_agent, session_type, client_id ` | 803 | 0.14 | 3.4% | 713 |
+| `SELECT * FROM audit_log WHERE user_id = $1 ORDER BY created_at DESC, id DESC LIMIT $2` | 422 | 0.23 | 2.9% | 789 |
+| `INSERT INTO email_verification_tokens (user_id, token_hash, expires_at, request_ip, request_user_agent, target` | 153 | 0.58 | 2.7% | 217 |
+| `SELECT * FROM sessions WHERE id = $1 FOR UPDATE` | 4 135 | 0.02 | 2.7% | 0 |
+
+### Purge (batches of 5 000 rows)
+
+| Job | 10k: rows / ms per batch | 100k: rows / ms per batch | 1M: rows / ms per batch |
+|---|---:|---:|---:|
+| `sessions` | 5 000 / 66.3, 5 000 / 27.9, 5 000 / 26.8 | 5 000 / 269, 5 000 / 159, 5 000 / 128 | 5 000 / 2 329, 5 000 / 738, 5 000 / 775 |
+| `login_attempts` | 45 / 13.8, 0 / 8.9, 0 / 8.7 | 417 / 126, 0 / 93.3, 0 / 92.9 | 4 629 / 2 754, 0 / 839, 0 / 832 |
+| `email_verification_tokens` | 5 000 / 14.2, 4 973 / 38.7, 0 / 1.0 | 5 000 / 11.4, 5 000 / 11.3, 5 000 / 15.7 | 5 000 / 15.5, 5 000 / 11.7, 5 000 / 15.2 |
+| `password_reset_tokens` | 1 000 / 3.9, 0 / 0.29, 0 / 0.25 | 5 000 / 8.6, 4 000 / 6.9, 0 / 0.34 | 5 000 / 10.0, 5 000 / 8.6, 5 000 / 8.7 |
+| `rotate_audit_log_partitions` | 0 / 1.8 | 0 / 1.8 | 0 / 4.4 |
diff --git a/docs/perf/img/db-p99.svg b/docs/perf/img/db-p99.svg
new file mode 100644
index 0000000..3b0eadd
--- /dev/null
+++ b/docs/perf/img/db-p99.svg
@@ -0,0 +1,76 @@
+
\ No newline at end of file
diff --git a/docs/perf/img/db-throughput.svg b/docs/perf/img/db-throughput.svg
new file mode 100644
index 0000000..6904b38
--- /dev/null
+++ b/docs/perf/img/db-throughput.svg
@@ -0,0 +1,335 @@
+
\ No newline at end of file
diff --git a/docs/perf/img/http-p95.svg b/docs/perf/img/http-p95.svg
new file mode 100644
index 0000000..bcfc2ae
--- /dev/null
+++ b/docs/perf/img/http-p95.svg
@@ -0,0 +1,308 @@
+
\ No newline at end of file
diff --git a/docs/perf/img/http-throughput.svg b/docs/perf/img/http-throughput.svg
new file mode 100644
index 0000000..35491aa
--- /dev/null
+++ b/docs/perf/img/http-throughput.svg
@@ -0,0 +1,316 @@
+
\ No newline at end of file
diff --git a/docs/perf/performance-report.md b/docs/perf/performance-report.md
new file mode 100644
index 0000000..05e9a33
--- /dev/null
+++ b/docs/perf/performance-report.md
@@ -0,0 +1,487 @@
+# Performance report - database and API
+
+Campaign of 15 September 2026, commit `3ac984d`. Raw data:
+[`data.md`](data.md); protocol and limits below. The recommendations have
+been applied and measured again: see
+[section 6](#6-follow-up-on-the-recommendations).
+
+## Summary
+
+1. **The number of accounts has little effect on the calls.** From 10 000 to
+ 1 000 000 accounts (database from 109 MB to 10.5 GB), the maximum throughput
+ of authenticated calls drops by 2 to 10% and their p95 latency at equal load
+ does not move (1.6 → 1.8 ms at 16 clients on `GET /users/me`). Every query
+ on the hot path remains an index scan: 0.01 to 0.04 ms on the database side
+ at 1 million accounts.
+2. **The limiting component is the API CPU, not the database.** On
+ authenticated reads, the API saturates its 3 cores from 16 clients
+ (11 000 to 14 000 req/s) while PostgreSQL uses only 1.3 to 1.9 of its 3
+ cores. Beyond that, additional clients only wait: p95 of 1.8 ms at 16
+ clients, 7 ms at 64, 23 ms at 256.
+3. **Sign-ins size the service.** Argon2id caps the throughput of `login` and
+ `register` at 33-38 req/s on 3 cores (≈ 90 ms of computation per hash).
+ At 256 concurrent sign-ins, each one waits 7 to 8 seconds. This computation
+ is well isolated: in mixed traffic at 256 clients, the profile answers in
+ 0.8 ms (p50) and refresh in 4.1 ms while the sign-ins wait.
+4. **Refresh plateaus around 5 000 req/s, limited by PostgreSQL** (2.5 to 2.6
+ cores out of 3: one write transaction with synchronous commit per call),
+ whatever the volume.
+5. **Only writes degrade with volume.** At 1 million accounts, the sign-in
+ transaction loses 25 to 35% of its throughput (6 422 → 4 805 tx/s at
+ 32 connections, p99 8.2 → 11 ms) and reads 11 400 blocks per second from
+ disk: the database outgrows the memory allocated to it, and updating indexes
+ with random keys (token hash, UUID) becomes a matter of I/O.
+6. **Two fixes stand out**: the session purge costs 0.7 to 2.3 s per batch of
+ 5 000 rows at 1 million accounts (it rereads the whole backlog on every
+ batch), and nearly 900 MB of indexes never used by the application slow
+ down every write.
+
+## 1. What was measured
+
+**Question:** how do the database and the API calls behave as the number of
+accounts grows and as the load increases?
+
+Two axes, crossed:
+
+| Axis | Values |
+|------|--------|
+| Data volume | 10 000, 100 000 and 1 000 000 accounts, with the history an account accumulates |
+| Load | 1, 4, 16, 64 and 256 concurrent clients (HTTP); 1, 8 and 32 connections (SQL) |
+
+Three families of measurements at each volume:
+
+1. **HTTP calls** end to end, per scenario and in mixed traffic:
+ throughput, p50/p95/p99/max latencies, errors, CPU consumed by each component.
+2. **Application queries** run directly on PostgreSQL through the repository
+ functions (the same SQL as the API, as prepared statements): throughput and
+ latency by number of connections.
+3. **Database state**: actual execution plans (`EXPLAIN ANALYZE, BUFFERS`),
+ table and index sizes, `pg_stat_statements` statistics during mixed
+ traffic, duration of purge batches.
+
+## 2. Protocol
+
+### Machine and isolation
+
+- QEMU virtual machine, 8 vCPUs ("QEMU Virtual CPU version 2.5+"),
+ 23.4 GB of memory, SSD disk, Debian 13 (kernel 6.12).
+- PostgreSQL 17.11, measured commit `3ac984d`.
+- Campaign of 15 September 2026, from 09:52 to 11:18, seeding included
+ (9 minutes to grow from 100 000 to 1 000 000 accounts).
+
+Everything runs on the same virtual machine. So that the components do not
+steal CPU time from each other and the consumption of each one can be
+measured, each is pinned to its own cores:
+
+| Cores | Component |
+|-------|-----------|
+| 0-2 | PostgreSQL |
+| 3 | Redis and NATS |
+| 4-6 | API (`auth-api`, release build) |
+| 7 | Load generator |
+
+The CPU consumption of each component is read from `/proc/stat` on its cores
+during the measurement window.
+
+### Configuration
+
+- **PostgreSQL 17** on disk (SSD), with production durability:
+ `fsync`, `synchronous_commit` and `full_page_writes` enabled.
+ `shared_buffers=4GB`, `effective_cache_size=12GB`, `work_mem=16MB`,
+ `random_page_cost=1.1`, `max_wal_size=8GB`, `pg_stat_statements` and
+ `track_io_timing`.
+- **Redis** without persistence, **NATS** with JetStream on disk.
+- **API** in release build, PostgreSQL pool of 32 connections and Redis pool of
+ 32. **Argon2id with production parameters** (64 MiB, 3 iterations,
+ 4 lanes), limited to the 3 cores of the API.
+- The **rate limiter stays enabled** (its cost is part of every request), but
+ its limits are raised so that it rejects nothing. CAPTCHA is disabled, the
+ lockout threshold raised, and logging set to `warn`.
+- Emails go to a local Mailpit.
+
+### Data
+
+The data set is deterministic and grows in steps (the 100 000 accounts
+include the first 10 000). Per account:
+
+| Data | Quantity |
+|------|----------|
+| Active sessions | 2 |
+| Expired or revoked sessions | 3 (past the purge delay) |
+| Sign-in attempts over 90 days | 10, including 2 failures |
+| Audit entries over 25 days | 15 |
+| Used email verification token | 1 |
+| TOTP and 10 recovery codes | 1 account in 5 |
+| Email second factor | 1 account in 20 |
+| Used password reset token | 1 account in 10 |
+
+At 1 000 000 accounts: 5 million sessions, 10 million sign-in attempts and
+15 million audit entries. All accounts share the same Argon2id hash computed
+with the production parameters, so verifying a password costs what it costs
+in production.
+
+### Load generator
+
+- **Closed loop**: each virtual client sends its next request as soon as the
+ previous one has answered, without pause. The measured throughput is
+ therefore the maximum the system sustains at that concurrency, and the
+ latency is the one a waiting client sees. It is not a fixed-rate test.
+- **Access tokens**: up to 100 000 tokens signed in advance for accounts drawn
+ uniformly, which makes misses in the session validity cache realistic.
+- **Client addresses**: each request comes from an address drawn among
+ 262 144, so that per-address budgets behave as they would with real
+ traffic.
+- **Refresh**: each virtual client signs in once before the clock starts, then
+ follows its own rotation chain.
+- **Measurement**: 5 seconds of warm-up then 20 seconds of measurement per HTTP
+ point (3 + 10 seconds per SQL point). Redis is flushed before each HTTP point.
+ Latencies go into a histogram with 1% resolution.
+
+### HTTP scenarios
+
+| Scenario | Call | What it exercises |
+|----------|------|-------------------|
+| `profile` | `GET /users/me` | Token check (Redis), account read |
+| `sessions` | `GET /users/me/sessions` | Active sessions of an account |
+| `audit` | `GET /users/me/audit?limit=50` | History, partitioned table |
+| `two_factor` | `GET /users/me/two-factor` | Second factors and remaining codes |
+| `refresh` | `POST /auth/refresh` | Refresh token rotation (transaction), token issuance |
+| `login` | `POST /auth/login` | Argon2id, anti-brute-force counters, sign-in writes |
+| `register` | `POST /auth/register` | Argon2id, account creation, email |
+| `mixed` | all | 50% refresh, 20% profile, 10% sessions, 5% audit, 5% 2FA, 8% sign-ins, 2% registrations |
+
+The mix of the mixed traffic reflects a real authentication service: resource
+servers verify access tokens locally (JWKS), so the API mostly sees refreshes,
+then account pages, then sign-ins.
+
+### Measured SQL queries
+
+| Query | Used by |
+|-------|---------|
+| User by email | Sign-in |
+| Session by refresh token | Refresh |
+| Session validity | Every authenticated request, on cache miss |
+| Active sessions of an account | `GET /users/me/sessions` |
+| Recent failures by identifier, by address | Sign-in (anti-brute-force) |
+| Consecutive failures of an account | Failed sign-in (lockout) |
+| Roles and permissions | Issuance of every access token |
+| History page | `GET /users/me/audit` |
+| Second factors and codes | `GET /users/me/two-factor` |
+| Sign-in writes | Session, account, attempt and audit in one transaction |
+
+## 3. Results
+
+### 3.1 Capacity at 1 million accounts
+
+| Call | Maximum throughput | Reached at | p95 at 16 clients | Limiting component |
+|------|-------------------:|-----------:|------------------:|--------------------|
+| `GET /users/me` | 12 620 req/s | 16 clients | 1.8 ms | API CPU (2.98 / 3 cores) |
+| `GET /users/me/sessions` | 12 315 req/s | 16 clients | 1.8 ms | API CPU |
+| `GET /users/me/audit` | 11 745 req/s | 16 clients | 1.9 ms | API CPU |
+| `GET /users/me/two-factor` | 11 247 req/s | 16 clients | 1.9 ms | API CPU |
+| `POST /auth/refresh` | 5 055 req/s | 64 clients | 7.2 ms | PostgreSQL (2.6 / 3 cores) |
+| `POST /auth/login` | 33 req/s | 4 clients | 517 ms | Argon2id (API CPU) |
+| `POST /auth/register` | 33 req/s | 4 clients | 549 ms | Argon2id (API CPU) |
+| Mixed traffic | 322 req/s | 4 clients | 468 ms | Argon2id (10% of the traffic) |
+
+No errors across the 120 HTTP points of the campaign, up to 256 concurrent
+clients. The load generator never exceeded 0.51 core: the ceilings are indeed
+those of the server.
+
+Mixed traffic plateaus low because it runs in a closed loop: the 10% of
+sign-ins and registrations tie up the virtual clients for hundreds of
+milliseconds. Its value lies elsewhere: it shows that fast calls are not
+penalized by the waiting sign-ins.
+
+| Mixed traffic, 1M accounts, 256 clients | req/s | p50 | p95 |
+|-----------------------------------------|------:|----:|----:|
+| refresh | 156 | 4.1 ms | 10.7 ms |
+| profile | 66 | 0.81 ms | 5.6 ms |
+| sessions | 31 | 0.87 ms | 5.7 ms |
+| sign-in | 27 | 7.7 s | 7.8 s |
+
+### 3.2 Effect of the number of accounts
+
+
+
+| Metric | 10 000 | 100 000 | 1 000 000 | Change |
+|--------|-------:|--------:|----------:|-------:|
+| Database size | 109 MB | 1.3 GB | 10.5 GB | x97 |
+| `GET /users/me`, maximum throughput | 14 086 | 13 096 | 12 620 req/s | -10% |
+| `GET /users/me`, p95 at 16 clients | 1.6 ms | 1.7 ms | 1.8 ms | +0.2 ms |
+| `GET /users/me/audit`, maximum throughput | 12 306 | 12 067 | 11 745 req/s | -5% |
+| `POST /auth/refresh`, maximum throughput | 5 139 | 5 324 | 5 055 req/s | -2% |
+| `POST /auth/login`, maximum throughput | 38 | 34 | 33 req/s | -13% |
+| `POST /auth/login`, p50 at 1 client | 69 ms | 76 ms | 80 ms | +11 ms |
+| Sign-in transaction, 32 connections | 6 422 | 6 493 | 4 805 tx/s | -25% |
+| Sign-in transaction, p99 at 32 connections | 8.2 ms | 8.0 ms | 11.0 ms | +2.8 ms |
+
+Reads barely slow down: a lookup in a B-tree index costs one more level when
+the table grows a hundredfold, and the pages that matter stay in memory (cache
+hit ratio of 99.8 to 100% from 16 clients).
+
+Writes, however, do slow down at 1 million accounts. During mixed traffic,
+`pg_stat_statements` shows inserts into `sessions`, `login_attempts` and
+`audit_log` going from 0.1 ms to 0.4-0.56 ms on average, with block reads on
+every call. The database (10.5 GB, of which 5.6 GB are indexes) exceeds the
+4 GB of `shared_buffers`, and each insert updates indexes whose keys are
+random (token hash, UUID): the page to modify is rarely in memory.
+
+The drop in sign-in throughput (-13%) does not come from the database:
+PostgreSQL uses only 0.1 core there, and it is the Argon2id computation itself
+that goes from about 79 to 90 ms per hash. Unverified hypothesis: Argon2id is
+bound by memory bandwidth (64 MiB per hash), which inside the virtual machine
+is shared with the 10 GB of database cache.
+
+### 3.3 Effect of load
+
+
+
+Every call follows the same pattern, whatever the volume:
+
+- **Up to the ceiling**, throughput grows almost linearly with the number of
+ clients and latency stays stable (0.7 to 1.8 ms for reads).
+- **At the ceiling**, throughput stops moving and latency grows in proportion
+ to the number of clients: each one waits its turn. For reads, p95 goes from
+ 1.8 ms (16 clients) to 7 ms (64) then 23 ms (256).
+- **No collapse**: throughput at 256 clients is equal to or higher than at
+ 64 clients, with no errors and no timeouts.
+
+For sign-ins, the ceiling is reached at 4 clients (3 cores, 3 hashes at a
+time): p95 of 158 ms at 4 clients, 517 ms at 16, 2 s at 64 and 7.9 s at 256.
+
+### 3.4 Where the CPU time goes
+
+Average cost of one call at the ceiling, at 1 million accounts (cores consumed
+divided by throughput):
+
+| Call | API | PostgreSQL | Redis + NATS |
+|------|----:|-----------:|-------------:|
+| `GET /users/me` | 0.24 ms | 0.12 ms | 0.05 ms |
+| `GET /users/me/audit` | 0.25 ms | 0.15 ms | 0.05 ms |
+| `GET /users/me/two-factor` | 0.26 ms | 0.16 ms | 0.05 ms |
+| `POST /auth/refresh` | 0.44 ms | 0.51 ms | 0.06 ms |
+| `POST /auth/login` | 90 ms | 3 ms | 0.3 ms |
+| `POST /auth/register` | 93 ms | 3 ms | 0.3 ms |
+
+On an authenticated call, the API spends twice as much CPU as the database:
+verifying the ES256 signature of the token, the rate limiter and serialization
+weigh more than the SQL query. A refresh costs twice as much, split evenly
+between the API (signing a new token) and PostgreSQL (session lock, rotation,
+insert, roles and permissions in one transaction).
+
+Breakdown of SQL time during mixed traffic at 1 million accounts:
+
+| Query | Share of SQL time | Mean |
+|-------|------------------:|-----:|
+| Session insert (refresh) | 15% | 0.12 ms |
+| Session insert (sign-in) | 13% | 0.56 ms |
+| Revocation of the rotated session | 12% | 0.09 ms |
+| Insert into `audit_log` | 11% | 0.39 ms |
+| Insert into `login_attempts` | 10% | 0.43 ms |
+| Roles and permissions | 6% | 0.04 ms |
+
+More than 60% of SQL time goes into writes, even though they are a minority
+of the calls.
+
+### 3.5 Database
+
+
+
+**Every hot-path query uses an index at every volume** (plans in
+[`data.md`](data.md#execution-plans)): 0.01 to 0.04 ms of execution at
+1 million accounts, with no disk reads. From the application, a query costs
+0.16 to 0.32 ms (p50, one connection), round trip and preparation included,
+and stays under 0.65 ms at p99 with 8 connections (0.94 ms for second factors,
+which run two queries), the same at 10 000 and at 1 million accounts.
+
+
+
+The read throughputs in this chart **underestimate PostgreSQL**: from
+8 connections, the load generator saturates its core (1.00) while PostgreSQL
+uses only 1.6 to 2.1 of its 3 cores. The 19 000 to 28 000 queries per second
+measured are a lower bound. The write transaction, on the other hand, is not
+bound by the client (0.3 to 0.7 core): its drop at 1 million accounts is real.
+
+The single-connection measurements of the 1M step were taken right after
+seeding, with a cold cache: they show disk reads (up to 375 ms of I/O per
+second for the history page) that disappear at the following points.
+
+What the volume costs in storage:
+
+| Table | Rows at 1M | Table | Indexes |
+|-------|-----------:|------:|--------:|
+| `audit_log` | 15.3 M | 1.5 GB | 2.4 GB |
+| `sessions` | 6.0 M | 1.4 GB | 1.5 GB |
+| `login_attempts` | 10.3 M | 1.1 GB | 1.1 GB |
+| `recovery_codes` | 2.0 M | 226 MB | 443 MB |
+| `users` | 1.0 M | 225 MB | 158 MB |
+
+That is about 10 KB per account, indexes included, half of it in indexes.
+
+**Indexes never scanned during the whole campaign**, at 1 million accounts:
+
+| Index | Size | Purpose |
+|-------|-----:|---------|
+| `audit_log_*_request_id_created_at_idx` | 755 MB | Only `audit::find_by_request_id` uses it, and no route calls that function |
+| `login_attempts_pkey` | 403 MB | Kept on purpose (logical replication) |
+| `idx_sessions_family_created` | 330 MB | Useful: revoking a family on replay, not exercised here |
+| `recovery_codes_code_hash_key` | 147 MB | Useful: sign-in with a recovery code, not exercised here |
+| `idx_sessions_family_active` | 118 MB | **Unusable**: revocation filters on `revoked_at IS NULL OR compromised_at IS NULL OR ...`, which this partial index cannot serve |
+
+### 3.6 Purge
+
+| Job (batches of 5 000 rows) | 10k | 100k | 1M |
+|-----------------------------|----:|-----:|---:|
+| Expired or revoked sessions | 27-66 ms | 128-269 ms | 738-2 329 ms |
+| Sign-in attempts (empty batch) | 9 ms | 93 ms | 832 ms |
+| Verification and reset tokens | 4-39 ms | 7-16 ms | 9-16 ms |
+
+The cost of a session batch grows with the backlog, not with the batch size:
+`cleanup_expired_sessions` selects the candidates with a `UNION` followed by a
+`LIMIT`, and deduplication forces PostgreSQL to read every candidate
+(3 million at 1M accounts) before keeping 5 000. At this rate, clearing a
+backlog of 3 million sessions takes more than 7 minutes of continuous
+deletion.
+
+The empty `login_attempts` batch (832 ms) is partly an artifact of the data
+set: the rows were inserted account by account rather than in chronological
+order, which makes the BRIN index on `attempted_at` ineffective. In
+production, attempts arrive in order and the BRIN index stays selective; it
+must however be checked after any bulk data import.
+
+## 4. Estimated capacity
+
+Based on the throughputs measured on 3 API cores, for a service with 1 million
+accounts. The traffic assumptions are orders of magnitude, to be replaced with
+your own:
+
+| Assumption | Peak traffic | Measured capacity | Headroom |
+|------------|-------------:|------------------:|---------:|
+| 50 000 users active at the same time, one refresh every 15 minutes | 56 refresh/s | 5 055 req/s | x90 |
+| Each one loads 20 account pages per hour | 280 req/s | 11 000-12 600 req/s | x40 |
+| 20% of the accounts sign in during the peak hour | 56 sign-ins/s | 33 req/s | **x0.6** |
+
+Authenticated calls and refresh leave considerable headroom. **Sign-ins are
+the first saturation point**: a peak of 56 sign-ins per second requires about
+5 API cores with the current Argon2id parameters, that is two instances of the
+measured size to keep some headroom. Since the API is stateless, this is
+solved by adding instances; each added core brings 11 to 13 sign-ins per
+second and uses 64 MiB of memory per hash in progress.
+
+## 5. Recommendations
+
+In order of impact:
+
+1. **Size the API for sign-ins, not for requests.** Plan for 11 to 13 sign-ins
+ per second per core, `ARGON2_MAX_CONCURRENCY` equal to the number of cores
+ and 64 MiB of memory per core for Argon2id. Monitor
+ `argon2_queue_available_permits`: when it stays at 0 for a sustained period,
+ sign-ins pile up and one more instance is needed.
+2. **Bound the cost of a session purge batch.** Replace the `UNION ... LIMIT`
+ of `cleanup_expired_sessions` with two independent bounded deletes (expired
+ sessions, then revoked ones), each with its own `LIMIT`, in a new
+ migration. The cost of a batch becomes proportional to the batch again.
+3. **Drop `idx_sessions_family_active`** (118 MB at 1M, unusable), and decide
+ what to do with the audit `request_id` index (755 MB at 1M, no route uses
+ it): keep it only if support looks up the audit log by request identifier.
+ Every removed index makes all inserts cheaper.
+4. **Beyond one million accounts, give PostgreSQL enough memory for its write
+ indexes.** This is the only path that degrades with volume. At 1M, the
+ indexes of `sessions`, `login_attempts` and `audit_log` weigh 5 GB: aim for
+ memory (`shared_buffers` and the OS cache) that holds them, or shorten the
+ retention periods (90 days of attempts, 12 months of audit) that make these
+ tables grow.
+5. **Low priority:** the history page also scans the 12 future monthly
+ partitions, which are empty (0.04 ms in total today); bounding the query to
+ `created_at <= now()` would let them be excluded. Refresh, limited by
+ PostgreSQL, keeps x90 headroom over the traffic assumption: no need to
+ optimize it for now.
+
+## 6. Follow-up on the recommendations
+
+All recommendations were applied (retention functions and indexes in the migrations, code,
+monitoring and documentation), then measured on the same database of
+1 million accounts, before and after, under the same conditions. Writes were
+measured twice on each side: the ranges show the spread between the two
+passes.
+
+| # | Recommendation | What was done | Before | After |
+|---|----------------|---------------|-------:|------:|
+| 1 | Size the API for sign-ins | "Capacity Planning" guide in the operations runbook; the API logs its Argon2 budget at startup and warns if the container memory limit does not cover it | - | Documented |
+| 2 | Monitor Argon2 saturation | Two alerts: no free slot for 5 minutes (warning) and 15 minutes (critical) | 1 alert at 5 min | 2 levels |
+| 3 | Bound the session purge | Expired then revoked sessions in two bounded deletes; every purge goes through a TID scan (`ctid = ANY (ARRAY(...))`) | 750-820 ms per batch | **24-27 ms per batch** |
+| 4 | Drop `idx_sessions_family_active` | Dropped | 118 MB | 0 |
+| 5 | Audit `request_id` index | Dropped, together with the `audit::find_by_request_id` function that no route called | 755 MB | 0 |
+| 6 | PostgreSQL memory beyond one million | "Size PostgreSQL's memory" section of the database deployment guide: sizes per volume, settings, check query | - | Documented |
+| 7 | BRIN after an import | "Bulk Imports" procedure (correlation check, `CLUSTER`), verified on the test database | empty batch: 0.9-3.6 s | **empty batch: 0.2-1.7 ms** |
+| 8 | History page bounded to `created_at <= NOW()` | Bounded query: the 12 future partitions are pruned at execution time | 14 partitions read | 2 partitions + default |
+| 9 | Refresh | No action (x90 headroom) | - | - |
+
+Effects measured at 1 million accounts:
+
+| Metric | Before | After | Interpretation |
+|--------|-------:|------:|----------------|
+| Database size | 10 910 MB | 10 123 MB | -787 MB of indexes |
+| `audit_log` indexes | 2 371 MB | 1 632 MB | -31% |
+| Session purge batch | 750-820 ms | 24-27 ms | x30; the plan no longer gathers the 4.9 million candidate rows of both branches (113 MB written to disk before) |
+| Empty attempts purge batch, ordered table | 0.9-3.6 s | 0.2-1.7 ms | `CLUSTER` of 10 million rows in 31 s; correlation -0.04 → 1 |
+| Sign-in transaction, 1 connection | 424-441 tx/s | 342-472 tx/s | within noise |
+| Sign-in transaction, 8 connections | 1 382-1 888 tx/s | 2 118-2 499 tx/s | better |
+| Sign-in transaction, 32 connections | 4 868-5 387 tx/s | 5 064-5 380 tx/s | within noise |
+| Cache hit ratio during these writes | 0.88-0.97 | 0.92-1.00 | fewer indexes to keep in memory |
+| History page, 1 connection, warm cache | - | 4 777-4 802 req/s, p50 0.21 ms | level of 10 000 accounts |
+| History page, 8 connections | 17 878 req/s | 17 730 req/s | unchanged |
+
+What these figures show:
+
+- **The purge was the real problem**, and it was worse than expected: it was
+ not only the `UNION`, but the `ctid IN (SELECT ... LIMIT)` pattern that
+ aggregated every candidate before keeping 5 000. Clearing a backlog of
+ 3 million sessions now takes about 15 seconds instead of more than 7 minutes.
+- **Dropping the two indexes** frees 787 MB and improves the cache hit ratio of
+ writes. The throughput of the sign-in transaction only clearly improves at
+ 8 connections: at 1 and 32 connections, the difference stays within the
+ variation between two passes. The main gain is space and memory headroom,
+ not throughput.
+- **Bounding the history page** has a negligible effect, as expected
+ (0.035 → 0.032 ms of execution): the future partitions were empty.
+- A measurement right after the migration showed a slower history page
+ (1 626 req/s): that was the cold cache (ratio of 0.70), gone by the next
+ measurement.
+
+Verification: full test suite (547 tests) and Clippy with no warnings.
+Raw data for this second measurement: `reports/perf/followup-20260915-*/`
+(not under version control).
+
+## Limits
+
+- **A single virtual machine**: the load generator, the API and the database
+ share the same host, pinned to separate cores. There is no network latency,
+ no TLS and no Nginx between the client and the API: in production, each call
+ also pays a network round trip and encryption.
+- **Generator on one core**: its consumption is measured at every point. In
+ HTTP, it never exceeded 0.51 core. In the SQL benchmark, it reaches 1 core
+ from 8 connections on reads: those throughputs are lower bounds
+ (see 3.5).
+- **Closed loop**: latencies under heavy load are those of a client waiting
+ its turn. Open traffic (users who arrive without waiting for the others)
+ produces longer queues once saturation is reached.
+- **Synthetic, uniform data**: no "hot" accounts; all accounts have the same
+ history profile.
+- **Cumulative volumes**: the write scenarios of one step (sign-ins,
+ registrations, sign-in transactions) add rows to the next step; this is
+ negligible compared with the volumes.
+- **QEMU virtual machine**: absolute performance depends on the host. What
+ carries over are the trends (evolution with volume, saturation point,
+ limiting component), not the raw figures.
+
+## Reproducing
+
+```bash
+make perf # the campaign, several hours
+make perf-report RUN=reports/perf/ # tables and charts into docs/perf/
+```
+
+The protocol and the variables are described in [`perf/README.md`](../../perf/README.md).
+All the raw data of this report is in [`data.md`](data.md).
diff --git a/fuzz/.gitignore b/fuzz/.gitignore
new file mode 100644
index 0000000..9d5396a
--- /dev/null
+++ b/fuzz/.gitignore
@@ -0,0 +1,6 @@
+# Generated by cargo fuzz. Curated inputs live in seeds/, crash reproducers
+# worth keeping in regressions/: both are committed and replayed on stable.
+target/
+corpus/
+artifacts/
+coverage/
diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock
new file mode 100644
index 0000000..984049e
--- /dev/null
+++ b/fuzz/Cargo.lock
@@ -0,0 +1,4140 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "aead"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99"
+dependencies = [
+ "crypto-common 0.2.2",
+ "inout",
+]
+
+[[package]]
+name = "aes"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32"
+dependencies = [
+ "cipher",
+ "cpubits",
+ "cpufeatures 0.3.1",
+]
+
+[[package]]
+name = "aes-gcm"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f"
+dependencies = [
+ "aead",
+ "aes",
+ "cipher",
+ "ctr",
+ "ctutils",
+ "ghash",
+]
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
+[[package]]
+name = "anyhow"
+version = "1.0.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+
+[[package]]
+name = "arbitrary"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
+
+[[package]]
+name = "arcstr"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d"
+
+[[package]]
+name = "argon2"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
+dependencies = [
+ "base64ct",
+ "blake2",
+ "cpufeatures 0.2.17",
+ "password-hash",
+]
+
+[[package]]
+name = "async-lock"
+version = "3.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-nats"
+version = "0.49.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fad3cd6df81292728e2a8cb1f1dcb4d7e7a1ab59b80c14fbbcba2baf9d5cf86a"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-util",
+ "memchr",
+ "nkeys",
+ "nuid",
+ "pin-project",
+ "portable-atomic",
+ "rand 0.10.2",
+ "regex",
+ "ring",
+ "rustls-native-certs",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "serde",
+ "serde_json",
+ "serde_nanos",
+ "serde_repr",
+ "thiserror",
+ "time",
+ "tokio",
+ "tokio-rustls",
+ "tokio-stream",
+ "tokio-util",
+ "tokio-websockets",
+ "tracing",
+ "tryhard",
+ "url",
+]
+
+[[package]]
+name = "async-trait"
+version = "0.1.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.5",
+]
+
+[[package]]
+name = "atoi"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "auth-api"
+version = "0.1.0"
+dependencies = [
+ "aes-gcm",
+ "anyhow",
+ "argon2",
+ "async-nats",
+ "axum",
+ "axum-prometheus",
+ "base64 0.22.1",
+ "deadpool",
+ "deadpool-redis",
+ "dotenvy",
+ "email_address",
+ "ipnetwork",
+ "jsonwebtoken",
+ "lettre",
+ "metrics",
+ "p256",
+ "rand 0.10.2",
+ "rand_core 0.6.4",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "sha2 0.11.0",
+ "sqlx",
+ "tera",
+ "thiserror",
+ "time",
+ "tokio",
+ "totp-rs",
+ "tower-http 0.7.1",
+ "tracing",
+ "tracing-subscriber",
+ "utoipa",
+ "uuid",
+]
+
+[[package]]
+name = "auth-api-fuzz"
+version = "0.0.0"
+dependencies = [
+ "auth-api",
+ "libfuzzer-sys",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "aws-lc-rs"
+version = "1.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
+dependencies = [
+ "aws-lc-sys",
+ "untrusted 0.7.1",
+ "zeroize",
+]
+
+[[package]]
+name = "aws-lc-sys"
+version = "0.45.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
+dependencies = [
+ "cc",
+ "cmake",
+ "dunce",
+ "fs_extra",
+ "pkg-config",
+]
+
+[[package]]
+name = "axum"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
+dependencies = [
+ "axum-core",
+ "bytes",
+ "form_urlencoded",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "itoa",
+ "matchit",
+ "memchr",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde_core",
+ "serde_json",
+ "serde_path_to_error",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "axum-core"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "http-body-util",
+ "mime",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "axum-prometheus"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27e29a21d8a876465c009f1a69883fdfdf428a3ea42719a0c96021076c34cdf0"
+dependencies = [
+ "axum",
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "matchit",
+ "metrics",
+ "metrics-exporter-prometheus",
+ "pin-project-lite",
+ "tokio",
+ "tower",
+ "tower-http 0.7.1",
+]
+
+[[package]]
+name = "base16ct"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
+
+[[package]]
+name = "base32"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "base64"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
+
+[[package]]
+name = "base64ct"
+version = "1.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+
+[[package]]
+name = "bitflags"
+version = "2.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "blake2"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
+dependencies = [
+ "digest 0.10.7",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
+dependencies = [
+ "hybrid-array",
+]
+
+[[package]]
+name = "bstr"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f"
+dependencies = [
+ "memchr",
+ "serde_core",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "cc"
+version = "1.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4"
+dependencies = [
+ "find-msvc-tools",
+ "jobserver",
+ "libc",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "cfg_aliases"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
+
+[[package]]
+name = "chacha20"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.1",
+ "rand_core 0.10.1",
+]
+
+[[package]]
+name = "cipher"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
+dependencies = [
+ "block-buffer 0.12.1",
+ "crypto-common 0.2.2",
+ "inout",
+]
+
+[[package]]
+name = "cmake"
+version = "0.1.58"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "cmov"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
+
+[[package]]
+name = "combine"
+version = "4.6.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "memchr",
+ "pin-project-lite",
+ "tokio",
+ "tokio-util",
+]
+
+[[package]]
+name = "const-oid"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+
+[[package]]
+name = "const-oid"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
+
+[[package]]
+name = "constant_time_eq"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
+
+[[package]]
+name = "core-foundation"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "cpubits"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc"
+version = "3.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
+dependencies = [
+ "crc-catalog",
+]
+
+[[package]]
+name = "crc-catalog"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
+
+[[package]]
+name = "crossbeam-epoch"
+version = "0.9.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-queue"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
+
+[[package]]
+name = "crypto-bigint"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
+dependencies = [
+ "generic-array",
+ "rand_core 0.6.4",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
+dependencies = [
+ "getrandom 0.4.3",
+ "hybrid-array",
+ "rand_core 0.10.1",
+]
+
+[[package]]
+name = "ctr"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21"
+dependencies = [
+ "cipher",
+]
+
+[[package]]
+name = "ctutils"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
+dependencies = [
+ "cmov",
+]
+
+[[package]]
+name = "curve25519-dalek"
+version = "4.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "curve25519-dalek-derive",
+ "digest 0.10.7",
+ "fiat-crypto",
+ "rustc_version",
+ "subtle",
+]
+
+[[package]]
+name = "curve25519-dalek-derive"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "data-encoding"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
+
+[[package]]
+name = "deadpool"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e98a7e119cd347f4201e1159b19831029e203e2d8b790547708e8157b4acf1e"
+dependencies = [
+ "deadpool-runtime",
+ "tokio",
+]
+
+[[package]]
+name = "deadpool-redis"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "243852fff546d8c5aac6ceaac7587f7137915b102c3fbec6256a64ce52452bd2"
+dependencies = [
+ "deadpool",
+ "redis",
+]
+
+[[package]]
+name = "deadpool-runtime"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2657f61fb1dd8bf37a8d51093cc7cee4e77125b22f7753f49b289f831bec2bae"
+dependencies = [
+ "tokio",
+]
+
+[[package]]
+name = "der"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+dependencies = [
+ "const-oid 0.9.6",
+ "pem-rfc7468",
+ "zeroize",
+]
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer 0.10.4",
+ "const-oid 0.9.6",
+ "crypto-common 0.1.6",
+ "subtle",
+]
+
+[[package]]
+name = "digest"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
+dependencies = [
+ "block-buffer 0.12.1",
+ "const-oid 0.10.2",
+ "crypto-common 0.2.2",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.5",
+]
+
+[[package]]
+name = "dotenvy"
+version = "0.15.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
+
+[[package]]
+name = "dunce"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
+
+[[package]]
+name = "ecdsa"
+version = "0.16.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
+dependencies = [
+ "der",
+ "digest 0.10.7",
+ "elliptic-curve",
+ "rfc6979",
+ "signature",
+ "spki",
+]
+
+[[package]]
+name = "ed25519"
+version = "2.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
+dependencies = [
+ "signature",
+]
+
+[[package]]
+name = "ed25519-dalek"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
+dependencies = [
+ "curve25519-dalek",
+ "ed25519",
+ "sha2 0.10.9",
+ "signature",
+ "subtle",
+]
+
+[[package]]
+name = "either"
+version = "1.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "elliptic-curve"
+version = "0.13.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
+dependencies = [
+ "base16ct",
+ "base64ct",
+ "crypto-bigint",
+ "digest 0.10.7",
+ "ff",
+ "generic-array",
+ "group",
+ "pem-rfc7468",
+ "pkcs8",
+ "rand_core 0.6.4",
+ "sec1",
+ "serde_json",
+ "serdect",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "email-encoding"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "420b9da095f052ea597503e39073b5b3c522f7db933fbac202d91d24492693fd"
+dependencies = [
+ "base64 0.23.1",
+ "memchr",
+]
+
+[[package]]
+name = "email_address"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "etcetera"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943"
+dependencies = [
+ "cfg-if",
+ "home",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "event-listener"
+version = "5.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
+dependencies = [
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+dependencies = [
+ "event-listener",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "evmap"
+version = "11.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b8874945f036109c72242964c1174cf99434e30cfa45bf45fedc983f50046f8"
+dependencies = [
+ "hashbag",
+ "left-right",
+ "smallvec",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+
+[[package]]
+name = "ff"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
+dependencies = [
+ "rand_core 0.6.4",
+ "subtle",
+]
+
+[[package]]
+name = "fiat-crypto"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
+
+[[package]]
+name = "flume"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+ "spin",
+]
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "fs_extra"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
+
+[[package]]
+name = "futures-channel"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-intrusive"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f"
+dependencies = [
+ "futures-core",
+ "lock_api",
+ "parking_lot",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
+
+[[package]]
+name = "futures-sink"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-core",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "generator"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "libc",
+ "log",
+ "rustversion",
+ "windows-link",
+ "windows-result",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
+dependencies = [
+ "typenum",
+ "version_check",
+ "zeroize",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "wasi",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 5.3.0",
+ "wasip2",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "r-efi 6.0.0",
+ "rand_core 0.10.1",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "ghash"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5"
+dependencies = [
+ "polyval",
+]
+
+[[package]]
+name = "globset"
+version = "0.4.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988"
+dependencies = [
+ "aho-corasick",
+ "bstr",
+ "log",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "group"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
+dependencies = [
+ "ff",
+ "rand_core 0.6.4",
+ "subtle",
+]
+
+[[package]]
+name = "hashbag"
+version = "0.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7040a10f52cba493ddb09926e15d10a9d8a28043708a405931fe4c6f19fac064"
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash 0.1.5",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+dependencies = [
+ "foldhash 0.2.0",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "hashlink"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
+dependencies = [
+ "hashbrown 0.15.5",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "hkdf"
+version = "0.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
+dependencies = [
+ "hmac",
+]
+
+[[package]]
+name = "hmac"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
+dependencies = [
+ "digest 0.10.7",
+]
+
+[[package]]
+name = "home"
+version = "0.5.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "hostname"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "windows-link",
+]
+
+[[package]]
+name = "http"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
+[[package]]
+name = "hybrid-array"
+version = "0.4.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
+dependencies = [
+ "typenum",
+]
+
+[[package]]
+name = "hyper"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "http",
+ "http-body",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-rustls"
+version = "0.27.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
+dependencies = [
+ "http",
+ "hyper",
+ "hyper-util",
+ "rustls",
+ "tokio",
+ "tokio-rustls",
+ "tower-service",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "hyper",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "socket2",
+ "tokio",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
+
+[[package]]
+name = "icu_properties"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
+dependencies = [
+ "displaydoc",
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
+
+[[package]]
+name = "icu_provider"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "inout"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
+dependencies = [
+ "hybrid-array",
+]
+
+[[package]]
+name = "ipnet"
+version = "2.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
+
+[[package]]
+name = "ipnetwork"
+version = "0.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "jni"
+version = "0.22.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
+dependencies = [
+ "cfg-if",
+ "combine",
+ "jni-macros",
+ "jni-sys",
+ "log",
+ "simd_cesu8",
+ "thiserror",
+ "walkdir",
+ "windows-link",
+]
+
+[[package]]
+name = "jni-macros"
+version = "0.22.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "simd_cesu8",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
+dependencies = [
+ "jni-sys-macros",
+]
+
+[[package]]
+name = "jni-sys-macros"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
+dependencies = [
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "jobserver"
+version = "0.1.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
+dependencies = [
+ "getrandom 0.4.3",
+ "libc",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.105"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "jsonwebtoken"
+version = "10.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc"
+dependencies = [
+ "aws-lc-rs",
+ "base64 0.22.1",
+ "getrandom 0.2.17",
+ "js-sys",
+ "pem",
+ "serde",
+ "serde_json",
+ "signature",
+ "simple_asn1",
+ "zeroize",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+dependencies = [
+ "spin",
+]
+
+[[package]]
+name = "left-right"
+version = "0.11.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8bc015ded5d9b3054dbbdb63332cdd6ee42352ccef19e911e25117490e2f48ee"
+dependencies = [
+ "crossbeam-utils",
+ "loom",
+ "slab",
+]
+
+[[package]]
+name = "lettre"
+version = "0.11.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2c646bd5cc763b1087b15493e29a64be6147ba8f19342004fa52048ee596eae"
+dependencies = [
+ "async-trait",
+ "base64 0.23.1",
+ "email-encoding",
+ "email_address",
+ "fastrand",
+ "futures-io",
+ "futures-util",
+ "hostname",
+ "httpdate",
+ "idna",
+ "mime",
+ "nom",
+ "percent-encoding",
+ "quoted_printable",
+ "rustls",
+ "socket2",
+ "tokio",
+ "tokio-rustls",
+ "url",
+ "webpki-roots 1.0.9",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "libfuzzer-sys"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2"
+dependencies = [
+ "arbitrary",
+ "cc",
+]
+
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
+[[package]]
+name = "libredox"
+version = "0.1.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6480ccc157a1389bb2e4891b24751b0f798ba640d22386f23143fbcc89da195a"
+dependencies = [
+ "bitflags",
+ "libc",
+ "plain",
+ "redox_syscall 0.9.4",
+]
+
+[[package]]
+name = "libsqlite3-sys"
+version = "0.30.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
+dependencies = [
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "litemap"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
+
+[[package]]
+name = "loom"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca"
+dependencies = [
+ "cfg-if",
+ "generator",
+ "scoped-tls",
+ "tracing",
+ "tracing-subscriber",
+]
+
+[[package]]
+name = "lru-slab"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
+
+[[package]]
+name = "matchers"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
+dependencies = [
+ "regex-automata",
+]
+
+[[package]]
+name = "matchit"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
+
+[[package]]
+name = "md-5"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
+dependencies = [
+ "cfg-if",
+ "digest 0.10.7",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "metrics"
+version = "0.24.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "89550ee9f79e88fef3119de263694973a8adb26c21d75322164fb8c493039fe2"
+dependencies = [
+ "portable-atomic",
+ "rapidhash",
+]
+
+[[package]]
+name = "metrics-exporter-prometheus"
+version = "0.18.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1db0d8f1fc9e62caebd0319e11eaec5822b0186c171568f0480b46a0137f9108"
+dependencies = [
+ "base64 0.22.1",
+ "evmap",
+ "indexmap",
+ "metrics",
+ "metrics-util",
+ "quanta",
+ "thiserror",
+]
+
+[[package]]
+name = "metrics-util"
+version = "0.20.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96f8722f8562635f92f8ed992f26df0532266eb03d5202607c20c0d7e9745e13"
+dependencies = [
+ "crossbeam-epoch",
+ "crossbeam-utils",
+ "hashbrown 0.16.1",
+ "metrics",
+ "quanta",
+ "rand 0.9.5",
+ "rand_xoshiro",
+ "rapidhash",
+ "sketches-ddsketch",
+]
+
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "mio"
+version = "1.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "nkeys"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "879011babc47a1c7fdf5a935ae3cfe94f34645ca0cac1c7f6424b36fc743d1bf"
+dependencies = [
+ "data-encoding",
+ "ed25519",
+ "ed25519-dalek",
+ "getrandom 0.2.17",
+ "log",
+ "rand 0.8.8",
+ "signatory",
+]
+
+[[package]]
+name = "nom"
+version = "8.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "nuid"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc895af95856f929163a0aa20c26a78d26bfdc839f51b9d5aa7a5b79e52b7e83"
+dependencies = [
+ "rand 0.8.8",
+]
+
+[[package]]
+name = "num-bigint"
+version = "0.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-bigint-dig"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
+dependencies = [
+ "lazy_static",
+ "libm",
+ "num-integer",
+ "num-iter",
+ "num-traits",
+ "rand 0.8.8",
+ "smallvec",
+ "zeroize",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
+[[package]]
+name = "num-integer"
+version = "0.1.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-iter"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+ "libm",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "openssl-probe"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
+
+[[package]]
+name = "p256"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
+dependencies = [
+ "ecdsa",
+ "elliptic-curve",
+ "primeorder",
+ "sha2 0.10.9",
+]
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall 0.5.18",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "password-hash"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
+dependencies = [
+ "base64ct",
+ "rand_core 0.6.4",
+ "subtle",
+]
+
+[[package]]
+name = "pem"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
+dependencies = [
+ "base64 0.22.1",
+ "serde_core",
+]
+
+[[package]]
+name = "pem-rfc7468"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
+dependencies = [
+ "base64ct",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pin-project"
+version = "1.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
+dependencies = [
+ "pin-project-internal",
+]
+
+[[package]]
+name = "pin-project-internal"
+version = "1.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pkcs1"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
+dependencies = [
+ "der",
+ "pkcs8",
+ "spki",
+]
+
+[[package]]
+name = "pkcs8"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
+dependencies = [
+ "der",
+ "spki",
+]
+
+[[package]]
+name = "pkg-config"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
+
+[[package]]
+name = "plain"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
+
+[[package]]
+name = "polyval"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd"
+dependencies = [
+ "cpubits",
+ "cpufeatures 0.3.1",
+ "universal-hash",
+]
+
+[[package]]
+name = "portable-atomic"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
+
+[[package]]
+name = "potential_utf"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "primeorder"
+version = "0.13.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
+dependencies = [
+ "elliptic-curve",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quanta"
+version = "0.12.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
+dependencies = [
+ "crossbeam-utils",
+ "libc",
+ "once_cell",
+ "raw-cpuid",
+ "wasi",
+ "web-sys",
+ "winapi",
+]
+
+[[package]]
+name = "quinn"
+version = "0.11.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
+dependencies = [
+ "bytes",
+ "cfg_aliases",
+ "pin-project-lite",
+ "quinn-proto",
+ "quinn-udp",
+ "rustc-hash",
+ "rustls",
+ "socket2",
+ "thiserror",
+ "tokio",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-proto"
+version = "0.11.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc"
+dependencies = [
+ "aws-lc-rs",
+ "bytes",
+ "getrandom 0.4.3",
+ "lru-slab",
+ "rand 0.10.2",
+ "rand_pcg",
+ "ring",
+ "rustc-hash",
+ "rustls",
+ "rustls-pki-types",
+ "slab",
+ "thiserror",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-udp"
+version = "0.5.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
+dependencies = [
+ "cfg_aliases",
+ "libc",
+ "once_cell",
+ "socket2",
+ "tracing",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "quoted_printable"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972"
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rand"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
+dependencies = [
+ "libc",
+ "rand_chacha 0.3.1",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
+dependencies = [
+ "rand_chacha 0.9.0",
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "rand"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
+dependencies = [
+ "chacha20",
+ "getrandom 0.4.3",
+ "rand_core 0.10.1",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.17",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
+
+[[package]]
+name = "rand_pcg"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
+dependencies = [
+ "rand_core 0.10.1",
+]
+
+[[package]]
+name = "rand_xoshiro"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41"
+dependencies = [
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "rapidhash"
+version = "4.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e"
+dependencies = [
+ "rustversion",
+]
+
+[[package]]
+name = "raw-cpuid"
+version = "11.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "redis"
+version = "1.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2acbc41a996f7652b2ddd9dfd98cc4ff602cfd742ae35382f07f608405ab50ed"
+dependencies = [
+ "arcstr",
+ "async-lock",
+ "bytes",
+ "cfg-if",
+ "combine",
+ "futures-util",
+ "itoa",
+ "percent-encoding",
+ "pin-project-lite",
+ "ryu",
+ "sha1_smol",
+ "socket2",
+ "tokio",
+ "tokio-util",
+ "url",
+ "xxhash-rust",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "regex"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "reqwest"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029"
+dependencies = [
+ "base64 0.23.1",
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "percent-encoding",
+ "pin-project-lite",
+ "quinn",
+ "rustls",
+ "rustls-pki-types",
+ "rustls-platform-verifier",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-rustls",
+ "tower",
+ "tower-http 0.6.11",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "rfc6979"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
+dependencies = [
+ "hmac",
+ "subtle",
+]
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted 0.9.0",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rsa"
+version = "0.9.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
+dependencies = [
+ "const-oid 0.9.6",
+ "digest 0.10.7",
+ "num-bigint-dig",
+ "num-integer",
+ "num-traits",
+ "pkcs1",
+ "pkcs8",
+ "rand_core 0.6.4",
+ "signature",
+ "spki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
+dependencies = [
+ "aws-lc-rs",
+ "log",
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-native-certs"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
+dependencies = [
+ "openssl-probe",
+ "rustls-pki-types",
+ "schannel",
+ "security-framework",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
+dependencies = [
+ "web-time",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-platform-verifier"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
+dependencies = [
+ "core-foundation",
+ "core-foundation-sys",
+ "jni",
+ "log",
+ "once_cell",
+ "rustls",
+ "rustls-native-certs",
+ "rustls-platform-verifier-android",
+ "rustls-webpki",
+ "security-framework",
+ "security-framework-sys",
+ "webpki-root-certs",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustls-platform-verifier-android"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
+dependencies = [
+ "aws-lc-rs",
+ "ring",
+ "rustls-pki-types",
+ "untrusted 0.9.0",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "schannel"
+version = "0.1.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "scoped-tls"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "sec1"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
+dependencies = [
+ "base16ct",
+ "der",
+ "generic-array",
+ "pkcs8",
+ "serdect",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "security-framework"
+version = "3.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
+dependencies = [
+ "bitflags",
+ "core-foundation",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.5",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_nanos"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a93142f0367a4cc53ae0fead1bcda39e85beccfad3dcd717656cacab94b12985"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "serde_norway"
+version = "0.9.42"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e408f29489b5fd500fab51ff1484fc859bb655f32c671f307dcd733b72e8168c"
+dependencies = [
+ "indexmap",
+ "itoa",
+ "ryu",
+ "serde",
+ "unsafe-libyaml-norway",
+]
+
+[[package]]
+name = "serde_path_to_error"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
+dependencies = [
+ "itoa",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "serde_repr"
+version = "0.1.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.5",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "serdect"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177"
+dependencies = [
+ "base16ct",
+ "serde",
+]
+
+[[package]]
+name = "sha1"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "digest 0.10.7",
+]
+
+[[package]]
+name = "sha1_smol"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "digest 0.10.7",
+]
+
+[[package]]
+name = "sha2"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.1",
+ "digest 0.11.3",
+]
+
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "signatory"
+version = "0.27.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1e303f8205714074f6068773f0e29527e0453937fe837c9717d066635b65f31"
+dependencies = [
+ "pkcs8",
+ "rand_core 0.6.4",
+ "signature",
+ "zeroize",
+]
+
+[[package]]
+name = "signature"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
+dependencies = [
+ "digest 0.10.7",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "simd_cesu8"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
+dependencies = [
+ "rustc_version",
+ "simdutf8",
+]
+
+[[package]]
+name = "simdutf8"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
+
+[[package]]
+name = "simple_asn1"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
+dependencies = [
+ "num-bigint",
+ "num-traits",
+ "thiserror",
+ "time",
+]
+
+[[package]]
+name = "sketches-ddsketch"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "socket2"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "spin"
+version = "0.9.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
+dependencies = [
+ "lock_api",
+]
+
+[[package]]
+name = "spki"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
+dependencies = [
+ "base64ct",
+ "der",
+]
+
+[[package]]
+name = "sqlx"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc"
+dependencies = [
+ "sqlx-core",
+ "sqlx-macros",
+ "sqlx-mysql",
+ "sqlx-postgres",
+ "sqlx-sqlite",
+]
+
+[[package]]
+name = "sqlx-core"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "crc",
+ "crossbeam-queue",
+ "either",
+ "event-listener",
+ "futures-core",
+ "futures-intrusive",
+ "futures-io",
+ "futures-util",
+ "hashbrown 0.15.5",
+ "hashlink",
+ "indexmap",
+ "ipnetwork",
+ "log",
+ "memchr",
+ "once_cell",
+ "percent-encoding",
+ "rustls",
+ "serde",
+ "serde_json",
+ "sha2 0.10.9",
+ "smallvec",
+ "thiserror",
+ "time",
+ "tokio",
+ "tokio-stream",
+ "tracing",
+ "url",
+ "uuid",
+ "webpki-roots 0.26.11",
+]
+
+[[package]]
+name = "sqlx-macros"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "sqlx-core",
+ "sqlx-macros-core",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "sqlx-macros-core"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b"
+dependencies = [
+ "dotenvy",
+ "either",
+ "heck",
+ "hex",
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "serde",
+ "serde_json",
+ "sha2 0.10.9",
+ "sqlx-core",
+ "sqlx-mysql",
+ "sqlx-postgres",
+ "sqlx-sqlite",
+ "syn 2.0.119",
+ "tokio",
+ "url",
+]
+
+[[package]]
+name = "sqlx-mysql"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
+dependencies = [
+ "atoi",
+ "base64 0.22.1",
+ "bitflags",
+ "byteorder",
+ "bytes",
+ "crc",
+ "digest 0.10.7",
+ "dotenvy",
+ "either",
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-util",
+ "generic-array",
+ "hex",
+ "hkdf",
+ "hmac",
+ "itoa",
+ "log",
+ "md-5",
+ "memchr",
+ "once_cell",
+ "percent-encoding",
+ "rand 0.8.8",
+ "rsa",
+ "serde",
+ "sha1",
+ "sha2 0.10.9",
+ "smallvec",
+ "sqlx-core",
+ "stringprep",
+ "thiserror",
+ "time",
+ "tracing",
+ "uuid",
+ "whoami",
+]
+
+[[package]]
+name = "sqlx-postgres"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
+dependencies = [
+ "atoi",
+ "base64 0.22.1",
+ "bitflags",
+ "byteorder",
+ "crc",
+ "dotenvy",
+ "etcetera",
+ "futures-channel",
+ "futures-core",
+ "futures-util",
+ "hex",
+ "hkdf",
+ "hmac",
+ "home",
+ "ipnetwork",
+ "itoa",
+ "log",
+ "md-5",
+ "memchr",
+ "once_cell",
+ "rand 0.8.8",
+ "serde",
+ "serde_json",
+ "sha2 0.10.9",
+ "smallvec",
+ "sqlx-core",
+ "stringprep",
+ "thiserror",
+ "time",
+ "tracing",
+ "uuid",
+ "whoami",
+]
+
+[[package]]
+name = "sqlx-sqlite"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
+dependencies = [
+ "atoi",
+ "flume",
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-intrusive",
+ "futures-util",
+ "libsqlite3-sys",
+ "log",
+ "percent-encoding",
+ "serde",
+ "serde_urlencoded",
+ "sqlx-core",
+ "thiserror",
+ "time",
+ "tracing",
+ "url",
+ "uuid",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "stringprep"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
+dependencies = [
+ "unicode-bidi",
+ "unicode-normalization",
+ "unicode-properties",
+]
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tera"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61e0cadeeb54426080b4c5395d59769ce8293f542cc25511c167c742b2175809"
+dependencies = [
+ "globset",
+ "serde",
+ "walkdir",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.5",
+]
+
+[[package]]
+name = "thread_local"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "time"
+version = "0.3.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
+dependencies = [
+ "deranged",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+
+[[package]]
+name = "time-macros"
+version = "0.2.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
+
+[[package]]
+name = "tokio"
+version = "1.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.5",
+]
+
+[[package]]
+name = "tokio-rustls"
+version = "0.26.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67"
+dependencies = [
+ "rustls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-stream"
+version = "0.1.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "libc",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-websockets"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f591660438b3038dd04d16c938271c79e7e06260ad2ea2885a4861bfb238605d"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "http",
+ "httparse",
+ "rand 0.8.8",
+ "ring",
+ "rustls-pki-types",
+ "tokio",
+ "tokio-rustls",
+ "tokio-util",
+ "webpki-roots 0.26.11",
+]
+
+[[package]]
+name = "totp-rs"
+version = "5.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50e69a15e21b2ff22c415446983978bded3244195f17d59cb113551c1e806f91"
+dependencies = [
+ "base32",
+ "constant_time_eq",
+ "hmac",
+ "rand 0.9.5",
+ "sha1",
+ "sha2 0.10.9",
+]
+
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
+dependencies = [
+ "bitflags",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "pin-project-lite",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "url",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c"
+dependencies = [
+ "bitflags",
+ "bytes",
+ "http",
+ "http-body",
+ "percent-encoding",
+ "pin-project-lite",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "log",
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-serde"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1"
+dependencies = [
+ "serde",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
+dependencies = [
+ "matchers",
+ "nu-ansi-term",
+ "once_cell",
+ "regex-automata",
+ "serde",
+ "serde_json",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing",
+ "tracing-core",
+ "tracing-log",
+ "tracing-serde",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
+name = "tryhard"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fe58ebd5edd976e0fe0f8a14d2a04b7c81ef153ea9a54eebc42e67c2c23b4e5"
+dependencies = [
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "unicode-bidi"
+version = "0.3.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-normalization"
+version = "0.1.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "unicode-properties"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
+
+[[package]]
+name = "universal-hash"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96"
+dependencies = [
+ "crypto-common 0.2.2",
+ "ctutils",
+]
+
+[[package]]
+name = "unsafe-libyaml-norway"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39abd59bf32521c7f2301b52d05a6a2c975b6003521cbd0c6dc1582f0a22104"
+
+[[package]]
+name = "untrusted"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
+
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "utoipa"
+version = "5.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160"
+dependencies = [
+ "indexmap",
+ "serde",
+ "serde_json",
+ "serde_norway",
+ "utoipa-gen",
+]
+
+[[package]]
+name = "utoipa-gen"
+version = "5.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "uuid",
+]
+
+[[package]]
+name = "uuid"
+version = "1.26.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
+dependencies = [
+ "getrandom 0.4.3",
+ "js-sys",
+ "serde_core",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.4+wasi-0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasite"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.128"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.78"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.128"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.128"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.5",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.128"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.105"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "webpki-root-certs"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "0.26.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
+dependencies = [
+ "webpki-roots 1.0.9",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
+name = "whoami"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
+dependencies = [
+ "libredox",
+ "wasite",
+]
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+dependencies = [
+ "windows-targets 0.48.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
+dependencies = [
+ "windows_aarch64_gnullvm 0.48.5",
+ "windows_aarch64_msvc 0.48.5",
+ "windows_i686_gnu 0.48.5",
+ "windows_i686_msvc 0.48.5",
+ "windows_x86_64_gnu 0.48.5",
+ "windows_x86_64_gnullvm 0.48.5",
+ "windows_x86_64_msvc 0.48.5",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "writeable"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
+
+[[package]]
+name = "xxhash-rust"
+version = "0.8.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6"
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.57"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.57"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
+dependencies = [
+ "zeroize_derive",
+]
+
+[[package]]
+name = "zeroize_derive"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "zerotrie"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.5",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml
new file mode 100644
index 0000000..5890f4b
--- /dev/null
+++ b/fuzz/Cargo.toml
@@ -0,0 +1,111 @@
+[package]
+name = "auth-api-fuzz"
+version = "0.0.0"
+edition = "2024"
+publish = false
+
+[package.metadata]
+cargo-fuzz = true
+
+# A workspace of its own: the fuzz targets build with nightly and sanitizers,
+# apart from the service.
+[workspace]
+members = ["."]
+
+[dependencies]
+auth-api = { path = "..", features = ["fuzzing"] }
+libfuzzer-sys = "0.4"
+
+[profile.release]
+debug = 1
+
+[[bin]]
+name = "access_token"
+path = "fuzz_targets/access_token.rs"
+test = false
+doc = false
+bench = false
+
+[[bin]]
+name = "audit_cursor"
+path = "fuzz_targets/audit_cursor.rs"
+test = false
+doc = false
+bench = false
+
+[[bin]]
+name = "client_ip"
+path = "fuzz_targets/client_ip.rs"
+test = false
+doc = false
+bench = false
+
+[[bin]]
+name = "client_registration"
+path = "fuzz_targets/client_registration.rs"
+test = false
+doc = false
+bench = false
+
+[[bin]]
+name = "keyring"
+path = "fuzz_targets/keyring.rs"
+test = false
+doc = false
+bench = false
+
+[[bin]]
+name = "pkce"
+path = "fuzz_targets/pkce.rs"
+test = false
+doc = false
+bench = false
+
+[[bin]]
+name = "pre_auth_state"
+path = "fuzz_targets/pre_auth_state.rs"
+test = false
+doc = false
+bench = false
+
+[[bin]]
+name = "pwned_range"
+path = "fuzz_targets/pwned_range.rs"
+test = false
+doc = false
+bench = false
+
+[[bin]]
+name = "redirect_uri"
+path = "fuzz_targets/redirect_uri.rs"
+test = false
+doc = false
+bench = false
+
+[[bin]]
+name = "totp_code"
+path = "fuzz_targets/totp_code.rs"
+test = false
+doc = false
+bench = false
+
+[[bin]]
+name = "validators"
+path = "fuzz_targets/validators.rs"
+test = false
+doc = false
+bench = false
+
+[[bin]]
+name = "webhook_url"
+path = "fuzz_targets/webhook_url.rs"
+test = false
+doc = false
+bench = false
+
+[[bin]]
+name = "webauthn"
+path = "fuzz_targets/webauthn.rs"
+test = false
+doc = false
+bench = false
diff --git a/fuzz/fuzz_targets/access_token.rs b/fuzz/fuzz_targets/access_token.rs
new file mode 100644
index 0000000..d7fdf15
--- /dev/null
+++ b/fuzz/fuzz_targets/access_token.rs
@@ -0,0 +1,3 @@
+#![no_main]
+
+libfuzzer_sys::fuzz_target!(|data: &[u8]| auth_api::fuzzing::access_token(data));
diff --git a/fuzz/fuzz_targets/audit_cursor.rs b/fuzz/fuzz_targets/audit_cursor.rs
new file mode 100644
index 0000000..274f561
--- /dev/null
+++ b/fuzz/fuzz_targets/audit_cursor.rs
@@ -0,0 +1,3 @@
+#![no_main]
+
+libfuzzer_sys::fuzz_target!(|data: &[u8]| auth_api::fuzzing::audit_cursor(data));
diff --git a/fuzz/fuzz_targets/client_ip.rs b/fuzz/fuzz_targets/client_ip.rs
new file mode 100644
index 0000000..a797649
--- /dev/null
+++ b/fuzz/fuzz_targets/client_ip.rs
@@ -0,0 +1,3 @@
+#![no_main]
+
+libfuzzer_sys::fuzz_target!(|data: &[u8]| auth_api::fuzzing::client_ip(data));
diff --git a/fuzz/fuzz_targets/client_registration.rs b/fuzz/fuzz_targets/client_registration.rs
new file mode 100644
index 0000000..22e39ec
--- /dev/null
+++ b/fuzz/fuzz_targets/client_registration.rs
@@ -0,0 +1,3 @@
+#![no_main]
+
+libfuzzer_sys::fuzz_target!(|data: &[u8]| auth_api::fuzzing::client_registration(data));
diff --git a/fuzz/fuzz_targets/keyring.rs b/fuzz/fuzz_targets/keyring.rs
new file mode 100644
index 0000000..5482e1c
--- /dev/null
+++ b/fuzz/fuzz_targets/keyring.rs
@@ -0,0 +1,3 @@
+#![no_main]
+
+libfuzzer_sys::fuzz_target!(|data: &[u8]| auth_api::fuzzing::keyring(data));
diff --git a/fuzz/fuzz_targets/pkce.rs b/fuzz/fuzz_targets/pkce.rs
new file mode 100644
index 0000000..0f547b6
--- /dev/null
+++ b/fuzz/fuzz_targets/pkce.rs
@@ -0,0 +1,3 @@
+#![no_main]
+
+libfuzzer_sys::fuzz_target!(|data: &[u8]| auth_api::fuzzing::pkce(data));
diff --git a/fuzz/fuzz_targets/pre_auth_state.rs b/fuzz/fuzz_targets/pre_auth_state.rs
new file mode 100644
index 0000000..9291ab7
--- /dev/null
+++ b/fuzz/fuzz_targets/pre_auth_state.rs
@@ -0,0 +1,3 @@
+#![no_main]
+
+libfuzzer_sys::fuzz_target!(|data: &[u8]| auth_api::fuzzing::pre_auth_state(data));
diff --git a/fuzz/fuzz_targets/pwned_range.rs b/fuzz/fuzz_targets/pwned_range.rs
new file mode 100644
index 0000000..bbc1159
--- /dev/null
+++ b/fuzz/fuzz_targets/pwned_range.rs
@@ -0,0 +1,3 @@
+#![no_main]
+
+libfuzzer_sys::fuzz_target!(|data: &[u8]| auth_api::fuzzing::pwned_range(data));
diff --git a/fuzz/fuzz_targets/redirect_uri.rs b/fuzz/fuzz_targets/redirect_uri.rs
new file mode 100644
index 0000000..e36b8e4
--- /dev/null
+++ b/fuzz/fuzz_targets/redirect_uri.rs
@@ -0,0 +1,3 @@
+#![no_main]
+
+libfuzzer_sys::fuzz_target!(|data: &[u8]| auth_api::fuzzing::redirect_uri(data));
diff --git a/fuzz/fuzz_targets/totp_code.rs b/fuzz/fuzz_targets/totp_code.rs
new file mode 100644
index 0000000..6bafbf1
--- /dev/null
+++ b/fuzz/fuzz_targets/totp_code.rs
@@ -0,0 +1,3 @@
+#![no_main]
+
+libfuzzer_sys::fuzz_target!(|data: &[u8]| auth_api::fuzzing::totp_code(data));
diff --git a/fuzz/fuzz_targets/validators.rs b/fuzz/fuzz_targets/validators.rs
new file mode 100644
index 0000000..0a0c4bd
--- /dev/null
+++ b/fuzz/fuzz_targets/validators.rs
@@ -0,0 +1,3 @@
+#![no_main]
+
+libfuzzer_sys::fuzz_target!(|data: &[u8]| auth_api::fuzzing::validators(data));
diff --git a/fuzz/fuzz_targets/webauthn.rs b/fuzz/fuzz_targets/webauthn.rs
new file mode 100644
index 0000000..71d308b
--- /dev/null
+++ b/fuzz/fuzz_targets/webauthn.rs
@@ -0,0 +1,3 @@
+#![no_main]
+
+libfuzzer_sys::fuzz_target!(|data: &[u8]| auth_api::fuzzing::webauthn(data));
diff --git a/fuzz/fuzz_targets/webhook_url.rs b/fuzz/fuzz_targets/webhook_url.rs
new file mode 100644
index 0000000..06f39ec
--- /dev/null
+++ b/fuzz/fuzz_targets/webhook_url.rs
@@ -0,0 +1,3 @@
+#![no_main]
+
+libfuzzer_sys::fuzz_target!(|data: &[u8]| auth_api::fuzzing::webhook_url(data));
diff --git a/fuzz/regressions/validators/password_minimum_counted_in_bytes b/fuzz/regressions/validators/password_minimum_counted_in_bytes
new file mode 100644
index 0000000..c2b674f
--- /dev/null
+++ b/fuzz/regressions/validators/password_minimum_counted_in_bytes
@@ -0,0 +1 @@
+n@My‮e 1
\ No newline at end of file
diff --git a/fuzz/seeds/access_token/alg_none b/fuzz/seeds/access_token/alg_none
new file mode 100644
index 0000000..fb5b17b
--- /dev/null
+++ b/fuzz/seeds/access_token/alg_none
@@ -0,0 +1 @@
+eyJhbGciOiJub25lIn0.eyJzdWIiOiJ4In0.
\ No newline at end of file
diff --git a/fuzz/seeds/access_token/edit_header b/fuzz/seeds/access_token/edit_header
new file mode 100644
index 0000000..2cd3956
Binary files /dev/null and b/fuzz/seeds/access_token/edit_header differ
diff --git a/fuzz/seeds/access_token/edit_signature b/fuzz/seeds/access_token/edit_signature
new file mode 100644
index 0000000..3fa7285
--- /dev/null
+++ b/fuzz/seeds/access_token/edit_signature
@@ -0,0 +1 @@
+,A
\ No newline at end of file
diff --git a/fuzz/seeds/access_token/untouched b/fuzz/seeds/access_token/untouched
new file mode 100644
index 0000000..e69de29
diff --git a/fuzz/seeds/audit_cursor/negative_time b/fuzz/seeds/audit_cursor/negative_time
new file mode 100644
index 0000000..b543614
--- /dev/null
+++ b/fuzz/seeds/audit_cursor/negative_time
@@ -0,0 +1 @@
+LTg2NDAwMDAwMDAwMDAwOjZmMWMxYTNlLTdkNWItNGI4ZS05YTUxLTNlMGIxZjdmM2MyYQ
\ No newline at end of file
diff --git a/fuzz/seeds/audit_cursor/overflow b/fuzz/seeds/audit_cursor/overflow
new file mode 100644
index 0000000..3fd145b
--- /dev/null
+++ b/fuzz/seeds/audit_cursor/overflow
@@ -0,0 +1 @@
+OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OjZmMWMxYTNlLTdkNWItNGI4ZS05YTUxLTNlMGIxZjdmM2MyYQ
\ No newline at end of file
diff --git a/fuzz/seeds/audit_cursor/padded b/fuzz/seeds/audit_cursor/padded
new file mode 100644
index 0000000..437c7ff
--- /dev/null
+++ b/fuzz/seeds/audit_cursor/padded
@@ -0,0 +1 @@
+MTo2ZjFjMWEzZS03ZDViLTRiOGUtOWE1MS0zZTBiMWY3ZjNjMmE=
\ No newline at end of file
diff --git a/fuzz/seeds/audit_cursor/valid b/fuzz/seeds/audit_cursor/valid
new file mode 100644
index 0000000..f4691c5
--- /dev/null
+++ b/fuzz/seeds/audit_cursor/valid
@@ -0,0 +1 @@
+MTcwMDAwMDAwMDEyMzQ1Njc4OTo2ZjFjMWEzZS03ZDViLTRiOGUtOWE1MS0zZTBiMWY3ZjNjMmE
\ No newline at end of file
diff --git a/fuzz/seeds/client_ip/all_trusted_hops b/fuzz/seeds/client_ip/all_trusted_hops
new file mode 100644
index 0000000..6937670
Binary files /dev/null and b/fuzz/seeds/client_ip/all_trusted_hops differ
diff --git a/fuzz/seeds/client_ip/garbage_hop b/fuzz/seeds/client_ip/garbage_hop
new file mode 100644
index 0000000..dc800b8
Binary files /dev/null and b/fuzz/seeds/client_ip/garbage_hop differ
diff --git a/fuzz/seeds/client_ip/ipv6_peer b/fuzz/seeds/client_ip/ipv6_peer
new file mode 100644
index 0000000..a6e2f7b
Binary files /dev/null and b/fuzz/seeds/client_ip/ipv6_peer differ
diff --git a/fuzz/seeds/client_ip/trusted_chain b/fuzz/seeds/client_ip/trusted_chain
new file mode 100644
index 0000000..2a038fe
Binary files /dev/null and b/fuzz/seeds/client_ip/trusted_chain differ
diff --git a/fuzz/seeds/client_ip/untrusted_peer b/fuzz/seeds/client_ip/untrusted_peer
new file mode 100644
index 0000000..f3d4448
Binary files /dev/null and b/fuzz/seeds/client_ip/untrusted_peer differ
diff --git a/fuzz/seeds/client_registration/empty b/fuzz/seeds/client_registration/empty
new file mode 100644
index 0000000..e69de29
diff --git a/fuzz/seeds/client_registration/full b/fuzz/seeds/client_registration/full
new file mode 100644
index 0000000..4745bf6
Binary files /dev/null and b/fuzz/seeds/client_registration/full differ
diff --git a/fuzz/seeds/client_registration/loopback_client b/fuzz/seeds/client_registration/loopback_client
new file mode 100644
index 0000000..1204891
Binary files /dev/null and b/fuzz/seeds/client_registration/loopback_client differ
diff --git a/fuzz/seeds/keyring/edit_tag b/fuzz/seeds/keyring/edit_tag
new file mode 100644
index 0000000..d3248a4
Binary files /dev/null and b/fuzz/seeds/keyring/edit_tag differ
diff --git a/fuzz/seeds/keyring/prefix_only b/fuzz/seeds/keyring/prefix_only
new file mode 100644
index 0000000..4dc341d
--- /dev/null
+++ b/fuzz/seeds/keyring/prefix_only
@@ -0,0 +1 @@
+v1:
\ No newline at end of file
diff --git a/fuzz/seeds/keyring/unknown_kid b/fuzz/seeds/keyring/unknown_kid
new file mode 100644
index 0000000..caac0ad
--- /dev/null
+++ b/fuzz/seeds/keyring/unknown_kid
@@ -0,0 +1 @@
+v1:0000000000000000:AAAA
\ No newline at end of file
diff --git a/fuzz/seeds/keyring/untouched b/fuzz/seeds/keyring/untouched
new file mode 100644
index 0000000..e69de29
diff --git a/fuzz/seeds/pkce/padded_challenge b/fuzz/seeds/pkce/padded_challenge
new file mode 100644
index 0000000..3811f62
--- /dev/null
+++ b/fuzz/seeds/pkce/padded_challenge
@@ -0,0 +1 @@
+E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM=
diff --git a/fuzz/seeds/pkce/rfc7636 b/fuzz/seeds/pkce/rfc7636
new file mode 100644
index 0000000..ec86d78
--- /dev/null
+++ b/fuzz/seeds/pkce/rfc7636
@@ -0,0 +1,2 @@
+E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
+dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
\ No newline at end of file
diff --git a/fuzz/seeds/pkce/short_verifier b/fuzz/seeds/pkce/short_verifier
new file mode 100644
index 0000000..b001237
--- /dev/null
+++ b/fuzz/seeds/pkce/short_verifier
@@ -0,0 +1,2 @@
+E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
+short
\ No newline at end of file
diff --git a/fuzz/seeds/pre_auth_state/email b/fuzz/seeds/pre_auth_state/email
new file mode 100644
index 0000000..99d5454
--- /dev/null
+++ b/fuzz/seeds/pre_auth_state/email
@@ -0,0 +1 @@
+{"user_id":"6f1c1a3e-7d5b-4b8e-9a51-3e0b1f7f3c2a","method":"email"}
\ No newline at end of file
diff --git a/fuzz/seeds/pre_auth_state/legacy_uuid b/fuzz/seeds/pre_auth_state/legacy_uuid
new file mode 100644
index 0000000..4096f18
--- /dev/null
+++ b/fuzz/seeds/pre_auth_state/legacy_uuid
@@ -0,0 +1 @@
+6f1c1a3e-7d5b-4b8e-9a51-3e0b1f7f3c2a
\ No newline at end of file
diff --git a/fuzz/seeds/pre_auth_state/no_method b/fuzz/seeds/pre_auth_state/no_method
new file mode 100644
index 0000000..e768f90
--- /dev/null
+++ b/fuzz/seeds/pre_auth_state/no_method
@@ -0,0 +1 @@
+{"user_id":"6f1c1a3e-7d5b-4b8e-9a51-3e0b1f7f3c2a"}
\ No newline at end of file
diff --git a/fuzz/seeds/pre_auth_state/totp b/fuzz/seeds/pre_auth_state/totp
new file mode 100644
index 0000000..4b48070
--- /dev/null
+++ b/fuzz/seeds/pre_auth_state/totp
@@ -0,0 +1 @@
+{"user_id":"6f1c1a3e-7d5b-4b8e-9a51-3e0b1f7f3c2a","remember_me":true,"method":"totp"}
\ No newline at end of file
diff --git a/fuzz/seeds/pre_auth_state/unknown_method b/fuzz/seeds/pre_auth_state/unknown_method
new file mode 100644
index 0000000..1838f99
--- /dev/null
+++ b/fuzz/seeds/pre_auth_state/unknown_method
@@ -0,0 +1 @@
+{"user_id":"6f1c1a3e-7d5b-4b8e-9a51-3e0b1f7f3c2a","method":"sms"}
\ No newline at end of file
diff --git a/fuzz/seeds/pwned_range/breached b/fuzz/seeds/pwned_range/breached
new file mode 100644
index 0000000..c7bcaa4
--- /dev/null
+++ b/fuzz/seeds/pwned_range/breached
@@ -0,0 +1,3 @@
+1E4C9B93F3F0682250B6CF8331B7EE68FD8
+0018A45C4D1DEF81644B54AB7F969B88D65:1
+1E4C9B93F3F0682250B6CF8331B7EE68FD8:9659365
diff --git a/fuzz/seeds/pwned_range/malformed b/fuzz/seeds/pwned_range/malformed
new file mode 100644
index 0000000..375430d
--- /dev/null
+++ b/fuzz/seeds/pwned_range/malformed
@@ -0,0 +1,4 @@
+ABC
+ABC:many
+:5
+ABC
diff --git a/fuzz/seeds/pwned_range/padded b/fuzz/seeds/pwned_range/padded
new file mode 100644
index 0000000..fa5fc4d
--- /dev/null
+++ b/fuzz/seeds/pwned_range/padded
@@ -0,0 +1,2 @@
+011053FD0102E94D6AE2F8B83D76FAF94F6
+011053FD0102E94D6AE2F8B83D76FAF94F6:0
diff --git a/fuzz/seeds/redirect_uri/exact b/fuzz/seeds/redirect_uri/exact
new file mode 100644
index 0000000..a063f88
--- /dev/null
+++ b/fuzz/seeds/redirect_uri/exact
@@ -0,0 +1,3 @@
+
+https://app.example.com/cb
+https://app.example.com/cb
\ No newline at end of file
diff --git a/fuzz/seeds/redirect_uri/ipv6_fragment b/fuzz/seeds/redirect_uri/ipv6_fragment
new file mode 100644
index 0000000..1948944
--- /dev/null
+++ b/fuzz/seeds/redirect_uri/ipv6_fragment
@@ -0,0 +1,3 @@
+L
+http://[::1]:8080/cb#x
+http://[::1]/cb
\ No newline at end of file
diff --git a/fuzz/seeds/redirect_uri/localhost b/fuzz/seeds/redirect_uri/localhost
new file mode 100644
index 0000000..8ce4f2d
--- /dev/null
+++ b/fuzz/seeds/redirect_uri/localhost
@@ -0,0 +1,3 @@
+L
+http://localhost:5000/cb
+http://127.0.0.1/cb
\ No newline at end of file
diff --git a/fuzz/seeds/redirect_uri/loopback_port b/fuzz/seeds/redirect_uri/loopback_port
new file mode 100644
index 0000000..b12a88c
--- /dev/null
+++ b/fuzz/seeds/redirect_uri/loopback_port
@@ -0,0 +1,4 @@
+L
+http://127.0.0.1:5000/cb
+http://127.0.0.1/cb
+https://app.example.com/cb
\ No newline at end of file
diff --git a/fuzz/seeds/redirect_uri/port_zero b/fuzz/seeds/redirect_uri/port_zero
new file mode 100644
index 0000000..2443238
--- /dev/null
+++ b/fuzz/seeds/redirect_uri/port_zero
@@ -0,0 +1,3 @@
+L
+http://127.0.0.1:0/cb
+http://127.0.0.1/cb
\ No newline at end of file
diff --git a/fuzz/seeds/totp_code/fullwidth b/fuzz/seeds/totp_code/fullwidth
new file mode 100644
index 0000000..3e1096e
--- /dev/null
+++ b/fuzz/seeds/totp_code/fullwidth
@@ -0,0 +1 @@
+123456
\ No newline at end of file
diff --git a/fuzz/seeds/totp_code/rfc_vector b/fuzz/seeds/totp_code/rfc_vector
new file mode 100644
index 0000000..f027019
--- /dev/null
+++ b/fuzz/seeds/totp_code/rfc_vector
@@ -0,0 +1 @@
+287082
\ No newline at end of file
diff --git a/fuzz/seeds/totp_code/short b/fuzz/seeds/totp_code/short
new file mode 100644
index 0000000..bd41cba
--- /dev/null
+++ b/fuzz/seeds/totp_code/short
@@ -0,0 +1 @@
+12345
\ No newline at end of file
diff --git a/fuzz/seeds/totp_code/zeros b/fuzz/seeds/totp_code/zeros
new file mode 100644
index 0000000..555f462
--- /dev/null
+++ b/fuzz/seeds/totp_code/zeros
@@ -0,0 +1 @@
+000000
\ No newline at end of file
diff --git a/fuzz/seeds/validators/bidi_label b/fuzz/seeds/validators/bidi_label
new file mode 100644
index 0000000..77daaf5
--- /dev/null
+++ b/fuzz/seeds/validators/bidi_label
@@ -0,0 +1 @@
+ My‮Phone
diff --git a/fuzz/seeds/validators/email b/fuzz/seeds/validators/email
new file mode 100644
index 0000000..c95385d
--- /dev/null
+++ b/fuzz/seeds/validators/email
@@ -0,0 +1 @@
+first.last+tag@mail.example.org
\ No newline at end of file
diff --git a/fuzz/seeds/validators/locale b/fuzz/seeds/validators/locale
new file mode 100644
index 0000000..2c4c454
--- /dev/null
+++ b/fuzz/seeds/validators/locale
@@ -0,0 +1 @@
+en
\ No newline at end of file
diff --git a/fuzz/seeds/validators/long_email b/fuzz/seeds/validators/long_email
new file mode 100644
index 0000000..a1f4701
--- /dev/null
+++ b/fuzz/seeds/validators/long_email
@@ -0,0 +1 @@
+aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@example.com
\ No newline at end of file
diff --git a/fuzz/seeds/validators/multibyte_password b/fuzz/seeds/validators/multibyte_password
new file mode 100644
index 0000000..13500e7
--- /dev/null
+++ b/fuzz/seeds/validators/multibyte_password
@@ -0,0 +1 @@
+ÉÉÉÉ1!
\ No newline at end of file
diff --git a/fuzz/seeds/validators/user_code b/fuzz/seeds/validators/user_code
new file mode 100644
index 0000000..5e0f8fe
--- /dev/null
+++ b/fuzz/seeds/validators/user_code
@@ -0,0 +1 @@
+ABCD-2345
\ No newline at end of file
diff --git a/fuzz/seeds/validators/username b/fuzz/seeds/validators/username
new file mode 100644
index 0000000..e606afd
--- /dev/null
+++ b/fuzz/seeds/validators/username
@@ -0,0 +1 @@
+alice_42
\ No newline at end of file
diff --git a/fuzz/seeds/webauthn/assertion b/fuzz/seeds/webauthn/assertion
new file mode 100644
index 0000000..af354bb
Binary files /dev/null and b/fuzz/seeds/webauthn/assertion differ
diff --git a/fuzz/seeds/webauthn/client_data b/fuzz/seeds/webauthn/client_data
new file mode 100644
index 0000000..d835e49
--- /dev/null
+++ b/fuzz/seeds/webauthn/client_data
@@ -0,0 +1 @@
+{"type":"webauthn.get","challenge":"Y2hhbGxlbmdl","origin":"https://auth.example.com"}
\ No newline at end of file
diff --git a/fuzz/seeds/webauthn/registration b/fuzz/seeds/webauthn/registration
new file mode 100644
index 0000000..adce251
Binary files /dev/null and b/fuzz/seeds/webauthn/registration differ
diff --git a/fuzz/seeds/webhook_url/credentials b/fuzz/seeds/webhook_url/credentials
new file mode 100644
index 0000000..73787f8
--- /dev/null
+++ b/fuzz/seeds/webhook_url/credentials
@@ -0,0 +1 @@
+http://user:pw@[::ffff:127.0.0.1]:8080/#f
\ No newline at end of file
diff --git a/fuzz/seeds/webhook_url/https b/fuzz/seeds/webhook_url/https
new file mode 100644
index 0000000..ab3e9d7
--- /dev/null
+++ b/fuzz/seeds/webhook_url/https
@@ -0,0 +1 @@
+https://hooks.example.com/auth?x=1
\ No newline at end of file
diff --git a/fuzz/seeds/webhook_url/nat64 b/fuzz/seeds/webhook_url/nat64
new file mode 100644
index 0000000..3ce2f49
--- /dev/null
+++ b/fuzz/seeds/webhook_url/nat64
@@ -0,0 +1 @@
+64:ff9b::a00:1
\ No newline at end of file
diff --git a/migrations/0001_extensions.sql b/migrations/0001_extensions.sql
index a3312c9..0cfc762 100644
--- a/migrations/0001_extensions.sql
+++ b/migrations/0001_extensions.sql
@@ -1,6 +1,13 @@
--- 0001_extensions.sql
--- Enables PostgreSQL extensions required by the schema:
--- - pgcrypto: generates UUIDs with gen_random_uuid()
--- - citext: provides case-insensitive text for identifiers like email
+-- Extensions and the trigger function shared by tables with an updated_at column.
+-- - pgcrypto: gen_random_uuid()
+-- - citext: case-insensitive text for e-mail addresses and sign-in identifiers
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE EXTENSION IF NOT EXISTS "citext";
+
+CREATE OR REPLACE FUNCTION set_updated_at()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = NOW();
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
diff --git a/migrations/0002_users.sql b/migrations/0002_users.sql
index 39a56df..e6aab55 100644
--- a/migrations/0002_users.sql
+++ b/migrations/0002_users.sql
@@ -1,15 +1,5 @@
--- 0002_users.sql
--- Creates the core users table used by the API for authentication and profile data.
--- Stores account identity, lifecycle status, locale preference, verification state,
--- and password hash metadata. Also defines the generic updated_at trigger reused later.
-CREATE OR REPLACE FUNCTION set_updated_at()
-RETURNS TRIGGER AS $$
-BEGIN
- NEW.updated_at = NOW();
- RETURN NEW;
-END;
-$$ LANGUAGE plpgsql;
-
+-- User accounts: identity, lifecycle status, locale, verification state and
+-- password hash.
CREATE TYPE user_status AS ENUM (
'active',
'inactive',
@@ -29,6 +19,7 @@ CREATE TABLE users (
username VARCHAR(50) NOT NULL,
email CITEXT NOT NULL,
password_hash TEXT NOT NULL,
+
CONSTRAINT users_username_key UNIQUE (username),
CONSTRAINT users_email_key UNIQUE (email),
CONSTRAINT users_username_format CHECK (username ~ '^[a-zA-Z0-9_]{3,50}$'),
@@ -45,6 +36,5 @@ CREATE TRIGGER users_set_updated_at
BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-CREATE INDEX idx_users_status ON users (status);
-CREATE INDEX idx_users_last_login ON users (last_login_at);
+-- Only locked accounts are looked up by lockout expiry.
CREATE INDEX idx_users_locked_until ON users (locked_until) WHERE locked_until IS NOT NULL;
diff --git a/migrations/0003_rbac.sql b/migrations/0003_rbac.sql
new file mode 100644
index 0000000..3dfa8e6
--- /dev/null
+++ b/migrations/0003_rbac.sql
@@ -0,0 +1,55 @@
+-- Role-based access control: roles, the permission catalog, the permissions
+-- each role grants, the roles each user holds, and the default role granted at
+-- registration.
+CREATE TABLE roles (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ is_default BOOLEAN NOT NULL DEFAULT FALSE,
+ name VARCHAR(50) NOT NULL,
+ description TEXT,
+
+ CONSTRAINT roles_name_key UNIQUE (name)
+);
+
+-- At most one default role.
+CREATE UNIQUE INDEX idx_roles_default ON roles (is_default) WHERE is_default = TRUE;
+
+CREATE TABLE permissions (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ resource VARCHAR(50) NOT NULL,
+ action VARCHAR(50) NOT NULL,
+ -- Always consistent with resource + action, never set by hand.
+ name TEXT GENERATED ALWAYS AS (resource || ':' || action) STORED,
+ description TEXT,
+
+ CONSTRAINT permissions_resource_action_key UNIQUE (resource, action)
+);
+
+CREATE UNIQUE INDEX idx_permissions_name ON permissions (name);
+CREATE INDEX idx_permissions_resource ON permissions (resource);
+
+CREATE TABLE role_permissions (
+ role_id UUID NOT NULL REFERENCES roles (id) ON DELETE CASCADE,
+ permission_id UUID NOT NULL REFERENCES permissions (id) ON DELETE CASCADE,
+
+ PRIMARY KEY (role_id, permission_id)
+);
+
+CREATE INDEX idx_role_permissions_permission ON role_permissions (permission_id);
+
+CREATE TABLE user_roles (
+ user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ role_id UUID NOT NULL REFERENCES roles (id) ON DELETE CASCADE,
+ granted_by UUID REFERENCES users (id) ON DELETE SET NULL,
+ granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
+ PRIMARY KEY (user_id, role_id)
+);
+
+CREATE INDEX idx_user_roles_role ON user_roles (role_id);
+-- Deleting a user clears the grants it made.
+CREATE INDEX idx_user_roles_granted_by ON user_roles (granted_by) WHERE granted_by IS NOT NULL;
+
+INSERT INTO roles (name, description, is_default) VALUES
+ ('user', 'Default role assigned on registration', TRUE);
diff --git a/migrations/0003_roles.sql b/migrations/0003_roles.sql
deleted file mode 100644
index 0700068..0000000
--- a/migrations/0003_roles.sql
+++ /dev/null
@@ -1,15 +0,0 @@
--- 0003_roles.sql
--- Creates application roles used by the RBAC layer.
--- A role groups permissions under a stable name and can optionally be marked
--- as the default role automatically granted to newly registered users.
-CREATE TABLE roles (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
- is_default BOOLEAN NOT NULL DEFAULT FALSE,
- name VARCHAR(50) NOT NULL,
- description TEXT,
-
- CONSTRAINT roles_name_key UNIQUE (name)
-);
-
-CREATE UNIQUE INDEX idx_roles_default ON roles (is_default) WHERE is_default = TRUE;
diff --git a/migrations/0004_permissions.sql b/migrations/0004_permissions.sql
deleted file mode 100644
index ad4833b..0000000
--- a/migrations/0004_permissions.sql
+++ /dev/null
@@ -1,18 +0,0 @@
--- 0004_permissions.sql
--- Creates the permissions catalog for the RBAC system.
--- Each permission is normalized as resource + action and exposes a generated
--- resource:action name that is convenient for authorization checks and tokens.
-CREATE TABLE permissions (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
- resource VARCHAR(50) NOT NULL,
- action VARCHAR(50) NOT NULL,
- -- Always consistent with resource + action, never manually set
- name TEXT GENERATED ALWAYS AS (resource || ':' || action) STORED,
- description TEXT,
-
- CONSTRAINT permissions_resource_action_key UNIQUE (resource, action)
-);
-
-CREATE UNIQUE INDEX idx_permissions_name ON permissions (name);
-CREATE INDEX idx_permissions_resource ON permissions (resource);
diff --git a/migrations/0004_registered_clients.sql b/migrations/0004_registered_clients.sql
new file mode 100644
index 0000000..6fd6cf2
--- /dev/null
+++ b/migrations/0004_registered_clients.sql
@@ -0,0 +1,49 @@
+-- Client applications allowed to use the native sign-in flows (device
+-- authorization, RFC 8628, and the authorization code flow with PKCE).
+--
+-- registered_clients: the primary client is the application this instance
+-- owns; the others are third-party applications. A request naming an unknown
+-- client is refused.
+-- - scopes: permissions a token issued for the client may carry, intersected
+-- with the user's own (empty = unrestricted);
+-- - redirect_uris: exact redirect URIs accepted by the authorization code flow;
+-- - allows_loopback_redirect: a loopback redirect on any port is accepted for
+-- a registered path (RFC 8252 section 7.3);
+-- - default_max_sessions: concurrent device sessions per user for a
+-- non-primary client without a user_client_quotas row.
+-- user_client_quotas: per-user override of a client's session limit.
+CREATE TABLE registered_clients (
+ client_id VARCHAR(100) PRIMARY KEY,
+ display_name VARCHAR(200) NOT NULL,
+ is_primary BOOLEAN NOT NULL DEFAULT FALSE,
+ scopes TEXT[] NOT NULL DEFAULT '{}',
+ redirect_uris TEXT[] NOT NULL DEFAULT '{}',
+ allows_loopback_redirect BOOLEAN NOT NULL DEFAULT FALSE,
+ default_max_sessions SMALLINT NOT NULL DEFAULT 5,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
+ CONSTRAINT registered_clients_client_id_format CHECK (client_id ~ '^[A-Za-z0-9._-]{1,100}$'),
+ CONSTRAINT registered_clients_default_max_sessions_positive CHECK (default_max_sessions > 0)
+);
+
+-- At most one primary client.
+CREATE UNIQUE INDEX idx_registered_clients_primary
+ ON registered_clients (is_primary) WHERE is_primary = TRUE;
+
+CREATE TABLE user_client_quotas (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ client_id VARCHAR(100) NOT NULL REFERENCES registered_clients (client_id) ON DELETE CASCADE,
+ max_sessions SMALLINT NOT NULL DEFAULT 1,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
+ CONSTRAINT user_client_quotas_unique UNIQUE (user_id, client_id),
+ CONSTRAINT user_client_quotas_max_sessions_positive CHECK (max_sessions > 0)
+);
+
+CREATE INDEX idx_user_client_quotas_user ON user_client_quotas (user_id);
+
+CREATE TRIGGER user_client_quotas_set_updated_at
+ BEFORE UPDATE ON user_client_quotas
+ FOR EACH ROW EXECUTE FUNCTION set_updated_at();
diff --git a/migrations/0005_role_permissions.sql b/migrations/0005_role_permissions.sql
deleted file mode 100644
index de47e73..0000000
--- a/migrations/0005_role_permissions.sql
+++ /dev/null
@@ -1,12 +0,0 @@
--- 0005_role_permissions.sql
--- Joins roles to permissions.
--- This pivot table expresses which permissions are granted by each role and is
--- the main link used to resolve effective authorization for a user.
-CREATE TABLE role_permissions (
- role_id UUID NOT NULL REFERENCES roles (id) ON DELETE CASCADE,
- permission_id UUID NOT NULL REFERENCES permissions (id) ON DELETE CASCADE,
-
- PRIMARY KEY (role_id, permission_id)
-);
-
-CREATE INDEX idx_role_permissions_permission ON role_permissions (permission_id);
diff --git a/migrations/0007_sessions.sql b/migrations/0005_sessions.sql
similarity index 60%
rename from migrations/0007_sessions.sql
rename to migrations/0005_sessions.sql
index 06c9a1b..30f9aa4 100644
--- a/migrations/0007_sessions.sql
+++ b/migrations/0005_sessions.sql
@@ -1,7 +1,6 @@
--- 0007_sessions.sql
--- Creates persistent user sessions used for login state, refresh-token rotation,
--- per-device visibility, revocation, and compromise detection.
--- token_hash stores the SHA-256 of the actual token; plaintext tokens are never persisted.
+-- Sessions: one row per refresh token. A refresh rotates the session into a new
+-- row of the same family; replaying a rotated token revokes the whole family.
+-- token_hash is the SHA-256 of the refresh token, never the token itself.
CREATE TYPE session_type AS ENUM ('web', 'device');
CREATE TYPE session_compromise_reason AS ENUM (
@@ -14,21 +13,27 @@ CREATE TABLE sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
session_family_id UUID NOT NULL DEFAULT gen_random_uuid(),
+ -- Start of the sign-in the family comes from: the absolute session lifetime
+ -- (JWT_MAX_SESSION_LIFETIME_SECS) counts from it, not from the latest rotation.
+ family_created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
revoked_at TIMESTAMPTZ,
rotated_at TIMESTAMPTZ,
compromised_at TIMESTAMPTZ,
+ compromise_reason session_compromise_reason,
replaced_by_session_id UUID REFERENCES sessions (id) ON DELETE SET NULL,
- ip_address INET,
- device_name VARCHAR(100),
- remember_me BOOLEAN NOT NULL DEFAULT false,
token_hash BYTEA NOT NULL,
- user_agent TEXT,
session_type session_type NOT NULL DEFAULT 'web',
client_id VARCHAR(100),
- compromise_reason session_compromise_reason,
+ -- Permissions consented for the client the session was issued to; NULL for
+ -- an unrestricted session.
+ scopes TEXT[],
+ ip_address INET,
+ user_agent TEXT,
+ device_name VARCHAR(100),
+ remember_me BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT sessions_token_hash_key UNIQUE (token_hash),
CONSTRAINT sessions_expires_after_creation CHECK (expires_at > created_at),
@@ -49,15 +54,26 @@ CREATE TABLE sessions (
replaced_by_session_id IS NULL OR replaced_by_session_id <> id
),
CONSTRAINT sessions_token_hash_length CHECK (octet_length(token_hash) = 32)
+)
+-- The table changes fast (a row per sign-in and per refresh): vacuum and
+-- analyze at 2 % and 1 % of changed rows instead of the default 20 % and 10 %,
+-- so it does not bloat and the planner's statistics stay current.
+WITH (
+ autovacuum_vacuum_scale_factor = 0.02,
+ autovacuum_analyze_scale_factor = 0.01,
+ autovacuum_vacuum_threshold = 1000,
+ autovacuum_analyze_threshold = 500
);
+-- Deleting a user reads its sessions through this index.
+CREATE INDEX idx_sessions_user ON sessions (user_id);
CREATE INDEX idx_sessions_user_active ON sessions (user_id, last_used_at DESC) WHERE revoked_at IS NULL;
-CREATE INDEX idx_sessions_expires_active ON sessions (expires_at) WHERE revoked_at IS NULL;
CREATE INDEX idx_sessions_family_created ON sessions (session_family_id, created_at DESC);
-CREATE INDEX idx_sessions_family_active
- ON sessions (session_family_id, last_used_at DESC) WHERE revoked_at IS NULL;
CREATE UNIQUE INDEX idx_sessions_replaced_by
ON sessions (replaced_by_session_id) WHERE replaced_by_session_id IS NOT NULL;
+-- Retention deletes by expiry and by revocation, whatever the row's state.
+CREATE INDEX idx_sessions_expires_at ON sessions (expires_at);
+CREATE INDEX idx_sessions_revoked_at ON sessions (revoked_at) WHERE revoked_at IS NOT NULL;
CREATE OR REPLACE FUNCTION revoke_session_family(
p_session_id UUID,
@@ -93,27 +109,39 @@ BEGIN
END;
$$ LANGUAGE plpgsql;
-ALTER TABLE sessions SET (
- autovacuum_vacuum_scale_factor = 0.02,
- autovacuum_analyze_scale_factor = 0.01,
- autovacuum_vacuum_threshold = 1000,
- autovacuum_analyze_threshold = 500
-);
-
+-- Retention, called by the application's cleanup task. Each call deletes at
+-- most batch_size rows (NULL: all), so a backlog is swept in short
+-- transactions. Rows are selected with `ctid = ANY (ARRAY(SELECT ... LIMIT n))`,
+-- a TID scan over exactly the rows found: `ctid IN (...)` hashed the whole
+-- candidate set first (950 ms against 20 ms for a 5 000-row batch at 1 million
+-- accounts). Expired sessions go first, then revoked ones, each through its
+-- own index and within what is left of the batch.
CREATE OR REPLACE FUNCTION cleanup_expired_sessions(
- grace_interval INTERVAL DEFAULT '7 days'
+ grace_interval INTERVAL DEFAULT '7 days',
+ batch_size INTEGER DEFAULT NULL
)
RETURNS INTEGER AS $$
DECLARE
- deleted INTEGER;
+ expired INTEGER;
+ revoked INTEGER;
BEGIN
- WITH deleted_rows AS (
- DELETE FROM sessions
+ DELETE FROM sessions WHERE ctid = ANY (ARRAY(
+ SELECT ctid FROM sessions
WHERE expires_at < NOW() - grace_interval
- OR (revoked_at IS NOT NULL AND revoked_at < NOW() - grace_interval)
- RETURNING id
- )
- SELECT count(*) INTO deleted FROM deleted_rows;
- RETURN deleted;
+ LIMIT batch_size
+ ));
+ GET DIAGNOSTICS expired = ROW_COUNT;
+ IF batch_size IS NOT NULL AND expired >= batch_size THEN
+ RETURN expired;
+ END IF;
+
+ -- NULL batch_size stays NULL: no limit.
+ DELETE FROM sessions WHERE ctid = ANY (ARRAY(
+ SELECT ctid FROM sessions
+ WHERE revoked_at IS NOT NULL AND revoked_at < NOW() - grace_interval
+ LIMIT batch_size - expired
+ ));
+ GET DIAGNOSTICS revoked = ROW_COUNT;
+ RETURN expired + revoked;
END;
$$ LANGUAGE plpgsql;
diff --git a/migrations/0006_authorization_codes.sql b/migrations/0006_authorization_codes.sql
new file mode 100644
index 0000000..ce5990e
--- /dev/null
+++ b/migrations/0006_authorization_codes.sql
@@ -0,0 +1,53 @@
+-- Authorization code flow with PKCE (RFC 6749 section 4.1, RFC 7636, RFC 8252).
+--
+-- A code is a single-use bearer of a user's consent, stored as a SHA-256 hash
+-- like every other token: a database read must not yield something redeemable.
+CREATE TABLE authorization_codes (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ code_hash BYTEA NOT NULL,
+ user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ client_id VARCHAR(100) NOT NULL REFERENCES registered_clients (client_id) ON DELETE CASCADE,
+ -- Compared exactly at redemption (RFC 6749 section 4.1.3).
+ redirect_uri TEXT NOT NULL,
+ code_challenge TEXT NOT NULL,
+ code_challenge_method VARCHAR(10) NOT NULL DEFAULT 'S256',
+ -- Consented scopes, frozen at approval; NULL for an unrestricted client.
+ scopes TEXT[],
+ expires_at TIMESTAMPTZ NOT NULL,
+ consumed_at TIMESTAMPTZ,
+ -- Session issued from the code, revoked if the code is ever replayed.
+ session_id UUID REFERENCES sessions (id) ON DELETE SET NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
+ CONSTRAINT authorization_codes_code_hash_key UNIQUE (code_hash),
+ CONSTRAINT authorization_codes_code_hash_length CHECK (octet_length(code_hash) = 32),
+ CONSTRAINT authorization_codes_method_supported CHECK (code_challenge_method = 'S256'),
+ CONSTRAINT authorization_codes_challenge_format CHECK (code_challenge ~ '^[A-Za-z0-9_-]{43}$')
+);
+
+CREATE INDEX idx_authorization_codes_expires ON authorization_codes (expires_at);
+CREATE INDEX idx_authorization_codes_user ON authorization_codes (user_id);
+-- Deleting a session clears the reference through this index.
+CREATE INDEX idx_authorization_codes_session
+ ON authorization_codes (session_id) WHERE session_id IS NOT NULL;
+
+-- Consumed codes are kept past expiry for a grace period, so a replay still
+-- finds the row it replays and revokes the session it produced. Bounded like
+-- cleanup_expired_sessions.
+CREATE OR REPLACE FUNCTION cleanup_expired_authorization_codes(
+ grace_interval INTERVAL DEFAULT '1 hour',
+ batch_size INTEGER DEFAULT NULL
+)
+RETURNS INTEGER AS $$
+DECLARE
+ deleted INTEGER;
+BEGIN
+ DELETE FROM authorization_codes WHERE ctid = ANY (ARRAY(
+ SELECT ctid FROM authorization_codes
+ WHERE expires_at < NOW() - grace_interval
+ LIMIT batch_size
+ ));
+ GET DIAGNOSTICS deleted = ROW_COUNT;
+ RETURN deleted;
+END;
+$$ LANGUAGE plpgsql;
diff --git a/migrations/0006_user_roles.sql b/migrations/0006_user_roles.sql
deleted file mode 100644
index 8f4d51a..0000000
--- a/migrations/0006_user_roles.sql
+++ /dev/null
@@ -1,14 +0,0 @@
--- 0006_user_roles.sql
--- Joins users to roles.
--- Tracks which role is assigned to which user, when it was granted, and
--- optionally which administrator or actor granted it.
-CREATE TABLE user_roles (
- user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
- role_id UUID NOT NULL REFERENCES roles (id) ON DELETE CASCADE,
- granted_by UUID REFERENCES users (id) ON DELETE SET NULL,
- granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-
- PRIMARY KEY (user_id, role_id)
-);
-
-CREATE INDEX idx_user_roles_role ON user_roles (role_id);
diff --git a/migrations/0007_two_factor.sql b/migrations/0007_two_factor.sql
new file mode 100644
index 0000000..858451d
--- /dev/null
+++ b/migrations/0007_two_factor.sql
@@ -0,0 +1,153 @@
+-- Second factors: the methods a user enrolled (TOTP, e-mail codes), the e-mail
+-- codes sent during a challenge, recovery codes, and the durable TOTP replay
+-- guard. Retention functions are bounded like cleanup_expired_sessions.
+CREATE TYPE two_factor_type AS ENUM ('totp', 'email');
+
+CREATE TABLE two_factor_methods (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ last_used_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ method_type two_factor_type NOT NULL,
+ is_primary BOOLEAN NOT NULL DEFAULT FALSE,
+ is_verified BOOLEAN NOT NULL DEFAULT FALSE,
+ -- Encrypted by the application before insert.
+ totp_secret TEXT,
+
+ CONSTRAINT two_factor_primary_requires_verification CHECK (NOT is_primary OR is_verified),
+ CONSTRAINT two_factor_method_payload CHECK (
+ (method_type = 'totp' AND totp_secret IS NOT NULL)
+ OR (method_type = 'email' AND totp_secret IS NULL)
+ )
+);
+
+CREATE TRIGGER two_factor_methods_set_updated_at
+ BEFORE UPDATE ON two_factor_methods
+ FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+
+-- (user_id) alone is served by the prefix of idx_2fa_user_created.
+CREATE INDEX idx_2fa_user_created ON two_factor_methods (user_id, created_at DESC);
+CREATE INDEX idx_2fa_user_verified ON two_factor_methods (user_id) WHERE is_verified = TRUE;
+CREATE UNIQUE INDEX idx_2fa_user_totp ON two_factor_methods (user_id) WHERE method_type = 'totp';
+CREATE UNIQUE INDEX idx_2fa_user_email ON two_factor_methods (user_id) WHERE method_type = 'email';
+CREATE UNIQUE INDEX idx_2fa_user_primary ON two_factor_methods (user_id) WHERE is_primary = TRUE;
+
+-- Short-lived codes sent by e-mail during a challenge.
+CREATE TABLE email_2fa_codes (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ code_hash BYTEA NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ expires_at TIMESTAMPTZ NOT NULL,
+ used_at TIMESTAMPTZ
+);
+
+CREATE INDEX idx_email_2fa_codes_user ON email_2fa_codes (user_id, expires_at DESC);
+CREATE INDEX idx_email_2fa_codes_expires_at ON email_2fa_codes (expires_at);
+
+CREATE OR REPLACE FUNCTION cleanup_expired_email_2fa_codes(
+ grace_interval INTERVAL DEFAULT '1 day',
+ batch_size INTEGER DEFAULT NULL
+)
+RETURNS INTEGER AS $$
+DECLARE
+ deleted INTEGER;
+BEGIN
+ DELETE FROM email_2fa_codes WHERE ctid = ANY (ARRAY(
+ SELECT ctid FROM email_2fa_codes
+ WHERE expires_at < NOW() - grace_interval
+ LIMIT batch_size
+ ));
+ GET DIAGNOSTICS deleted = ROW_COUNT;
+ RETURN deleted;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Hashed recovery codes, each consumed at most once.
+CREATE TABLE recovery_codes (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ expires_at TIMESTAMPTZ,
+ used_at TIMESTAMPTZ,
+ code_position SMALLINT NOT NULL,
+ code_hash BYTEA NOT NULL,
+
+ CONSTRAINT recovery_codes_code_hash_key UNIQUE (code_hash),
+ CONSTRAINT recovery_codes_user_position_key UNIQUE (user_id, code_position),
+ CONSTRAINT recovery_codes_code_hash_length CHECK (octet_length(code_hash) = 32),
+ CONSTRAINT recovery_codes_position_range CHECK (code_position BETWEEN 1 AND 20),
+ CONSTRAINT recovery_codes_expiration_consistency CHECK (expires_at IS NULL OR expires_at > created_at),
+ CONSTRAINT recovery_codes_used_after_creation CHECK (used_at IS NULL OR used_at >= created_at)
+)
+WITH (
+ autovacuum_vacuum_scale_factor = 0.05,
+ autovacuum_analyze_scale_factor = 0.02,
+ autovacuum_vacuum_threshold = 1000,
+ autovacuum_analyze_threshold = 500
+);
+
+CREATE INDEX idx_recovery_codes_user ON recovery_codes (user_id);
+CREATE INDEX idx_recovery_codes_user_active
+ ON recovery_codes (user_id, code_position) WHERE used_at IS NULL;
+CREATE INDEX idx_recovery_codes_expires_at
+ ON recovery_codes (expires_at) WHERE expires_at IS NOT NULL;
+
+CREATE OR REPLACE FUNCTION cleanup_expired_recovery_codes(
+ grace_interval INTERVAL DEFAULT '7 days',
+ batch_size INTEGER DEFAULT NULL
+)
+RETURNS INTEGER AS $$
+DECLARE
+ deleted INTEGER;
+BEGIN
+ DELETE FROM recovery_codes WHERE ctid = ANY (ARRAY(
+ SELECT ctid FROM recovery_codes
+ WHERE expires_at IS NOT NULL AND expires_at < NOW() - grace_interval
+ LIMIT batch_size
+ ));
+ GET DIAGNOSTICS deleted = ROW_COUNT;
+ RETURN deleted;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Durable TOTP replay guard: the SHA-256 of every accepted TOTP code, so a code
+-- cannot be used twice within its validity window even when Redis (the fast
+-- path) is unavailable. Codes repeat over time (6 digits, 30-second steps), so
+-- rows live only for the window (current step +/- TOTP_SKEW): the repository
+-- purges the user's expired rows on every attempt, and cleanup_used_totp_codes
+-- sweeps the rest.
+CREATE TABLE used_totp_codes (
+ user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ code_hash BYTEA NOT NULL,
+ used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
+ PRIMARY KEY (user_id, code_hash),
+ CONSTRAINT used_totp_codes_hash_length CHECK (octet_length(code_hash) = 32)
+)
+-- Constant insert and delete churn: vacuum early.
+WITH (
+ autovacuum_vacuum_scale_factor = 0.02,
+ autovacuum_vacuum_threshold = 200
+);
+
+CREATE INDEX idx_used_totp_codes_used_at ON used_totp_codes (used_at);
+
+CREATE OR REPLACE FUNCTION cleanup_used_totp_codes(
+ retention INTERVAL DEFAULT '90 seconds',
+ batch_size INTEGER DEFAULT NULL
+)
+RETURNS INTEGER AS $$
+DECLARE
+ deleted INTEGER;
+BEGIN
+ DELETE FROM used_totp_codes WHERE ctid = ANY (ARRAY(
+ SELECT ctid FROM used_totp_codes
+ WHERE used_at < NOW() - retention
+ LIMIT batch_size
+ ));
+ GET DIAGNOSTICS deleted = ROW_COUNT;
+ RETURN deleted;
+END;
+$$ LANGUAGE plpgsql;
diff --git a/migrations/0008_account_tokens.sql b/migrations/0008_account_tokens.sql
new file mode 100644
index 0000000..6c032b2
--- /dev/null
+++ b/migrations/0008_account_tokens.sql
@@ -0,0 +1,97 @@
+-- Single-use tokens sent by e-mail: address verification (at registration and
+-- for an e-mail change, bound to the exact address verified) and password
+-- reset. Stored as SHA-256 hashes; at most one unused token per user.
+-- Retention functions are bounded like cleanup_expired_sessions.
+CREATE TABLE email_verification_tokens (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ token_hash BYTEA NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ expires_at TIMESTAMPTZ NOT NULL,
+ used_at TIMESTAMPTZ,
+ request_ip INET,
+ request_user_agent TEXT,
+ target_email CITEXT NOT NULL,
+
+ CONSTRAINT email_verification_tokens_token_hash_key UNIQUE (token_hash),
+ CONSTRAINT email_verification_tokens_token_hash_length CHECK (octet_length(token_hash) = 32),
+ CONSTRAINT email_verification_tokens_expires_after_creation CHECK (expires_at > created_at),
+ CONSTRAINT email_verification_tokens_used_after_creation CHECK (used_at IS NULL OR used_at >= created_at),
+ CONSTRAINT email_verification_tokens_target_email_format CHECK (
+ target_email ~* '^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$'
+ )
+)
+WITH (
+ autovacuum_vacuum_scale_factor = 0.05,
+ autovacuum_analyze_scale_factor = 0.02,
+ autovacuum_vacuum_threshold = 1000,
+ autovacuum_analyze_threshold = 500
+);
+
+CREATE INDEX idx_email_verification_tokens_user ON email_verification_tokens (user_id);
+CREATE UNIQUE INDEX idx_email_verification_tokens_user_active
+ ON email_verification_tokens (user_id) WHERE used_at IS NULL;
+CREATE INDEX idx_email_verification_tokens_expires_at ON email_verification_tokens (expires_at);
+
+CREATE OR REPLACE FUNCTION cleanup_expired_email_verification_tokens(
+ grace_interval INTERVAL DEFAULT '1 day',
+ batch_size INTEGER DEFAULT NULL
+)
+RETURNS INTEGER AS $$
+DECLARE
+ deleted INTEGER;
+BEGIN
+ DELETE FROM email_verification_tokens WHERE ctid = ANY (ARRAY(
+ SELECT ctid FROM email_verification_tokens
+ WHERE expires_at < NOW() - grace_interval
+ LIMIT batch_size
+ ));
+ GET DIAGNOSTICS deleted = ROW_COUNT;
+ RETURN deleted;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TABLE password_reset_tokens (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ token_hash BYTEA NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ expires_at TIMESTAMPTZ NOT NULL,
+ used_at TIMESTAMPTZ,
+ request_ip INET,
+ request_user_agent TEXT,
+
+ CONSTRAINT password_reset_tokens_token_hash_key UNIQUE (token_hash),
+ CONSTRAINT password_reset_tokens_token_hash_length CHECK (octet_length(token_hash) = 32),
+ CONSTRAINT password_reset_tokens_expires_after_creation CHECK (expires_at > created_at),
+ CONSTRAINT password_reset_tokens_used_after_creation CHECK (used_at IS NULL OR used_at >= created_at)
+)
+WITH (
+ autovacuum_vacuum_scale_factor = 0.05,
+ autovacuum_analyze_scale_factor = 0.02,
+ autovacuum_vacuum_threshold = 1000,
+ autovacuum_analyze_threshold = 500
+);
+
+CREATE INDEX idx_password_reset_tokens_user ON password_reset_tokens (user_id);
+CREATE UNIQUE INDEX idx_password_reset_tokens_user_active
+ ON password_reset_tokens (user_id) WHERE used_at IS NULL;
+CREATE INDEX idx_password_reset_tokens_expires_at ON password_reset_tokens (expires_at);
+
+CREATE OR REPLACE FUNCTION cleanup_expired_password_reset_tokens(
+ grace_interval INTERVAL DEFAULT '1 day',
+ batch_size INTEGER DEFAULT NULL
+)
+RETURNS INTEGER AS $$
+DECLARE
+ deleted INTEGER;
+BEGIN
+ DELETE FROM password_reset_tokens WHERE ctid = ANY (ARRAY(
+ SELECT ctid FROM password_reset_tokens
+ WHERE expires_at < NOW() - grace_interval
+ LIMIT batch_size
+ ));
+ GET DIAGNOSTICS deleted = ROW_COUNT;
+ RETURN deleted;
+END;
+$$ LANGUAGE plpgsql;
diff --git a/migrations/0008_two_factor_methods.sql b/migrations/0008_two_factor_methods.sql
deleted file mode 100644
index dd4cd14..0000000
--- a/migrations/0008_two_factor_methods.sql
+++ /dev/null
@@ -1,35 +0,0 @@
--- 0008_two_factor_methods.sql
--- Creates the second-factor registry for each user account.
--- Supports TOTP and email-based OTP, with constraints ensuring only the
--- fields relevant to each method are populated.
-CREATE TYPE two_factor_type AS ENUM ('totp', 'email');
-
-CREATE TABLE two_factor_methods (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
- last_used_at TIMESTAMPTZ,
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
- updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
- method_type two_factor_type NOT NULL,
- is_primary BOOLEAN NOT NULL DEFAULT FALSE,
- is_verified BOOLEAN NOT NULL DEFAULT FALSE,
- -- Encrypted at the application layer before insert
- totp_secret TEXT,
-
- CONSTRAINT two_factor_primary_requires_verification CHECK (NOT is_primary OR is_verified),
- CONSTRAINT two_factor_method_payload CHECK (
- (method_type = 'totp' AND totp_secret IS NOT NULL)
- OR (method_type = 'email' AND totp_secret IS NULL)
- )
-);
-
-CREATE TRIGGER two_factor_methods_set_updated_at
- BEFORE UPDATE ON two_factor_methods
- FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-
-CREATE INDEX idx_2fa_user ON two_factor_methods (user_id);
-CREATE INDEX idx_2fa_user_verified ON two_factor_methods (user_id) WHERE is_verified = TRUE;
-CREATE UNIQUE INDEX idx_2fa_user_totp ON two_factor_methods (user_id) WHERE method_type = 'totp';
-CREATE UNIQUE INDEX idx_2fa_user_email ON two_factor_methods (user_id) WHERE method_type = 'email';
-CREATE UNIQUE INDEX idx_2fa_user_primary ON two_factor_methods (user_id) WHERE is_primary = TRUE;
-CREATE INDEX idx_2fa_user_created ON two_factor_methods (user_id, created_at DESC);
diff --git a/migrations/0009_email_2fa_codes.sql b/migrations/0009_email_2fa_codes.sql
deleted file mode 100644
index 88889b7..0000000
--- a/migrations/0009_email_2fa_codes.sql
+++ /dev/null
@@ -1,30 +0,0 @@
--- 0009_email_2fa_codes.sql
--- Short-lived OTP codes sent by email during the Email 2FA challenge.
--- One active code per user at a time; expired or used codes are kept briefly for auditing.
-CREATE TABLE email_2fa_codes (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
- code_hash BYTEA NOT NULL,
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
- expires_at TIMESTAMPTZ NOT NULL,
- used_at TIMESTAMPTZ
-);
-
-CREATE INDEX idx_email_2fa_codes_user ON email_2fa_codes (user_id, expires_at DESC);
-
-CREATE OR REPLACE FUNCTION cleanup_expired_email_2fa_codes(
- grace_interval INTERVAL DEFAULT '1 day'
-)
-RETURNS INTEGER AS $$
-DECLARE
- deleted INTEGER;
-BEGIN
- WITH deleted_rows AS (
- DELETE FROM email_2fa_codes
- WHERE expires_at < NOW() - grace_interval
- RETURNING id
- )
- SELECT count(*) INTO deleted FROM deleted_rows;
- RETURN deleted;
-END;
-$$ LANGUAGE plpgsql;
diff --git a/migrations/0013_login_attempts.sql b/migrations/0009_login_attempts.sql
similarity index 62%
rename from migrations/0013_login_attempts.sql
rename to migrations/0009_login_attempts.sql
index ebe0c7a..0c4b054 100644
--- a/migrations/0013_login_attempts.sql
+++ b/migrations/0009_login_attempts.sql
@@ -1,7 +1,5 @@
--- 0013_login_attempts.sql
--- Creates the operational login-attempt ledger.
--- Stores recent successful and failed authentication attempts for brute-force
--- detection, risk scoring, lockout logic, and support/security investigations.
+-- Sign-in attempts, successful and failed: brute-force counters, lockout, and
+-- the security history of an account. Kept 90 days by default.
CREATE TYPE login_failure_reason AS ENUM (
'unknown_identifier',
'invalid_password',
@@ -31,40 +29,45 @@ CREATE TABLE login_attempts (
(was_successful AND failure_reason IS NULL)
OR (NOT was_successful AND failure_reason IS NOT NULL)
)
+)
+-- An insert per sign-in attempt and a purge by age: vacuum, freeze and analyze
+-- at 2 %, 2 % and 1 % of changed rows instead of the defaults.
+WITH (
+ autovacuum_vacuum_scale_factor = 0.02,
+ autovacuum_vacuum_insert_scale_factor = 0.02,
+ autovacuum_analyze_scale_factor = 0.01,
+ autovacuum_vacuum_threshold = 1000,
+ autovacuum_analyze_threshold = 500
);
-CREATE INDEX idx_login_attempts_identifier_time
- ON login_attempts (attempted_identifier, attempted_at DESC);
CREATE INDEX idx_login_attempts_user_time
ON login_attempts (user_id, attempted_at DESC) WHERE user_id IS NOT NULL;
+-- Brute-force counters only count failures.
CREATE INDEX idx_login_attempts_failed_identifier_time
ON login_attempts (attempted_identifier, attempted_at DESC) WHERE was_successful = FALSE;
CREATE INDEX idx_login_attempts_failed_ip_time
ON login_attempts (request_ip, attempted_at DESC)
WHERE was_successful = FALSE AND request_ip IS NOT NULL;
-CREATE INDEX idx_login_attempts_attempted_at ON login_attempts (attempted_at);
+-- Retention deletes by age: a BRIN index serves it at a fraction of a B-tree.
CREATE INDEX idx_login_attempts_time_brin ON login_attempts USING BRIN (attempted_at);
-ALTER TABLE login_attempts SET (
- autovacuum_vacuum_scale_factor = 0.05,
- autovacuum_analyze_scale_factor = 0.02,
- autovacuum_vacuum_threshold = 1000,
- autovacuum_analyze_threshold = 500
-);
-
+-- Bounded like cleanup_expired_sessions. The BRIN index is selective only while
+-- rows sit on disk in time order, as the API writes them: see "Bulk Imports" in
+-- the operations runbook for data loaded in another order.
CREATE OR REPLACE FUNCTION cleanup_old_login_attempts(
- retention_interval INTERVAL DEFAULT '90 days'
+ retention_interval INTERVAL DEFAULT '90 days',
+ batch_size INTEGER DEFAULT NULL
)
RETURNS INTEGER AS $$
DECLARE
deleted INTEGER;
BEGIN
- WITH deleted_rows AS (
- DELETE FROM login_attempts
+ DELETE FROM login_attempts WHERE ctid = ANY (ARRAY(
+ SELECT ctid FROM login_attempts
WHERE attempted_at < NOW() - retention_interval
- RETURNING id
- )
- SELECT count(*) INTO deleted FROM deleted_rows;
+ LIMIT batch_size
+ ));
+ GET DIAGNOSTICS deleted = ROW_COUNT;
RETURN deleted;
END;
$$ LANGUAGE plpgsql;
diff --git a/migrations/0014_audit_log.sql b/migrations/0010_audit_log.sql
similarity index 68%
rename from migrations/0014_audit_log.sql
rename to migrations/0010_audit_log.sql
index b8c1815..8bb3f24 100644
--- a/migrations/0014_audit_log.sql
+++ b/migrations/0010_audit_log.sql
@@ -1,8 +1,8 @@
--- 0014_audit_log.sql
--- Creates the append-only audit log for security-relevant events.
--- This table is partitioned by month, optimized for long-term retention and
--- forensic analysis, and records actions like logins, role changes, 2FA events,
--- and session compromise handling.
+-- Append-only audit log of security events, partitioned by month.
+--
+-- Partitions are created and dropped by rotate_audit_log_partitions(), which the
+-- application calls at startup and from its cleanup task with the configured
+-- retention; the application is the only scheduler.
CREATE TYPE audit_action AS ENUM (
'login',
'login_failed',
@@ -31,10 +31,10 @@ CREATE TYPE audit_action AS ENUM (
'reauthenticated',
'username_changed',
'recovery_code_used',
- 'email_changed'
+ 'email_changed',
+ 'encryption_key_rotated'
);
-
CREATE TABLE audit_log (
id UUID NOT NULL DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users (id) ON DELETE SET NULL,
@@ -56,6 +56,10 @@ WITH (
autovacuum_analyze_threshold = 1000
);
+-- Creates the monthly partitions from last month to lookahead_months ahead (at
+-- least one), and drops those older than retention_months; retention_months <= 0
+-- keeps every partition. Concurrent callers (instances starting together) are
+-- serialized.
CREATE OR REPLACE FUNCTION rotate_audit_log_partitions(
retention_months INTEGER DEFAULT 6,
lookahead_months INTEGER DEFAULT 12
@@ -63,13 +67,15 @@ CREATE OR REPLACE FUNCTION rotate_audit_log_partitions(
RETURNS VOID AS $$
DECLARE
create_start DATE := (date_trunc('month', NOW()) - INTERVAL '1 month')::DATE;
- create_end DATE := (date_trunc('month', NOW()) + make_interval(months => lookahead_months))::DATE;
- keep_from DATE := (date_trunc('month', NOW()) - make_interval(months => retention_months))::DATE;
+ create_end DATE := (date_trunc('month', NOW()) + make_interval(months => GREATEST(lookahead_months, 1)))::DATE;
+ keep_from DATE := (date_trunc('month', NOW()) - make_interval(months => GREATEST(retention_months, 0)))::DATE;
month_start DATE;
part_name TEXT;
rel_name TEXT;
rel_month DATE;
BEGIN
+ PERFORM pg_advisory_xact_lock(hashtextextended('rotate_audit_log_partitions', 0));
+
FOR month_start IN
SELECT generate_series(create_start, create_end, INTERVAL '1 month')::DATE
LOOP
@@ -81,6 +87,10 @@ BEGIN
);
END LOOP;
+ IF retention_months <= 0 THEN
+ RETURN;
+ END IF;
+
FOR rel_name IN
SELECT c.relname
FROM pg_class c
@@ -100,39 +110,8 @@ $$ LANGUAGE plpgsql;
SELECT rotate_audit_log_partitions();
-DO $$
-BEGIN
- BEGIN
- CREATE EXTENSION IF NOT EXISTS pg_cron;
- EXCEPTION
- WHEN insufficient_privilege THEN
- RAISE NOTICE 'pg_cron extension not installed (insufficient privilege); schedule rotate_audit_log_partitions() externally.';
- RETURN;
- WHEN undefined_file THEN
- RAISE NOTICE 'pg_cron extension is not available on this PostgreSQL instance; schedule rotate_audit_log_partitions() externally.';
- RETURN;
- WHEN feature_not_supported THEN
- RAISE NOTICE 'pg_cron extension is not supported on this PostgreSQL instance; schedule rotate_audit_log_partitions() externally.';
- RETURN;
- WHEN others THEN
- RAISE NOTICE 'pg_cron extension could not be loaded (%), schedule rotate_audit_log_partitions() externally.', SQLERRM;
- RETURN;
- END;
-
- IF NOT EXISTS (
- SELECT 1
- FROM cron.job
- WHERE jobname = 'audit_log_partition_rotation'
- ) THEN
- PERFORM cron.schedule(
- 'audit_log_partition_rotation',
- '15 2 * * *',
- 'SELECT rotate_audit_log_partitions();'
- );
- END IF;
-END;
-$$;
-
+-- Rows are never updated or deleted, except to detach a deleted user
+-- (user_id set to NULL by the foreign key, every other column unchanged).
CREATE OR REPLACE FUNCTION prevent_audit_log_modification()
RETURNS TRIGGER AS $$
BEGIN
@@ -157,6 +136,4 @@ CREATE TRIGGER audit_log_append_only
FOR EACH ROW EXECUTE FUNCTION prevent_audit_log_modification();
CREATE INDEX idx_audit_log_user ON audit_log (user_id, created_at DESC) WHERE user_id IS NOT NULL;
-CREATE INDEX idx_audit_log_request ON audit_log (request_id, created_at DESC) WHERE request_id IS NOT NULL;
-CREATE INDEX idx_audit_log_action ON audit_log (action, created_at DESC);
CREATE INDEX idx_audit_log_created_brin ON audit_log USING BRIN (created_at);
diff --git a/migrations/0010_email_verification_tokens.sql b/migrations/0010_email_verification_tokens.sql
deleted file mode 100644
index 20b405d..0000000
--- a/migrations/0010_email_verification_tokens.sql
+++ /dev/null
@@ -1,52 +0,0 @@
--- 0010_email_verification_tokens.sql
--- Creates one-time email verification tokens.
--- Used both for initial registration verification and for email change flows,
--- with single-use semantics, expiration, and the exact target email being verified.
-CREATE TABLE email_verification_tokens (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
- token_hash BYTEA NOT NULL,
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
- expires_at TIMESTAMPTZ NOT NULL,
- used_at TIMESTAMPTZ,
- request_ip INET,
- request_user_agent TEXT,
- target_email CITEXT NOT NULL,
-
- CONSTRAINT email_verification_tokens_token_hash_key UNIQUE (token_hash),
- CONSTRAINT email_verification_tokens_token_hash_length CHECK (octet_length(token_hash) = 32),
- CONSTRAINT email_verification_tokens_expires_after_creation CHECK (expires_at > created_at),
- CONSTRAINT email_verification_tokens_used_after_creation CHECK (used_at IS NULL OR used_at >= created_at),
- CONSTRAINT email_verification_tokens_target_email_format CHECK (
- target_email ~* '^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$'
- )
-);
-
-CREATE UNIQUE INDEX idx_email_verification_tokens_user_active
- ON email_verification_tokens (user_id) WHERE used_at IS NULL;
-CREATE INDEX idx_email_verification_tokens_expires_active
- ON email_verification_tokens (expires_at) WHERE used_at IS NULL;
-
-ALTER TABLE email_verification_tokens SET (
- autovacuum_vacuum_scale_factor = 0.05,
- autovacuum_analyze_scale_factor = 0.02,
- autovacuum_vacuum_threshold = 1000,
- autovacuum_analyze_threshold = 500
-);
-
-CREATE OR REPLACE FUNCTION cleanup_expired_email_verification_tokens(
- grace_interval INTERVAL DEFAULT '1 day'
-)
-RETURNS INTEGER AS $$
-DECLARE
- deleted INTEGER;
-BEGIN
- WITH deleted_rows AS (
- DELETE FROM email_verification_tokens
- WHERE expires_at < NOW() - grace_interval
- RETURNING id
- )
- SELECT count(*) INTO deleted FROM deleted_rows;
- RETURN deleted;
-END;
-$$ LANGUAGE plpgsql;
diff --git a/migrations/0011_event_outbox.sql b/migrations/0011_event_outbox.sql
new file mode 100644
index 0000000..1019b2d
--- /dev/null
+++ b/migrations/0011_event_outbox.sql
@@ -0,0 +1,49 @@
+-- Transactional outbox of the domain events delivered to NATS JetStream.
+--
+-- An event is inserted in the transaction of the change it announces: a
+-- committed change always has its event, and a rolled-back change announces
+-- nothing. A background relay publishes pending events in `seq` order, waits
+-- for JetStream to store each one, and uses the event id as the message id so a
+-- publication repeated after a crash is deduplicated.
+CREATE TABLE event_outbox (
+ seq BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ id UUID NOT NULL DEFAULT gen_random_uuid(),
+ subject TEXT NOT NULL,
+ payload JSONB NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ published_at TIMESTAMPTZ,
+ attempts INTEGER NOT NULL DEFAULT 0,
+ next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ last_error TEXT,
+
+ CONSTRAINT event_outbox_id_key UNIQUE (id),
+ CONSTRAINT event_outbox_subject_format CHECK (subject ~ '^events\.auth\.[a-z_]+(\.[a-z_]+)*$'),
+ CONSTRAINT event_outbox_payload_object CHECK (jsonb_typeof(payload) = 'object'),
+ CONSTRAINT event_outbox_attempts_non_negative CHECK (attempts >= 0)
+);
+
+-- The relay reads the head of the queue.
+CREATE INDEX idx_event_outbox_pending ON event_outbox (seq) WHERE published_at IS NULL;
+-- Retention deletes published events by age.
+CREATE INDEX idx_event_outbox_published_at
+ ON event_outbox (published_at) WHERE published_at IS NOT NULL;
+
+-- Published events are kept a while for investigation, then swept by the
+-- application's cleanup task, in batches like the other retention functions.
+CREATE OR REPLACE FUNCTION cleanup_published_events(
+ retention INTERVAL DEFAULT '7 days',
+ batch_size INTEGER DEFAULT NULL
+)
+RETURNS INTEGER AS $$
+DECLARE
+ deleted INTEGER;
+BEGIN
+ DELETE FROM event_outbox WHERE ctid = ANY (ARRAY(
+ SELECT ctid FROM event_outbox
+ WHERE published_at < NOW() - retention
+ LIMIT batch_size
+ ));
+ GET DIAGNOSTICS deleted = ROW_COUNT;
+ RETURN deleted;
+END;
+$$ LANGUAGE plpgsql;
diff --git a/migrations/0011_password_reset_tokens.sql b/migrations/0011_password_reset_tokens.sql
deleted file mode 100644
index 6eb83c2..0000000
--- a/migrations/0011_password_reset_tokens.sql
+++ /dev/null
@@ -1,48 +0,0 @@
--- 0011_password_reset_tokens.sql
--- Creates one-time password reset tokens for "forgot password" workflows.
--- Tokens are stored as hashes only, expire automatically, and are consumed once
--- to avoid replay or reuse after a successful password change.
-CREATE TABLE password_reset_tokens (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
- token_hash BYTEA NOT NULL,
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
- expires_at TIMESTAMPTZ NOT NULL,
- used_at TIMESTAMPTZ,
- request_ip INET,
- request_user_agent TEXT,
-
- CONSTRAINT password_reset_tokens_token_hash_key UNIQUE (token_hash),
- CONSTRAINT password_reset_tokens_token_hash_length CHECK (octet_length(token_hash) = 32),
- CONSTRAINT password_reset_tokens_expires_after_creation CHECK (expires_at > created_at),
- CONSTRAINT password_reset_tokens_used_after_creation CHECK (used_at IS NULL OR used_at >= created_at)
-);
-
-CREATE UNIQUE INDEX idx_password_reset_tokens_user_active
- ON password_reset_tokens (user_id) WHERE used_at IS NULL;
-CREATE INDEX idx_password_reset_tokens_expires_active
- ON password_reset_tokens (expires_at) WHERE used_at IS NULL;
-
-ALTER TABLE password_reset_tokens SET (
- autovacuum_vacuum_scale_factor = 0.05,
- autovacuum_analyze_scale_factor = 0.02,
- autovacuum_vacuum_threshold = 1000,
- autovacuum_analyze_threshold = 500
-);
-
-CREATE OR REPLACE FUNCTION cleanup_expired_password_reset_tokens(
- grace_interval INTERVAL DEFAULT '1 day'
-)
-RETURNS INTEGER AS $$
-DECLARE
- deleted INTEGER;
-BEGIN
- WITH deleted_rows AS (
- DELETE FROM password_reset_tokens
- WHERE expires_at < NOW() - grace_interval
- RETURNING id
- )
- SELECT count(*) INTO deleted FROM deleted_rows;
- RETURN deleted;
-END;
-$$ LANGUAGE plpgsql;
diff --git a/migrations/0012_purge_unverified_accounts.sql b/migrations/0012_purge_unverified_accounts.sql
new file mode 100644
index 0000000..ea64aa9
--- /dev/null
+++ b/migrations/0012_purge_unverified_accounts.sql
@@ -0,0 +1,44 @@
+-- Accounts whose address was never verified are purged after a configurable
+-- age, so an address registered by mistake (or by someone else) becomes free
+-- again. Each purge is audited and announced with `user.deleted`, like a
+-- deletion by the user, in the same transaction as the deletion.
+CREATE OR REPLACE FUNCTION purge_unverified_accounts(
+ age INTERVAL,
+ batch_size INTEGER DEFAULT NULL
+)
+RETURNS INTEGER AS $$
+DECLARE
+ doomed UUID[];
+ purged INTEGER;
+BEGIN
+ SELECT array_agg(id) INTO doomed
+ FROM (
+ SELECT id FROM users
+ WHERE status = 'pending_verification' AND created_at < NOW() - age
+ ORDER BY created_at
+ LIMIT batch_size
+ FOR UPDATE SKIP LOCKED
+ ) oldest;
+
+ IF doomed IS NULL THEN
+ RETURN 0;
+ END IF;
+
+ -- Written before the deletion: the foreign key then sets user_id to NULL.
+ INSERT INTO audit_log (user_id, action, metadata)
+ SELECT id, 'account_deleted', '{"reason": "never_verified"}'::JSONB
+ FROM unnest(doomed) AS id;
+
+ INSERT INTO event_outbox (subject, payload)
+ SELECT 'events.auth.user.deleted', jsonb_build_object('user_id', id)
+ FROM unnest(doomed) AS id;
+
+ DELETE FROM users WHERE id = ANY (doomed);
+ GET DIAGNOSTICS purged = ROW_COUNT;
+ RETURN purged;
+END;
+$$ LANGUAGE plpgsql;
+
+-- The purge reads pending accounts by age.
+CREATE INDEX idx_users_pending_created
+ ON users (created_at) WHERE status = 'pending_verification';
diff --git a/migrations/0012_recovery_codes.sql b/migrations/0012_recovery_codes.sql
deleted file mode 100644
index 574c5a5..0000000
--- a/migrations/0012_recovery_codes.sql
+++ /dev/null
@@ -1,49 +0,0 @@
--- 0012_recovery_codes.sql
--- Creates hashed recovery codes used as backup access factors.
--- These codes allow account recovery when the primary second factor is unavailable
--- and are tracked individually so each code can be consumed exactly once.
-CREATE TABLE recovery_codes (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
- expires_at TIMESTAMPTZ,
- used_at TIMESTAMPTZ,
- code_position SMALLINT NOT NULL,
- code_hash BYTEA NOT NULL,
-
- CONSTRAINT recovery_codes_code_hash_key UNIQUE (code_hash),
- CONSTRAINT recovery_codes_user_position_key UNIQUE (user_id, code_position),
- CONSTRAINT recovery_codes_code_hash_length CHECK (octet_length(code_hash) = 32),
- CONSTRAINT recovery_codes_position_range CHECK (code_position BETWEEN 1 AND 20),
- CONSTRAINT recovery_codes_expiration_consistency CHECK (expires_at IS NULL OR expires_at > created_at),
- CONSTRAINT recovery_codes_used_after_creation CHECK (used_at IS NULL OR used_at >= created_at)
-);
-
-CREATE INDEX idx_recovery_codes_user_active
- ON recovery_codes (user_id, code_position) WHERE used_at IS NULL;
-CREATE INDEX idx_recovery_codes_expires_active
- ON recovery_codes (expires_at) WHERE used_at IS NULL AND expires_at IS NOT NULL;
-
-ALTER TABLE recovery_codes SET (
- autovacuum_vacuum_scale_factor = 0.05,
- autovacuum_analyze_scale_factor = 0.02,
- autovacuum_vacuum_threshold = 1000,
- autovacuum_analyze_threshold = 500
-);
-
-CREATE OR REPLACE FUNCTION cleanup_expired_recovery_codes(
- grace_interval INTERVAL DEFAULT '7 days'
-)
-RETURNS INTEGER AS $$
-DECLARE
- deleted INTEGER;
-BEGIN
- WITH deleted_rows AS (
- DELETE FROM recovery_codes
- WHERE expires_at IS NOT NULL AND expires_at < NOW() - grace_interval
- RETURNING id
- )
- SELECT count(*) INTO deleted FROM deleted_rows;
- RETURN deleted;
-END;
-$$ LANGUAGE plpgsql;
diff --git a/migrations/0013_personal_data.sql b/migrations/0013_personal_data.sql
new file mode 100644
index 0000000..9fb9052
--- /dev/null
+++ b/migrations/0013_personal_data.sql
@@ -0,0 +1,134 @@
+-- Personal data: what a deleted account leaves behind, and how long the audit
+-- log keeps full client addresses.
+
+-- The audit log stays append-only. Two narrowing updates are allowed and
+-- nothing else: detaching a deleted user (user_id to NULL), and forgetting or
+-- coarsening a client address (ip_address to NULL, or to a network containing
+-- it).
+CREATE OR REPLACE FUNCTION prevent_audit_log_modification()
+RETURNS TRIGGER AS $$
+BEGIN
+ IF TG_OP = 'UPDATE'
+ AND NEW.id = OLD.id
+ AND NEW.request_id IS NOT DISTINCT FROM OLD.request_id
+ AND NEW.created_at = OLD.created_at
+ AND NEW.action = OLD.action
+ AND NEW.metadata = OLD.metadata
+ AND (NEW.user_id IS NOT DISTINCT FROM OLD.user_id OR NEW.user_id IS NULL)
+ AND (
+ NEW.ip_address IS NOT DISTINCT FROM OLD.ip_address
+ OR NEW.ip_address IS NULL
+ OR (masklen(NEW.ip_address) < masklen(OLD.ip_address)
+ AND OLD.ip_address <<= NEW.ip_address)
+ )
+ AND (NEW.user_id IS DISTINCT FROM OLD.user_id
+ OR NEW.ip_address IS DISTINCT FROM OLD.ip_address) THEN
+ RETURN NEW;
+ END IF;
+
+ RAISE EXCEPTION 'audit_log is append-only';
+END;
+$$ LANGUAGE plpgsql;
+
+-- Forget what an account leaves outside its own rows, in the transaction that
+-- deletes it and before its row goes: the client addresses of its audit
+-- entries, and its sign-in attempts, recorded under its id or under the
+-- identifiers typed for it before it existed. The audit entries themselves stay,
+-- without identity, for the retention period.
+CREATE OR REPLACE FUNCTION forget_account_traces(p_user_id UUID)
+RETURNS VOID AS $$
+DECLARE
+ identity RECORD;
+BEGIN
+ SELECT email, username INTO identity FROM users WHERE id = p_user_id;
+
+ UPDATE audit_log SET ip_address = NULL
+ WHERE user_id = p_user_id AND ip_address IS NOT NULL;
+
+ DELETE FROM login_attempts WHERE user_id = p_user_id;
+
+ IF identity.email IS NOT NULL THEN
+ -- Attempts without an account id are failures: their partial index serves this.
+ DELETE FROM login_attempts
+ WHERE was_successful = FALSE
+ AND attempted_identifier IN (identity.email, identity.username::CITEXT);
+ END IF;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Purge of never-verified accounts (0012), forgetting their traces too.
+CREATE OR REPLACE FUNCTION purge_unverified_accounts(
+ age INTERVAL,
+ batch_size INTEGER DEFAULT NULL
+)
+RETURNS INTEGER AS $$
+DECLARE
+ doomed UUID[];
+ purged INTEGER;
+BEGIN
+ SELECT array_agg(id) INTO doomed
+ FROM (
+ SELECT id FROM users
+ WHERE status = 'pending_verification' AND created_at < NOW() - age
+ ORDER BY created_at
+ LIMIT batch_size
+ FOR UPDATE SKIP LOCKED
+ ) oldest;
+
+ IF doomed IS NULL THEN
+ RETURN 0;
+ END IF;
+
+ PERFORM forget_account_traces(id) FROM unnest(doomed) AS id;
+
+ -- Written before the deletion: the foreign key then sets user_id to NULL.
+ INSERT INTO audit_log (user_id, action, metadata)
+ SELECT id, 'account_deleted', '{"reason": "never_verified"}'::JSONB
+ FROM unnest(doomed) AS id;
+
+ INSERT INTO event_outbox (subject, payload)
+ SELECT 'events.auth.user.deleted', jsonb_build_object('user_id', id)
+ FROM unnest(doomed) AS id;
+
+ DELETE FROM users WHERE id = ANY (doomed);
+ GET DIAGNOSTICS purged = ROW_COUNT;
+ RETURN purged;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Audit entries still holding a full client address: the coarsening job reads
+-- them by age, and a row leaves the index once coarsened.
+CREATE INDEX idx_audit_log_full_address
+ ON audit_log (created_at)
+ WHERE ip_address IS NOT NULL
+ AND masklen(ip_address) = CASE WHEN family(ip_address) = 4 THEN 32 ELSE 128 END;
+
+-- Keep the network of an address older than `age` and drop the host part:
+-- /24 for IPv4, /48 for IPv6. Enough to investigate abuse from a network,
+-- no longer an identifier of a subscriber. Batched by primary key, since
+-- ctid is not unique across partitions.
+CREATE OR REPLACE FUNCTION coarsen_audit_addresses(
+ age INTERVAL,
+ batch_size INTEGER DEFAULT NULL
+)
+RETURNS INTEGER AS $$
+DECLARE
+ coarsened INTEGER;
+BEGIN
+ UPDATE audit_log AS entry
+ SET ip_address = network(set_masklen(
+ entry.ip_address,
+ CASE WHEN family(entry.ip_address) = 4 THEN 24 ELSE 48 END
+ ))
+ FROM (
+ SELECT created_at, id FROM audit_log
+ WHERE ip_address IS NOT NULL
+ AND masklen(ip_address) = CASE WHEN family(ip_address) = 4 THEN 32 ELSE 128 END
+ AND created_at < NOW() - age
+ LIMIT batch_size
+ ) AS due
+ WHERE entry.created_at = due.created_at AND entry.id = due.id;
+ GET DIAGNOSTICS coarsened = ROW_COUNT;
+ RETURN coarsened;
+END;
+$$ LANGUAGE plpgsql;
diff --git a/migrations/0014_known_devices.sql b/migrations/0014_known_devices.sql
new file mode 100644
index 0000000..7e93b1a
--- /dev/null
+++ b/migrations/0014_known_devices.sql
@@ -0,0 +1,35 @@
+-- Devices an account signed in from, to tell its owner about a sign-in from a
+-- new one. A device is its browser and operating system families, hashed:
+-- versions and network addresses are left out, so updates and mobile networks
+-- do not raise false alarms.
+CREATE TABLE known_devices (
+ user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ fingerprint BYTEA NOT NULL,
+ first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
+ PRIMARY KEY (user_id, fingerprint),
+ CONSTRAINT known_devices_fingerprint_length CHECK (octet_length(fingerprint) = 32)
+);
+
+-- Retention deletes devices unseen for a while.
+CREATE INDEX idx_known_devices_last_seen ON known_devices (last_seen_at);
+
+-- Bounded like the other retention functions.
+CREATE OR REPLACE FUNCTION cleanup_stale_known_devices(
+ age INTERVAL,
+ batch_size INTEGER DEFAULT NULL
+)
+RETURNS INTEGER AS $$
+DECLARE
+ deleted INTEGER;
+BEGIN
+ DELETE FROM known_devices WHERE ctid = ANY (ARRAY(
+ SELECT ctid FROM known_devices
+ WHERE last_seen_at < NOW() - age
+ LIMIT batch_size
+ ));
+ GET DIAGNOSTICS deleted = ROW_COUNT;
+ RETURN deleted;
+END;
+$$ LANGUAGE plpgsql;
diff --git a/migrations/0015_administration.sql b/migrations/0015_administration.sql
new file mode 100644
index 0000000..97f7ef8
--- /dev/null
+++ b/migrations/0015_administration.sql
@@ -0,0 +1,49 @@
+-- Administration: the permissions the /admin routes require, the `admin` role
+-- granting all of them, and the audit actions of administrative changes.
+INSERT INTO permissions (resource, action, description) VALUES
+ ('users', 'read', 'Search accounts and read their details'),
+ ('users', 'manage', 'Suspend, reactivate, unlock, sign out, reset and delete accounts'),
+ ('roles', 'manage', 'Create and delete roles, set their permissions, assign them to accounts'),
+ ('clients', 'manage', 'Register, update and remove client applications'),
+ ('audit', 'read', 'Read the audit log of every account'),
+ ('webhooks', 'manage', 'Manage webhook subscriptions and their deliveries')
+ON CONFLICT (resource, action) DO NOTHING;
+
+INSERT INTO roles (name, description, is_default)
+VALUES ('admin', 'Administrators: every administrative permission', FALSE)
+ON CONFLICT (name) DO NOTHING;
+
+INSERT INTO role_permissions (role_id, permission_id)
+SELECT roles.id, permissions.id
+FROM roles CROSS JOIN permissions
+WHERE roles.name = 'admin'
+ AND permissions.name IN (
+ 'users:read', 'users:manage', 'roles:manage', 'clients:manage', 'audit:read', 'webhooks:manage'
+ )
+ON CONFLICT DO NOTHING;
+
+ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'account_unlocked';
+ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'password_reset_forced';
+ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'role_created';
+ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'role_deleted';
+ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'role_permissions_changed';
+ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'client_registered';
+ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'client_updated';
+ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'client_deleted';
+
+-- An administrator unlocking an account also forgives the failed sign-ins that
+-- locked it: the consecutive failures count from the later of the last success
+-- and this date.
+ALTER TABLE users ADD COLUMN lockout_cleared_at TIMESTAMPTZ;
+
+-- Administrators search accounts by the start of an address or a username.
+CREATE INDEX idx_users_email_prefix ON users ((lower(email::text)) text_pattern_ops);
+CREATE INDEX idx_users_username_prefix ON users ((lower(username::text)) text_pattern_ops);
+-- Listing pages newest first.
+CREATE INDEX idx_users_created ON users (created_at DESC, id DESC);
+
+-- The global audit log, newest first.
+CREATE INDEX idx_audit_log_created ON audit_log (created_at DESC, id DESC);
+-- Sessions of a client application, revoked when the client is removed.
+CREATE INDEX idx_sessions_client_active ON sessions (client_id)
+ WHERE client_id IS NOT NULL AND revoked_at IS NULL;
diff --git a/migrations/0015_login_locations.sql b/migrations/0015_login_locations.sql
deleted file mode 100644
index 40d675e..0000000
--- a/migrations/0015_login_locations.sql
+++ /dev/null
@@ -1,23 +0,0 @@
--- 0015_login_locations.sql
--- Login location history used for behavioral risk scoring.
--- One row per distinct (user, country, city, user_agent); refreshed on each
--- successful login so recent vs. stale observations can be distinguished.
-CREATE TABLE login_locations (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
- country TEXT NOT NULL,
- city TEXT NOT NULL,
- user_agent TEXT NOT NULL,
- ip_address INET NOT NULL,
- latitude DOUBLE PRECISION,
- longitude DOUBLE PRECISION,
- last_seen TIMESTAMPTZ NOT NULL DEFAULT now(),
- first_seen TIMESTAMPTZ NOT NULL DEFAULT now()
-);
-
-CREATE UNIQUE INDEX idx_login_locations_upsert
- ON login_locations (user_id, country, city, user_agent);
-CREATE INDEX idx_login_locations_last_seen
- ON login_locations (user_id, last_seen DESC);
-CREATE INDEX idx_login_locations_user_country
- ON login_locations (user_id, country);
diff --git a/migrations/0016_data_export.sql b/migrations/0016_data_export.sql
new file mode 100644
index 0000000..5f34e83
--- /dev/null
+++ b/migrations/0016_data_export.sql
@@ -0,0 +1,2 @@
+-- Account owners download what the service stores about them.
+ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'data_exported';
diff --git a/migrations/0016_seed.sql b/migrations/0016_seed.sql
deleted file mode 100644
index d98b866..0000000
--- a/migrations/0016_seed.sql
+++ /dev/null
@@ -1,6 +0,0 @@
--- 0016_seed.sql
--- Inserts the base authorization data required by the application to boot with
--- sensible defaults: standard roles and the initial permission catalog.
--- This migration provides the minimum RBAC dataset expected by the API.
-INSERT INTO roles (name, description, is_default) VALUES
- ('user', 'Default role assigned on registration', TRUE);
diff --git a/migrations/0017_cleanup_schedule.sql b/migrations/0017_cleanup_schedule.sql
deleted file mode 100644
index 3186985..0000000
--- a/migrations/0017_cleanup_schedule.sql
+++ /dev/null
@@ -1,43 +0,0 @@
--- 0017_cleanup_schedule.sql
--- Schedules nightly cleanup jobs for expired operational data via pg_cron.
--- If pg_cron is unavailable, the application background task handles cleanup instead.
--- Cleanup functions are defined in their respective table migrations (0007, 0009-0013).
-DO $$
-BEGIN
- BEGIN
- CREATE EXTENSION IF NOT EXISTS pg_cron;
- EXCEPTION
- WHEN insufficient_privilege THEN
- RAISE NOTICE 'pg_cron not available (insufficient privilege); cleanup will run via the application background task.';
- RETURN;
- WHEN undefined_file THEN
- RAISE NOTICE 'pg_cron not available on this instance; cleanup will run via the application background task.';
- RETURN;
- WHEN feature_not_supported THEN
- RAISE NOTICE 'pg_cron not supported on this instance; cleanup will run via the application background task.';
- RETURN;
- WHEN others THEN
- RAISE NOTICE 'pg_cron could not be loaded (%); cleanup will run via the application background task.', SQLERRM;
- RETURN;
- END;
-
- IF NOT EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'cleanup_expired_sessions') THEN
- PERFORM cron.schedule('cleanup_expired_sessions', '0 3 * * *', 'SELECT cleanup_expired_sessions()');
- END IF;
- IF NOT EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'cleanup_expired_email_2fa_codes') THEN
- PERFORM cron.schedule('cleanup_expired_email_2fa_codes', '5 3 * * *', 'SELECT cleanup_expired_email_2fa_codes()');
- END IF;
- IF NOT EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'cleanup_expired_email_verification_tokens') THEN
- PERFORM cron.schedule('cleanup_expired_email_verification_tokens', '10 3 * * *', 'SELECT cleanup_expired_email_verification_tokens()');
- END IF;
- IF NOT EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'cleanup_expired_password_reset_tokens') THEN
- PERFORM cron.schedule('cleanup_expired_password_reset_tokens', '15 3 * * *', 'SELECT cleanup_expired_password_reset_tokens()');
- END IF;
- IF NOT EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'cleanup_expired_recovery_codes') THEN
- PERFORM cron.schedule('cleanup_expired_recovery_codes', '20 3 * * *', 'SELECT cleanup_expired_recovery_codes()');
- END IF;
- IF NOT EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'cleanup_old_login_attempts') THEN
- PERFORM cron.schedule('cleanup_old_login_attempts', '30 3 * * *', 'SELECT cleanup_old_login_attempts()');
- END IF;
-END;
-$$;
diff --git a/migrations/0017_magic_links.sql b/migrations/0017_magic_links.sql
new file mode 100644
index 0000000..b1d6502
--- /dev/null
+++ b/migrations/0017_magic_links.sql
@@ -0,0 +1,41 @@
+-- Sign-in links sent by email, for deployments that enable them
+-- (MAGIC_LINK_ENABLED). The link replaces the password, never the second factor.
+CREATE TABLE magic_link_tokens (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ token_hash BYTEA NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ expires_at TIMESTAMPTZ NOT NULL,
+ used_at TIMESTAMPTZ,
+ request_ip INET,
+ request_user_agent TEXT,
+
+ CONSTRAINT magic_link_tokens_token_hash_key UNIQUE (token_hash),
+ CONSTRAINT magic_link_tokens_token_hash_length CHECK (octet_length(token_hash) = 32),
+ CONSTRAINT magic_link_tokens_expires_after_creation CHECK (expires_at > created_at),
+ CONSTRAINT magic_link_tokens_used_after_creation CHECK (used_at IS NULL OR used_at >= created_at)
+);
+
+-- A new link replaces the pending ones of the account.
+CREATE INDEX idx_magic_link_tokens_user_pending ON magic_link_tokens (user_id) WHERE used_at IS NULL;
+CREATE INDEX idx_magic_link_tokens_expires ON magic_link_tokens (expires_at);
+
+CREATE OR REPLACE FUNCTION cleanup_expired_magic_link_tokens(
+ grace_interval INTERVAL DEFAULT '1 day',
+ batch_size INTEGER DEFAULT NULL
+)
+RETURNS INTEGER AS $$
+DECLARE
+ deleted INTEGER;
+BEGIN
+ DELETE FROM magic_link_tokens WHERE ctid = ANY (ARRAY(
+ SELECT ctid FROM magic_link_tokens
+ WHERE expires_at < NOW() - grace_interval
+ LIMIT batch_size
+ ));
+ GET DIAGNOSTICS deleted = ROW_COUNT;
+ RETURN deleted;
+END;
+$$ LANGUAGE plpgsql;
+
+ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'magic_link_sent';
diff --git a/migrations/0018_personal_access_tokens.sql b/migrations/0018_personal_access_tokens.sql
new file mode 100644
index 0000000..2a3f358
--- /dev/null
+++ b/migrations/0018_personal_access_tokens.sql
@@ -0,0 +1,28 @@
+-- Personal access tokens: long-lived credentials an account creates for its own
+-- scripts, exchanged for short-lived access tokens. Each one owns a session of
+-- type `personal_access_token`, so every revocation path (the token list, the
+-- session list, a password change, an administrator) ends it the same way.
+ALTER TYPE session_type ADD VALUE IF NOT EXISTS 'personal_access_token';
+
+CREATE TABLE personal_access_tokens (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ session_id UUID NOT NULL REFERENCES sessions (id) ON DELETE CASCADE,
+ name VARCHAR(100) NOT NULL,
+ token_hash BYTEA NOT NULL,
+ scopes TEXT[] NOT NULL DEFAULT '{}',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ expires_at TIMESTAMPTZ NOT NULL,
+ last_used_at TIMESTAMPTZ,
+
+ CONSTRAINT personal_access_tokens_token_hash_key UNIQUE (token_hash),
+ CONSTRAINT personal_access_tokens_session_key UNIQUE (session_id),
+ CONSTRAINT personal_access_tokens_token_hash_length CHECK (octet_length(token_hash) = 32),
+ CONSTRAINT personal_access_tokens_name_not_blank CHECK (char_length(btrim(name)) > 0),
+ CONSTRAINT personal_access_tokens_expires_after_creation CHECK (expires_at > created_at)
+);
+
+CREATE INDEX idx_personal_access_tokens_user ON personal_access_tokens (user_id, created_at DESC);
+
+ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'personal_access_token_created';
+ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'personal_access_token_revoked';
diff --git a/migrations/0018_registered_clients.sql b/migrations/0018_registered_clients.sql
deleted file mode 100644
index b041d44..0000000
--- a/migrations/0018_registered_clients.sql
+++ /dev/null
@@ -1,38 +0,0 @@
--- 0018_registered_clients.sql
--- Client registry for the device authorization flow (RFC 8628).
---
--- registered_clients: known client applications allowed to use the device
--- flow. The primary client is the native application owned by this auth-api
--- instance; external clients are third-party applications granted access
--- through federation. Device auth requests with an unknown client_id are
--- rejected.
--- user_client_quotas: per-client concurrent device-session limits. When no
--- quota row exists for a (user_id, client_id) pair, device auth is denied.
-CREATE TABLE registered_clients (
- client_id VARCHAR(100) PRIMARY KEY,
- display_name VARCHAR(200) NOT NULL,
- is_primary BOOLEAN NOT NULL DEFAULT false,
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
-);
-
--- At most one client can be primary.
-CREATE UNIQUE INDEX idx_registered_clients_primary
- ON registered_clients (is_primary) WHERE is_primary = true;
-
-CREATE TABLE user_client_quotas (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
- client_id VARCHAR(100) NOT NULL,
- max_sessions SMALLINT NOT NULL DEFAULT 1,
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
- updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-
- CONSTRAINT user_client_quotas_unique UNIQUE (user_id, client_id),
- CONSTRAINT user_client_quotas_max_sessions_positive CHECK (max_sessions > 0)
-);
-
-CREATE INDEX idx_user_client_quotas_user ON user_client_quotas (user_id);
-
-CREATE TRIGGER user_client_quotas_set_updated_at
- BEFORE UPDATE ON user_client_quotas
- FOR EACH ROW EXECUTE FUNCTION set_updated_at();
diff --git a/migrations/0019_used_totp_codes.sql b/migrations/0019_used_totp_codes.sql
deleted file mode 100644
index 87a5348..0000000
--- a/migrations/0019_used_totp_codes.sql
+++ /dev/null
@@ -1,57 +0,0 @@
--- 0019_used_totp_codes.sql
--- Durable TOTP replay guard: records the SHA-256 of every successfully
--- verified TOTP code so that a code cannot be consumed twice within its
--- validity window, even if Redis (the fast-path cache) is unavailable.
---
--- TOTP codes naturally repeat over time (6 digits, 30-second steps), so rows
--- MUST be short-lived: the repository purges the user's expired rows on every
--- attempt, cleanup_used_totp_codes() sweeps leftovers, and the primary key
--- only needs to hold uniqueness for the ~90-second validity window
--- (current step +/- TOTP_SKEW).
-CREATE TABLE used_totp_codes (
- user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
- code_hash BYTEA NOT NULL,
- used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-
- PRIMARY KEY (user_id, code_hash),
- CONSTRAINT used_totp_codes_hash_length CHECK (octet_length(code_hash) = 32)
-);
-
--- Rows are extremely short-lived; keep autovacuum aggressive so the table
--- never accumulates dead tuples from the constant insert/delete churn.
-ALTER TABLE used_totp_codes SET (
- autovacuum_vacuum_scale_factor = 0.02,
- autovacuum_vacuum_threshold = 200
-);
-
-CREATE OR REPLACE FUNCTION cleanup_used_totp_codes(
- retention INTERVAL DEFAULT '90 seconds'
-)
-RETURNS INTEGER AS $$
-DECLARE
- deleted INTEGER;
-BEGIN
- WITH deleted_rows AS (
- DELETE FROM used_totp_codes
- WHERE used_at < NOW() - retention
- RETURNING user_id
- )
- SELECT count(*) INTO deleted FROM deleted_rows;
- RETURN deleted;
-END;
-$$ LANGUAGE plpgsql;
-
--- Schedule via pg_cron when available (same pattern as 0017_cleanup_schedule.sql);
--- the application background task is the fallback.
-DO $$
-BEGIN
- IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_cron') THEN
- RAISE NOTICE 'pg_cron not available; cleanup_used_totp_codes will run via the application background task.';
- RETURN;
- END IF;
-
- IF NOT EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'cleanup_used_totp_codes') THEN
- PERFORM cron.schedule('cleanup_used_totp_codes', '*/10 * * * *', 'SELECT cleanup_used_totp_codes()');
- END IF;
-END;
-$$;
diff --git a/migrations/0019_webhooks.sql b/migrations/0019_webhooks.sql
new file mode 100644
index 0000000..410c838
--- /dev/null
+++ b/migrations/0019_webhooks.sql
@@ -0,0 +1,75 @@
+-- Webhooks: domain events delivered over HTTPS to endpoints registered by an
+-- administrator, signed with a per-endpoint secret (Standard Webhooks).
+CREATE TABLE webhook_endpoints (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ url TEXT NOT NULL,
+ description VARCHAR(200),
+ -- Event names such as `user.deleted`, or `*` for every event.
+ events TEXT[] NOT NULL,
+ -- Signing secret, encrypted with the application keyring.
+ secret TEXT NOT NULL,
+ enabled BOOLEAN NOT NULL DEFAULT TRUE,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
+ CONSTRAINT webhook_endpoints_url_length CHECK (char_length(url) BETWEEN 1 AND 2000),
+ CONSTRAINT webhook_endpoints_events_not_empty CHECK (cardinality(events) > 0)
+);
+
+CREATE TRIGGER webhook_endpoints_set_updated_at
+ BEFORE UPDATE ON webhook_endpoints
+ FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+
+-- One delivery per endpoint and event, recorded in the transaction of the
+-- change, like the event itself.
+CREATE TABLE webhook_deliveries (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ endpoint_id UUID NOT NULL REFERENCES webhook_endpoints (id) ON DELETE CASCADE,
+ event_id UUID NOT NULL,
+ event_name TEXT NOT NULL,
+ payload JSONB NOT NULL,
+ occurred_at TIMESTAMPTZ NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ attempts INTEGER NOT NULL DEFAULT 0,
+ next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ delivered_at TIMESTAMPTZ,
+ -- Set when the last attempt failed: no retry follows.
+ failed_at TIMESTAMPTZ,
+ last_status SMALLINT,
+ last_error TEXT,
+
+ CONSTRAINT webhook_deliveries_event_key UNIQUE (endpoint_id, event_id),
+ CONSTRAINT webhook_deliveries_one_outcome CHECK (delivered_at IS NULL OR failed_at IS NULL)
+);
+
+-- The dispatcher's queue: deliveries still to attempt, soonest first.
+CREATE INDEX idx_webhook_deliveries_due ON webhook_deliveries (next_attempt_at)
+ WHERE delivered_at IS NULL AND failed_at IS NULL;
+CREATE INDEX idx_webhook_deliveries_endpoint ON webhook_deliveries (endpoint_id, created_at DESC);
+CREATE INDEX idx_webhook_deliveries_finished ON webhook_deliveries (created_at)
+ WHERE delivered_at IS NOT NULL OR failed_at IS NOT NULL;
+
+-- Finished deliveries are kept a while for inspection, then deleted.
+CREATE OR REPLACE FUNCTION cleanup_finished_webhook_deliveries(
+ retention INTERVAL,
+ batch_size INTEGER DEFAULT NULL
+)
+RETURNS INTEGER AS $$
+DECLARE
+ deleted INTEGER;
+BEGIN
+ DELETE FROM webhook_deliveries WHERE ctid = ANY (ARRAY(
+ SELECT ctid FROM webhook_deliveries
+ WHERE (delivered_at IS NOT NULL OR failed_at IS NOT NULL)
+ AND created_at < NOW() - retention
+ LIMIT batch_size
+ ));
+ GET DIAGNOSTICS deleted = ROW_COUNT;
+ RETURN deleted;
+END;
+$$ LANGUAGE plpgsql;
+
+ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'webhook_created';
+ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'webhook_updated';
+ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'webhook_deleted';
+ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'webhook_secret_rotated';
diff --git a/migrations/0020_oauth.sql b/migrations/0020_oauth.sql
new file mode 100644
index 0000000..adfa298
--- /dev/null
+++ b/migrations/0020_oauth.sql
@@ -0,0 +1,9 @@
+-- Confidential clients: a client holding a secret authenticates at the token
+-- endpoint (client_secret_basic or client_secret_post). The secret is 256 random
+-- bits, so its SHA-256 digest is stored, not a slow hash.
+ALTER TABLE registered_clients
+ ADD COLUMN client_secret_hash BYTEA,
+ ADD CONSTRAINT registered_clients_secret_hash_length
+ CHECK (client_secret_hash IS NULL OR octet_length(client_secret_hash) = 32);
+
+ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'client_secret_rotated';
diff --git a/migrations/0021_client_credentials.sql b/migrations/0021_client_credentials.sql
new file mode 100644
index 0000000..e614fb6
--- /dev/null
+++ b/migrations/0021_client_credentials.sql
@@ -0,0 +1,8 @@
+-- The client credentials grant: a confidential client obtains tokens for
+-- itself, carrying its registered scopes and no user. Enabled per client.
+ALTER TABLE registered_clients
+ ADD COLUMN allows_client_credentials BOOLEAN NOT NULL DEFAULT FALSE,
+ ADD CONSTRAINT registered_clients_client_credentials_confidential
+ CHECK (NOT allows_client_credentials OR client_secret_hash IS NOT NULL),
+ ADD CONSTRAINT registered_clients_client_credentials_scoped
+ CHECK (NOT allows_client_credentials OR cardinality(scopes) > 0);
diff --git a/migrations/0022_openid_connect.sql b/migrations/0022_openid_connect.sql
new file mode 100644
index 0000000..310319b
--- /dev/null
+++ b/migrations/0022_openid_connect.sql
@@ -0,0 +1,5 @@
+-- OpenID Connect: the nonce of an authentication request travels with its
+-- authorization code into the ID token.
+ALTER TABLE authorization_codes
+ ADD COLUMN nonce TEXT,
+ ADD CONSTRAINT authorization_codes_nonce_length CHECK (nonce IS NULL OR char_length(nonce) <= 512);
diff --git a/migrations/0023_passkeys.sql b/migrations/0023_passkeys.sql
new file mode 100644
index 0000000..9930c4a
--- /dev/null
+++ b/migrations/0023_passkeys.sql
@@ -0,0 +1,28 @@
+-- Passkeys (WebAuthn discoverable credentials): an account signs in with one
+-- instead of its password, and holding one counts as a second factor.
+CREATE TABLE passkeys (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ credential_id BYTEA NOT NULL,
+ -- COSE key as the authenticator encoded it.
+ public_key BYTEA NOT NULL,
+ algorithm INTEGER NOT NULL,
+ sign_count BIGINT NOT NULL DEFAULT 0,
+ aaguid UUID NOT NULL,
+ name VARCHAR(100) NOT NULL,
+ backup_eligible BOOLEAN NOT NULL DEFAULT FALSE,
+ backed_up BOOLEAN NOT NULL DEFAULT FALSE,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ last_used_at TIMESTAMPTZ,
+
+ CONSTRAINT passkeys_credential_id_key UNIQUE (credential_id),
+ CONSTRAINT passkeys_credential_id_length CHECK (octet_length(credential_id) BETWEEN 1 AND 1023),
+ CONSTRAINT passkeys_algorithm_supported CHECK (algorithm IN (-7, -8, -257)),
+ CONSTRAINT passkeys_sign_count_range CHECK (sign_count BETWEEN 0 AND 4294967295),
+ CONSTRAINT passkeys_name_not_blank CHECK (char_length(btrim(name)) > 0)
+);
+
+CREATE INDEX idx_passkeys_user ON passkeys (user_id, created_at);
+
+ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'passkey_registered';
+ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'passkey_removed';
diff --git a/migrations/0024_external_identities.sql b/migrations/0024_external_identities.sql
new file mode 100644
index 0000000..e27c7d5
--- /dev/null
+++ b/migrations/0024_external_identities.sql
@@ -0,0 +1,19 @@
+-- Accounts linked to identities at external providers (Google, GitHub, OpenID
+-- Connect). A link is made by the signed-in owner of the account, never
+-- inferred from a matching email address.
+CREATE TABLE external_identities (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ provider VARCHAR(50) NOT NULL,
+ -- The provider's stable identifier of the person (`sub`, GitHub user id).
+ subject TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ last_used_at TIMESTAMPTZ,
+
+ CONSTRAINT external_identities_subject_key UNIQUE (provider, subject),
+ CONSTRAINT external_identities_one_per_provider UNIQUE (user_id, provider),
+ CONSTRAINT external_identities_subject_length CHECK (char_length(subject) BETWEEN 1 AND 255)
+);
+
+ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'external_identity_linked';
+ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'external_identity_unlinked';
diff --git a/migrations/SHA256SUMS b/migrations/SHA256SUMS
new file mode 100644
index 0000000..a276c93
--- /dev/null
+++ b/migrations/SHA256SUMS
@@ -0,0 +1,24 @@
+f68dfef5383a0f730e87df31c73443ae4dc79e4dabb5301290736812c9079b3e 0001_extensions.sql
+75c3f7990273d25596b976b35d23b19943194be187c5d8946e879ceb6dcbf415 0002_users.sql
+8ac6b4d8d2ea3dd284bb329b09fc1a50bfb50b65670dfdef5dff18551db22740 0003_rbac.sql
+437f7ea65b6248fdfd8b9c6d67e481bc6a6ae76830ec1a804cefae412a03167d 0004_registered_clients.sql
+67f08436838218647411474b1f14d57d5624959040d658f72783042de61fa395 0005_sessions.sql
+c543849eb6b0374192f3d2776ef18bef71c38b3e64227f65abf1e6765bcb1cfe 0006_authorization_codes.sql
+9a77f658341cc377e103c9f95687816d35e4eb8d30d04b1e79f48364ae8296c6 0007_two_factor.sql
+e0eb86b8960dad4b71c3cd2b451ef248ab72cc51bc9be7a7264deeb676f7953e 0008_account_tokens.sql
+6f3bb804844530fdc7e3bcf3dceefb1080c3b033a0f2a2f38c3fbcde2d943a12 0009_login_attempts.sql
+93ee032de3ecb20f74e90f74961c605715c97e14eceb4965857665f2dff55a4b 0010_audit_log.sql
+76b27021b2b3e978ee4222edf821fbda8d78f39693fdd446279dc4d4d61377dc 0011_event_outbox.sql
+b8de15f6c36964e20011d9f1e27328ad29f4741bf4934e278fed80c082296407 0012_purge_unverified_accounts.sql
+06bafbf6337f34cd2593cf3e65fb2924b81dd3ff99beb5ffdb7681c35276b054 0013_personal_data.sql
+f6434d27992410fe4844cf77d826695d2c064bf9f7cc2533d6ad43982493c011 0014_known_devices.sql
+cf488c3d1447968682de07e2b693d061338fb20762016bc1a13b21cd0479c68c 0015_administration.sql
+532f7077a3499defe32d57f9d914df1727de127480250143abbd82b0c2196c85 0016_data_export.sql
+0fa236d98e376fd23d9535187bd831f281892f2c7f99fa2d685ab760ab1f4927 0017_magic_links.sql
+42a25675eca8b31f97dfa3812a213eefc6bd648384ba63bd381913afdee8a25f 0018_personal_access_tokens.sql
+d5701c00b0e287b24a17103b7d6c0973918cc9c48aacf3693ad5df6d3124b0a1 0019_webhooks.sql
+a1e01ce9c1cde16545191dc1f31ec725dcfc4d15393657077f057efea7f72a20 0020_oauth.sql
+240080d0368c592655a65c0e1374b351258416c8967182ec8dc9c49f65abe0c3 0021_client_credentials.sql
+2515d6388f54364f07f460c8e3ac97b09a039e0ec2a6c4f458c3dcb103f062f5 0022_openid_connect.sql
+a3fdddb551010b532efa344548a0b467652649068b939f99de46d4a8fdaf1053 0023_passkeys.sql
+075c2bb4a512689cb03c870b347cf1687944b0e9f0ccaeb8be1327b6af9279fb 0024_external_identities.sql
diff --git a/nats.conf b/nats.conf
new file mode 100644
index 0000000..5cd2a36
--- /dev/null
+++ b/nats.conf
@@ -0,0 +1,21 @@
+# NATS broker of the API VPS (docker-compose.api.yml).
+
+# Monitoring endpoint, probed by the health check and scraped by the exporter.
+# Not published on the host.
+http_port: 8222
+
+# Durable user events. Memory store unused by the API; the file store is capped
+# well above the stream's own ceiling (256 MiB, 30 days).
+jetstream {
+ store_dir: /data
+ max_memory_store: 64MB
+ max_file_store: 1GB
+}
+
+# Limits for a broker with a handful of clients.
+max_connections: 64
+max_payload: 1MB
+
+# authorization { token: "..." }, mounted as a compose secret next to this
+# file: an include path is relative to the including file's directory.
+include auth.conf
diff --git a/nginx/nginx.conf b/nginx/nginx.conf
index a8ee07a..2b7eed9 100644
--- a/nginx/nginx.conf
+++ b/nginx/nginx.conf
@@ -1,126 +1,189 @@
-# Rate limiting zones - defined at http level, used in server blocks.
+# Reverse proxy in front of auth-api: TLS termination, volumetric rate limits,
+# and the security headers of every response - including the ones nginx
+# generates itself (429, 502, 405), which never reach the application.
#
-# These limits act as a first line of defence at the gateway level.
-# The application also enforces its own per-endpoint limits via Redis
-# (RATE_LIMIT_RPM, RATE_LIMIT_AUTH_RPM). Both layers are intentionally
-# active: Nginx protects against volumetric floods before they reach the
-# app, while application-level limits provide per-user and per-token
-# granularity. Keep Nginx limits >= application limits to avoid masking
-# application-level 429 responses with gateway-level 429s.
-limit_req_zone $binary_remote_addr zone=api_general:10m rate=300r/m;
-limit_req_zone $binary_remote_addr zone=api_auth:10m rate=20r/m;
+# The application keeps its own per-client limits in Redis
+# (RATE_LIMIT_RPM=300, RATE_LIMIT_AUTH_RPM=20). Nginx absorbs floods before they
+# reach it at twice those rates, so a client under its limit never meets nginx's
+# 429 and an abusive one gets the application's (with its Retry-After).
+limit_req_zone $binary_remote_addr zone=api_general:10m rate=600r/m;
+limit_req_zone $binary_remote_addr zone=api_auth:10m rate=40r/m;
+
+# One line per client request, correlated with the API's logs by request_id.
+log_format auth_api_json escape=json
+ '{"time":"$time_iso8601","request_id":"$request_id","client":"$remote_addr",'
+ '"method":"$request_method","uri":"$uri","status":$status,'
+ '"bytes":$body_bytes_sent,"duration":$request_time,'
+ '"upstream":"$upstream_addr","upstream_status":"$upstream_status",'
+ '"upstream_duration":"$upstream_response_time","user_agent":"$http_user_agent"}';
+
+# The API instances of docker-compose.api.yml. An instance that refuses or
+# times out is skipped for 10 s. Profile L: add 127.0.0.1:3003 and 3004.
+upstream auth_api {
+ server 127.0.0.1:3001 max_fails=3 fail_timeout=10s;
+ server 127.0.0.1:3002 max_fails=3 fail_timeout=10s;
+ keepalive 64;
+}
server {
listen 80;
+ listen [::]:80;
server_name _;
- # Redirect all HTTP to HTTPS
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
server_name api.example.com;
- # ---------------------------------------------------------------------------
+ # -------------------------------------------------------------------------
# TLS
- # ---------------------------------------------------------------------------
+ # -------------------------------------------------------------------------
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
- ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256;
+ ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
+ # No OCSP stapling: Let's Encrypt stopped operating OCSP responders in
+ # 2025, and stapling an unavailable responder only produces errors.
- # OCSP stapling
- ssl_stapling on;
- ssl_stapling_verify on;
- resolver 1.1.1.1 1.0.0.1 valid=300s;
- resolver_timeout 5s;
+ server_tokens off;
- # ---------------------------------------------------------------------------
- # Security headers
- # ---------------------------------------------------------------------------
+ access_log /var/log/nginx/auth-api.access.log auth_api_json;
+ error_log /var/log/nginx/auth-api.error.log warn;
+
+ # -------------------------------------------------------------------------
+ # Security headers - one source of truth
+ # -------------------------------------------------------------------------
+ # The application sets the same headers for direct access; its copies are
+ # hidden here so a proxied response never carries two conflicting values.
+ # Values match the application's, except HSTS which adds `preload`: that
+ # decision belongs to whoever owns the domain's TLS edge.
+
+ proxy_hide_header Strict-Transport-Security;
+ proxy_hide_header X-Frame-Options;
+ proxy_hide_header X-Content-Type-Options;
+ proxy_hide_header X-XSS-Protection;
+ proxy_hide_header Referrer-Policy;
+ proxy_hide_header Content-Security-Policy;
+ proxy_hide_header Permissions-Policy;
+ proxy_hide_header X-Powered-By;
+
+ add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
+ add_header X-Frame-Options "DENY" always;
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header X-XSS-Protection "0" always;
+ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+ add_header Content-Security-Policy "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" always;
+ add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
+
+ # -------------------------------------------------------------------------
+ # Request limits
+ # -------------------------------------------------------------------------
- add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
- add_header X-Frame-Options "DENY" always;
- add_header X-Content-Type-Options "nosniff" always;
- add_header Referrer-Policy "no-referrer" always;
- add_header Content-Security-Policy "default-src 'none'" always;
- add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
+ client_max_body_size 64k;
+ client_body_timeout 10s;
+ client_header_timeout 10s;
- # Hide Nginx version
- server_tokens off;
+ # HEAD is served as GET by the API; uptime probes use it.
+ if ($request_method !~ ^(GET|HEAD|POST|PATCH|DELETE|OPTIONS)$) {
+ return 405;
+ }
- # ---------------------------------------------------------------------------
- # Request limits
- # ---------------------------------------------------------------------------
+ # Shared proxy settings (inherited by every location below).
+ proxy_http_version 1.1;
+ proxy_set_header Connection "";
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ # Overwrite rather than append: the client cannot inject hops the
+ # application would then trust.
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ # The API keeps this identifier (it matches its format) in every log line.
+ proxy_set_header X-Request-Id $request_id;
+ proxy_connect_timeout 5s;
+ # Above the API's own 30-second request timeout (Argon2 queueing during a
+ # sign-in storm): nginx never gives up on a request the API still completes.
+ proxy_read_timeout 35s;
+ proxy_send_timeout 35s;
+
+ # An instance that refuses the connection (restarting, stopped) or fails
+ # before answering: the request goes to the other one. nginx never resends a
+ # POST, PATCH or DELETE that already reached an instance.
+ proxy_next_upstream error timeout http_502 http_503;
+ proxy_next_upstream_tries 2;
+ proxy_next_upstream_timeout 10s;
+
+ # -------------------------------------------------------------------------
+ # Public keys - must stay reachable
+ # -------------------------------------------------------------------------
+ # Exact match: wins over the hidden-file rule below, which would otherwise
+ # deny every /.well-known path and leave resource servers unable to verify
+ # tokens. The application sets its own Cache-Control for this document.
+
+ location = /.well-known/jwks.json {
+ limit_req zone=api_general burst=50 nodelay;
+ limit_req_status 429;
+ limit_except GET { deny all; }
- client_max_body_size 64k;
- client_body_timeout 10s;
- client_header_timeout 10s;
+ proxy_pass http://auth_api;
+ proxy_read_timeout 10s;
+ proxy_send_timeout 10s;
+ }
+
+ # Probes for uptime monitoring: short timeouts, not logged.
+ location ~ ^/(live|ready|health)$ {
+ limit_req zone=api_general burst=50 nodelay;
+ limit_req_status 429;
+ access_log off;
- # ---------------------------------------------------------------------------
- # Block unwanted access
- # ---------------------------------------------------------------------------
+ proxy_pass http://auth_api;
+ proxy_read_timeout 5s;
+ proxy_send_timeout 5s;
+ }
- # Hidden files (.env, .git, etc.)
+ # Hidden files (.env, .git, ...).
location ~ /\. {
deny all;
}
- # Block unsupported HTTP methods
- if ($request_method !~ ^(GET|POST|PATCH|DELETE|OPTIONS)$) {
- return 405;
- }
-
- # ---------------------------------------------------------------------------
- # Auth routes - strict rate limit
- # ---------------------------------------------------------------------------
+ # -------------------------------------------------------------------------
+ # Credential-bearing routes - strict rate limit
+ # -------------------------------------------------------------------------
+ # Anchored on both ends: `/auth/login-anything` is not a login route.
+ # Logout stays in the general zone so an exhausted auth budget never
+ # prevents someone from ending their session.
- location ~ ^/auth/(register|login|refresh|forgot-password|reset-password|two-factor) {
+ location ~ ^/auth/(register|login|refresh|verify-email|forgot-password|reset-password|two-factor/[a-z-]+|device|device/token|device/verify|authorize|authorize/token)$ {
limit_req zone=api_auth burst=10 nodelay;
limit_req_status 429;
- proxy_pass http://127.0.0.1:3000;
- proxy_http_version 1.1;
-
- proxy_set_header Host $host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_pass http://auth_api;
+ }
- proxy_hide_header X-Powered-By;
+ location ~ ^/users/me/(reauth|email/(start|verify-current|submit|confirm))$ {
+ limit_req zone=api_auth burst=10 nodelay;
+ limit_req_status 429;
- proxy_read_timeout 10s;
- proxy_send_timeout 10s;
- proxy_connect_timeout 5s;
+ proxy_pass http://auth_api;
}
- # ---------------------------------------------------------------------------
- # All other API routes - general rate limit
- # ---------------------------------------------------------------------------
+ # -------------------------------------------------------------------------
+ # Everything else - general rate limit
+ # -------------------------------------------------------------------------
location / {
limit_req zone=api_general burst=50 nodelay;
limit_req_status 429;
- proxy_pass http://127.0.0.1:3000;
- proxy_http_version 1.1;
-
- proxy_set_header Host $host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto $scheme;
-
- proxy_hide_header X-Powered-By;
-
- proxy_read_timeout 30s;
- proxy_send_timeout 30s;
- proxy_connect_timeout 5s;
+ proxy_pass http://auth_api;
}
}
diff --git a/perf/README.md b/perf/README.md
new file mode 100644
index 0000000..45d572b
--- /dev/null
+++ b/perf/README.md
@@ -0,0 +1,126 @@
+# Performance campaign
+
+Measures the database and the HTTP API as a function of data volume (number of
+accounts) and load (concurrent clients). The latest report is
+[`docs/perf/performance-report.md`](../docs/perf/performance-report.md).
+
+## Running it
+
+```bash
+make perf # hours: 10k, 100k, 1M accounts
+make perf-report RUN=reports/perf/ # tables and charts into docs/perf/
+```
+
+`perf/run.sh` builds the release binaries, starts the dedicated infrastructure
+(`perf/infra.sh`), then for each volume grows the data set and runs every
+measurement. It needs `postgres`, `psql`, `redis-server`, `redis-cli` and
+`nats-server`, found in `PATH` or through `PG_BIN`, `REDIS_SERVER`,
+`REDIS_CLI` and `NATS_SERVER`.
+
+Nothing else CPU-intensive may run on the machine meanwhile: the components
+share it and are pinned to separate cores.
+
+| Variable | Default | Meaning |
+|----------|---------|---------|
+| `VOLUMES` | `10000 100000 1000000` | Accounts, cumulative |
+| `CONCURRENCY` | `1 4 16 64 256` | Concurrent HTTP clients |
+| `HTTP_SCENARIOS` | all eight | `profile sessions audit two_factor refresh login register mixed` |
+| `DB_SCENARIOS` | all eleven | Application queries, see `perf_load db` |
+| `DB_CONCURRENCY` | `1 8 32` | Database connections |
+| `DURATION` / `WARMUP` | `20` / `5` | Seconds per HTTP point |
+| `DB_DURATION` / `DB_WARMUP` | `10` / `3` | Seconds per database point |
+| `PG_CPUS`, `CACHE_CPUS`, `API_CPUS`, `LOAD_CPUS` | `0-2`, `3`, `4-6`, `7` | CPU pinning |
+| `OUT` | `reports/perf/` | Results directory |
+
+A smoke run takes a few minutes:
+
+```bash
+VOLUMES="2000 4000" CONCURRENCY="1 8" DB_CONCURRENCY=2 DURATION=3 WARMUP=1 \
+ DB_DURATION=2 DB_WARMUP=1 STATEMENTS_CONCURRENCY=8 SEED_CHUNK=1500 \
+ OUT=reports/perf/smoke perf/run.sh
+```
+
+## Pieces
+
+| File | Role |
+|------|------|
+| `infra.sh` | PostgreSQL 17 on disk with production durability, Redis, NATS; pinned |
+| `seed.sql` | Deterministic, incremental data set (identifiers derive from the account index) |
+| `run.sh` | The campaign |
+| `../src/bin/perf_load.rs` | Load generator, database benchmark, plans, sizes, statement statistics |
+| `report.py` | Tables and SVG charts from `results.jsonl` |
+
+## What the load generator does
+
+- **Closed loop:** each virtual client sends its next request as soon as the
+ previous one answers. Throughput is the maximum the system sustains at that
+ concurrency; latency is what a waiting client sees. It is not a fixed-rate
+ test.
+- **Tokens:** up to 100 000 access tokens are signed before the run for
+ accounts drawn uniformly, so session-cache misses are realistic.
+- **Client addresses:** each request comes from a random address among 262 144
+ (`X-Forwarded-For`, trusted from 127.0.0.1), so per-address budgets behave as
+ with real traffic.
+- **Refresh chains:** each virtual client signs in once before the clock starts
+ and then follows its own rotation chain.
+- **Measurements:** a 1 %-resolution histogram per operation, CPU per pinned
+ core group from `/proc/stat`, and `pg_stat_database` counters over the
+ measurement window.
+
+## Sizing validation
+
+`make sizing` (`perf/sizing.sh`) checks the profiles of `deploy/profiles/` the
+way production runs them: the production image in a container with the
+profile's CPU quota and memory limit, PostgreSQL and Redis started with the
+settings of `deploy/db/`, NATS with `nats.conf` and the profile's limits, each
+on its own cores. It needs only Docker and the release build of `perf_load`.
+
+| Phase | Measures | Criterion |
+|-------|----------|-----------|
+| `signin` | Sign-ins per second with 1 to 4 CPUs, p95, CPU throttling, peak memory; then 64 clients at once | 11 per CPU or more (within 15 %), no error, peak under 90 % of the limit, the instance survives the overload |
+| `footprint` | Mixed scenario at profile M on each volume: Redis memory and key families, NATS memory and CPU, PostgreSQL buffer hit ratio, pool saturation | No error, NATS under 70 % of its limit, database pool never full, no Redis pool wait |
+| `restore` | `pg_dump \| gzip` then `psql --single-transaction` of the largest volume, as `backup-db.sh` and `restore-db.sh` | Every account restored; the times are the RTO |
+| `soak` | `SOAK_SECS` of mixed traffic at `SOAK_PROFILE` | No error, no restart, working set growth under `SOAK_MAX_GROWTH_PCT` |
+
+| Variable | Default | |
+|----------|---------|-|
+| `PHASES` | `signin footprint restore soak` | Phases to run |
+| `VOLUMES` | `100000 1000000` | Accounts, cumulative; sign-ins run on the first |
+| `SIGNIN_CPUS` | `1 2 3 4` | CPU quotas tried |
+| `SIGNIN_SECS` / `OVERLOAD_SECS` | `60` / `30` | |
+| `FOOTPRINT_SECS` / `FOOTPRINT_CONCURRENCY` | `180` / `64` | |
+| `SOAK_PROFILE` / `SOAK_SECS` / `SOAK_CONCURRENCY` | `m` / `3600` / `32` | |
+| `PG_CPUS`, `PG_MEMORY`, `CACHE_CPUS`, `LOAD_CPUS` | `0-2`, `8g`, `3`, `7` | The API gets CPUs 4-6 (4-7 with 4 CPUs) |
+| `KEEP_DATA` | `0` | `1` keeps the seeded database volume for the next run |
+
+The verdicts are written to `summary.md` next to `results.jsonl`, and the script
+fails when one fails.
+
+## Soak test
+
+`make soak` (`perf/soak.sh`) runs the mixed scenario for an hour against one API
+process, on a data set of 10 000 accounts in its own `auth_soak` database, and
+samples the API's resident memory every 15 seconds into `memory.csv`. It fails
+when a request errors, or when memory over the last tenth of the run exceeds
+memory over the first tenth, after warm-up, by more than 20 %.
+
+| Variable | Default | |
+|----------|--------:|-|
+| `SOAK_SECS` | 3600 | Measured duration |
+| `SOAK_USERS` | 10000 | Accounts seeded |
+| `SOAK_CONCURRENCY` | 32 | Virtual users |
+| `SOAK_WARMUP` | 60 | Seconds excluded before measuring |
+| `SOAK_MAX_GROWTH_PCT` | 20 | Memory growth tolerated |
+
+The verdict and its figures are written to `soak.json` next to the run's
+results.
+
+First run, 2026-09-15 (same machine and CPU pinning as the campaign, production
+Argon2 parameters): 1 239 760 requests in 3 600 s at 32 virtual users, 344
+requests per second, no error. Resident memory went from 213.3 MiB over the
+first tenth to 213.6 MiB over the last (+0.2 %). Latency: p50 3.4 ms, p95 894 ms,
+p99 940 ms. The throughput is the ceiling the campaign measured for the same mix
+at 10 000 accounts (356 requests per second at 64 clients, p95 1 795 ms): the
+sign-ins and registrations of the mix each pay a full Argon2 hash on three API
+cores. The soak judges errors and memory only; it does not record latency over
+time.
diff --git a/perf/infra.sh b/perf/infra.sh
new file mode 100755
index 0000000..928efac
--- /dev/null
+++ b/perf/infra.sh
@@ -0,0 +1,89 @@
+#!/usr/bin/env bash
+# Dedicated, disk-backed infrastructure for performance runs.
+#
+# PostgreSQL runs with production durability (fsync, synchronous commit, full
+# page writes) on the local disk, never on tmpfs. Each service is pinned to its
+# own CPUs so the load generator and the API do not steal time from the
+# database, and so per-CPU utilization can be attributed to one component:
+#
+# PG_CPUS (default 0-2) PostgreSQL
+# CACHE_CPUS (default 3) Redis and NATS
+# API_CPUS (default 4-6) auth-api (see run.sh)
+# LOAD_CPUS (default 7) load generator (see run.sh)
+#
+# Usage: perf/infra.sh start|stop|status
+set -euo pipefail
+
+PERF_HOME=${PERF_HOME:-$HOME/.cache/auth-api-perf}
+PG_BIN=${PG_BIN:-$(dirname "$(command -v postgres)")}
+REDIS_SERVER=${REDIS_SERVER:-$(command -v redis-server)}
+REDIS_CLI=${REDIS_CLI:-$(command -v redis-cli)}
+NATS_SERVER=${NATS_SERVER:-$(command -v nats-server)}
+PG_PORT=${PG_PORT:-5434}
+REDIS_PORT=${REDIS_PORT:-6381}
+NATS_PORT=${NATS_PORT:-4225}
+PG_CPUS=${PG_CPUS:-0-2}
+CACHE_CPUS=${CACHE_CPUS:-3}
+
+listening() { ss -ltn | grep -q ":$1 "; }
+
+start() {
+ mkdir -p "$PERF_HOME/run" "$PERF_HOME/js"
+ if [ ! -d "$PERF_HOME/pgdata" ]; then
+ "$PG_BIN/initdb" -D "$PERF_HOME/pgdata" -U postgres --auth=trust >/dev/null
+ fi
+ if ! listening "$PG_PORT"; then
+ taskset -c "$PG_CPUS" "$PG_BIN/pg_ctl" -D "$PERF_HOME/pgdata" -l "$PERF_HOME/run/pg.log" -w start -o "\
+ -p $PG_PORT -c listen_addresses=127.0.0.1 -c unix_socket_directories='' \
+ -c max_connections=200 \
+ -c shared_buffers=4GB -c effective_cache_size=12GB \
+ -c work_mem=16MB -c maintenance_work_mem=1GB \
+ -c random_page_cost=1.1 -c effective_io_concurrency=200 \
+ -c fsync=on -c synchronous_commit=on -c full_page_writes=on -c wal_compression=on \
+ -c max_wal_size=8GB -c min_wal_size=1GB \
+ -c checkpoint_timeout=15min -c checkpoint_completion_target=0.9 \
+ -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=top \
+ -c track_io_timing=on" >/dev/null
+ fi
+ "$PG_BIN/psql" -h 127.0.0.1 -p "$PG_PORT" -U postgres -tAc \
+ "SELECT 1 FROM pg_database WHERE datname = 'auth_perf'" | grep -q 1 \
+ || "$PG_BIN/createdb" -h 127.0.0.1 -p "$PG_PORT" -U postgres auth_perf
+ "$PG_BIN/psql" -h 127.0.0.1 -p "$PG_PORT" -U postgres -d auth_perf -qc \
+ "CREATE EXTENSION IF NOT EXISTS pg_stat_statements"
+
+ if ! listening "$REDIS_PORT"; then
+ taskset -c "$CACHE_CPUS" "$REDIS_SERVER" --port "$REDIS_PORT" --bind 127.0.0.1 \
+ --save '' --appendonly no --maxmemory 2gb --maxmemory-policy noeviction \
+ --daemonize yes --dir "$PERF_HOME/run" --logfile "$PERF_HOME/run/redis.log" \
+ --pidfile "$PERF_HOME/run/redis.pid"
+ fi
+ if ! listening "$NATS_PORT"; then
+ nohup taskset -c "$CACHE_CPUS" "$NATS_SERVER" -a 127.0.0.1 -p "$NATS_PORT" \
+ -js -sd "$PERF_HOME/js" -P "$PERF_HOME/run/nats.pid" \
+ > "$PERF_HOME/run/nats.log" 2>&1 &
+ fi
+ sleep 1
+ status
+}
+
+stop() {
+ "$PG_BIN/pg_ctl" -D "$PERF_HOME/pgdata" -m fast stop >/dev/null 2>&1 || true
+ "$REDIS_CLI" -p "$REDIS_PORT" shutdown nosave >/dev/null 2>&1 || true
+ if [ -f "$PERF_HOME/run/nats.pid" ]; then
+ kill "$(cat "$PERF_HOME/run/nats.pid")" 2>/dev/null || true
+ fi
+ status
+}
+
+status() {
+ for port in "$PG_PORT" "$REDIS_PORT" "$NATS_PORT"; do
+ if listening "$port"; then echo "port $port: up"; else echo "port $port: down"; fi
+ done
+}
+
+case "${1:-}" in
+ start) start ;;
+ stop) stop ;;
+ status) status ;;
+ *) echo "usage: $0 start|stop|status" >&2; exit 2 ;;
+esac
diff --git a/perf/report.py b/perf/report.py
new file mode 100644
index 0000000..71ea838
--- /dev/null
+++ b/perf/report.py
@@ -0,0 +1,515 @@
+#!/usr/bin/env python3
+"""Turn a perf/run.sh results directory into tables and SVG charts.
+
+ python3 perf/report.py reports/perf/ docs/perf
+
+Writes