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 @@

- Latest release License: MIT Rust 2024 edition Framework: Axum @@ -13,87 +12,75 @@ Container: Docker

-**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 + +![Throughput](img/http-throughput.svg) + +![p95 latency](img/http-p95.svg) + +### 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 + +![Query p99 latency](img/db-p99.svg) + +![Query throughput](img/db-throughput.svg) + +### 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 @@ + + +p99 latency of the application queries, 8 connections +Milliseconds, logarithmic scale, one dot per user volume + + +10k + + +100k + + +1M + +0.1 + +1 + +10 +ms +User by email + + + + +Session by refresh token + + + + +Session validity + + + + +Active sessions of an account + + + + +Recent failures by identifier + + + + +Recent failures by address + + + + +Consecutive failures of an account + + + + +Roles and permissions + + + + +History page (50) + + + + +Second factors and codes + + + + +Sign-in writes (tx) + + + + + \ 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 @@ + + +Query throughput by number of connections +Queries per second per volume. Reads: the generator caps at 1 core from 8 connections, so these rates are lower bounds + + +10k + + +100k + + +1M +User by email + +0 + +10k + +20k + +30k + +1 +8 +32 +connections + + + + + + + + + + + + +1M +10k +100k +Session by refresh token + +0 + +10k + +20k + +30k + +1 +8 +32 +connections + + + + + + + + + + + + +1M +10k +100k +Session validity + +0 + +10k + +20k + +30k + +1 +8 +32 +connections + + + + + + + + + + + + +1M +100k +10k +Active sessions of an account + +0 + +10k + +20k + +30k + +1 +8 +32 +connections + + + + + + + + + + + + +1M +100k +10k +Recent failures by identifier + +0 + +10k + +20k + +30k + +1 +8 +32 +connections + + + + + + + + + + + + +1M +100k +10k +Recent failures by address + +0 + +10k + +20k + +30k + +1 +8 +32 +connections + + + + + + + + + + + + +100k +10k +1M +Consecutive failures of an account + +0 + +10k + +20k + +30k + +1 +8 +32 +connections + + + + + + + + + + + + +100k +1M +10k +Roles and permissions + +0 + +10k + +20k + +30k + +1 +8 +32 +connections + + + + + + + + + + + + +100k +10k +1M +History page (50) + +0 + +10k + +20k + +30k + +1 +8 +32 +connections + + + + + + + + + + + + +1M +100k +10k +Second factors and codes + +0 + +5k + +10k + +15k + +1 +8 +32 +connections + + + + + + + + + + + + +1M +10k +100k +Sign-in writes (tx) + +0 + +2k + +4k + +6k + +8k + +1 +8 +32 +connections + + + + + + + + + + + + +100k +10k +1M + \ 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 @@ + + +p95 latency by number of concurrent clients +Milliseconds, logarithmic scale, one line per user volume + + +10k + + +100k + + +1M +GET /users/me + +0.1 + +1 + +10 + +100 + +1 +4 +16 +64 +256 +concurrent clients + + + + + + + + + + + + + + + + + + +1M +10k +100k +GET /users/me/sessions + +0.1 + +1 + +10 + +100 + +1 +4 +16 +64 +256 +concurrent clients + + + + + + + + + + + + + + + + + + +10k +1M +100k +GET /users/me/audit + +0.1 + +1 + +10 + +100 + +1 +4 +16 +64 +256 +concurrent clients + + + + + + + + + + + + + + + + + + +10k +1M +100k +GET /users/me/two-factor + +0.1 + +1 + +10 + +100 + +1 +4 +16 +64 +256 +concurrent clients + + + + + + + + + + + + + + + + + + +10k +100k +1M +POST /auth/refresh + +1 + +10 + +100 + +1 +4 +16 +64 +256 +concurrent clients + + + + + + + + + + + + + + + + + + +1M +10k +100k +POST /auth/login + +10 + +100 + +1 s + +10 s + +1 +4 +16 +64 +256 +concurrent clients + + + + + + + + + + + + + + + + + + +1M +100k +10k +POST /auth/register + +10 + +100 + +1 s + +10 s + +1 +4 +16 +64 +256 +concurrent clients + + + + + + + + + + + + + + + + + + +1M +100k +10k +Mixed traffic + +10 + +100 + +1 s + +10 s + +1 +4 +16 +64 +256 +concurrent clients + + + + + + + + + + + + + + + + + + +100k +1M +10k + \ 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 @@ + + +Throughput by number of concurrent clients +Requests per second, successful or not, one line per user volume + + +10k + + +100k + + +1M +GET /users/me + +0 + +5k + +10k + +15k + +1 +4 +16 +64 +256 +concurrent clients + + + + + + + + + + + + + + + + + + +10k +100k +1M +GET /users/me/sessions + +0 + +5k + +10k + +15k + +1 +4 +16 +64 +256 +concurrent clients + + + + + + + + + + + + + + + + + + +10k +100k +1M +GET /users/me/audit + +0 + +5k + +10k + +15k + +1 +4 +16 +64 +256 +concurrent clients + + + + + + + + + + + + + + + + + + +100k +10k +1M +GET /users/me/two-factor + +0 + +5k + +10k + +15k + +1 +4 +16 +64 +256 +concurrent clients + + + + + + + + + + + + + + + + + + +10k +1M +100k +POST /auth/refresh + +0 + +2k + +4k + +6k + +1 +4 +16 +64 +256 +concurrent clients + + + + + + + + + + + + + + + + + + +100k +10k +1M +POST /auth/login + +0 + +10 + +20 + +30 + +40 + +1 +4 +16 +64 +256 +concurrent clients + + + + + + + + + + + + + + + + + + +10k +100k +1M +POST /auth/register + +0 + +10 + +20 + +30 + +40 + +1 +4 +16 +64 +256 +concurrent clients + + + + + + + + + + + + + + + + + + +10k +100k +1M +Mixed traffic + +0 + +100 + +200 + +300 + +400 + +1 +4 +16 +64 +256 +concurrent clients + + + + + + + + + + + + + + + + + + +10k +1M +100k + \ 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 + +![Throughput by number of concurrent clients](img/http-throughput.svg) + +| 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 + +![p95 latency by number of concurrent clients](img/http-p95.svg) + +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 + +![p99 latency of the application queries](img/db-p99.svg) + +**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. + +![Query throughput by number of connections](img/db-throughput.svg) + +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 /data.md (every table) and /img/*.svg. The narrative +report (docs/perf/performance-report.md) quotes and links them. Standard +library only. +""" + +import json +import math +import sys +from collections import defaultdict +from pathlib import Path + +# Chart chrome and categorical slots 1-3 of the validated reference palette +# (the first three validate all-pairs; slot 3 sits under 3:1 on the surface, +# so every line carries a direct label and every chart has a table). +SURFACE = "#fcfcfb" +INK = "#0b0b0b" +INK_2 = "#52514e" +MUTED = "#898781" +GRID = "#e1e0d9" +AXIS = "#c3c2b7" +SERIES = ["#2a78d6", "#eb6834", "#1baf7a"] +FONT = 'system-ui, -apple-system, "Segoe UI", sans-serif' + +SCENARIO_TITLES = { + "profile": "GET /users/me", + "sessions": "GET /users/me/sessions", + "audit": "GET /users/me/audit", + "two_factor": "GET /users/me/two-factor", + "refresh": "POST /auth/refresh", + "login": "POST /auth/login", + "register": "POST /auth/register", + "mixed": "Mixed traffic", +} + +QUERY_TITLES = { + "user_by_email": "User by email", + "session_by_token": "Session by refresh token", + "session_validation": "Session validity", + "active_sessions": "Active sessions of an account", + "failures_by_identifier": "Recent failures by identifier", + "failures_by_ip": "Recent failures by address", + "consecutive_failures": "Consecutive failures of an account", + "rbac": "Roles and permissions", + "audit_page": "History page (50)", + "two_factor_overview": "Second factors and codes", + "sign_in_write": "Sign-in writes (tx)", +} + + +def load(run_dir): + lines = [json.loads(l) for l in (run_dir / "results.jsonl").read_text().splitlines() if l.strip()] + env_path = run_dir / "environment.json" + env = json.loads(env_path.read_text()) if env_path.exists() else {} + if not env.get("cpu_model"): + cpuinfo = Path("/proc/cpuinfo") + if cpuinfo.exists(): + model = next((l.split(":", 1)[1].strip() for l in cpuinfo.read_text().splitlines() + if l.startswith("model name")), None) + env["cpu_model"] = model + return lines, env + + +def users_label(n): + if n >= 1_000_000 and n % 1_000_000 == 0: + return f"{n // 1_000_000}M" + if n >= 1_000 and n % 1_000 == 0: + return f"{n // 1_000}k" + return str(n) + + +def num(v, digits=0): + if v is None: + return "-" + if digits == 0: + return f"{v:,.0f}".replace(",", " ") + return f"{v:,.{digits}f}".replace(",", " ") + + +def ms(v): + if v is None: + return "-" + if v < 1: + return f"{v:.2f}" + if v < 100: + return f"{v:.1f}" + return num(v) + + +def mb(b): + return num((b or 0) / 1_048_576, 1 if (b or 0) < 10 * 1_048_576 else 0) + + +def setting_value(s): + """pg_settings value in human units (memory settings come in kB or 8kB pages).""" + unit, value = s.get("unit") or "", s["setting"] + factor = {"kB": 1, "8kB": 8, "MB": 1024}.get(unit) + if factor is None or not value.isdigit(): + return value + unit + kib = int(value) * factor + for size, suffix in ((1024 * 1024, "GB"), (1024, "MB")): + if kib >= size and kib % size == 0: + return f"{kib // size}{suffix}" + return f"{kib}kB" + + +def esc(text): + return str(text).replace("&", "&").replace("<", "<").replace(">", ">") + + +# SVG primitives ------------------------------------------------------------- + + +def svg_open(width, height): + return [ + f'', + f'', + ] + + +def text(x, y, content, size=11, fill=MUTED, anchor="start", weight="400"): + return ( + f'{esc(content)}' + ) + + +def legend(parts, x, y, names): + cursor = x + for i, name in enumerate(names): + parts.append( + f'' + ) + parts.append( + f'' + ) + parts.append(text(cursor + 24, y, name, 12, INK_2)) + cursor += 24 + 7.5 * len(name) + 22 + + +def nice_ceiling(v): + if v <= 0: + return 1 + exp = 10 ** math.floor(math.log10(v)) + for step in (1, 1.2, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10): + if v <= step * exp: + return step * exp + return 10 * exp + + +def nice_step(raw): + """Smallest 1, 2, 2.5 or 5 x 10^n at least `raw`.""" + if raw <= 0: + return 1 + exp = 10 ** math.floor(math.log10(raw)) + for factor in (1, 2, 2.5, 5, 10): + if raw <= factor * exp: + return factor * exp + return 10 * exp + + +def axis_number(v): + if v >= 1000: + return f"{v / 1000:g}k" + return f"{v:g}" + + +def log_bounds(values): + lo = min(v for v in values if v > 0) + hi = max(values) + return 10 ** math.floor(math.log10(lo)), 10 ** math.ceil(math.log10(hi)) + + +def tick_label(v): + if v >= 1000: + return f"{v / 1000:g} s" if v % 1000 == 0 else f"{v:g}" + return f"{v:g}" + + +def line_panel(parts, ox, oy, w, h, title, xlabels, series, log_y, unit): + """series: [(name, [y or None per x])]. Draws into parts at (ox, oy).""" + left, right, top, bottom = 48, 40, 26, 30 + pw, ph = w - left - right, h - top - bottom + parts.append(text(ox, oy + 14, title, 12, INK, weight="600")) + values = [v for _, ys in series for v in ys if v is not None and v > 0] + if not values: + return + if log_y: + lo, hi = log_bounds(values) + ticks = [10 ** e for e in range(int(math.log10(lo)), int(math.log10(hi)) + 1)] + def ypos(v): + return oy + top + ph - (math.log10(v) - math.log10(lo)) / (math.log10(hi) - math.log10(lo)) * ph + else: + step = nice_step(max(values) / 4) + hi = step * math.ceil(max(values) / step) + ticks = [step * k for k in range(int(round(hi / step)) + 1)] + def ypos(v): + return oy + top + ph - v / hi * ph + for t in ticks: + y = ypos(t) if (not log_y or t > 0) else oy + top + ph + parts.append( + f'' + ) + label = tick_label(t) if log_y else axis_number(t) + parts.append(text(ox + left - 6, y + 4, label, 10, MUTED, "end")) + parts.append( + f'' + ) + step = pw / max(1, len(xlabels) - 1) + xpos = [ox + left + i * step for i in range(len(xlabels))] + for x, label in zip(xpos, xlabels): + parts.append(text(x, oy + top + ph + 16, label, 10, MUTED, "middle")) + parts.append(text(ox + left + pw, oy + top + ph + 28, unit, 10, MUTED, "end")) + + ends = [] + for si, (name, ys) in enumerate(series): + points = [(x, ypos(v)) for x, v in zip(xpos, ys) if v is not None and v > 0] + if not points: + continue + path = " ".join(f"{'M' if i == 0 else 'L'}{x:.1f},{y:.1f}" for i, (x, y) in enumerate(points)) + parts.append( + f'' + ) + for x, y in points: + parts.append( + f'' + ) + ends.append([points[-1][1], points[-1][0], name]) + # Direct labels at line ends, nudged apart. + ends.sort() + for i in range(1, len(ends)): + if ends[i][0] - ends[i - 1][0] < 12: + ends[i][0] = ends[i - 1][0] + 12 + for y, x, name in ends: + parts.append(text(x + 8, y + 4, name, 10, INK_2)) + + +def small_multiples(path, title, subtitle, panels, xlabels, series_names, log_y, unit, cols=4): + pw, ph = 300, 200 + rows = math.ceil(len(panels) / cols) + width, height = cols * pw + 24, rows * ph + 76 + parts = svg_open(width, height) + parts.append(text(12, 22, title, 15, INK, weight="600")) + parts.append(text(12, 40, subtitle, 12, INK_2)) + legend(parts, 12, 62, series_names) + for i, (panel_title, series) in enumerate(panels): + line_panel( + parts, 12 + (i % cols) * pw, 72 + (i // cols) * ph, pw - 12, ph - 8, + panel_title, xlabels, series, log_y, unit, + ) + parts.append("") + path.write_text("\n".join(parts)) + + +def dot_plot(path, title, subtitle, rows, series_names, unit): + """rows: [(label, [value or None per series])], log x axis.""" + label_w, width = 230, 900 + row_h, top = 26, 80 + height = top + len(rows) * row_h + 40 + parts = svg_open(width, height) + parts.append(text(12, 22, title, 15, INK, weight="600")) + parts.append(text(12, 40, subtitle, 12, INK_2)) + legend(parts, 12, 62, series_names) + values = [v for _, vs in rows for v in vs if v] + lo, hi = log_bounds(values) + pl, pr = label_w, width - 40 + def xpos(v): + return pl + (math.log10(v) - math.log10(lo)) / (math.log10(hi) - math.log10(lo)) * (pr - pl) + e = int(math.log10(lo)) + while 10 ** e <= hi: + x = xpos(10 ** e) + parts.append(f'') + parts.append(text(x, top + len(rows) * row_h + 16, tick_label(10 ** e), 10, MUTED, "middle")) + e += 1 + parts.append(text(pr, top + len(rows) * row_h + 32, unit, 10, MUTED, "end")) + for r, (label, vs) in enumerate(rows): + y = top + r * row_h + row_h / 2 + parts.append(text(12, y + 4, label, 12, INK_2)) + present = [(xpos(v), si) for si, v in enumerate(vs) if v] + if len(present) > 1: + xs = [x for x, _ in present] + parts.append(f'') + for x, si in present: + parts.append(f'') + parts.append("") + path.write_text("\n".join(parts)) + + +# Report --------------------------------------------------------------------- + + +def main(): + run_dir, out_dir = Path(sys.argv[1]), Path(sys.argv[2]) + img = out_dir / "img" + img.mkdir(parents=True, exist_ok=True) + lines, env = load(run_dir) + + by_kind = defaultdict(list) + for line in lines: + by_kind[line["kind"]].append(line) + volumes = sorted({l["users"] for l in lines if "users" in l}) + vnames = [users_label(v) for v in volumes][:3] + volumes = volumes[:3] + + md = ["", ""] + + # Environment and data set + md += ["## Measurement environment", ""] + md += ["| Item | Value |", "|---|---|"] + for key, label in [ + ("cpu_model", "CPU"), ("cpus", "Cores"), ("memory_gb", "Memory (GB)"), + ("kernel", "Kernel"), ("postgres", "PostgreSQL"), ("commit", "Measured commit"), + ("cpu_groups", "CPU pinning"), ("api_db_pool", "API PostgreSQL pool"), + ("argon2", "Argon2id"), ("duration_secs", "HTTP measurement (s)"), ("warmup_secs", "HTTP warm-up (s)"), + ("db_duration_secs", "SQL measurement (s)"), + ]: + if key in env: + md.append(f"| {label} | {esc(env[key])} |") + explains = {l["users"]: l for l in by_kind["explain"]} + if explains: + settings = next(iter(explains.values()))["settings"] + md.append("| PostgreSQL settings | " + ", ".join( + f"`{s['name']}={setting_value(s)}`" for s in settings) + " |") + md.append("") + + seeds = {l["users"]: l for l in by_kind["seed"]} + md += ["## Data volumes", ""] + relations_of_interest = ["users", "sessions", "login_attempts", "audit_log", "two_factor_methods", "recovery_codes"] + header = "| Table | " + " | ".join(f"{n}: rows | {n}: table / index (MB)" for n in vnames) + " |" + md += [header, "|---|" + "---:|---:|" * len(vnames)] + for rel in relations_of_interest: + row = [f"`{rel}`"] + for v in volumes: + e = explains.get(v) + r = next((x for x in (e or {}).get("relations", []) if x["relation"] == rel), None) + row.append(num(r["rows"]) if r else "-") + row.append(f"{mb(r['table_bytes'])} / {mb(r['index_bytes'])}" if r else "-") + md.append("| " + " | ".join(row) + " |") + md.append("| **Whole database (MB)** | " + " | ".join( + (mb(explains[v]["database_bytes"]) if v in explains else "-") + " | " for v in volumes).rstrip(" |") + " | |") + md.append("| Seeding time (s) | " + " | ".join( + (f"{seeds[v]['seconds']} (+{num(seeds[v]['added'])} accounts)" if v in seeds else "-") + " | " for v in volumes).rstrip(" |") + " | |") + md.append("") + + # HTTP + http = by_kind["http"] + scenarios = [s for s in SCENARIO_TITLES if any(l["scenario"] == s for l in http)] + levels = sorted({l["concurrency"] for l in http}) + idx = {(l["scenario"], l["users"], l["concurrency"]): l for l in http} + + if http: + small_multiples( + img / "http-throughput.svg", + "Throughput by number of concurrent clients", + "Requests per second, successful or not, one line per user volume", + [(SCENARIO_TITLES[s], [(n, [idx.get((s, v, c), {}).get("rps") for c in levels]) for n, v in zip(vnames, volumes)]) for s in scenarios], + [str(c) for c in levels], vnames, False, "concurrent clients", + ) + small_multiples( + img / "http-p95.svg", + "p95 latency by number of concurrent clients", + "Milliseconds, logarithmic scale, one line per user volume", + [(SCENARIO_TITLES[s], [(n, [idx.get((s, v, c), {}).get("latency_ms", {}).get("p95") for c in levels]) for n, v in zip(vnames, volumes)]) for s in scenarios], + [str(c) for c in levels], vnames, True, "concurrent clients", + ) + md += ["## HTTP API", "", "![Throughput](img/http-throughput.svg)", "", "![p95 latency](img/http-p95.svg)", ""] + md += ["### Maximum throughput per scenario", ""] + md += ["| Scenario | " + " | ".join(f"{n}: max req/s (clients) | {n}: p95 at that point (ms)" for n in vnames) + " |", + "|---|" + "---:|---:|" * len(vnames)] + for s in scenarios: + row = [SCENARIO_TITLES[s]] + for v in volumes: + points = [idx[(s, v, c)] for c in levels if (s, v, c) in idx] + if not points: + row += ["-", "-"] + continue + best = max(points, key=lambda p: p["rps"]) + row += [f"{num(best['rps'])} ({best['concurrency']})", ms(best["latency_ms"]["p95"])] + md.append("| " + " | ".join(row) + " |") + md.append("") + for s in scenarios: + md += [f"### {SCENARIO_TITLES[s]}", ""] + md += ["| 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 |", + "|---:|---:|---:|---:|---:|---:|---:|---|---:|---:|---:|---:|---:|"] + for v in volumes: + for c in levels: + l = idx.get((s, v, c)) + if not l: + continue + errors = ", ".join(f"{k}x{num(n)}" for k, n in l["errors"].items()) or "0" + cpu = l.get("cpu", {}) + db = l.get("db", {}) + md.append("| " + " | ".join([ + users_label(v), str(c), num(l["rps"]), ms(l["latency_ms"]["p50"]), ms(l["latency_ms"]["p95"]), + ms(l["latency_ms"]["p99"]), ms(l["latency_ms"]["max"]), errors, + num(cpu.get("api", {}).get("cores_busy"), 2), num(cpu.get("postgres", {}).get("cores_busy"), 2), + num(cpu.get("cache", {}).get("cores_busy"), 2), num(db.get("commits_per_sec")), + f"{db['cache_hit_ratio']:.4f}" if "cache_hit_ratio" in db else "-", + ]) + " |") + if s == "mixed": + md += ["", "Breakdown by operation at the highest volume and the highest concurrency:", ""] + top = idx.get((s, volumes[-1], levels[-1])) + if top: + md += ["| Operation | req/s | p50 (ms) | p95 (ms) | p99 (ms) | Errors |", "|---|---:|---:|---:|---:|---|"] + for op, st in top["operations"].items(): + md.append(f"| {op} | {num(st['rps'])} | {ms(st['latency_ms']['p50'])} | {ms(st['latency_ms']['p95'])} | {ms(st['latency_ms']['p99'])} | {', '.join(f'{k}x{n}' for k, n in st['errors'].items()) or '0'} |") + md.append("") + + # Database + db_lines = by_kind["db"] + if db_lines: + dlevels = sorted({l["concurrency"] for l in db_lines}) + didx = {(l["scenario"], l["users"], l["concurrency"]): l for l in db_lines} + queries = [q for q in QUERY_TITLES if any(l["scenario"] == q for l in db_lines)] + ref = 8 if 8 in dlevels else dlevels[len(dlevels) // 2] + dot_plot( + img / "db-p99.svg", + f"p99 latency of the application queries, {ref} connections", + "Milliseconds, logarithmic scale, one dot per user volume", + [(QUERY_TITLES[q], [didx.get((q, v, ref), {}).get("latency_ms", {}).get("p99") for v in volumes]) for q in queries], + vnames, "ms", + ) + small_multiples( + img / "db-throughput.svg", + "Query throughput by number of connections", + "Queries per second per volume. Reads: the generator caps at 1 core from 8 connections, so these rates are lower bounds", + [(QUERY_TITLES[q], [(n, [didx.get((q, v, c), {}).get("rps") for c in dlevels]) for n, v in zip(vnames, volumes)]) for q in queries], + [str(c) for c in dlevels], vnames, False, "connections", cols=4, + ) + md += ["## Database", "", f"![Query p99 latency](img/db-p99.svg)", "", "![Query throughput](img/db-throughput.svg)", ""] + md += ["### Application queries", ""] + md += ["| Query | Users | " + " | ".join(f"{c} conn.: req/s | {c} conn.: p50 / p99 (ms)" for c in dlevels) + " | PG CPU at " + str(dlevels[-1]) + " conn. | Disk reads/s |", + "|---|---:|" + "---:|---:|" * len(dlevels) + "---:|---:|"] + for q in queries: + for v in volumes: + row = [QUERY_TITLES[q], users_label(v)] + for c in dlevels: + l = didx.get((q, v, c)) + row += [num(l["rps"]), f"{ms(l['latency_ms']['p50'])} / {ms(l['latency_ms']['p99'])}"] if l else ["-", "-"] + last = didx.get((q, v, dlevels[-1]), {}) + row.append(num(last.get("cpu", {}).get("postgres", {}).get("cores_busy"), 2)) + row.append(num(last.get("db", {}).get("blocks_read_per_sec"))) + md.append("| " + " | ".join(row) + " |") + md.append("") + + # Plans + if explains: + md += ["### Execution plans", "", "Median of 7 `EXPLAIN (ANALYZE, BUFFERS)` runs for an account in the middle of the range.", ""] + names = [p["query"] for p in explains[volumes[0]]["plans"]] + md += ["| Query | " + " | ".join(f"{n}: ms (blocks read)" for n in vnames) + " | Plan at the highest volume |", + "|---|" + "---:|" * len(vnames) + "---|"] + for name in names: + row = [f"`{name}`"] + last_plan = None + for v in volumes: + p = next((x for x in explains.get(v, {}).get("plans", []) if x["query"] == name), None) + if p: + row.append(f"{ms(p['execution_ms_median'])} ({num(p['shared_read_blocks'] or 0)})") + last_plan = p + else: + row.append("-") + row.append(" → ".join(esc(n) for n in (last_plan or {}).get("nodes", [])) or "-") + md.append("| " + " | ".join(row) + " |") + md.append("") + top_v = volumes[-1] + md += [f"### Largest indexes at {users_label(top_v)} users", "", "| Index | Table | Size (MB) | Scans |", "|---|---|---:|---:|"] + for ix in explains[top_v]["indexes"][:15]: + md.append(f"| `{ix['index']}` | `{ix['relation']}` | {mb(ix['bytes'])} | {num(ix['scans'])} |") + md.append("") + + statements = by_kind["statements"] + if statements: + for st in statements: + md += [f"### `pg_stat_statements` at {users_label(st['users'])} users ({esc(st['context'])})", ""] + total = sum(x["total_ms"] for x in st["statements"]) or 1 + md += ["| Query | Calls | Mean (ms) | Share of SQL time | Blocks read |", "|---|---:|---:|---:|---:|"] + for x in st["statements"][:12]: + query = esc(x["query"][:110]).replace("|", "\\|") + md.append(f"| `{query}` | {num(x['calls'])} | {ms(x['mean_ms'])} | {x['total_ms'] / total:.1%} | {num(x['shared_read'])} |") + md.append("") + + cleanups = by_kind["cleanup"] + if cleanups: + md += ["### Purge (batches of 5 000 rows)", "", "| Job | " + " | ".join(f"{users_label(c['users'])}: rows / ms per batch" for c in cleanups) + " |", + "|---|" + "---:|" * len(cleanups) + ""] + jobs = [] + for c in cleanups: + for b in c["batches"]: + if b["job"] not in jobs: + jobs.append(b["job"]) + for job in jobs: + row = [f"`{job}`"] + for c in cleanups: + batches = [b for b in c["batches"] if b["job"] == job] + row.append(", ".join(f"{num(b['deleted'])} / {ms(b['ms'])}" for b in batches)) + md.append("| " + " | ".join(row) + " |") + md.append("") + + (out_dir / "data.md").write_text("\n".join(md)) + print(f"wrote {out_dir / 'data.md'} and {len(list(img.glob('*.svg')))} charts") + + +if __name__ == "__main__": + main() diff --git a/perf/run.sh b/perf/run.sh new file mode 100755 index 0000000..7580144 --- /dev/null +++ b/perf/run.sh @@ -0,0 +1,207 @@ +#!/usr/bin/env bash +# Performance campaign: data volume x load, for the database and the HTTP API. +# +# For each volume of VOLUMES (users), the data set is grown to that size, then: +# 1. query plans, relation and index sizes (perf_load explain) +# 2. the application's queries at DB_CONCURRENCY (perf_load db) +# 3. every HTTP scenario at each CONCURRENCY level (perf_load http) +# 4. pg_stat_statements over the mixed scenario, retention batches +# +# Everything lands in $OUT/results.jsonl; perf/report.py writes the report. +# The infrastructure is started by perf/infra.sh (see it for CPU pinning). +set -euo pipefail +ROOT=$(cd "$(dirname "$0")/.." && pwd) +cd "$ROOT" + +VOLUMES=${VOLUMES:-"10000 100000 1000000"} +CONCURRENCY=${CONCURRENCY:-"1 4 16 64 256"} +HTTP_SCENARIOS=${HTTP_SCENARIOS:-"profile sessions audit two_factor refresh login register mixed"} +DB_SCENARIOS=${DB_SCENARIOS-"user_by_email session_by_token session_validation active_sessions failures_by_identifier failures_by_ip consecutive_failures rbac audit_page two_factor_overview sign_in_write"} +DB_CONCURRENCY=${DB_CONCURRENCY:-"1 8 32"} +DURATION=${DURATION:-20} +WARMUP=${WARMUP:-5} +DB_DURATION=${DB_DURATION:-10} +DB_WARMUP=${DB_WARMUP:-3} +STATEMENTS_CONCURRENCY=${STATEMENTS_CONCURRENCY:-64} +SEED_CHUNK=${SEED_CHUNK:-50000} +PG_PORT=${PG_PORT:-5434} +REDIS_PORT=${REDIS_PORT:-6381} +NATS_PORT=${NATS_PORT:-4225} +API_PORT=${API_PORT:-3100} +SMTP_PORT=${SMTP_PORT:-1026} +API_DB_POOL=${API_DB_POOL:-32} +PG_CPUS=${PG_CPUS:-0-2} +CACHE_CPUS=${CACHE_CPUS:-3} +API_CPUS=${API_CPUS:-4-6} +LOAD_CPUS=${LOAD_CPUS:-7} +PG_BIN=${PG_BIN:-$(dirname "$(command -v postgres)")} +REDIS_CLI=${REDIS_CLI:-$(command -v redis-cli)} +OUT=${OUT:-reports/perf/$(date +%Y%m%d-%H%M%S)} +# The campaign database, dropped and seeded again on every run. +PERF_DB=${PERF_DB:-auth_perf} +export PG_BIN REDIS_CLI PG_PORT REDIS_PORT NATS_PORT PG_CPUS CACHE_CPUS + +export PERF_PASSWORD=${PERF_PASSWORD:-Perf-Password-2026!} +export PERF_CPU_GROUPS="postgres=$PG_CPUS;cache=$CACHE_CPUS;api=$API_CPUS;load=$LOAD_CPUS" +export DATABASE_URL="postgres://postgres@127.0.0.1:$PG_PORT/$PERF_DB" +export BASE_URL="http://127.0.0.1:$API_PORT" +export APP_PUBLIC_URL="$BASE_URL" +JWT_PRIVATE_KEY=$(grep '^JWT_PRIVATE_KEY=' .env.dev | cut -d= -f2-) +JWT_PUBLIC_KEY=$(grep '^JWT_PUBLIC_KEY=' .env.dev | cut -d= -f2-) +export JWT_PRIVATE_KEY JWT_PUBLIC_KEY + +PSQL=("$PG_BIN/psql" -h 127.0.0.1 -p "$PG_PORT" -U postgres -X -q -v ON_ERROR_STOP=1) +LOAD=(taskset -c "$LOAD_CPUS" "$ROOT/target/release/perf_load") + +mkdir -p "$OUT/api" +OUT=$(cd "$OUT" && pwd) +RESULTS="$OUT/results.jsonl" +log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$OUT/run.log"; } + +API_PID="" +start_api() { + (cd "$OUT/api" && exec taskset -c "$API_CPUS" "$ROOT/target/release/auth-api" >>api.log 2>&1) & + API_PID=$! + for _ in $(seq 1 60); do + curl -fsS "$BASE_URL/health" >/dev/null 2>&1 && return 0 + sleep 0.5 + done + log "API did not start; see $OUT/api/api.log" + exit 1 +} +stop_api() { + if [ -n "$API_PID" ]; then + kill "$API_PID" 2>/dev/null || true + wait "$API_PID" 2>/dev/null || true + API_PID="" + fi +} +trap stop_api EXIT + +log "building" +cargo build --release --quiet --bin auth-api --bin perf_load + +log "infrastructure" +perf/infra.sh start | tee -a "$OUT/run.log" + +log "fresh database" +"${PSQL[@]}" -d postgres -c "DROP DATABASE IF EXISTS $PERF_DB WITH (FORCE)" -c "CREATE DATABASE $PERF_DB" +"${PSQL[@]}" -d "$PERF_DB" -c "CREATE EXTENSION IF NOT EXISTS pg_stat_statements" +"${LOAD[@]}" migrate +HASH=$("${LOAD[@]}" hash) + +# Every value single-quoted: dotenvy stops at the first line it cannot parse +# (the PEM keys contain spaces) and silently leaves the rest unset. +sed -E "s/^([A-Z0-9_]+)=(.*)$/\1='\2'/" > "$OUT/api/.env" <> "$RESULTS" + seeded=$volume + + log "$label: plans and sizes" + "${LOAD[@]}" explain --users "$volume" --label "$label" --out "$RESULTS" + + log "$label: database queries" + for scenario in $DB_SCENARIOS; do + for c in $DB_CONCURRENCY; do + "${LOAD[@]}" db --scenario "$scenario" --concurrency "$c" --duration "$DB_DURATION" \ + --warmup "$DB_WARMUP" --users "$volume" --label "$label" --out "$RESULTS" \ + | tee -a "$OUT/run.log" || log "db $scenario c=$c failed" + done + done + + log "$label: HTTP scenarios" + start_api + for scenario in $HTTP_SCENARIOS; do + for c in $CONCURRENCY; do + "$REDIS_CLI" -p "$REDIS_PORT" FLUSHALL >/dev/null + statements=0 + [ "$scenario" = mixed ] && [ "$c" = "$STATEMENTS_CONCURRENCY" ] && statements=1 + [ "$statements" = 1 ] && "${LOAD[@]}" statements reset + "${LOAD[@]}" http --scenario "$scenario" --concurrency "$c" --duration "$DURATION" \ + --warmup "$WARMUP" --users "$volume" --label "$label" --out "$RESULTS" \ + | tee -a "$OUT/run.log" || log "http $scenario c=$c failed" + [ "$statements" = 1 ] && "${LOAD[@]}" statements dump --users "$volume" --label "$label" \ + --context "mixed, $c virtual users" --out "$RESULTS" + done + done + stop_api + + log "$label: retention batches" + "${LOAD[@]}" cleanup --users "$volume" --label "$label" --out "$RESULTS" + "${PSQL[@]}" -d "$PERF_DB" -c "VACUUM (ANALYZE)" +done + +log "done: $RESULTS" diff --git a/perf/seed.sql b/perf/seed.sql new file mode 100644 index 0000000..46544ee --- /dev/null +++ b/perf/seed.sql @@ -0,0 +1,153 @@ +-- Deterministic, incremental data set for performance runs. +-- +-- Seeds users :from..:to (inclusive) with the history an account accumulates +-- in production. Identifiers derive from the user index, so the load +-- generator can address any user without reading the database: +-- +-- user id perf_uuid('perf-user-' || i) +-- email perf@example.com +-- active sessions perf_uuid('perf-session-' || i || '-' || k), k = 1..2 +-- refresh tokens 'perf-rt-' || i || '-' || k +-- +-- Per user: 2 active sessions and 3 expired or revoked ones, 10 login +-- attempts over 90 days (2 failures), 15 audit entries over 25 days, a used +-- email verification token. Every 5th user has TOTP with 10 recovery codes, +-- every 20th (offset 1) email two-factor, every 10th a used reset token. +-- Every user shares one Argon2id hash (:'hash') computed with production +-- parameters, so verifying a password costs what it costs in production. +-- +-- psql -v from=1 -v to=50000 -v hash='$argon2id$...' -f perf/seed.sql + +\set ON_ERROR_STOP on +SET synchronous_commit = off; + +-- First 16 bytes of the SHA-256 of a seed string, as a UUID: the load +-- generator derives the same identifiers without querying the database. +CREATE OR REPLACE FUNCTION perf_uuid(seed text) RETURNS uuid + LANGUAGE sql IMMUTABLE PARALLEL SAFE + AS $$ SELECT substr(encode(sha256(convert_to(seed, 'UTF8')), 'hex'), 1, 32)::uuid $$; + +BEGIN; + +INSERT INTO users (id, created_at, email_verified_at, last_login_at, status, + preferred_locale, username, email, password_hash) +SELECT perf_uuid('perf-user-' || i), + now() - ((i % 365) + 1) * interval '1 day', + now() - ((i % 365) + 1) * interval '1 day' + interval '1 minute', + now() - (i % 720) * interval '1 hour', + 'active', + CASE WHEN i % 4 = 0 THEN 'fr' ELSE 'en' END, + 'perf_' || i, + 'perf' || i || '@example.com', + :'hash' +FROM generate_series(:from, :to) i; + +INSERT INTO user_roles (user_id, role_id) +SELECT perf_uuid('perf-user-' || i), r.id +FROM generate_series(:from, :to) i +CROSS JOIN (SELECT id FROM roles WHERE is_default) r; + +-- Active sessions: the ones access tokens and refresh tokens point at. +INSERT INTO sessions (id, user_id, session_family_id, family_created_at, created_at, + last_used_at, expires_at, ip_address, device_name, remember_me, + token_hash, user_agent, session_type) +SELECT perf_uuid('perf-session-' || i || '-' || k), + perf_uuid('perf-user-' || i), + perf_uuid('perf-family-' || i || '-' || k), + now() - interval '5 days', + now() - interval '1 day', + now() - ((i + k) % 600) * interval '1 minute', + now() + interval '25 days', + ('10.' || (i % 250) || '.' || ((i / 250) % 250) || '.' || k)::inet, + CASE k WHEN 1 THEN 'Laptop' ELSE 'Phone' END, + k = 1, + sha256(convert_to('perf-rt-' || i || '-' || k, 'UTF8')), + 'Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0', + 'web' +FROM generate_series(:from, :to) i +CROSS JOIN generate_series(1, 2) k; + +-- History: expired and revoked sessions, past the cleanup grace period. +INSERT INTO sessions (user_id, session_family_id, family_created_at, created_at, last_used_at, + expires_at, revoked_at, ip_address, remember_me, token_hash, user_agent) +SELECT perf_uuid('perf-user-' || i), + perf_uuid('perf-old-family-' || i || '-' || k), + now() - interval '60 days', + now() - (30 + k) * interval '1 day', + now() - (29 + k) * interval '1 day', + now() - (5 + k) * interval '1 day', + now() - (20 + k) * interval '1 day', + '198.51.100.7', + false, + sha256(convert_to('perf-old-' || i || '-' || k, 'UTF8')), + 'Mozilla/5.0 (X11; Linux x86_64; rv:139.0) Gecko/20100101 Firefox/139.0' +FROM generate_series(:from, :to) i +CROSS JOIN generate_series(1, 3) k; + +INSERT INTO login_attempts (user_id, attempted_at, attempted_identifier, was_successful, + failure_reason, request_ip, request_user_agent) +SELECT perf_uuid('perf-user-' || i), + now() - interval '1 hour' - ((i * 7 + k * 13) % (90 * 24)) * interval '1 hour', + 'perf' || i || '@example.com', + k > 2, + CASE WHEN k <= 2 THEN 'invalid_password'::login_failure_reason END, + ('10.' || (i % 250) || '.' || ((i / 250) % 250) || '.' || (k * 20))::inet, + CASE WHEN k <= 2 THEN 'Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Firefox/140.0' END +FROM generate_series(:from, :to) i +CROSS JOIN generate_series(1, 10) k; + +INSERT INTO audit_log (user_id, request_id, created_at, action, ip_address, metadata) +SELECT perf_uuid('perf-user-' || i), + perf_uuid('perf-request-' || i || '-' || k), + now() - ((i * 11 + k * 97) % (25 * 24 * 60)) * interval '1 minute', + (ARRAY['login', 'login', 'login', 'logout', 'reauthenticated', 'password_changed', + 'session_revoked', 'two_factor_verified']::audit_action[])[1 + (i + k) % 8], + ('10.' || (i % 250) || '.' || ((i / 250) % 250) || '.9')::inet, + '{}'::jsonb +FROM generate_series(:from, :to) i +CROSS JOIN generate_series(1, 15) k; + +INSERT INTO two_factor_methods (user_id, method_type, is_primary, is_verified, totp_secret, + created_at, last_used_at) +SELECT perf_uuid('perf-user-' || i), 'totp', true, true, + 'v1:perfseed:' || encode(sha256(convert_to('perf-totp-' || i, 'UTF8')), 'base64'), + now() - interval '100 days', + now() - (i % 20) * interval '1 day' +FROM generate_series(:from, :to) i +WHERE i % 5 = 0; + +INSERT INTO two_factor_methods (user_id, method_type, is_primary, is_verified, created_at) +SELECT perf_uuid('perf-user-' || i), 'email', true, true, now() - interval '50 days' +FROM generate_series(:from, :to) i +WHERE i % 20 = 1; + +INSERT INTO recovery_codes (user_id, code_position, code_hash, created_at, expires_at, used_at) +SELECT perf_uuid('perf-user-' || i), p, + sha256(convert_to('perf-rc-' || i || '-' || p, 'UTF8')), + now() - interval '100 days', + now() + interval '265 days', + CASE WHEN p <= 2 THEN now() - interval '10 days' END +FROM generate_series(:from, :to) i +CROSS JOIN generate_series(1, 10) p +WHERE i % 5 = 0; + +INSERT INTO email_verification_tokens (user_id, token_hash, target_email, created_at, + expires_at, used_at) +SELECT perf_uuid('perf-user-' || i), + sha256(convert_to('perf-evt-' || i, 'UTF8')), + 'perf' || i || '@example.com', + now() - ((i % 365) + 1) * interval '1 day', + now() - ((i % 365) + 1) * interval '1 day' + interval '1 day', + now() - ((i % 365) + 1) * interval '1 day' + interval '10 minutes' +FROM generate_series(:from, :to) i; + +INSERT INTO password_reset_tokens (user_id, token_hash, created_at, expires_at, used_at) +SELECT perf_uuid('perf-user-' || i), + sha256(convert_to('perf-prt-' || i, 'UTF8')), + now() - interval '40 days', + now() - interval '40 days' + interval '30 minutes', + now() - interval '40 days' + interval '5 minutes' +FROM generate_series(:from, :to) i +WHERE i % 10 = 0; + +COMMIT; diff --git a/perf/sizing.sh b/perf/sizing.sh new file mode 100755 index 0000000..0d28c1e --- /dev/null +++ b/perf/sizing.sh @@ -0,0 +1,436 @@ +#!/usr/bin/env bash +# Sizing validation: the production image under the CPU and memory limits of +# the profiles (deploy/profiles), against PostgreSQL and Redis started with the +# settings of deploy/db and NATS with nats.conf, each container on its own +# cores with real cgroup quotas. +# +# Phases (PHASES, in this order): +# signin sign-ins per second, latency, CPU throttling and peak memory with +# 1 to 4 CPUs at the memory limit of the matching profile, then an +# overload of 64 clients the instance must survive +# footprint the mixed scenario at profile M on each volume: Redis memory and +# key families, NATS memory and CPU, PostgreSQL cache ratio, pool +# saturation +# restore dump and restore time of the largest volume (the RTO) +# soak SOAK_SECS of mixed traffic at SOAK_PROFILE: no error, no restart, +# stable memory +# +# The load generator is target/release/perf_load (perf/README.md). Results go to +# $OUT/results.jsonl, the verdicts to $OUT/summary.md. +# +# Usage: perf/sizing.sh (make sizing). Hours with the default volumes. +set -Eeuo pipefail +ROOT=$(cd "$(dirname "$0")/.." && pwd) +cd "$ROOT" + +PHASES=${PHASES:-"signin footprint restore soak"} +VOLUMES=${VOLUMES:-"100000 1000000"} +SIGNIN_CPUS=${SIGNIN_CPUS:-"1 2 3 4"} +SIGNIN_SECS=${SIGNIN_SECS:-60} +OVERLOAD_SECS=${OVERLOAD_SECS:-30} +FOOTPRINT_SECS=${FOOTPRINT_SECS:-180} +FOOTPRINT_CONCURRENCY=${FOOTPRINT_CONCURRENCY:-64} +SOAK_PROFILE=${SOAK_PROFILE:-m} +SOAK_SECS=${SOAK_SECS:-3600} +SOAK_CONCURRENCY=${SOAK_CONCURRENCY:-32} +SOAK_MAX_GROWTH_PCT=${SOAK_MAX_GROWTH_PCT:-20} +WARMUP=${WARMUP:-10} +SEED_CHUNK=${SEED_CHUNK:-50000} +PG_CPUS=${PG_CPUS:-0-2} +PG_MEMORY=${PG_MEMORY:-8g} +CACHE_CPUS=${CACHE_CPUS:-3} +LOAD_CPUS=${LOAD_CPUS:-7} +# Kept after the run when 1, so a later run does not seed a million accounts again. +KEEP_DATA=${KEEP_DATA:-0} +OUT=${OUT:-reports/perf/sizing-$(date +%Y%m%d-%H%M%S)} + +PG_IMAGE=${PG_IMAGE:-$(sed -n 's/^ *image: *\(postgres:.*\)$/\1/p' docker-compose.test.yml | head -1)} +REDIS_IMAGE=${REDIS_IMAGE:-$(sed -n 's/^ *image: *\(redis:.*\)$/\1/p' docker-compose.test.yml | head -1)} +NATS_IMAGE=${NATS_IMAGE:-$(sed -n 's/^ *image: *\(nats:.*\)$/\1/p' docker-compose.api.yml | head -1)} +API_IMAGE=auth-api:sizing +NET=auth-sizing +SUBNET=172.31.250.0/24 +PG_PORT=55440 +API_PORT=3200 +METRICS_PORT=3201 +DB=auth_sizing + +mkdir -p "$OUT" +OUT=$(cd "$OUT" && pwd) +RESULTS="$OUT/results.jsonl" +SAMPLER="" + +log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$OUT/run.log"; } +psql_db() { docker exec -i auth-sizing-pg psql -U postgres -d "$DB" -X -q -v ON_ERROR_STOP=1 "$@"; } +redis() { docker exec auth-sizing-redis redis-cli "$@" | tr -d '\r'; } +dev_env() { grep "^$1=" .env.dev | cut -d= -f2-; } +profile_value() { sed -n "s/^$2=//p" "deploy/profiles/$1.env"; } + +# One JSON object per line: emit KIND key=value...; numbers stay numbers. +emit() { + python3 - "$RESULTS" "$@" <<'PY' +import json, sys +out, kind, *pairs = sys.argv[1:] +record = {"kind": kind} +for pair in pairs: + key, value = pair.split("=", 1) + for cast in (int, float): + try: + value = cast(value) + break + except ValueError: + pass + record[key] = value +with open(out, "a") as f: + f.write(json.dumps(record) + "\n") +PY +} + +# rps, p95 and failed requests of the last HTTP run in results.jsonl. +last_http() { + python3 - "$RESULTS" <<'PY' +import json, sys +runs = [r for r in map(json.loads, filter(str.strip, open(sys.argv[1]))) if r.get("kind") == "http"] +run = runs[-1] +errors = run.get("errors") or 0 +errors = sum(errors.values()) if isinstance(errors, dict) else int(errors) +print(run["rps"], run["latency_ms"]["p95"], errors) +PY +} + +cgroup_dir() { + local id + id=$(docker inspect -f '{{.Id}}' "$1") + for dir in "/sys/fs/cgroup/system.slice/docker-$id.scope" "/sys/fs/cgroup/docker/$id"; do + [ -d "$dir" ] && { echo "$dir"; return; } + done + echo "no cgroup v2 directory for $1" >&2 + return 1 +} +cpu_stat() { awk -v key="$2" '$1 == key { print $2 }' "$1/cpu.stat"; } + +teardown() { + if [ -n "$SAMPLER" ]; then kill "$SAMPLER" 2>/dev/null || true; fi + docker rm -f auth-sizing-api auth-sizing-nats auth-sizing-redis auth-sizing-pg >/dev/null 2>&1 || true + docker network rm "$NET" >/dev/null 2>&1 || true + if [ "$KEEP_DATA" != 1 ]; then + docker volume rm auth-sizing-pgdata auth-sizing-dump >/dev/null 2>&1 || true + fi +} +trap teardown EXIT +trap 'log "failed at line $LINENO: $BASH_COMMAND"' ERR + +# --- Dependencies ------------------------------------------------------------- + +start_dependencies() { + docker network create --subnet "$SUBNET" "$NET" >/dev/null + + # PostgreSQL with the DB VPS settings (profile M), minus the listen address. + local pg_args=() line key value + while IFS= read -r line; do + line=${line%%#*} + [[ $line =~ ^[[:space:]]*([a-z_.]+)[[:space:]]*=[[:space:]]*(.*[^[:space:]])[[:space:]]*$ ]] || continue + key=${BASH_REMATCH[1]} + value=${BASH_REMATCH[2]} + value=${value#\'} + value=${value%\'} + [ "$key" = listen_addresses ] && continue + pg_args+=(-c "$key=$value") + done < deploy/db/postgresql.auth-api.conf + docker run -d --name auth-sizing-pg --network "$NET" --cpuset-cpus "$PG_CPUS" \ + --memory "$PG_MEMORY" --shm-size 1g -p "127.0.0.1:$PG_PORT:5432" \ + -e POSTGRES_HOST_AUTH_METHOD=trust -e POSTGRES_DB="$DB" \ + -v auth-sizing-pgdata:/var/lib/postgresql/data -v auth-sizing-dump:/dump \ + "$PG_IMAGE" postgres "${pg_args[@]}" >/dev/null + for _ in $(seq 1 60); do + docker exec auth-sizing-pg pg_isready -U postgres -d "$DB" >/dev/null 2>&1 && break + sleep 1 + done + # The entrypoint restarts the server once after creating the database. + sleep 3 + docker exec auth-sizing-pg pg_isready -U postgres -d "$DB" >/dev/null + + # Redis with the DB VPS settings, minus the address, port and ACL file. + local redis_args=() + while read -r key value; do + case "$key" in "" | \#* | bind | port | aclfile | protected-mode) continue ;; esac + redis_args+=("--$key" "$value") + done < deploy/db/redis.auth-api.conf + docker run -d --name auth-sizing-redis --network "$NET" --cpuset-cpus "$CACHE_CPUS" \ + "$REDIS_IMAGE" redis-server "${redis_args[@]}" >/dev/null + + # NATS with nats.conf and the limits of profile M. + printf 'authorization { token: "sizing-token" }\n' > "$OUT/nats-auth.conf" + chmod 644 "$OUT/nats-auth.conf" + docker run -d --name auth-sizing-nats --network "$NET" --cpuset-cpus "$CACHE_CPUS" \ + --cpus "$(profile_value m NATS_CPUS)" --memory "$(profile_value m NATS_MEMORY)" \ + -e GOMEMLIMIT="$(profile_value m NATS_GOMEMLIMIT)" \ + -v "$ROOT/nats.conf:/etc/nats/nats.conf:ro" -v "$OUT/nats-auth.conf:/etc/nats/auth.conf:ro" \ + "$NATS_IMAGE" --config /etc/nats/nats.conf >/dev/null +} + +# --- API ---------------------------------------------------------------------- + +# start_api CPUS MEMORY RESERVATION ARGON2 DB_POOL REDIS_POOL +start_api() { + local cpus=$1 cpuset=4-6 + [ "$cpus" -gt 3 ] && cpuset=4-7 + docker rm -f auth-sizing-api >/dev/null 2>&1 || true + docker run -d --name auth-sizing-api --network "$NET" \ + --cpuset-cpus "$cpuset" --cpus "$cpus" --memory "$2" --memory-reservation "$3" \ + --pids-limit 256 --read-only --cap-drop ALL --security-opt no-new-privileges:true \ + -p "127.0.0.1:$API_PORT:3000" -p "127.0.0.1:$METRICS_PORT:9464" \ + -e APP_ENV=development -e SERVER_HOST=0.0.0.0 -e SERVER_PORT=3000 \ + -e APP_PUBLIC_URL="http://127.0.0.1:$API_PORT" -e FRONTEND_URL=http://127.0.0.1:5173 \ + -e TRUSTED_PROXY_CIDRS="$SUBNET" \ + -e DATABASE_URL="postgres://postgres@auth-sizing-pg:5432/$DB" \ + -e DB_MAX_CONNECTIONS="$5" -e REDIS_URL=redis://auth-sizing-redis:6379 -e REDIS_POOL_SIZE="$6" \ + -e NATS_URL=nats://sizing-token@auth-sizing-nats:4222 \ + -e JWT_PRIVATE_KEY="$(dev_env JWT_PRIVATE_KEY)" -e JWT_PUBLIC_KEY="$(dev_env JWT_PUBLIC_KEY)" \ + -e ENCRYPTION_KEY="$(dev_env ENCRYPTION_KEY)" \ + -e ARGON2_MEMORY_KIB="$ARGON2_MEMORY_KIB" -e ARGON2_ITERATIONS="$ARGON2_ITERATIONS" \ + -e ARGON2_PARALLELISM="$ARGON2_PARALLELISM" -e ARGON2_MAX_CONCURRENCY="$4" \ + -e RATE_LIMIT_RPM=1000000000 -e RATE_LIMIT_AUTH_RPM=1000000000 \ + -e RATE_LIMIT_FAIL_OPEN=false -e RATE_LIMIT_ALLOW_MISSING_IP=false -e LOCKOUT_THRESHOLD=1000000 \ + -e SMTP_HOST=127.0.0.1 -e SMTP_PORT=1 -e SMTP_USERNAME= -e SMTP_PASSWORD= \ + -e SMTP_FROM_ADDRESS=perf@example.com \ + -e DEVICE_AUTH_VERIFICATION_URI=http://127.0.0.1:5173/device \ + -e LOG_LEVEL=warn -e LOG_FORMAT=json -e METRICS_ENABLED=true -e METRICS_PORT=9464 \ + -e CLEANUP_INTERVAL_SECS=86400 \ + "$API_IMAGE" >/dev/null + for _ in $(seq 1 60); do + [ "$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$API_PORT/ready")" = 200 ] && return 0 + sleep 1 + done + docker logs auth-sizing-api > "$OUT/api-failed.log" 2>&1 + log "the API did not become ready; see $OUT/api-failed.log" + exit 1 +} + +# start_profile NAME: the API with a profile's limits. +start_profile() { + start_api "$(profile_value "$1" API_CPUS)" "$(profile_value "$1" API_MEMORY)" \ + "$(profile_value "$1" API_MEMORY_RESERVATION)" "$(profile_value "$1" ARGON2_MAX_CONCURRENCY)" \ + "$(profile_value "$1" DB_MAX_CONNECTIONS)" "$(profile_value "$1" REDIS_POOL_SIZE)" +} + +load() { + taskset -c "$LOAD_CPUS" "$ROOT/target/release/perf_load" "$@" >> "$OUT/run.log" +} + +# Pool, queue and memory gauges of the API and Redis memory, every 2 seconds. +sampler_start() { + local file=$1 + echo "secs,db_in_use,db_max,redis_waiting,argon2_free,working_set,redis_used" > "$file" + ( + started=$(date +%s) + while :; do + metrics=$(curl -s --max-time 2 "http://127.0.0.1:$METRICS_PORT/metrics" || true) + gauge() { printf '%s\n' "$metrics" | awk -v name="$1" 'index($0, name " ") == 1 { printf "%d", $2 }'; } + used=$(redis INFO memory 2>/dev/null | sed -n 's/^used_memory://p') + echo "$(( $(date +%s) - started )),$(gauge 'auth_db_pool_connections{state="in_use"}'),$(gauge 'auth_db_pool_connections{state="max"}'),$(gauge auth_redis_pool_waiting),$(gauge argon2_queue_available_permits),$(gauge auth_container_memory_working_set_bytes),$used" >> "$file" + sleep 2 + done + ) & + SAMPLER=$! +} +sampler_stop() { + kill "$SAMPLER" 2>/dev/null || true + wait "$SAMPLER" 2>/dev/null || true + SAMPLER="" +} +column_max() { + python3 -c "import csv,sys; print(max([int(r[sys.argv[2]]) for r in csv.DictReader(open(sys.argv[1])) if r[sys.argv[2]]] or [0]))" "$1" "$2" +} + +# --- Data set ----------------------------------------------------------------- + +seed_to() { + local volume=$1 from to seeded started + seeded=$(psql_db -tAc "SELECT count(*) FROM users WHERE email LIKE 'perf%@example.com'") + [ "$seeded" -ge "$volume" ] && { log "users: $seeded already seeded"; return; } + log "seeding users $((seeded + 1))..$volume" + started=$(date +%s) + from=$((seeded + 1)) + while [ "$from" -le "$volume" ]; do + to=$((from + SEED_CHUNK - 1)) + [ "$to" -gt "$volume" ] && to=$volume + psql_db -v from="$from" -v to="$to" -v hash="$HASH" -f - < perf/seed.sql + from=$((to + 1)) + done + psql_db -c "VACUUM (ANALYZE)" -c "CHECKPOINT" + emit seed users="$volume" seconds=$(( $(date +%s) - started )) +} + +# --- Phases ------------------------------------------------------------------- + +phase_signin() { + local volume=$1 cpus profile memory reservation pool cg periods throttled rps p95 errors + for cpus in $SIGNIN_CPUS; do + # The profile with this CPU limit, if one has it (none has 2 CPUs). + profile=$({ grep -l "^API_CPUS=$cpus\$" deploy/profiles/*.env || true; } | head -1 | xargs -r basename | cut -d. -f1) + memory=$( [ -n "$profile" ] && profile_value "$profile" API_MEMORY || echo 512M) + reservation=$( [ -n "$profile" ] && profile_value "$profile" API_MEMORY_RESERVATION || echo 256M) + pool=$( [ -n "$profile" ] && profile_value "$profile" DB_MAX_CONNECTIONS || echo $((4 * cpus))) + log "signin: $cpus CPU, memory $memory (profile ${profile:-none})" + start_api "$cpus" "$memory" "$reservation" "$cpus" "$pool" "$pool" + cg=$(cgroup_dir auth-sizing-api) + periods=$(cpu_stat "$cg" nr_periods) + throttled=$(cpu_stat "$cg" nr_throttled) + load http --scenario login --concurrency $((4 * cpus)) --duration "$SIGNIN_SECS" \ + --warmup "$WARMUP" --users "$volume" --label "signin cpus=$cpus" --out "$RESULTS" + read -r rps p95 errors < <(last_http) + periods=$(( $(cpu_stat "$cg" nr_periods) - periods )) + throttled=$(( $(cpu_stat "$cg" nr_throttled) - throttled )) + local peak limit + peak=$(cat "$cg/memory.peak") + limit=$(cat "$cg/memory.max") + + log "signin: overload, 64 clients" + load http --scenario login --concurrency 64 --duration "$OVERLOAD_SECS" \ + --warmup 2 --users "$volume" --label "signin-overload cpus=$cpus" --out "$RESULTS" + local overload_p95 overload_errors overload_peak running oom + read -r _ overload_p95 overload_errors < <(last_http) + overload_peak=$(cat "$cg/memory.peak") + running=$(docker inspect -f '{{.State.Running}}' auth-sizing-api) + oom=$(docker inspect -f '{{.State.OOMKilled}}' auth-sizing-api) + emit signin cpus="$cpus" profile="${profile:-none}" memory_limit_bytes="$limit" \ + rps="$rps" p95_ms="$p95" errors="$errors" throttled_periods="$throttled" periods="$periods" \ + peak_bytes="$peak" overload_p95_ms="$overload_p95" overload_errors="$overload_errors" \ + overload_peak_bytes="$overload_peak" running="$running" oom_killed="$oom" + done +} + +phase_footprint() { + local volume=$1 cg_nats nats_usage hit read rps p95 errors started + log "footprint: users=$volume, profile M, mixed at $FOOTPRINT_CONCURRENCY clients" + start_profile m + redis FLUSHALL >/dev/null + cg_nats=$(cgroup_dir auth-sizing-nats) + nats_usage=$(cpu_stat "$cg_nats" usage_usec) + read -r hit read < <(psql_db -tA -F ' ' -c "SELECT blks_hit, blks_read FROM pg_stat_database WHERE datname = '$DB'") + sampler_start "$OUT/footprint-$volume.csv" + started=$(date +%s) + load http --scenario mixed --concurrency "$FOOTPRINT_CONCURRENCY" --duration "$FOOTPRINT_SECS" \ + --warmup "$WARMUP" --users "$volume" --label "footprint users=$volume" --out "$RESULTS" + local elapsed=$(( $(date +%s) - started )) + sampler_stop + read -r rps p95 errors < <(last_http) + local hit2 read2 + read -r hit2 read2 < <(psql_db -tA -F ' ' -c "SELECT blks_hit, blks_read FROM pg_stat_database WHERE datname = '$DB'") + local cache_ratio families + cache_ratio=$(python3 -c "h=$hit2-$hit; r=$read2-$read; print(round(h/(h+r), 4) if h+r else 1)") + families=$(redis --scan --count 1000 | sed 's/:.*//' | sort | uniq -c | sort -rn | awk '{printf "%s%s=%s", sep, $2, $1; sep=","}') + emit footprint users="$volume" rps="$rps" p95_ms="$p95" errors="$errors" \ + redis_used_peak_bytes="$(column_max "$OUT/footprint-$volume.csv" redis_used)" \ + redis_used_end_bytes="$(redis INFO memory | sed -n 's/^used_memory://p')" \ + redis_keys="$(redis DBSIZE)" redis_key_families="$families" \ + nats_peak_bytes="$(cat "$cg_nats/memory.peak")" nats_limit_bytes="$(cat "$cg_nats/memory.max")" \ + nats_cpu_cores="$(python3 -c "print(round(($(cpu_stat "$cg_nats" usage_usec) - $nats_usage) / 1e6 / $elapsed, 3))")" \ + pg_cache_ratio="$cache_ratio" \ + db_pool_in_use_max="$(column_max "$OUT/footprint-$volume.csv" db_in_use)" \ + db_pool_max="$(column_max "$OUT/footprint-$volume.csv" db_max)" \ + redis_pool_waiting_max="$(column_max "$OUT/footprint-$volume.csv" redis_waiting)" \ + api_working_set_max_bytes="$(column_max "$OUT/footprint-$volume.csv" working_set)" \ + api_limit_bytes="$(cat "$(cgroup_dir auth-sizing-api)/memory.max")" +} + +phase_restore() { + local volume=$1 started dump_secs restore_secs size source restored + docker rm -f auth-sizing-api >/dev/null 2>&1 || true + log "restore: dump of users=$volume (pg_dump | gzip, as backup-db.sh)" + started=$(date +%s) + docker exec auth-sizing-pg sh -c "pg_dump -U postgres --no-owner $DB | gzip > /dump/auth.sql.gz" + dump_secs=$(( $(date +%s) - started )) + size=$(docker exec auth-sizing-pg stat -c %s /dump/auth.sql.gz) + log "restore: into a fresh database (psql --single-transaction, as restore-db.sh)" + docker exec auth-sizing-pg createdb -U postgres auth_restored + started=$(date +%s) + docker exec auth-sizing-pg sh -c \ + "gunzip -c /dump/auth.sql.gz | psql -U postgres -d auth_restored -X -q -v ON_ERROR_STOP=1 --single-transaction > /dev/null" + restore_secs=$(( $(date +%s) - started )) + source=$(psql_db -tAc "SELECT count(*) FROM users") + restored=$(docker exec auth-sizing-pg psql -U postgres -d auth_restored -tAc "SELECT count(*) FROM users") + docker exec auth-sizing-pg dropdb -U postgres auth_restored + docker exec auth-sizing-pg rm -f /dump/auth.sql.gz + emit restore users="$volume" database_bytes="$(psql_db -tAc "SELECT pg_database_size('$DB')")" \ + dump_bytes="$size" dump_secs="$dump_secs" restore_secs="$restore_secs" \ + users_source="$source" users_restored="$restored" +} + +phase_soak() { + local volume=$1 + log "soak: profile $SOAK_PROFILE, users=$volume, mixed at $SOAK_CONCURRENCY clients for $SOAK_SECS s" + start_profile "$SOAK_PROFILE" + sampler_start "$OUT/soak.csv" + load http --scenario mixed --concurrency "$SOAK_CONCURRENCY" --duration "$SOAK_SECS" \ + --warmup 60 --users "$volume" --label "soak profile=$SOAK_PROFILE" --out "$RESULTS" + sampler_stop + local rps p95 errors + read -r rps p95 errors < <(last_http) + local verdict + verdict=$(python3 - "$OUT/soak.csv" "$SOAK_MAX_GROWTH_PCT" <<'PY' +import csv, statistics, sys +rows = [r for r in csv.DictReader(open(sys.argv[1])) if r["working_set"] and int(r["secs"]) >= 60] +memory = [int(r["working_set"]) for r in rows] +tenth = max(1, len(memory) // 10) +first, last = statistics.median(memory[:tenth]), statistics.median(memory[-tenth:]) +print(first, last, round((last - first) / first * 100, 1)) +PY +) + local first last growth + read -r first last growth <<< "$verdict" + emit soak profile="$SOAK_PROFILE" users="$volume" secs="$SOAK_SECS" rps="$rps" p95_ms="$p95" \ + errors="$errors" working_set_first_bytes="$first" working_set_last_bytes="$last" \ + growth_pct="$growth" max_growth_pct="$SOAK_MAX_GROWTH_PCT" \ + restarts="$(docker inspect -f '{{.RestartCount}}' auth-sizing-api)" \ + running="$(docker inspect -f '{{.State.Running}}' auth-sizing-api)" \ + oom_killed="$(docker inspect -f '{{.State.OOMKilled}}' auth-sizing-api)" +} + +# --- Run ---------------------------------------------------------------------- + +has_phase() { [[ " $PHASES " == *" $1 "* ]]; } + +read -r ARGON2_MEMORY_KIB ARGON2_ITERATIONS ARGON2_PARALLELISM < <( + for key in ARGON2_MEMORY_KIB ARGON2_ITERATIONS ARGON2_PARALLELISM; do + sed -n "s/^$key=//p" config.prod.env + done | paste -sd ' ') +export PERF_PASSWORD=${PERF_PASSWORD:-Perf-Password-2026!} +export PERF_CPU_GROUPS="postgres=$PG_CPUS;cache=$CACHE_CPUS;api=4-7;load=$LOAD_CPUS" +export DATABASE_URL="postgres://postgres@127.0.0.1:$PG_PORT/$DB" +export BASE_URL="http://127.0.0.1:$API_PORT" +export APP_PUBLIC_URL="$BASE_URL" +JWT_PRIVATE_KEY=$(dev_env JWT_PRIVATE_KEY) +JWT_PUBLIC_KEY=$(dev_env JWT_PUBLIC_KEY) +export JWT_PRIVATE_KEY JWT_PUBLIC_KEY + +log "building the image and the load generator" +docker build -q -t "$API_IMAGE" . >/dev/null +cargo build --release --quiet --bin perf_load +docker rm -f auth-sizing-api auth-sizing-nats auth-sizing-redis auth-sizing-pg >/dev/null 2>&1 || true +docker network rm "$NET" >/dev/null 2>&1 || true +start_dependencies +load migrate +HASH=$(taskset -c "$LOAD_CPUS" "$ROOT/target/release/perf_load" hash) +emit environment cpus="$(nproc)" commit="$(git rev-parse --short HEAD)" \ + argon2="m=$ARGON2_MEMORY_KIB,t=$ARGON2_ITERATIONS,p=$ARGON2_PARALLELISM" \ + pg_cpus="$PG_CPUS" pg_memory="$PG_MEMORY" cache_cpus="$CACHE_CPUS" load_cpus="$LOAD_CPUS" + +first=1 +largest="" +for volume in $VOLUMES; do + seed_to "$volume" + if [ "$first" = 1 ] && has_phase signin; then phase_signin "$volume"; fi + first=0 + if has_phase footprint; then phase_footprint "$volume"; fi + largest=$volume +done +if has_phase restore; then phase_restore "$largest"; fi +if has_phase soak; then phase_soak "$largest"; fi +docker rm -f auth-sizing-api >/dev/null 2>&1 || true + +# The report exits non-zero when a verdict fails: that is the result, not an +# error of this script. +trap - ERR +python3 perf/sizing_report.py "$RESULTS" | tee "$OUT/summary.md" diff --git a/perf/sizing_report.py b/perf/sizing_report.py new file mode 100644 index 0000000..c2d057f --- /dev/null +++ b/perf/sizing_report.py @@ -0,0 +1,138 @@ +"""Verdicts of perf/sizing.sh: a Markdown summary of results.jsonl. + +Criteria (docs/deploy/guides/operations.md, section 9): +- sign-ins: 11 per second per CPU within 15 %, no error, peak memory under 90 % + of the limit, the instance survives an overload without being killed; +- footprint: no error, NATS under 70 % of its memory limit, database pool + never full, no Redis pool wait. PostgreSQL's buffer hit ratio is shown, not + judged: it counts only shared_buffers, and the load reads accounts uniformly + across the whole database (below 0.99 at 1 million accounts with 8 or 14 GB); +- soak: no error, no restart, working set growth under the tolerance. + +Usage: python3 perf/sizing_report.py +""" + +import json +import sys + +MIB = 1024 * 1024 +SIGNINS_PER_CPU = 11 +TOLERANCE = 0.15 + + +def mib(value): + return f"{value / MIB:.0f} MiB" + + +def verdict(ok): + return "PASS" if ok else "FAIL" + + +def main(path): + records = [json.loads(line) for line in open(path) if line.strip()] + by_kind = {} + for record in records: + by_kind.setdefault(record["kind"], []).append(record) + failures = 0 + out = ["# Sizing validation", ""] + + for env in by_kind.get("environment", []): + out.append( + f"Commit {env['commit']}, {env['cpus']} host CPUs, Argon2 {env['argon2']}; " + f"PostgreSQL on CPUs {env['pg_cpus']} with {env['pg_memory']}, Redis and NATS " + f"on {env['cache_cpus']}, load generator on {env['load_cpus']}." + ) + out.append("") + + if "signin" in by_kind: + out += [ + "## Sign-ins under CPU quota", + "", + "| CPUs | Profile | Sign-ins/s | Per CPU | p95 | Throttled | Peak memory | Overload peak | Overload p95 | Verdict |", + "|-----:|---------|-----------:|--------:|----:|----------:|------------:|--------------:|-------------:|---------|", + ] + for r in by_kind["signin"]: + per_cpu = r["rps"] / r["cpus"] + limit = r["memory_limit_bytes"] + peak = max(r["peak_bytes"], r["overload_peak_bytes"]) + ok = ( + abs(per_cpu - SIGNINS_PER_CPU) / SIGNINS_PER_CPU <= TOLERANCE + or per_cpu > SIGNINS_PER_CPU + ) + ok = ok and r["errors"] == 0 and r["overload_errors"] == 0 + ok = ok and peak < 0.9 * limit and r["running"] == "true" and r["oom_killed"] == "false" + failures += not ok + throttled = r["throttled_periods"] / r["periods"] if r["periods"] else 0 + out.append( + f"| {r['cpus']} | {r['profile']} | {r['rps']:.1f} | {per_cpu:.1f} | {r['p95_ms']:.0f} ms " + f"| {throttled:.0%} | {mib(r['peak_bytes'])} / {mib(limit)} | {mib(r['overload_peak_bytes'])} " + f"| {r['overload_p95_ms']:.0f} ms | {verdict(ok)} |" + ) + out.append("") + + if "footprint" in by_kind: + out += [ + "## Footprint at profile M (mixed scenario)", + "", + "| Accounts | Req/s | p95 | Errors | Redis peak | Redis keys | NATS peak | NATS CPU | PG cache | DB pool max in use | Redis pool wait | API working set | Verdict |", + "|---------:|------:|----:|-------:|-----------:|-----------:|----------:|---------:|---------:|-------------------:|----------------:|----------------:|---------|", + ] + for r in by_kind["footprint"]: + ok = ( + r["errors"] == 0 + and r["nats_peak_bytes"] < 0.7 * r["nats_limit_bytes"] + and r["db_pool_in_use_max"] < r["db_pool_max"] + and r["redis_pool_waiting_max"] == 0 + and r["api_working_set_max_bytes"] < 0.9 * r["api_limit_bytes"] + ) + failures += not ok + out.append( + f"| {r['users']:,} | {r['rps']:.0f} | {r['p95_ms']:.0f} ms | {r['errors']} " + f"| {mib(r['redis_used_peak_bytes'])} | {r['redis_keys']:,} " + f"| {mib(r['nats_peak_bytes'])} / {mib(r['nats_limit_bytes'])} | {r['nats_cpu_cores']} " + f"| {r['pg_cache_ratio']} | {r['db_pool_in_use_max']} / {r['db_pool_max']} " + f"| {r['redis_pool_waiting_max']} | {mib(r['api_working_set_max_bytes'])} | {verdict(ok)} |" + ) + out.append("") + for r in by_kind["footprint"]: + out.append(f"Redis key families at {r['users']:,} accounts: {r['redis_key_families']}.") + out.append("") + + for r in by_kind.get("restore", []): + ok = r["users_source"] == r["users_restored"] + failures += not ok + out += [ + "## Restore", + "", + f"{r['users']:,} accounts, database {r['database_bytes'] / 1024**3:.1f} GiB: dump " + f"{r['dump_secs']} s ({r['dump_bytes'] / 1024**3:.2f} GiB compressed), restore " + f"{r['restore_secs']} s in one transaction, {r['users_restored']:,} accounts restored. " + f"{verdict(ok)}", + "", + ] + + for r in by_kind.get("soak", []): + ok = ( + r["errors"] == 0 + and r["restarts"] == 0 + and r["running"] == "true" + and r["growth_pct"] <= r["max_growth_pct"] + ) + failures += not ok + out += [ + "## Soak", + "", + f"Profile {r['profile'].upper()}, {r['users']:,} accounts, {r['secs']} s: {r['rps']:.0f} req/s, " + f"p95 {r['p95_ms']:.0f} ms, {r['errors']} errors, {r['restarts']} restarts; working set " + f"{mib(r['working_set_first_bytes'])} then {mib(r['working_set_last_bytes'])} " + f"({r['growth_pct']:+.1f} %, tolerance {r['max_growth_pct']} %). {verdict(ok)}", + "", + ] + + out.append(f"**{'All criteria met' if failures == 0 else f'{failures} verdict(s) failed'}.**") + print("\n".join(out)) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1])) diff --git a/perf/soak.sh b/perf/soak.sh new file mode 100755 index 0000000..dfe55c5 --- /dev/null +++ b/perf/soak.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Soak test: one API process under the mixed scenario for an hour, watching for +# errors and for resident memory that keeps growing. +# +# perf/run.sh does the work (release build, infrastructure, a data set of +# SOAK_USERS accounts in its own database) with a single long HTTP run; this +# script samples the API's memory meanwhile. It fails when a request errored, +# or when the API's memory over the last tenth of the run exceeds its memory +# over the first tenth, after warm-up, by more than SOAK_MAX_GROWTH_PCT. +set -euo pipefail +ROOT=$(cd "$(dirname "$0")/.." && pwd) +cd "$ROOT" + +SOAK_SECS=${SOAK_SECS:-3600} +SOAK_USERS=${SOAK_USERS:-10000} +SOAK_CONCURRENCY=${SOAK_CONCURRENCY:-32} +SOAK_WARMUP=${SOAK_WARMUP:-60} +SOAK_MAX_GROWTH_PCT=${SOAK_MAX_GROWTH_PCT:-20} +SAMPLE_SECS=${SAMPLE_SECS:-15} +export OUT=${OUT:-reports/perf/soak-$(date +%Y%m%d-%H%M%S)} +mkdir -p "$OUT" +MEMORY="$OUT/memory.csv" + +VOLUMES=$SOAK_USERS CONCURRENCY=$SOAK_CONCURRENCY HTTP_SCENARIOS=mixed DB_SCENARIOS="" \ + DURATION=$SOAK_SECS WARMUP=$SOAK_WARMUP STATEMENTS_CONCURRENCY=0 PERF_DB=auth_soak \ + perf/run.sh & +RUN_PID=$! +trap 'kill "$RUN_PID" 2>/dev/null || true' INT TERM + +# Resident memory of the API, sampled while it runs. +echo "elapsed_secs,rss_kib" > "$MEMORY" +started=$(date +%s) +while kill -0 "$RUN_PID" 2>/dev/null; do + pid=$(pgrep -n -f "^$ROOT/target/release/auth-api\$" || true) + if [ -n "$pid" ]; then + rss=$(awk '/^VmRSS:/ { print $2 }' "/proc/$pid/status" 2>/dev/null || true) + [ -n "$rss" ] && echo "$(( $(date +%s) - started )),$rss" >> "$MEMORY" + fi + sleep "$SAMPLE_SECS" +done +wait "$RUN_PID" + +python3 - "$OUT" "$SOAK_WARMUP" "$SAMPLE_SECS" "$SOAK_MAX_GROWTH_PCT" <<'PY' +import csv, json, statistics, sys + +out, warmup, sample_secs, max_growth = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]), float(sys.argv[4]) +results = [json.loads(line) for line in open(f"{out}/results.jsonl") if line.strip()] +http = [r for r in results if r.get("kind") == "http"] +if not http: + sys.exit("soak: no HTTP result in results.jsonl") +run = http[-1] +errors = run.get("errors") or {} +error_count = sum(errors.values()) if isinstance(errors, dict) else int(errors) + +rows = [row for row in csv.reader(open(f"{out}/memory.csv")) if row[0].isdigit()] +rss = [int(row[1]) for row in rows][max(1, warmup // sample_secs):] +if len(rss) < 10: + sys.exit(f"soak: {len(rss)} memory samples after warm-up, too few to judge") +tenth = max(1, len(rss) // 10) +first, last = statistics.median(rss[:tenth]), statistics.median(rss[-tenth:]) +growth = (last - first) / first * 100 + +summary = { + "duration_secs": run.get("duration_secs"), + "concurrency": run.get("concurrency"), + "rps": run.get("rps"), + "errors": errors, + "rss_first_tenth_kib": first, + "rss_last_tenth_kib": last, + "rss_growth_pct": round(growth, 1), + "max_growth_pct": max_growth, +} +json.dump(summary, open(f"{out}/soak.json", "w"), indent=2) +print(json.dumps(summary, indent=2)) +if error_count: + sys.exit(f"soak: {error_count} requests failed") +if growth > max_growth: + sys.exit(f"soak: memory grew {growth:.1f}% (limit {max_growth}%)") +print("soak: no error, memory stable") +PY diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..8db1bfb --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,8 @@ +# The toolchain of local builds and of the CI. Bump it deliberately: a new +# clippy brings new lints. `rust-version` in Cargo.toml stays the supported +# minimum. The Docker images keep their own pinned Rust image (this file is in +# .dockerignore, so the build does not download another toolchain). +[toolchain] +channel = "1.98.1" +components = ["rustfmt", "clippy"] +profile = "minimal" diff --git a/scripts/backup-db.sh b/scripts/backup-db.sh old mode 100644 new mode 100755 index 53db310..c75ca38 --- a/scripts/backup-db.sh +++ b/scripts/backup-db.sh @@ -1,77 +1,100 @@ #!/bin/bash # backup-db.sh - Encrypted PostgreSQL backup using age. # -# Produces: /var/backups/auth-api/auth_api_YYYYMMDD_HHMMSS.sql.gz.age -# Requires: age (https://github.com/FiloSottile/age), postgresql-client +# Produces $BACKUP_DIR/auth_api_YYYYMMDD_HHMMSS.sql.gz.age and, after a complete +# success only (offsite copy included), the node_exporter textfile metrics read +# by the AuthBackupMissing and AuthBackupShrunk alerts. # -# Setup: -# 1. Generate a key pair on a secure machine (NOT the DB VPS): -# age-keygen -o backup.key # keep this file offline/safe -# cat backup.key | grep "public" # copy the public key into AGE_PUBLIC_KEY below +# Configuration, /etc/auth-api/backup.env (docs/deploy/database/deployment.md, section 4): +# AGE_PUBLIC_KEY=age1... required; the private key never lives on this server +# OFFSITE_REMOTE=b2:auth-backups optional rclone remote:path +# RETAIN_DAYS=7 OFFSITE_RETAIN_DAYS=30 DB_NAME=auth_api +# BACKUP_DIR=/var/backups/auth-api TEXTFILE_DIR=/var/lib/node_exporter/textfile # -# 2. Deploy this script on the DB VPS: -# sudo cp backup-db.sh /opt/auth-api/backup-db.sh -# sudo chmod 700 /opt/auth-api/backup-db.sh -# sudo chown root:root /opt/auth-api/backup-db.sh +# Schedule (sudo crontab -e): +# 0 2 * * * /opt/auth-api/backup-db.sh >> /var/log/auth-api-backup.log 2>&1 # -# 3. Schedule via cron (sudo crontab -e): -# 0 2 * * * /opt/auth-api/backup-db.sh >> /var/log/auth-api-backup.log 2>&1 -# -# Restore: -# age --decrypt -i backup.key auth_api_YYYYMMDD_HHMMSS.sql.gz.age | gunzip | psql auth_api +# Restore with scripts/restore-db.sh: a bare `| psql` does not stop at the first +# error and leaves a half-restored database. set -euo pipefail -# -- Configuration ------------------------------------------------------------ - -DB_NAME="auth_api" -BACKUP_DIR="/var/backups/auth-api" -RETAIN_DAYS=7 +CONFIG_FILE=${BACKUP_CONFIG:-/etc/auth-api/backup.env} +if [[ -r "$CONFIG_FILE" ]]; then + # shellcheck disable=SC1090 + . "$CONFIG_FILE" +fi -# Public key generated by: age-keygen (keep the private key off this server) -AGE_PUBLIC_KEY="age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +DB_NAME=${DB_NAME:-auth_api} +BACKUP_DIR=${BACKUP_DIR:-/var/backups/auth-api} +RETAIN_DAYS=${RETAIN_DAYS:-7} +OFFSITE_REMOTE=${OFFSITE_REMOTE:-} +OFFSITE_RETAIN_DAYS=${OFFSITE_RETAIN_DAYS:-30} +TEXTFILE_DIR=${TEXTFILE_DIR:-/var/lib/node_exporter/textfile} +# The dump command, overridable for the drill. +PG_DUMP=${PG_DUMP:-sudo -u postgres pg_dump} -# Optional offsite copy: an rclone remote:path (e.g. "b2:auth-backups"). -# Empty skips the upload. Backups that only live on this VPS die with it. -OFFSITE_REMOTE="${OFFSITE_REMOTE:-}" -OFFSITE_RETAIN_DAYS=30 +log() { echo "$(date -Iseconds) [backup] $*"; } +fail() { log "ERROR: $*" >&2; exit 1; } -# -- Backup ------------------------------------------------------------------- +[[ "${AGE_PUBLIC_KEY:-}" == age1* && "${AGE_PUBLIC_KEY}" != *xxxxxxxxxx* ]] \ + || fail "AGE_PUBLIC_KEY is not set to a real age public key (in $CONFIG_FILE)" +command -v age >/dev/null || fail "age is not installed" TIMESTAMP=$(date +%Y%m%d_%H%M%S) BACKUP_FILE="$BACKUP_DIR/${DB_NAME}_${TIMESTAMP}.sql.gz.age" +PARTIAL="$BACKUP_FILE.partial" +STARTED=$(date +%s) mkdir -p "$BACKUP_DIR" chmod 700 "$BACKUP_DIR" - -# Dump, compress, encrypt (pipeline: nothing unencrypted touches disk) -sudo -u postgres pg_dump "$DB_NAME" \ - | gzip \ - | age --recipient "$AGE_PUBLIC_KEY" \ - > "$BACKUP_FILE" - -chmod 600 "$BACKUP_FILE" +# A failed run leaves nothing that looks like a backup. +trap 'rm -f "$PARTIAL"' EXIT + +# Dump, compress, encrypt: nothing unencrypted touches disk, and pipefail makes +# the failure of any stage the failure of the whole. +# shellcheck disable=SC2086 +$PG_DUMP "$DB_NAME" | gzip | age --recipient "$AGE_PUBLIC_KEY" > "$PARTIAL" +[[ -s "$PARTIAL" ]] || fail "the backup is empty" +chmod 600 "$PARTIAL" +mv "$PARTIAL" "$BACKUP_FILE" +trap - EXIT +SIZE=$(stat -c %s "$BACKUP_FILE") +log "written $BACKUP_FILE ($SIZE bytes)" # -- Offsite copy --------------------------------------------------------------- - -# The file is already age-encrypted, so the remote never sees plaintext. +# The file is already encrypted: the remote never sees plaintext. A backup that +# only lives on this VPS dies with it, so a failed copy fails the run. if [[ -n "$OFFSITE_REMOTE" ]]; then - if command -v rclone >/dev/null; then - rclone copy "$BACKUP_FILE" "$OFFSITE_REMOTE" \ - && echo "$(date -Iseconds) [backup] offsite copy OK - $OFFSITE_REMOTE" \ - || echo "$(date -Iseconds) [backup] WARNING: offsite copy failed" >&2 - # Prune old offsite copies (best-effort). - rclone delete --min-age "${OFFSITE_RETAIN_DAYS}d" "$OFFSITE_REMOTE" || true - else - echo "$(date -Iseconds) [backup] WARNING: OFFSITE_REMOTE set but rclone missing" >&2 - fi + command -v rclone >/dev/null || fail "OFFSITE_REMOTE is set but rclone is not installed" + rclone copy "$BACKUP_FILE" "$OFFSITE_REMOTE" || fail "offsite copy to $OFFSITE_REMOTE failed" + log "offsite copy OK - $OFFSITE_REMOTE" + rclone delete --min-age "${OFFSITE_RETAIN_DAYS}d" "$OFFSITE_REMOTE" \ + || log "WARNING: pruning offsite copies older than ${OFFSITE_RETAIN_DAYS} days failed" fi # -- Rotation ----------------------------------------------------------------- find "$BACKUP_DIR" -name "${DB_NAME}_*.sql.gz.age" -mtime +"$RETAIN_DAYS" -delete -# -- Log ---------------------------------------------------------------------- +# -- Metrics ------------------------------------------------------------------ +# Written last and atomically: they only ever describe a complete success. +if [[ -d "$TEXTFILE_DIR" ]]; then + METRICS="$TEXTFILE_DIR/auth_backup.prom" + cat > "$METRICS.tmp" </dev/null || { echo "ERROR: docker is required" >&2; exit 1; } -command -v age >/dev/null || { echo "ERROR: age is required" >&2; exit 1; } -command -v age-keygen >/dev/null || { echo "ERROR: age-keygen is required" >&2; exit 1; } -command -v psql >/dev/null || { echo "ERROR: psql is required" >&2; exit 1; } +for tool in docker age age-keygen psql; do + command -v "$tool" >/dev/null || { echo "ERROR: $tool is required" >&2; exit 1; } +done cleanup() { docker rm -f "$SRC" "$DST" >/dev/null 2>&1 || true @@ -41,15 +43,20 @@ trap cleanup EXIT log() { echo "$(date -Iseconds) [drill] $*"; } +pg_url() { # $1 = container, $2 = user, $3 = password, $4 = database + local port + port=$(docker port "$1" 5432/tcp | head -1 | awk -F: '{print $NF}') + echo "postgres://$2:$3@127.0.0.1:$port/$4" +} + start_postgres() { # $1 = container name docker run -d --name "$1" \ - -e POSTGRES_USER="$PGUSER" -e POSTGRES_PASSWORD="$PGPASSWORD" -e POSTGRES_DB="$PGDATABASE" \ + -e POSTGRES_USER="$SUPERUSER" -e POSTGRES_PASSWORD="$SUPERPASS" -e POSTGRES_DB=postgres \ -p 127.0.0.1::5432 "$PG_IMAGE" >/dev/null # Wait for a real host connection, not `pg_isready` inside the container: - # during initdb Postgres briefly accepts connections, then restarts, so a - # single in-container probe can pass right before the server goes away. + # during initdb Postgres briefly accepts connections, then restarts. local url - url=$(pg_url "$1") + url=$(pg_url "$1" "$SUPERUSER" "$SUPERPASS" postgres) for _ in $(seq 1 60); do if psql "$url" -c 'SELECT 1' >/dev/null 2>&1; then return 0 @@ -60,19 +67,47 @@ start_postgres() { # $1 = container name return 1 } -pg_url() { # $1 = container name - local port - port=$(docker port "$1" 5432/tcp | head -1 | awk -F: '{print $NF}') - echo "postgres://$PGUSER:$PGPASSWORD@127.0.0.1:$port/$PGDATABASE" +create_app_database() { # $1 = container: the role and database of section 2.1 + psql "$(pg_url "$1" "$SUPERUSER" "$SUPERPASS" postgres)" --set ON_ERROR_STOP=1 --quiet \ + -c "CREATE ROLE auth_api LOGIN PASSWORD '$APP_PASS'" \ + -c "CREATE DATABASE auth_api OWNER auth_api" } -# -- 1. Source database: migrations + witness data ------------------------------ +tables() { # $1 = url + psql "$1" -tAc "SELECT table_name FROM information_schema.tables + WHERE table_schema = 'public' AND table_type = 'BASE TABLE' ORDER BY 1" +} + +FAIL=0 +verify() { # $1 = label, $2 = expected, $3 = actual + if [[ "$2" == "$3" ]]; then + log "OK $1: $3" + else + log "FAIL $1: expected $2, got $3" + FAIL=1 + fi +} + +verify_counts() { # $1 = label, $2 = destination url + local table + verify "$1: tables" "$(tables "$SRC_URL" | tr '\n' ' ')" "$(tables "$2" | tr '\n' ' ')" + for table in $(tables "$SRC_URL"); do + verify "$1: rows in $table" \ + "$(psql "$SRC_URL" -tAc "SELECT count(*) FROM \"$table\"")" \ + "$(psql "$2" -tAc "SELECT count(*) FROM \"$table\"")" + done + verify "$1: witness row" "drill1@example.com" \ + "$(psql "$2" -tAc "SELECT email FROM users WHERE username = 'drill_user_1'")" +} + +# -- 1. Source database --------------------------------------------------------- log "starting source postgres ($PG_IMAGE)" start_postgres "$SRC" -SRC_URL=$(pg_url "$SRC") +create_app_database "$SRC" +SRC_URL=$(pg_url "$SRC" auth_api "$APP_PASS" auth_api) -log "applying migrations" +log "applying migrations as auth_api" for migration in "$ROOT_DIR"/migrations/*.sql; do psql "$SRC_URL" --set ON_ERROR_STOP=1 --quiet -f "$migration" >/dev/null done @@ -85,55 +120,50 @@ VALUES ('drill_user_2', 'drill2@example.com', repeat('y', 60), 'active', NOW()); SQL -count_rows() { # $1 = url, $2 = table - psql "$1" -tAc "SELECT count(*) FROM $2" -} - -SRC_USERS=$(count_rows "$SRC_URL" users) -SRC_ROLES=$(count_rows "$SRC_URL" roles) -SRC_PERMS=$(count_rows "$SRC_URL" permissions) -log "source counts: users=$SRC_USERS roles=$SRC_ROLES permissions=$SRC_PERMS" - -# -- 2. Backup with a throwaway age key (same pipeline as backup-db.sh) --------- +# -- 2. Backups through backup-db.sh -------------------------------------------- -log "generating throwaway age key" age-keygen -o "$WORK_DIR/backup.key" 2>/dev/null AGE_PUBLIC_KEY=$(age-keygen -y "$WORK_DIR/backup.key") +mkdir -p "$WORK_DIR/textfile" +backup() { # $1 = age public key + BACKUP_CONFIG=/nonexistent DB_NAME=auth_api AGE_PUBLIC_KEY="$1" \ + BACKUP_DIR="$WORK_DIR/backups" TEXTFILE_DIR="$WORK_DIR/textfile" \ + PG_DUMP="docker exec $SRC pg_dump -U $SUPERUSER" \ + "$ROOT_DIR/scripts/backup-db.sh" +} + +log "a backup that cannot be encrypted must leave nothing behind" +if backup "age1notavalidrecipient" >/dev/null 2>&1; then + verify "failed backup exit status" "non-zero" "0" +fi +verify "failed backup files" "0" "$(find "$WORK_DIR/backups" -type f 2>/dev/null | wc -l | tr -d ' ')" +verify "failed backup metrics" "0" "$(find "$WORK_DIR/textfile" -type f | wc -l | tr -d ' ')" -BACKUP_FILE="$WORK_DIR/drill.sql.gz.age" -log "backing up (pg_dump | gzip | age)" -docker exec "$SRC" pg_dump -U "$PGUSER" "$PGDATABASE" \ - | gzip \ - | age --recipient "$AGE_PUBLIC_KEY" \ - > "$BACKUP_FILE" -log "backup written: $(du -h "$BACKUP_FILE" | cut -f1)" +log "backing up with scripts/backup-db.sh" +backup "$AGE_PUBLIC_KEY" +BACKUP_FILE=$(find "$WORK_DIR/backups" -name '*.sql.gz.age' | head -1) +verify "backup files" "1" "$(find "$WORK_DIR/backups" -type f | wc -l | tr -d ' ')" +verify "success metric written" "1" "$(grep -c '^auth_backup_last_success_timestamp ' "$WORK_DIR/textfile/auth_backup.prom")" -# -- 3. Restore into a fresh database via the real restore script --------------- +# -- 3. Restores into a fresh database, as a non-superuser ---------------------- log "starting destination postgres" start_postgres "$DST" -DST_URL=$(pg_url "$DST") +create_app_database "$DST" +DST_URL=$(pg_url "$DST" auth_api "$APP_PASS" auth_api) -log "restoring with scripts/restore-db.sh" +log "restoring with scripts/restore-db.sh as auth_api" "$ROOT_DIR/scripts/restore-db.sh" -i "$WORK_DIR/backup.key" -f "$BACKUP_FILE" -d "$DST_URL" +verify_counts "restore" "$DST_URL" -# -- 4. Verify ------------------------------------------------------------------- - -FAIL=0 -verify() { # $1 = label, $2 = expected, $3 = actual - if [[ "$2" == "$3" ]]; then - log "OK $1: $3" - else - log "FAIL $1: expected $2, got $3" - FAIL=1 - fi -} +log "a second restore without --force must be refused" +if "$ROOT_DIR/scripts/restore-db.sh" -i "$WORK_DIR/backup.key" -f "$BACKUP_FILE" -d "$DST_URL" >/dev/null 2>&1; then + verify "restore over a database without --force" "refused" "accepted" +fi -verify "users count" "$SRC_USERS" "$(count_rows "$DST_URL" users)" -verify "roles count" "$SRC_ROLES" "$(count_rows "$DST_URL" roles)" -verify "permissions count" "$SRC_PERMS" "$(count_rows "$DST_URL" permissions)" -verify "witness row" "drill1@example.com" \ - "$(psql "$DST_URL" -tAc "SELECT email FROM users WHERE username = 'drill_user_1'")" +log "restoring again with --force" +"$ROOT_DIR/scripts/restore-db.sh" -i "$WORK_DIR/backup.key" -f "$BACKUP_FILE" -d "$DST_URL" --force +verify_counts "forced restore" "$DST_URL" if [[ "$FAIL" != "0" ]]; then log "DRILL FAILED" diff --git a/scripts/infra-check.sh b/scripts/infra-check.sh new file mode 100755 index 0000000..33c0340 --- /dev/null +++ b/scripts/infra-check.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# Checks of everything deployed besides the code, in three families: +# +# hygiene actionlint on the GitHub workflows, Trivy secret scan of the +# repository +# static docker compose config (the API compose with each profile and the +# L overlay, the development, test and monitoring compose files), +# Hadolint on both Dockerfiles, nginx -t on nginx/nginx.conf with a +# throwaway certificate, promtool (Prometheus configuration, both rule +# files, the rule unit tests), amtool, shellcheck on every script +# image the production image: build, size limit, Trivy (HIGH and CRITICAL +# vulnerabilities with a fix, and secrets) +# +# CHECKS selects the families (default: all three); the CI runs each family in +# its own job. Every selected check runs; the script fails at the end if any +# failed. +# +# Usage: scripts/infra-check.sh (make infra-check) +# CHECKS=static scripts/infra-check.sh +# Needs docker and openssl. +set -uo pipefail +ROOT=$(cd "$(dirname "$0")/.." && pwd) +cd "$ROOT" || exit 1 + +CHECKS=${CHECKS:-hygiene static image} +HADOLINT_IMAGE=${HADOLINT_IMAGE:-hadolint/hadolint:v2.15.1} +TRIVY_IMAGE=${TRIVY_IMAGE:-aquasec/trivy:0.74.0} +SHELLCHECK_IMAGE=${SHELLCHECK_IMAGE:-koalaman/shellcheck:v0.11.0} +ACTIONLINT_IMAGE=${ACTIONLINT_IMAGE:-rhysd/actionlint:1.7.12} +PROMETHEUS_IMAGE=${PROMETHEUS_IMAGE:-$(sed -n 's/^ *image: *\(prom\/prometheus:.*\)$/\1/p' deploy/monitoring/docker-compose.monitoring.yml | head -1)} +ALERTMANAGER_IMAGE=${ALERTMANAGER_IMAGE:-$(sed -n 's/^ *image: *\(prom\/alertmanager:.*\)$/\1/p' deploy/monitoring/docker-compose.monitoring.yml | head -1)} +NGINX_IMAGE=${NGINX_IMAGE:-nginx:1.27-alpine@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10} +IMAGE=${IMAGE:-auth-api:infra-check} +# The image weighs about 60 MiB: past this limit, something heavy got in. +MAX_IMAGE_MIB=${MAX_IMAGE_MIB:-100} + +for family in $CHECKS; do + case "$family" in + hygiene | static | image) ;; + *) echo "unknown check family '$family' (expected hygiene, static or image)" >&2; exit 2 ;; + esac +done +selected() { [[ " $CHECKS " == *" $1 "* ]]; } + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT +FAILED=() + +run() { + local name=$1 + shift + if "$@" > "$WORK/out" 2>&1; then + echo "ok $name" + else + echo "FAIL $name" + sed 's/^/ /' "$WORK/out" + FAILED+=("$name") + fi +} + +# --- hygiene ---------------------------------------------------------------- + +if selected hygiene; then + run "actionlint: .github/workflows" \ + docker run --rm -v "$ROOT:/repo:ro" -w /repo "$ACTIONLINT_IMAGE" -color + run "trivy: repository secrets" \ + docker run --rm -v "$ROOT:/src:ro" "$TRIVY_IMAGE" fs --scanners secret --exit-code 1 \ + --secret-config /src/trivy-secret.yaml --skip-dirs /src/.git --skip-dirs /src/target --skip-dirs /src/fuzz/target \ + --skip-dirs /src/reports --skip-dirs /src/dist /src +fi + +# --- static ----------------------------------------------------------------- + +if selected static; then + # Placeholders for the variables the compose files require. + compose_env() { + env AUTH_API_VERSION=check DATABASE_URL=x REDIS_URL=x JWT_PRIVATE_KEY=x JWT_PUBLIC_KEY=x \ + ENCRYPTION_KEY=x SMTP_USERNAME=x SMTP_PASSWORD=x CAPTCHA_SECRET=x NATS_URL=x "$@" + } + + for profile in s m l xl; do + files=(-f docker-compose.api.yml) + case "$profile" in l | xl) files+=(-f docker-compose.api.l.yml) ;; esac + run "compose: API, profile ${profile^^}" \ + compose_env docker compose --env-file "deploy/profiles/$profile.env" "${files[@]}" config -q + done + for file in docker-compose.dev.yml docker-compose.test.yml deploy/monitoring/docker-compose.monitoring.yml; do + [ -f "$file" ] && run "compose: $file" compose_env docker compose -f "$file" config -q + done + + for dockerfile in Dockerfile Dockerfile.dev; do + run "hadolint: $dockerfile" sh -c "docker run --rm -i '$HADOLINT_IMAGE' < '$dockerfile'" + done + + mkdir -p "$WORK/certs" + openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -days 1 \ + -subj /CN=api.example.com -keyout "$WORK/certs/privkey.pem" -out "$WORK/certs/fullchain.pem" 2>/dev/null + chmod 644 "$WORK/certs/privkey.pem" + run "nginx -t" docker run --rm \ + -v "$ROOT/nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro" \ + -v "$WORK/certs:/etc/letsencrypt/live/api.example.com:ro" \ + "$NGINX_IMAGE" nginx -t + + promtool() { + docker run --rm --entrypoint promtool -v "$ROOT:/r:ro" -w "/r/$1" "$PROMETHEUS_IMAGE" "${@:2}" + } + run "promtool: prometheus.yml" promtool deploy/monitoring check config --syntax-only prometheus.yml + run "promtool: alert rules" promtool . check rules \ + docs/deploy/guides/prometheus-alerts.yml deploy/monitoring/rules/infrastructure.yml + run "promtool: rule tests" promtool deploy/monitoring/rules test rules infrastructure.test.yml + run "amtool: alertmanager.yml" docker run --rm --entrypoint amtool -v "$ROOT/deploy/monitoring:/m:ro" \ + "$ALERTMANAGER_IMAGE" check-config /m/alertmanager.yml + + mapfile -t scripts < <(find scripts perf deploy -name '*.sh' -type f | sort) + run "shellcheck: ${#scripts[@]} scripts" \ + docker run --rm -v "$ROOT:/mnt:ro" -w /mnt "$SHELLCHECK_IMAGE" -x "${scripts[@]}" +fi + +# --- image ------------------------------------------------------------------ + +if selected image; then + image_size_within_limit() { + local size + size=$(docker image inspect -f '{{.Size}}' "$IMAGE") || return 1 + echo "$((size / 1048576)) MiB, limit $MAX_IMAGE_MIB MiB" + [ "$size" -le $((MAX_IMAGE_MIB * 1048576)) ] + } + run "docker build" docker build -q -t "$IMAGE" . + run "image size" image_size_within_limit + run "trivy: $IMAGE" docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \ + "$TRIVY_IMAGE" image --exit-code 1 --severity CRITICAL,HIGH --ignore-unfixed \ + --scanners vuln,secret "$IMAGE" +fi + +if [ "${#FAILED[@]}" -gt 0 ]; then + echo "infra-check: ${#FAILED[@]} check(s) failed: ${FAILED[*]}" + exit 1 +fi +echo "infra-check: every check passed ($CHECKS)" diff --git a/scripts/refresh-image-pins.sh b/scripts/refresh-image-pins.sh new file mode 100755 index 0000000..376135e --- /dev/null +++ b/scripts/refresh-image-pins.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Refresh every pinned image reference (`name:tag@sha256:...`) in the Dockerfiles +# and compose files to the digest its tag points at today. +# +# A digest cannot be repointed, which is why images are pinned by it; the other +# side is that a pinned image never receives security fixes. Run this before a +# release (or monthly), then rebuild, scan with Trivy and commit the new pins. +set -euo pipefail +ROOT=$(cd "$(dirname "$0")/.." && pwd) +cd "$ROOT" + +FILES=(Dockerfile Dockerfile.dev docker-compose.api.yml docker-compose.dev.yml docker-compose.test.yml) +for file in "${FILES[@]}"; do + refs=$(grep -oE '[a-z0-9./_-]+:[A-Za-z0-9._-]+@sha256:[a-f0-9]{64}' "$file" | sort -u || true) + for ref in $refs; do + image=${ref%@*} + old=${ref#*@} + new=$(docker buildx imagetools inspect "$image" --format '{{.Manifest.Digest}}') + if [[ "$new" != sha256:* ]]; then + echo "ERROR: cannot resolve $image" >&2 + exit 1 + fi + if [[ "$new" == "$old" ]]; then + echo "$file: $image up to date" + else + sed -i "s|${image}@${old}|${image}@${new}|g" "$file" + echo "$file: $image ${old:7:12} -> ${new:7:12}" + fi + done +done diff --git a/scripts/restore-db.sh b/scripts/restore-db.sh index 9a3c46d..d6d0d38 100755 --- a/scripts/restore-db.sh +++ b/scripts/restore-db.sh @@ -4,11 +4,16 @@ # Usage: # restore-db.sh -i -f -d [--force] # -# Refuses to restore into a database that already contains the `users` table -# unless --force is passed: a restore is destructive, so the expected flow is -# to restore into a FRESH database, verify it, then switch the API over. +# Connect as the application's role (auth_api), owner of the target database: +# the dump assigns every object to it. The restore runs in a single transaction, +# so a failure leaves the target as it was. # -# See docs/deploy/guides/operations.md section 3 for the full procedure. +# A database that already holds the `users` table is refused: a restore is +# destructive, and the expected flow is to restore into a FRESH database, +# verify it, then point the API at it. --force empties the target (its public +# schema is dropped and recreated) before restoring. +# +# See docs/deploy/database/deployment.md, section 4. set -euo pipefail @@ -39,23 +44,38 @@ done command -v age >/dev/null || { echo "ERROR: age is not installed" >&2; exit 1; } command -v psql >/dev/null || { echo "ERROR: psql is not installed" >&2; exit 1; } +log() { echo "$(date -Iseconds) [restore] $*"; } + # -- Safety check: refuse to overwrite an existing database --------------------- HAS_USERS=$(psql "$DB_URL" -tAc \ - "SELECT count(*) FROM information_schema.tables WHERE table_name = 'users'") + "SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'users'") -if [[ "$HAS_USERS" != "0" && "$FORCE" != "1" ]]; then - echo "ERROR: target database already contains a 'users' table." >&2 - echo "Restore into a fresh database, or pass --force to overwrite." >&2 - exit 1 +if [[ "$HAS_USERS" != "0" ]]; then + if [[ "$FORCE" != "1" ]]; then + echo "ERROR: target database already contains a 'users' table." >&2 + echo "Restore into a fresh database, or pass --force to overwrite." >&2 + exit 1 + fi + log "emptying the target database (--force)" + psql "$DB_URL" --set ON_ERROR_STOP=1 --quiet \ + -c 'DROP SCHEMA public CASCADE' -c 'CREATE SCHEMA public' fi # -- Restore -------------------------------------------------------------------- -echo "$(date -Iseconds) [restore] starting from $BACKUP_FILE" +log "starting from $BACKUP_FILE" age --decrypt -i "$KEY_FILE" "$BACKUP_FILE" \ | gunzip \ - | psql --set ON_ERROR_STOP=1 --quiet "$DB_URL" + | psql --set ON_ERROR_STOP=1 --single-transaction --quiet "$DB_URL" -echo "$(date -Iseconds) [restore] OK" +# -- Check ---------------------------------------------------------------------- + +MIGRATION=$(psql "$DB_URL" -tAc \ + "SELECT max(version) FROM _sqlx_migrations WHERE success" 2>/dev/null || true) +if [[ -n "$MIGRATION" ]]; then + log "OK - schema at migration $MIGRATION" +else + log "OK - WARNING: no _sqlx_migrations table; this dump was not migrated by sqlx" +fi diff --git a/scripts/rolling-update.sh b/scripts/rolling-update.sh new file mode 100755 index 0000000..e7c2abc --- /dev/null +++ b/scripts/rolling-update.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Replace the API instances one at a time with the image of AUTH_API_VERSION. +# +# nginx keeps sending traffic to the instance still running: a stopping +# instance finishes its requests and refuses new connections, which nginx +# passes to the other one. An instance is only left behind once it is healthy +# and ready; if the new version does not come up, the script stops there with +# the other instance still serving the previous one. +# +# Usage, secrets exported as in docs/deploy/guides/update.md: +# AUTH_API_VERSION=X.Y.Z scripts/rolling-update.sh +set -euo pipefail + +COMPOSE_DIR=${COMPOSE_DIR:-/srv/auth-api} +PROFILE_FILE=${PROFILE_FILE:-$COMPOSE_DIR/profile.env} +: "${AUTH_API_VERSION:?set AUTH_API_VERSION to the release being deployed}" + +COMPOSE_FILES=(-f "$COMPOSE_DIR/docker-compose.api.yml") +INSTANCES=(api-a:3001 api-b:3002) +if [[ -f "$COMPOSE_DIR/docker-compose.api.l.yml" ]]; then + COMPOSE_FILES+=(-f "$COMPOSE_DIR/docker-compose.api.l.yml") + INSTANCES+=(api-c:3003 api-d:3004) +fi +C=(docker compose --project-directory "$COMPOSE_DIR" --env-file "$PROFILE_FILE" "${COMPOSE_FILES[@]}") + +log() { echo "$(date -Iseconds) [rolling-update] $*"; } + +wait_ready() { # $1 = service, $2 = host port + local container health + for _ in $(seq 1 60); do + container=$("${C[@]}" ps -q "$1") + health=$(docker inspect -f '{{.State.Health.Status}}' "$container" 2>/dev/null || true) + if [[ "$health" == healthy ]] && curl -fsS -o /dev/null "http://127.0.0.1:$2/ready"; then + return 0 + fi + sleep 2 + done + log "ERROR: $1 is not ready after 120 s; the remaining instances keep serving" + "${C[@]}" logs --tail 50 "$1" >&2 + return 1 +} + +# The broker first; nothing happens when its definition did not change. +"${C[@]}" up -d --wait nats + +for instance in "${INSTANCES[@]}"; do + service=${instance%:*} + port=${instance#*:} + log "replacing $service with auth-api:$AUTH_API_VERSION" + "${C[@]}" up -d --no-deps --force-recreate "$service" + wait_ready "$service" "$port" + log "$service ready" +done + +log "every instance runs auth-api:$AUTH_API_VERSION" diff --git a/scripts/stack-smoke.sh b/scripts/stack-smoke.sh new file mode 100755 index 0000000..61fbdfb --- /dev/null +++ b/scripts/stack-smoke.sh @@ -0,0 +1,226 @@ +#!/usr/bin/env bash +# Production stack end to end, as a server runs it: docker-compose.api.yml with +# profile M (two instances, NATS token in a root-owned secret file) behind the +# repository's nginx.conf in TLS, with PostgreSQL and Redis beside it. +# +# Checks limits and hardening of the containers, the metrics listeners and the +# NATS exporter, nginx (redirect, headers, balancing, request id), a sign-in +# flow, failover with one instance stopped, a rolling update under load with no +# failed request, the account deletion event through the authenticated broker +# and a clean stop. +# +# Needs docker, curl, openssl, python3, ports 80, 443, 3001, 3002, 9465, 9466, +# 7777 and 55432 free on loopback, and outbound HTTPS to hcaptcha.com: the +# production configuration verifies CAPTCHA tokens (hCaptcha's test key pair). +# +# Usage: scripts/stack-smoke.sh (make stack-test). Logs in $OUT. +set -uo pipefail +ROOT=$(cd "$(dirname "$0")/.." && pwd) +cd "$ROOT" || exit 1 +OUT=${OUT:-reports/stack/$(date +%Y%m%d-%H%M%S)} +mkdir -p "$OUT" +S=$(cd "$OUT" && pwd) +D=$S/auth-smoke # deployment directory, like /srv/auth-api +KEYS=$S/keys +# An image with a shell, to own the secret file as root like on the server. +SHELL_IMAGE=redis:7-alpine@sha256:ff02b58f971e7d7d156a1267e283fcbbeee91773b6aa36c49dac28ecfe28eadf +unset COMPOSE_PROJECT_NAME + +mkdir -p "$D" "$KEYS/certs" +cp "$ROOT"/docker-compose.api.yml "$ROOT"/config.prod.env "$ROOT"/nats.conf "$ROOT"/scripts/rolling-update.sh "$D"/ +cp "$ROOT"/deploy/profiles/m.env "$D"/profile.env + +# Throwaway keys: ES256 signing key, and a certificate for api.example.com. +openssl ecparam -name prime256v1 -genkey -noout 2>/dev/null \ + | openssl pkcs8 -topk8 -nocrypt -out "$KEYS/jwt-private.pem" +openssl ec -in "$KEYS/jwt-private.pem" -pubout -out "$KEYS/jwt-public.pem" 2>/dev/null +openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -days 1 \ + -subj /CN=api.example.com -addext subjectAltName=DNS:api.example.com \ + -keyout "$KEYS/certs/privkey.pem" -out "$KEYS/certs/fullchain.pem" 2>/dev/null +chmod 644 "$KEYS/certs/privkey.pem" + +export AUTH_API_VERSION=smoke +export METRICS_BIND_ADDRESS=127.0.0.1 +POSTGRES_PASSWORD=$(openssl rand -hex 16) +export POSTGRES_PASSWORD +export DATABASE_URL=postgres://auth:${POSTGRES_PASSWORD}@postgres:5432/auth +export REDIS_URL=redis://redis:6379 +JWT_PRIVATE_KEY=$(cat "$KEYS"/jwt-private.pem) +JWT_PUBLIC_KEY=$(cat "$KEYS"/jwt-public.pem) +ENCRYPTION_KEY=$(openssl rand -base64 32) +export JWT_PRIVATE_KEY JWT_PUBLIC_KEY ENCRYPTION_KEY +export SMTP_USERNAME=smoke-relay-user SMTP_PASSWORD=smoke-relay-password +# hCaptcha's published test secret; TOKEN below is its test response. +export CAPTCHA_SECRET=0x0000000000000000000000000000000000000000 +NATS_TOKEN=$(openssl rand -hex 24) +export NATS_URL=nats://${NATS_TOKEN}@nats:4222 +install -m 600 /dev/null "$D"/nats-auth.conf +printf 'authorization { token: "%s" }\n' "$NATS_TOKEN" > "$D"/nats-auth.conf +# Owned by root, as on the server (the broker runs without capabilities). +docker run --rm --entrypoint sh -v "$D:/d" "$SHELL_IMAGE" -c 'chown 0:0 /d/nats-auth.conf && chmod 600 /d/nats-auth.conf' + +cat > "$D"/override.yml <<'YML' +# Smoke test only: in production the database and cache run on the DB VPS. +services: + postgres: + image: postgres:17-alpine@sha256:18cfe3ef5e6815560c98237d6216d1e5119702fb0f3894c8785dd58b8bbe5d73 + environment: { POSTGRES_USER: auth, POSTGRES_DB: auth, POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" } + ports: ["127.0.0.1:55432:5432"] + healthcheck: { test: ["CMD-SHELL", "pg_isready -U auth"], interval: 2s, retries: 30 } + networks: [auth-api] + redis: + image: redis:7-alpine@sha256:ff02b58f971e7d7d156a1267e283fcbbeee91773b6aa36c49dac28ecfe28eadf + healthcheck: { test: ["CMD", "redis-cli", "ping"], interval: 2s, retries: 30 } + networks: [auth-api] +YML +C=(docker compose --project-directory "$D" --env-file "$D/profile.env" -f "$D/docker-compose.api.yml" -f "$D/override.yml") +H=(curl -sk --max-time 20 --resolve api.example.com:443:127.0.0.1 --resolve api.example.com:80:127.0.0.1) +B=https://api.example.com +TOKEN=10000000-aaaa-bbbb-cccc-000000000001 +FAIL=0 +check() { if [ "$2" = "$3" ]; then echo "PASS $1 ($3)"; else echo "FAIL $1: expected $2, got $3"; FAIL=1; fi; } +psql_q() { "${C[@]}" exec -T postgres psql -U auth -d auth -tAc "$1"; } +json() { python3 -c "import sys,json; print(json.load(sys.stdin).get('$1',''))" 2>/dev/null; } +# shellcheck disable=SC2329 # run by the EXIT trap +teardown() { + "${C[@]}" logs --no-color > "$S/compose.log" 2>&1 + docker logs auth-smoke-nginx > "$S/nginx-container.log" 2>&1 + docker cp auth-smoke-nginx:/var/log/nginx/auth-api.access.log "$S/nginx-access.log" >/dev/null 2>&1 + docker rm -f auth-smoke-nginx >/dev/null 2>&1 + "${C[@]}" down -v --remove-orphans >/dev/null 2>&1 + docker run --rm --entrypoint sh -v "$D:/d" "$SHELL_IMAGE" -c 'rm -f /d/nats-auth.conf' >/dev/null 2>&1 +} +trap teardown EXIT + +echo "== image, dependencies, migrations" +docker build -q -t auth-api:smoke "$ROOT" >/dev/null; check "image build" 0 $? +"${C[@]}" up -d --wait postgres redis >/dev/null 2>&1; check "database and cache" 0 $? +(cd "$ROOT" && cargo build --release --quiet --bin perf_load && DATABASE_URL="postgres://auth:${POSTGRES_PASSWORD}@127.0.0.1:55432/auth" "$ROOT/target/release/perf_load" migrate >/dev/null); check "migrations" 0 $? + +echo "== docker-compose.api.yml with profile M" +"${C[@]}" up -d --wait nats nats-exporter api-a api-b >/dev/null 2>&1; check "stack up and healthy" 0 $? +for svc in api-a api-b; do + id=$("${C[@]}" ps -q $svc) + check "$svc healthy" healthy "$(docker inspect -f '{{.State.Health.Status}}' "$id")" + check "$svc CPU limit (profile M)" 3000000000 "$(docker inspect -f '{{.HostConfig.NanoCpus}}' "$id")" + check "$svc memory limit" 536870912 "$(docker inspect -f '{{.HostConfig.Memory}}' "$id")" + check "$svc memory reservation" 268435456 "$(docker inspect -f '{{.HostConfig.MemoryReservation}}' "$id")" + check "$svc pids limit" 256 "$(docker inspect -f '{{.HostConfig.PidsLimit}}' "$id")" + check "$svc stop grace" 40 "$(docker inspect -f '{{.Config.StopTimeout}}' "$id")" + check "$svc log rotation" 10m "$(docker inspect -f '{{index .HostConfig.LogConfig.Config "max-size"}}' "$id")" + check "$svc runs as non-root" 65532:65532 "$(docker inspect -f '{{.Config.User}}' "$id")" + check "$svc Argon2 concurrency from profile" 3 "$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$id" | sed -n 's/^ARGON2_MAX_CONCURRENCY=//p')" +done +check "api-a ready" 200 "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3001/ready)" +check "api-b ready" 200 "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3002/ready)" +check "metrics api-a" 200 "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:9465/metrics)" +check "metrics api-b" 200 "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:9466/metrics)" +metric() { curl -s "http://127.0.0.1:$1/metrics" | awk -v m="$2" '$1==m {printf "%d", $2}'; } +check "api-a publishes its memory limit" 536870912 "$(metric 9465 auth_container_memory_limit_bytes)" +ws=$(metric 9465 auth_container_memory_working_set_bytes) +check "api-a working set within limit" true "$([ "${ws:-0}" -gt 0 ] && [ "$ws" -lt 536870912 ] && echo true || echo false)" +check "api-b publishes CPU periods" true "$(curl -s http://127.0.0.1:9466/metrics | grep -q '^auth_container_cpu_periods_total ' && echo true || echo false)" +check "api-b publishes its start time" true "$(curl -s http://127.0.0.1:9466/metrics | grep -q '^auth_process_start_time_seconds ' && echo true || echo false)" +nats_metrics=false +for _ in $(seq 1 10); do + curl -s http://127.0.0.1:7777/metrics > "$S/nats-exporter.txt" 2>&1 + grep -q '^gnatsd_varz_connections' "$S/nats-exporter.txt" && { nats_metrics=true; break; } + sleep 1 +done +check "NATS exporter" true "$nats_metrics" +nats_id=$("${C[@]}" ps -q nats) +check "NATS token absent from broker arguments" false "$(docker inspect -f '{{json .Args}} {{json .Config.Cmd}}' "$nats_id" | grep -q "$NATS_TOKEN" && echo true || echo false)" +check "NATS memory limit" 201326592 "$(docker inspect -f '{{.HostConfig.Memory}}' "$nats_id")" +check "NATS JetStream file store cap" 1073741824 "$(docker run --rm --network auth-smoke_auth-api natsio/nats-box:0.14.5 wget -qO- http://nats:8222/jsz 2>/dev/null | python3 -c 'import sys,json; print(json.load(sys.stdin)["config"]["max_storage"])' 2>/dev/null)" + +echo "== nginx.conf in front, TLS" +docker run -d --name auth-smoke-nginx --network host \ + -v "$ROOT/nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro" \ + -v "$KEYS/certs:/etc/letsencrypt/live/api.example.com:ro" nginx:1.27-alpine@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10 >/dev/null +sleep 2 +docker exec auth-smoke-nginx nginx -t >/dev/null 2>&1; check "nginx -t" 0 $? +check "http redirects to https" 301 "$("${H[@]}" -o /dev/null -w '%{http_code}' http://api.example.com/live)" +check "ready through nginx" 200 "$("${H[@]}" -o /dev/null -w '%{http_code}' $B/ready)" +check "HEAD allowed" 200 "$("${H[@]}" -I -o /dev/null -w '%{http_code}' $B/live)" +check "one HSTS header" 1 "$("${H[@]}" -D - -o /dev/null $B/.well-known/jwks.json | grep -ci '^strict-transport-security')" +for _ in $(seq 1 20); do "${H[@]}" -o /dev/null $B/.well-known/jwks.json; done +sleep 1 +docker exec auth-smoke-nginx cat /var/log/nginx/auth-api.access.log > "$S/nginx-access.log" 2>/dev/null +check "requests balanced over both instances" 2 "$(python3 -c " +import json,sys +ups=set() +for l in open('$S/nginx-access.log'): + r=json.loads(l) + if r['uri']=='/.well-known/jwks.json': ups.add(r['upstream']) +print(len(ups))")" +rid=$("${H[@]}" -D - -o /dev/null $B/.well-known/jwks.json | sed -n 's/^x-request-id: //Ip' | tr -d '\r') +sleep 1 +check "nginx request id reaches the API" true "$(docker exec auth-smoke-nginx cat /var/log/nginx/auth-api.access.log | grep -q "\"request_id\":\"$rid\"" && echo true || echo false)" + +echo "== sign-in flow through nginx" +EMAIL=smoke.user@example.com; PASSWORD='Smoke-Password-2026!' +check "register" 202 "$("${H[@]}" -o /dev/null -w '%{http_code}' -H 'content-type: application/json' -d "{\"username\":\"smoke_user\",\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\",\"captcha_token\":\"$TOKEN\"}" $B/auth/register)" +psql_q "UPDATE users SET status='active', email_verified_at=NOW() WHERE email='$EMAIL'" >/dev/null +LOGIN=$("${H[@]}" -H 'content-type: application/json' -H 'X-Forwarded-For: 203.0.113.9' -d "{\"identifier\":\"$EMAIL\",\"password\":\"$PASSWORD\",\"captcha_token\":\"$TOKEN\"}" $B/auth/login) +ACCESS=$(printf '%s' "$LOGIN" | json access_token); REFRESH=$(printf '%s' "$LOGIN" | json refresh_token) +check "login issues tokens" true "$([ -n "$ACCESS" ] && echo true || echo false)" +check "client address recorded" 127.0.0.1 "$(psql_q "SELECT host(ip_address) FROM sessions ORDER BY created_at DESC LIMIT 1")" +check "profile" 200 "$("${H[@]}" -o /dev/null -w '%{http_code}' -H "authorization: Bearer $ACCESS" $B/users/me)" + +echo "== failover: one instance stopped" +"${C[@]}" stop api-a >/dev/null 2>&1 +bad=0; for _ in $(seq 1 30); do c=$("${H[@]}" -o /dev/null -w '%{http_code}' -H "authorization: Bearer $ACCESS" $B/users/me); [ "$c" = 200 ] || bad=$((bad+1)); done +check "GET errors with api-a stopped" 0 "$bad" +R=$("${H[@]}" -H 'content-type: application/json' -d "{\"refresh_token\":\"$REFRESH\"}" $B/auth/refresh) +REFRESH2=$(printf '%s' "$R" | json refresh_token) +check "POST refresh with api-a stopped" true "$([ -n "$REFRESH2" ] && echo true || echo false)" +[ -n "$REFRESH2" ] && REFRESH=$REFRESH2 +"${C[@]}" start api-a >/dev/null 2>&1 +for _ in $(seq 1 30); do [ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3001/ready)" = 200 ] && break; sleep 2; done +check "api-a back" 200 "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3001/ready)" + +echo "== rolling update under load" +# The GET load gets a session of its own: the refresh loop rotates its session, +# which revokes that session's access tokens. +LOGIN2=$("${H[@]}" -H 'content-type: application/json' -d "{\"identifier\":\"$EMAIL\",\"password\":\"$PASSWORD\",\"captcha_token\":\"$TOKEN\"}" $B/auth/login) +GET_ACCESS=$(printf '%s' "$LOGIN2" | json access_token) +check "second session for the GET load" true "$([ -n "$GET_ACCESS" ] && echo true || echo false)" +: > "$S/load-errors.txt" +end=$(( $(date +%s) + 600 )) +( ok=0; bad=0; while [ ! -f "$S/stop-load" ] && [ "$(date +%s)" -lt $end ]; do + c=$("${H[@]}" -o /dev/null -w '%{http_code}' -H "authorization: Bearer $GET_ACCESS" $B/users/me) + if [ "$c" = 200 ]; then ok=$((ok+1)); else bad=$((bad+1)); echo "GET $c $(date +%T)" >> "$S/load-errors.txt"; fi + sleep 0.3 + done; echo "$ok $bad" > "$S/load-get.txt" ) & +GET_PID=$! +( ok=0; bad=0; rt=$REFRESH; while [ ! -f "$S/stop-load" ] && [ "$(date +%s)" -lt $end ]; do + out=$("${H[@]}" -H 'content-type: application/json' -d "{\"refresh_token\":\"$rt\"}" -w '\n%{http_code}' $B/auth/refresh) + c=${out##*$'\n'}; body=${out%$'\n'*} + if [ "$c" = 200 ]; then ok=$((ok+1)); rt=$(printf '%s' "$body" | python3 -c 'import sys,json; print(json.load(sys.stdin)["refresh_token"])'); else bad=$((bad+1)); echo "REFRESH $c $(date +%T) $body" >> "$S/load-errors.txt"; fi + sleep 5 + done; echo "$ok $bad" > "$S/load-refresh.txt" ) & +REF_PID=$! +rm -f "$S/stop-load"; sleep 3 +start=$(date +%s) +COMPOSE_DIR="$D" PROFILE_FILE="$D/profile.env" "$D/rolling-update.sh" > "$S/rolling-update.log" 2>&1 +check "rolling update script" 0 $? +echo "INFO rolling update took $(( $(date +%s) - start ))s" +sleep 3; touch "$S/stop-load"; wait $GET_PID $REF_PID; rm -f "$S/stop-load" +read -r gok gbad < "$S/load-get.txt"; read -r rok rbad < "$S/load-refresh.txt" +echo "INFO load: GET ok=$gok, refresh ok=$rok" +check "GET errors during rolling update" 0 "$gbad" +check "refresh errors during rolling update" 0 "$rbad" + +echo "== deletion event through the token-protected broker" +code=$("${H[@]}" -o /dev/null -w '%{http_code}' -X DELETE -H 'content-type: application/json' -H "authorization: Bearer $ACCESS" -d "{\"current_password\":\"$PASSWORD\"}" $B/users/me) +if [ "$code" = 401 ]; then echo "INFO access token of a refreshed session: signing in again"; LOGIN=$("${H[@]}" -H 'content-type: application/json' -d "{\"identifier\":\"$EMAIL\",\"password\":\"$PASSWORD\",\"captcha_token\":\"$TOKEN\"}" $B/auth/login); ACCESS=$(printf '%s' "$LOGIN" | json access_token); code=$("${H[@]}" -o /dev/null -w '%{http_code}' -X DELETE -H 'content-type: application/json' -H "authorization: Bearer $ACCESS" -d "{\"current_password\":\"$PASSWORD\"}" $B/users/me); fi +check "account deletion" 204 "$code" +check "deletion event stored by JetStream" true "$(docker run --rm --network auth-smoke_auth-api natsio/nats-box:0.14.5 nats -s "nats://${NATS_TOKEN}@nats:4222" stream info AUTH_EVENTS -j 2>/dev/null | grep -q '"messages": *[1-9]' && echo true || echo false)" + +echo "== graceful stop" +"${C[@]}" stop api-b >/dev/null 2>&1 +check "api-b exits cleanly on SIGTERM" 0 "$(docker inspect -f '{{.State.ExitCode}}' "$("${C[@]}" ps -aq api-b)")" +check "shutdown logged" true "$("${C[@]}" logs --no-color api-b 2>&1 | grep -q 'shutdown complete' && echo true || echo false)" + +[ "$FAIL" = 0 ] && echo "STACK PASSED" || echo "STACK FAILED" +exit "$FAIL" diff --git a/src/bin/bench_http.rs b/src/bin/bench_http.rs index 586f52d..08e7047 100644 --- a/src/bin/bench_http.rs +++ b/src/bin/bench_http.rs @@ -26,7 +26,7 @@ use auth_api::{ }, services::{email_2fa, two_factor}, state::AppState, - utils::{crypto, password, time as time_utils}, + utils::{crypto, password}, }; #[derive(Debug, Clone)] @@ -388,11 +388,18 @@ async fn create_email_2fa_credentials( let mut users = Vec::with_capacity(count); for index in 0..count { let credential = create_active_user(state, prefix, index).await?; - let method_id = email_2fa::setup(state, credential.user_id) - .await - .map_err(|error| { - anyhow::anyhow!("failed to setup Email 2FA for benchmark user: {error:?}") - })?; + let method_id = email_2fa::setup( + state, + credential.user_id, + Uuid::nil(), + Some(&credential.password), + None, + None, + ) + .await + .map_err(|error| { + anyhow::anyhow!("failed to setup Email 2FA for benchmark user: {error:?}") + })?; let known_code = format!("{:06}", 100_000 + index as u32); let hash = crypto::sha256(known_code.as_bytes()); @@ -401,7 +408,7 @@ async fn create_email_2fa_credentials( &email_2fa_repo::NewEmail2faCode { user_id: credential.user_id, code_hash: &hash, - expires_at: time_utils::in_secs(600), + expires_at: ::time::OffsetDateTime::now_utc() + ::time::Duration::seconds(600), }, ) .await?; @@ -424,9 +431,16 @@ async fn create_totp_credentials( let mut users = Vec::with_capacity(count); for index in 0..count { let credential = create_active_user(state, prefix, index).await?; - let setup = two_factor::setup_totp(state, credential.user_id) - .await - .map_err(|error| anyhow::anyhow!("failed to setup TOTP benchmark method: {error:?}"))?; + let setup = two_factor::setup_totp( + state, + credential.user_id, + Uuid::nil(), + Some(&credential.password), + None, + None, + ) + .await + .map_err(|error| anyhow::anyhow!("failed to setup TOTP benchmark method: {error:?}"))?; let code = current_totp_code(&setup.base32_secret)?; let _ = two_factor::verify_setup(state, credential.user_id, setup.method_id, &code, None) .await @@ -508,7 +522,7 @@ async fn bench_register( &format!("{base_url}/auth/register"), Some(&body), None, - 201, + 202, ) .await }) @@ -726,22 +740,38 @@ async fn bench_forgot_password( warmup, iterations, credential, - move |credential, _| { + move |credential, run_index| { let base_url = base_url.clone(); let client = client.clone(); let state = state.clone(); + // A distinct documentation-range address per request: the + // per-IP budget (5 per 15 min) is not what this measures. + let client_ip = format!("198.18.{}.{}", worker_id % 256, run_index % 256); boxed_http(async move { - clear_forgot_password_rate_limit(&state).await; + clear_forgot_password_rate_limit(&state, credential.user_id).await; let body = json!({ "email": credential.email }); - send_json_expect_status( + match send_json_from( &client, reqwest::Method::POST, &format!("{base_url}/auth/forgot-password"), Some(&body), None, - 200, + Some(&client_ip), ) .await + { + Ok((status, payload)) => HttpOutcome { + status, + ok: status == 200, + error: (status != 200) + .then(|| format!("expected 200, got {status}: {payload}")), + }, + Err(e) => HttpOutcome { + status: 0, + ok: false, + error: Some(format!("request failed: {e:#}")), + }, + } }) }, ) @@ -1557,6 +1587,14 @@ async fn bench_revoke_session( ) .await .context("failed to create main revoke session")?; + reauthenticate( + &client, + &base_url, + &main_tokens.access_token, + &credential.password, + ) + .await + .context("failed to re-authenticate revoke session")?; // Pre-create one extra session per timed run. let total = warmup + iterations; @@ -1649,6 +1687,9 @@ async fn bench_email_change_start( ) .await .context("failed to create email_change_start session")?; + reauthenticate(&client, &base_url, &tokens.access_token, &credential.password) + .await + .context("failed to re-authenticate email_change_start session")?; run_worker_loop( worker_id, @@ -1720,6 +1761,9 @@ async fn bench_email_change_full( ) .await .context("failed to create email_change_full session")?; + reauthenticate(&client, &base_url, &tokens.access_token, &credential.password) + .await + .context("failed to re-authenticate email_change_full session")?; run_worker_loop( worker_id, @@ -1844,7 +1888,10 @@ fn parse_flow_token(payload: &str) -> Result { /// Replaces the `otp_hash` field inside `email_change_flow:{flow_token}` in Redis /// with the SHA-256 hash of `BENCH_EMAIL_CHANGE_OTP`, encoded as base64url. /// This lets the benchmark complete OTP verification steps without a real mail server. -async fn inject_known_otp_into_flow(redis: &deadpool_redis::Pool, flow_token: &str) { +async fn inject_known_otp_into_flow( + redis: &auth_api::utils::redis_pool::RedisPool, + flow_token: &str, +) { let key = format!("email_change_flow:{}", flow_token); if let Ok(mut conn) = redis.get().await && let Ok(raw) = conn.get::<_, String>(&key).await @@ -2224,8 +2271,23 @@ async fn send_json( url: &str, body: Option<&Value>, bearer: Option<&str>, +) -> Result<(u16, String)> { + send_json_from(client, method, url, body, bearer, None).await +} + +/// Like [`send_json`], presenting `forwarded_for` as the client address. +async fn send_json_from( + client: &reqwest::Client, + method: reqwest::Method, + url: &str, + body: Option<&Value>, + bearer: Option<&str>, + forwarded_for: Option<&str>, ) -> Result<(u16, String)> { let mut request = client.request(method, url); + if let Some(ip) = forwarded_for { + request = request.header("x-forwarded-for", ip); + } if let Some(token) = bearer { request = request.bearer_auth(token); } @@ -2289,7 +2351,7 @@ async fn replace_email_code(pool: &PgPool, user_id: Uuid, code: &str) -> Result< &email_2fa_repo::NewEmail2faCode { user_id, code_hash: &hash, - expires_at: time_utils::in_secs(600), + expires_at: ::time::OffsetDateTime::now_utc() + ::time::Duration::seconds(600), }, ) .await @@ -2304,13 +2366,35 @@ async fn clear_email_2fa_cooldown(state: &AppState, user_id: Uuid) { } } -async fn clear_forgot_password_rate_limit(state: &AppState) { +async fn clear_forgot_password_rate_limit(state: &AppState, user_id: Uuid) { if let Ok(mut conn) = state.redis.get().await { - let key = "fp_req:127.0.0.1"; - let _: Result<(), _> = conn.del(key).await; + let _: Result<(), _> = conn.del("fp_req:127.0.0.1").await; + let _: Result<(), _> = conn.del(format!("fp_account:{user_id}")).await; } } +/// Confirm the password once for a session, as a client does before sensitive +/// actions (session revocation, email change). Kept out of the timed region. +async fn reauthenticate( + client: &reqwest::Client, + base_url: &str, + access_token: &str, + password: &str, +) -> Result<()> { + let (status, payload) = send_json( + client, + reqwest::Method::POST, + &format!("{base_url}/users/me/reauth"), + Some(&json!({ "current_password": password })), + Some(access_token), + ) + .await?; + if status != 204 { + anyhow::bail!("reauth returned {status}: {payload}"); + } + Ok(()) +} + async fn clear_totp_reuse_key(state: &AppState, user_id: Uuid, code: &str) { // Clear the Redis fast-path key AND the durable authority (used_totp_codes) // so the same code can be replayed across timed iterations. Both layers diff --git a/src/bin/bench_sql.rs b/src/bin/bench_sql.rs index e0e991a..3332512 100644 --- a/src/bin/bench_sql.rs +++ b/src/bin/bench_sql.rs @@ -11,9 +11,7 @@ use sqlx::PgPool; use time::OffsetDateTime; use uuid::Uuid; -use auth_api::repositories::{ - login_attempt, login_location, session as session_repo, user as user_repo, -}; +use auth_api::repositories::{login_attempt, session as session_repo, user as user_repo}; #[derive(Debug)] struct SqlSeedData { @@ -23,11 +21,6 @@ struct SqlSeedData { hot_session_id: Uuid, hot_token_hash: Vec, hot_ip: IpNetwork, - hot_history_days: i32, - upsert_country: String, - upsert_city: String, - upsert_user_agent: String, - upsert_ip: IpNetwork, } #[derive(Debug, Clone, Serialize)] @@ -210,34 +203,6 @@ async fn main() -> Result<()> { }, ) .await?, - bench_sql_scenario( - "find_recent_for_risk", - "Risk-scoring history lookup over recent login locations.", - login_location::FIND_RECENT_FOR_RISK_SQL, - json!([seed.hot_user_id, seed.hot_history_days]), - iterations, - warmup, - || async { - sqlx::query(login_location::FIND_RECENT_FOR_RISK_SQL) - .bind(seed.hot_user_id) - .bind(seed.hot_history_days) - .fetch_all(&db.pool) - .await?; - Ok(()) - }, - || async { - sqlx::query_scalar::<_, Value>(&format!( - "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) {}", - login_location::FIND_RECENT_FOR_RISK_SQL - )) - .bind(seed.hot_user_id) - .bind(seed.hot_history_days) - .fetch_one(&db.pool) - .await - .map_err(Into::into) - }, - ) - .await?, bench_sql_scenario( "count_recent_failures_by_identifier", "Brute-force counter by identifier.", @@ -334,50 +299,6 @@ async fn main() -> Result<()> { }, ) .await?, - bench_sql_scenario( - "login_location_upsert", - "Upsert of the login-location tuple after a successful login.", - login_location::UPSERT_LOGIN_LOCATION_SQL, - json!([ - seed.hot_user_id, - seed.upsert_country, - seed.upsert_city, - seed.upsert_user_agent, - seed.upsert_ip.to_string() - ]), - iterations / 2, - warmup / 2, - || async { - sqlx::query(login_location::UPSERT_LOGIN_LOCATION_SQL) - .bind(seed.hot_user_id) - .bind(&seed.upsert_country) - .bind(&seed.upsert_city) - .bind(&seed.upsert_user_agent) - .bind(seed.upsert_ip) - .bind(Some(48.8566_f64)) - .bind(Some(2.3522_f64)) - .execute(&db.pool) - .await?; - Ok(()) - }, - || async { - sqlx::query_scalar::<_, Value>(&format!( - "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) {}", - login_location::UPSERT_LOGIN_LOCATION_SQL - )) - .bind(seed.hot_user_id) - .bind(&seed.upsert_country) - .bind(&seed.upsert_city) - .bind(&seed.upsert_user_agent) - .bind(seed.upsert_ip) - .bind(Some(48.8566_f64)) - .bind(Some(2.3522_f64)) - .fetch_one(&db.pool) - .await - .map_err(Into::into) - }, - ) - .await?, ]; let report = SqlBenchmarkReport { @@ -385,7 +306,7 @@ async fn main() -> Result<()> { notes: vec![ "SQL benchmarks execute against an isolated benchmark database created from migrations.".into(), "Each scenario captures both repeated client-side latency and a single EXPLAIN ANALYZE plan in JSON.".into(), - "Read scenarios use realistic hot-spot rows plus background data; the login_location upsert scenario measures the steady-state update path.".into(), + "Read scenarios use realistic hot-spot rows plus background data.".into(), ], scenarios, }; @@ -465,31 +386,6 @@ async fn seed_sql_dataset(pool: &PgPool) -> Result { .await .context("failed to seed benchmark login attempts")?; - sqlx::query( - "INSERT INTO login_locations - (user_id, country, city, user_agent, ip_address, latitude, longitude, last_seen, first_seen) - SELECT - u.id, - format('C%s', gs % 5), - format('City-%s-%s', u.seq, gs), - format('bulk-agent/%s', gs % 8), - format('198.19.%s.%s/32', ((u.seq % 200) + 1), ((gs % 200) + 1))::cidr, - 40.0 + (gs::double precision / 10.0), - 2.0 + (u.seq::double precision / 100.0), - NOW() - ((gs % 240) * INTERVAL '1 hour'), - NOW() - (((gs % 240) + 24) * INTERVAL '1 hour') - FROM ( - SELECT id, row_number() OVER (ORDER BY created_at) AS seq - FROM users - ORDER BY created_at - LIMIT 1000 - ) AS u - CROSS JOIN generate_series(1, 8) AS gs", - ) - .execute(pool) - .await - .context("failed to seed benchmark login locations")?; - let hot_email = "hot_login_user@example.com".to_string(); let hot_username = "hot_login_user".to_string(); let hot_user_id: Uuid = sqlx::query_scalar( @@ -565,53 +461,6 @@ async fn seed_sql_dataset(pool: &PgPool) -> Result { .context("failed to insert hot failed login attempt")?; } - let upsert_country = "FR".to_string(); - let upsert_city = "Paris-Hot".to_string(); - let upsert_user_agent = "hot-risk-agent/1.0".to_string(); - let upsert_ip: IpNetwork = "198.51.100.55/32".parse().expect("valid upsert ip"); - - for offset in 0..36 { - let country = if offset == 0 { - upsert_country.clone() - } else { - "DE".to_string() - }; - let city = if offset == 0 { - upsert_city.clone() - } else { - format!("City-Hot-{offset}") - }; - let user_agent = if offset == 0 { - upsert_user_agent.clone() - } else { - format!("hot-agent/{offset}") - }; - let ip = if offset == 0 { - upsert_ip - } else { - "198.51.100.99/32" - .parse::() - .expect("valid fallback ip") - }; - - sqlx::query( - "INSERT INTO login_locations - (user_id, country, city, user_agent, ip_address, latitude, longitude, last_seen, first_seen) - VALUES ($1, $2, $3, $4, $5, $6, $7, NOW() - ($8 * INTERVAL '2 hours'), NOW() - (($8 + 24) * INTERVAL '2 hours'))", - ) - .bind(hot_user_id) - .bind(country) - .bind(city) - .bind(user_agent) - .bind(ip) - .bind(Some(48.8566_f64 + (offset as f64 / 100.0))) - .bind(Some(2.3522_f64 + (offset as f64 / 100.0))) - .bind(offset) - .execute(pool) - .await - .context("failed to insert hot login location")?; - } - sqlx::query("ANALYZE users") .execute(pool) .await @@ -624,10 +473,6 @@ async fn seed_sql_dataset(pool: &PgPool) -> Result { .execute(pool) .await .context("failed to analyze login_attempts benchmark table")?; - sqlx::query("ANALYZE login_locations") - .execute(pool) - .await - .context("failed to analyze login_locations benchmark table")?; Ok(SqlSeedData { hot_user_id, @@ -636,11 +481,6 @@ async fn seed_sql_dataset(pool: &PgPool) -> Result { hot_session_id, hot_token_hash, hot_ip, - hot_history_days: 90, - upsert_country, - upsert_city, - upsert_user_agent, - upsert_ip, }) } diff --git a/src/bin/bench_support.rs b/src/bin/bench_support.rs index 8fba516..2ab039e 100644 --- a/src/bin/bench_support.rs +++ b/src/bin/bench_support.rs @@ -18,8 +18,8 @@ use auth_api::{ config::{ AuditConfig, CaptchaConfig, CleanupConfig, Config, CorsConfig, CryptoConfig, DatabaseConfig, DeviceAuthConfig, Environment, JwtConfig, LogConfig, LogFormat, MailConfig, - MetricsConfig, NatsConfig, RateLimitConfig, RedisConfig, RiskConfig, SecurityConfig, - ServerConfig, SmtpConfig, + MetricsConfig, NatsConfig, PwnedPasswordsConfig, RateLimitConfig, RedisConfig, + SecurityConfig, ServerConfig, SmtpConfig, TelemetryConfig, WebAuthnConfig, WebhookConfig, }, handlers, state::AppState, @@ -178,7 +178,10 @@ pub fn benchmark_config(db_url: &str, redis_url: &str) -> Config { config.server.host = "127.0.0.1".into(); config.server.port = 0; config.server.public_url = "http://127.0.0.1".into(); - config.server.trusted_proxy_cidrs.clear(); + // The benchmark client is the only peer; trusting it lets a scenario give + // each request its own client address through X-Forwarded-For, so per-IP + // budgets measure the endpoint instead of contention between workers. + config.server.trusted_proxy_cidrs = vec!["127.0.0.1/32".parse().expect("valid CIDR")]; config.database.url = db_url.into(); config.database.max_connections = config.database.max_connections.max(32); config.database.min_connections = config.database.min_connections.min(4); @@ -189,6 +192,10 @@ pub fn benchmark_config(db_url: &str, redis_url: &str) -> Config { config.rate_limit.fail_open_on_redis_error = true; config.rate_limit.allow_requests_without_ip = true; config.security.lockout_threshold = config.security.lockout_threshold.max(10_000); + // Workers re-authenticate once at setup, as a client does before sensitive + // actions; the window must outlast the longest scenario. + config.security.sensitive_action_reauth_secs = + config.security.sensitive_action_reauth_secs.max(3_600); config.captcha.secret = None; config.mail.smtp = SmtpConfig { host: "127.0.0.1".into(), @@ -317,6 +324,7 @@ fn fallback_config(db_url: &str, redis_url: &str) -> Config { host: "127.0.0.1".into(), port: 0, public_url: "http://localhost".into(), + frontend_url: "http://localhost".into(), trusted_proxy_cidrs: Vec::new(), }, database: DatabaseConfig { @@ -324,6 +332,7 @@ fn fallback_config(db_url: &str, redis_url: &str) -> Config { max_connections: 32, min_connections: 4, acquire_timeout_secs: 5, + read_url: None, }, redis: RedisConfig { url: redis_url.into(), @@ -336,11 +345,13 @@ fn fallback_config(db_url: &str, redis_url: &str) -> Config { url: std::env::var("BENCH_NATS_URL") .or_else(|_| std::env::var("TEST_NATS_URL")) .unwrap_or_else(|_| "nats://127.0.0.1:4222".into()), + stream_replicas: 1, }, jwt: JwtConfig { private_key: "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgL+1qOaZ7C+H1mGbV\njUP83/W450N4GfOnZSrQ7P//4Y2hRANCAAR4BApTJy8Anvp+O7YNVlTeCbBZ+1YJ\nk+r5ELHGFIXciAEGSrCTOkCm3yChSYroYWLE3ZN4reh6JDbIMX/QnBGx\n-----END PRIVATE KEY-----".into(), public_key: "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEeAQKUycvAJ76fju2DVZU3gmwWftW\nCZPq+RCxxhSF3IgBBkqwkzpApt8goUmK6GFixN2TeK3oeiQ2yDF/0JwRsQ==\n-----END PUBLIC KEY-----".into(), previous_public_key: None, + next_public_key: None, access_expiry_secs: 900, refresh_expiry_secs: 86400, short_session_expiry_secs: 3600, @@ -369,6 +380,8 @@ fn fallback_config(db_url: &str, redis_url: &str) -> Config { lockout_threshold: 10_000, lockout_duration_secs: 1800, sensitive_action_reauth_secs: 600, + new_device_alerts: true, + magic_links: false, }, captcha: CaptchaConfig { secret: None, @@ -392,23 +405,42 @@ fn fallback_config(db_url: &str, redis_url: &str) -> Config { templates_dir: "templates".into(), 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: false, + allow_private_networks: false, + 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, }, - risk: RiskConfig { - geoip_db_path: String::new(), - geoip_required: false, - alert_threshold: 30, - challenge_threshold: 60, - block_threshold: 80, - history_days: 90, + telemetry: TelemetryConfig { + otlp_endpoint: None, + service_name: "auth-api".into(), + sample_ratio: 0.1, }, log: LogConfig { level: "error".into(), @@ -418,6 +450,7 @@ fn fallback_config(db_url: &str, redis_url: &str) -> Config { 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, diff --git a/src/bin/openapi.rs b/src/bin/openapi.rs new file mode 100644 index 0000000..01ff96c --- /dev/null +++ b/src/bin/openapi.rs @@ -0,0 +1,7 @@ +//! Print the OpenAPI document generated from the code. +//! +//! cargo run --quiet --bin openapi > docs/dev/api/openapi.yaml + +fn main() { + print!("{}", auth_api::openapi::yaml()); +} diff --git a/src/bin/perf_load.rs b/src/bin/perf_load.rs new file mode 100644 index 0000000..37079bb --- /dev/null +++ b/src/bin/perf_load.rs @@ -0,0 +1,1304 @@ +//! Performance harness driven by `perf/run.sh`. +//! +//! Subcommands, every option given as `--name value`: +//! +//! - `hash`: print an Argon2id hash of `PERF_PASSWORD` with production parameters. +//! - `migrate`: apply `migrations/` to `DATABASE_URL`. +//! - `http`: closed-loop HTTP load against `BASE_URL` +//! (`--scenario --concurrency --duration --warmup --users --label --out`). +//! - `db`: the application's own repository queries against `DATABASE_URL`, one +//! pool connection per virtual user (same options). +//! - `explain`: query plans, relation sizes and cache ratio (`--users --label --out`). +//! - `cleanup`: time batches of the retention jobs (`--label --out`). +//! - `statements reset` / `statements dump`: `pg_stat_statements` snapshot. +//! +//! Users and sessions are addressed through the identifiers `perf/seed.sql` +//! derives from the user index. Results are appended to `--out`, one JSON object +//! per line, and turned into the report by `perf/report.py`. +//! +//! Environment: `BASE_URL`, `DATABASE_URL`, `APP_PUBLIC_URL`, `JWT_PRIVATE_KEY`, +//! `JWT_PUBLIC_KEY`, `PERF_PASSWORD`, `PERF_CPU_GROUPS` +//! (`postgres=0-2;cache=3;api=4-6;load=7`). + +use std::{ + collections::{BTreeMap, HashMap}, + io::Write, + net::IpAddr, + path::Path, + str::FromStr, + sync::Arc, + time::{Duration, Instant}, +}; + +use anyhow::{Context, Result, anyhow, bail}; +use ipnetwork::IpNetwork; +use serde_json::{Value, json}; +use sqlx::{PgPool, Row, postgres::PgPoolOptions}; +use time::OffsetDateTime; +use uuid::Uuid; + +use auth_api::{ + config::CryptoConfig, + domain::{audit::AuditAction, session::SessionType}, + repositories::{ + audit::{self, NewAuditEntry}, + login_attempt::{self, NewLoginAttempt}, + recovery_code, role, + session::{self as session_repo, NewSession}, + two_factor as tf_repo, user as user_repo, + }, + utils::{crypto, jwt, password}, +}; + +// Arguments + +struct Args(HashMap); + +impl Args { + fn parse(raw: &[String]) -> Result { + let mut map = HashMap::new(); + let mut it = raw.iter(); + while let Some(key) = it.next() { + let name = key + .strip_prefix("--") + .ok_or_else(|| anyhow!("unexpected argument {key}"))?; + let value = it.next().ok_or_else(|| anyhow!("--{name} needs a value"))?; + map.insert(name.to_owned(), value.clone()); + } + Ok(Self(map)) + } + + fn str(&self, name: &str) -> Result<&str> { + self.0 + .get(name) + .map(String::as_str) + .ok_or_else(|| anyhow!("missing --{name}")) + } + + fn num(&self, name: &str) -> Result { + self.str(name)? + .parse() + .map_err(|_| anyhow!("--{name} must be a number")) + } +} + +fn env(name: &str) -> Result { + std::env::var(name).with_context(|| format!("{name} is not set")) +} + +// Deterministic identifiers (mirrors perf/seed.sql) + +fn perf_uuid(seed: &str) -> Uuid { + let digest = crypto::sha256(seed.as_bytes()); + Uuid::from_slice(&digest[..16]).expect("16 bytes") +} + +fn user_id(i: u64) -> Uuid { + perf_uuid(&format!("perf-user-{i}")) +} + +fn email(i: u64) -> String { + format!("perf{i}@example.com") +} + +/// Users without a second factor: a password sign-in completes for them. +fn is_single_factor(i: u64) -> bool { + !i.is_multiple_of(5) && i % 20 != 1 +} + +// Random numbers without shared state + +struct Rng(u64); + +impl Rng { + fn new(seed: u64) -> Self { + Self(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1) + } + + fn next(&mut self) -> u64 { + let mut x = self.0; + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + self.0 = x; + x + } + + fn below(&mut self, n: u64) -> u64 { + self.next() % n.max(1) + } + + fn user(&mut self, users: u64) -> u64 { + 1 + self.below(users) + } + + fn single_factor_user(&mut self, users: u64) -> u64 { + loop { + let i = self.user(users); + if is_single_factor(i) { + return i; + } + } + } + + /// Client address from a pool of 262 144, so per-address budgets stay + /// spread as they are with real traffic. + fn client_ip(&mut self) -> String { + format!( + "100.{}.{}.{}", + 64 + self.below(4), + self.below(256), + 1 + self.below(254) + ) + } +} + +// Latency histogram: 10 µs buckets below 1 ms, then 1 % wide buckets + +const LINEAR_BUCKETS: usize = 100; +const BUCKETS: usize = LINEAR_BUCKETS + 1_200; + +#[derive(Clone)] +struct Histogram { + counts: Vec, + total: u64, + sum_us: u128, + max_us: u64, +} + +impl Histogram { + fn new() -> Self { + Self { + counts: vec![0; BUCKETS], + total: 0, + sum_us: 0, + max_us: 0, + } + } + + fn index(us: u64) -> usize { + if us < 1_000 { + (us / 10) as usize + } else { + let i = LINEAR_BUCKETS + ((us as f64 / 1_000.0).ln() / 1.01f64.ln()) as usize; + i.min(BUCKETS - 1) + } + } + + fn upper_us(index: usize) -> f64 { + if index < LINEAR_BUCKETS { + ((index + 1) * 10) as f64 + } else { + 1_000.0 * 1.01f64.powi((index - LINEAR_BUCKETS + 1) as i32) + } + } + + fn record(&mut self, elapsed: Duration) { + let us = elapsed.as_micros() as u64; + self.counts[Self::index(us)] += 1; + self.total += 1; + self.sum_us += u128::from(us); + self.max_us = self.max_us.max(us); + } + + fn merge(&mut self, other: &Histogram) { + for (a, b) in self.counts.iter_mut().zip(&other.counts) { + *a += b; + } + self.total += other.total; + self.sum_us += other.sum_us; + self.max_us = self.max_us.max(other.max_us); + } + + fn percentile_ms(&self, p: f64) -> f64 { + if self.total == 0 { + return 0.0; + } + let target = ((p / 100.0) * self.total as f64).ceil().max(1.0) as u64; + let mut seen = 0; + for (i, count) in self.counts.iter().enumerate() { + seen += count; + if seen >= target { + return (Self::upper_us(i).min(self.max_us as f64)) / 1_000.0; + } + } + self.max_us as f64 / 1_000.0 + } + + fn summary(&self) -> Value { + let mean = if self.total == 0 { + 0.0 + } else { + self.sum_us as f64 / self.total as f64 / 1_000.0 + }; + json!({ + "mean": round3(mean), + "p50": round3(self.percentile_ms(50.0)), + "p90": round3(self.percentile_ms(90.0)), + "p95": round3(self.percentile_ms(95.0)), + "p99": round3(self.percentile_ms(99.0)), + "p999": round3(self.percentile_ms(99.9)), + "max": round3(self.max_us as f64 / 1_000.0), + }) + } +} + +fn round3(v: f64) -> f64 { + (v * 1_000.0).round() / 1_000.0 +} + +// Per-operation results + +enum Outcome { + Ok, + Status(u16), + Failed, +} + +#[derive(Clone)] +struct OpStats { + latency: Histogram, + ok: u64, + errors: BTreeMap, +} + +struct Recorder { + measure_from: Instant, + ops: BTreeMap<&'static str, OpStats>, +} + +impl Recorder { + fn new(measure_from: Instant) -> Self { + Self { + measure_from, + ops: BTreeMap::new(), + } + } + + fn record(&mut self, op: &'static str, started: Instant, outcome: Outcome) { + if started < self.measure_from { + return; + } + let stats = self.ops.entry(op).or_insert_with(|| OpStats { + latency: Histogram::new(), + ok: 0, + errors: BTreeMap::new(), + }); + stats.latency.record(started.elapsed()); + match outcome { + Outcome::Ok => stats.ok += 1, + Outcome::Status(code) => *stats.errors.entry(code.to_string()).or_default() += 1, + Outcome::Failed => *stats.errors.entry("failed".into()).or_default() += 1, + } + } + + fn merge(&mut self, other: Recorder) { + for (op, stats) in other.ops { + match self.ops.get_mut(op) { + Some(mine) => { + mine.latency.merge(&stats.latency); + mine.ok += stats.ok; + for (k, v) in stats.errors { + *mine.errors.entry(k).or_default() += v; + } + } + None => { + self.ops.insert(op, stats); + } + } + } + } + + fn report(&self, duration: Duration) -> Value { + let secs = duration.as_secs_f64(); + let mut total = Histogram::new(); + let mut ok = 0; + let mut errors: BTreeMap = BTreeMap::new(); + let mut ops = serde_json::Map::new(); + for (op, stats) in &self.ops { + total.merge(&stats.latency); + ok += stats.ok; + for (k, v) in &stats.errors { + *errors.entry(k.clone()).or_default() += v; + } + ops.insert( + (*op).to_owned(), + json!({ + "requests": stats.latency.total, + "ok": stats.ok, + "rps": round3(stats.latency.total as f64 / secs), + "errors": stats.errors, + "latency_ms": stats.latency.summary(), + }), + ); + } + json!({ + "requests": total.total, + "ok": ok, + "rps": round3(total.total as f64 / secs), + "error_rate": if total.total == 0 { 0.0 } else { round3((total.total - ok) as f64 / total.total as f64) }, + "errors": errors, + "latency_ms": total.summary(), + "operations": ops, + }) + } +} + +// System and database counters over the measurement window + +fn cpu_times() -> Result> { + let stat = std::fs::read_to_string("/proc/stat")?; + let mut cpus = Vec::new(); + for line in stat.lines() { + let Some(rest) = line.strip_prefix("cpu") else { + continue; + }; + if !rest.starts_with(|c: char| c.is_ascii_digit()) { + continue; + } + let fields: Vec = rest + .split_whitespace() + .skip(1) + .filter_map(|f| f.parse().ok()) + .collect(); + let total: u64 = fields.iter().take(8).sum(); + let idle = fields.get(3).copied().unwrap_or(0); + let iowait = fields.get(4).copied().unwrap_or(0); + cpus.push((total, total - idle - iowait, iowait)); + } + Ok(cpus) +} + +fn cpu_groups() -> Vec<(String, Vec)> { + let spec = std::env::var("PERF_CPU_GROUPS") + .unwrap_or_else(|_| "postgres=0-2;cache=3;api=4-6;load=7".into()); + spec.split(';') + .filter_map(|group| { + let (name, cpus) = group.split_once('=')?; + let mut list = Vec::new(); + for part in cpus.split(',') { + match part.split_once('-') { + Some((a, b)) => { + list.extend(a.parse::().ok()?..=b.parse::().ok()?) + } + None => list.push(part.parse().ok()?), + } + } + Some((name.to_owned(), list)) + }) + .collect() +} + +fn cpu_report(before: &[(u64, u64, u64)], after: &[(u64, u64, u64)]) -> Value { + let mut report = serde_json::Map::new(); + for (name, cpus) in cpu_groups() { + let (mut busy, mut iowait) = (0.0, 0.0); + for cpu in cpus { + let (Some(b), Some(a)) = (before.get(cpu), after.get(cpu)) else { + continue; + }; + let total = (a.0 - b.0).max(1) as f64; + busy += (a.1 - b.1) as f64 / total; + iowait += (a.2 - b.2) as f64 / total; + } + report.insert( + name, + json!({ "cores_busy": round3(busy), "cores_iowait": round3(iowait) }), + ); + } + Value::Object(report) +} + +async fn db_counters(pool: &PgPool) -> Result<[i64; 6]> { + let row = sqlx::query( + "SELECT xact_commit, blks_hit, blks_read, tup_inserted, tup_fetched, + (blk_read_time + blk_write_time)::bigint + FROM pg_stat_database WHERE datname = current_database()", + ) + .fetch_one(pool) + .await?; + Ok([ + row.get(0), + row.get(1), + row.get(2), + row.get(3), + row.get(4), + row.get(5), + ]) +} + +fn db_report(before: [i64; 6], after: [i64; 6], duration: Duration) -> Value { + let secs = duration.as_secs_f64(); + let d = |i: usize| (after[i] - before[i]) as f64; + let blocks = d(1) + d(2); + json!({ + "commits_per_sec": round3(d(0) / secs), + "cache_hit_ratio": if blocks == 0.0 { 1.0 } else { round3(d(1) / blocks) }, + "blocks_read_per_sec": round3(d(2) / secs), + "rows_inserted_per_sec": round3(d(3) / secs), + "rows_fetched_per_sec": round3(d(4) / secs), + "io_time_ms_per_sec": round3(d(5) / secs), + }) +} + +fn append_line(path: &str, value: &Value) -> Result<()> { + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .with_context(|| format!("cannot open {path}"))?; + writeln!(file, "{value}")?; + Ok(()) +} + +async fn stats_pool() -> Result { + Ok(PgPoolOptions::new() + .max_connections(1) + .connect(&env("DATABASE_URL")?) + .await?) +} + +// HTTP load + +#[derive(Clone, Copy)] +enum Op { + Profile, + Sessions, + Audit, + TwoFactor, + Refresh, + Login, + Register, +} + +impl Op { + fn name(self) -> &'static str { + match self { + Op::Profile => "profile", + Op::Sessions => "sessions", + Op::Audit => "audit", + Op::TwoFactor => "two_factor", + Op::Refresh => "refresh", + Op::Login => "login", + Op::Register => "register", + } + } +} + +/// Share of each operation in the `mixed` scenario, in percent. Refreshes +/// dominate: resource servers verify access tokens offline, so an auth API +/// mostly sees refreshes, then account pages, then sign-ins. +const MIXED: [(Op, u64); 7] = [ + (Op::Refresh, 50), + (Op::Profile, 20), + (Op::Sessions, 10), + (Op::Audit, 5), + (Op::TwoFactor, 5), + (Op::Login, 8), + (Op::Register, 2), +]; + +struct HttpCtx { + client: reqwest::Client, + base: String, + users: u64, + password: String, + tokens: Vec, + run: String, + scenario: String, +} + +struct Vu { + id: usize, + rng: Rng, + refresh_token: Option, + counter: u64, +} + +fn needs_refresh_chain(scenario: &str) -> bool { + matches!(scenario, "refresh" | "mixed") +} + +fn needs_tokens(scenario: &str) -> bool { + matches!( + scenario, + "profile" | "sessions" | "audit" | "two_factor" | "mixed" + ) +} + +fn mint_tokens(count: usize, users: u64) -> Result> { + let public_url = env("APP_PUBLIC_URL")?; + let private = env("JWT_PRIVATE_KEY")?.replace("\\n", "\n"); + let public = env("JWT_PUBLIC_KEY")?.replace("\\n", "\n"); + let key = jwt::parse_encoding_key(&private)?; + let kid = jwt::compute_kid(&jwt::parse_p256_verifying_key(&public)?); + let exp = OffsetDateTime::now_utc().unix_timestamp() + 4 * 3600; + let mut rng = Rng::new(0xC0FFEE); + (0..count) + .map(|_| { + let i = rng.user(users); + let mut claims = jwt::Claims::new( + user_id(i), + perf_uuid(&format!("perf-session-{i}-1")), + exp - 4 * 3600, + exp, + ); + // Issued a moment ago: `nbf` is checked without leeway. + claims.nbf = Some(claims.iat - 60); + claims.iss = Some(public_url.clone()); + claims.aud = vec![public_url.clone()]; + Ok(jwt::encode_token(&claims, &key, Some(&kid))?) + }) + .collect() +} + +async fn login(ctx: &HttpCtx, vu: &mut Vu) -> (Outcome, Option) { + let i = vu.rng.single_factor_user(ctx.users); + let res = ctx + .client + .post(format!("{}/auth/login", ctx.base)) + .header("x-forwarded-for", vu.rng.client_ip()) + .json(&json!({ "identifier": email(i), "password": ctx.password, "device_name": "perf" })) + .send() + .await; + match res { + Ok(res) if res.status().as_u16() == 200 => match res.json::().await { + Ok(body) => match body["refresh_token"].as_str() { + Some(token) => (Outcome::Ok, Some(token.to_owned())), + None => (Outcome::Status(299), None), + }, + Err(_) => (Outcome::Failed, None), + }, + Ok(res) => { + let code = res.status().as_u16(); + let _ = res.bytes().await; + (Outcome::Status(code), None) + } + Err(_) => (Outcome::Failed, None), + } +} + +async fn expect(res: reqwest::Result, status: u16) -> Outcome { + match res { + Ok(res) => { + let code = res.status().as_u16(); + let _ = res.bytes().await; + if code == status { + Outcome::Ok + } else { + Outcome::Status(code) + } + } + Err(_) => Outcome::Failed, + } +} + +async fn step(ctx: &HttpCtx, vu: &mut Vu, op: Op) -> Outcome { + let ip = vu.rng.client_ip(); + let token = || { + ctx.tokens[vu.id.wrapping_mul(7_919).wrapping_add(vu.counter as usize) % ctx.tokens.len()] + .clone() + }; + match op { + Op::Profile | Op::Sessions | Op::Audit | Op::TwoFactor => { + let path = match op { + Op::Profile => "/users/me", + Op::Sessions => "/users/me/sessions", + Op::Audit => "/users/me/audit?limit=50", + _ => "/users/me/two-factor", + }; + let res = ctx + .client + .get(format!("{}{path}", ctx.base)) + .bearer_auth(token()) + .header("x-forwarded-for", ip) + .send() + .await; + expect(res, 200).await + } + Op::Refresh => { + let Some(current) = vu.refresh_token.clone() else { + return Outcome::Failed; + }; + let res = ctx + .client + .post(format!("{}/auth/refresh", ctx.base)) + .header("x-forwarded-for", ip) + .json(&json!({ "refresh_token": current })) + .send() + .await; + match res { + Ok(res) if res.status().as_u16() == 200 => match res.json::().await { + Ok(body) => { + vu.refresh_token = body["refresh_token"].as_str().map(str::to_owned); + Outcome::Ok + } + Err(_) => Outcome::Failed, + }, + other => { + vu.refresh_token = None; + expect(other, 200).await + } + } + } + Op::Login => login(ctx, vu).await.0, + Op::Register => { + vu.counter += 1; + let name = format!("r{}_{}_{}", ctx.run, vu.id, vu.counter); + let res = ctx + .client + .post(format!("{}/auth/register", ctx.base)) + .header("x-forwarded-for", ip) + .json(&json!({ + "username": name, + "email": format!("{name}@example.com"), + "password": ctx.password, + "locale": "en", + })) + .send() + .await; + expect(res, 202).await + } + } +} + +async fn http_vu(ctx: Arc, mut vu: Vu, measure_from: Instant, end: Instant) -> Recorder { + let mut recorder = Recorder::new(measure_from); + while Instant::now() < end { + let op = match ctx.scenario.as_str() { + "mixed" => { + let mut roll = vu.rng.below(100); + MIXED + .iter() + .find(|(_, weight)| { + if roll < *weight { + true + } else { + roll -= weight; + false + } + }) + .map(|(op, _)| *op) + .unwrap_or(Op::Refresh) + } + "profile" => Op::Profile, + "sessions" => Op::Sessions, + "audit" => Op::Audit, + "two_factor" => Op::TwoFactor, + "refresh" => Op::Refresh, + "login" => Op::Login, + _ => Op::Register, + }; + vu.counter += 1; + let started = Instant::now(); + let outcome = step(&ctx, &mut vu, op).await; + recorder.record(op.name(), started, outcome); + // A broken refresh chain is re-established outside the measurement. + if matches!(op, Op::Refresh) && vu.refresh_token.is_none() { + vu.refresh_token = login(&ctx, &mut vu).await.1; + } + } + recorder +} + +async fn run_http(args: &Args) -> Result<()> { + let scenario = args.str("scenario")?.to_owned(); + let concurrency: usize = args.num("concurrency")?; + let duration = Duration::from_secs(args.num("duration")?); + let warmup = Duration::from_secs(args.num("warmup")?); + let users: u64 = args.num("users")?; + let token_pool = users.min(100_000) as usize; + + let tokens = if needs_tokens(&scenario) { + mint_tokens(token_pool, users)? + } else { + Vec::new() + }; + let client = reqwest::Client::builder() + .pool_max_idle_per_host(concurrency) + .tcp_nodelay(true) + .timeout(Duration::from_secs(60)) + .build()?; + let ctx = Arc::new(HttpCtx { + client, + base: env("BASE_URL")?, + users, + password: env("PERF_PASSWORD")?, + tokens, + run: format!( + "{:06x}", + OffsetDateTime::now_utc().unix_timestamp() % 0xFF_FFFF + ), + scenario: scenario.clone(), + }); + + // Every virtual user signs in once before the clock starts, when its + // scenario needs a refresh token of its own. + let mut vus = Vec::with_capacity(concurrency); + let preflight_started = Instant::now(); + let mut preflight = Vec::with_capacity(concurrency); + for id in 0..concurrency { + let ctx = ctx.clone(); + let scenario = scenario.clone(); + preflight.push(tokio::spawn(async move { + let mut vu = Vu { + id, + rng: Rng::new(id as u64 + 1 + ctx.run.len() as u64 * 7_777), + refresh_token: None, + counter: 0, + }; + if needs_refresh_chain(&scenario) { + for _ in 0..3 { + vu.refresh_token = login(&ctx, &mut vu).await.1; + if vu.refresh_token.is_some() { + break; + } + } + } + vu + })); + } + for handle in preflight { + vus.push(handle.await?); + } + let preflight_secs = preflight_started.elapsed().as_secs_f64(); + + let stats = stats_pool().await.ok(); + let start = Instant::now(); + let measure_from = start + warmup; + let end = measure_from + duration; + let handles: Vec<_> = vus + .into_iter() + .map(|vu| tokio::spawn(http_vu(ctx.clone(), vu, measure_from, end))) + .collect(); + + tokio::time::sleep_until(measure_from.into()).await; + let cpu_before = cpu_times()?; + let db_before = match &stats { + Some(pool) => db_counters(pool).await.ok(), + None => None, + }; + tokio::time::sleep_until(end.into()).await; + let cpu_after = cpu_times()?; + let db_after = match &stats { + Some(pool) => db_counters(pool).await.ok(), + None => None, + }; + + let mut recorder = Recorder::new(measure_from); + for handle in handles { + recorder.merge(handle.await?); + } + + let mut line = recorder.report(duration); + let obj = line.as_object_mut().expect("object"); + obj.insert("kind".into(), json!("http")); + obj.insert("label".into(), json!(args.str("label")?)); + obj.insert("users".into(), json!(users)); + obj.insert("scenario".into(), json!(scenario)); + obj.insert("concurrency".into(), json!(concurrency)); + obj.insert("duration_secs".into(), json!(duration.as_secs())); + obj.insert("preflight_secs".into(), json!(round3(preflight_secs))); + obj.insert("cpu".into(), cpu_report(&cpu_before, &cpu_after)); + if let (Some(b), Some(a)) = (db_before, db_after) { + obj.insert("db".into(), db_report(b, a, duration)); + } + append_line(args.str("out")?, &line)?; + println!( + "http {scenario:<11} c={concurrency:<4} rps={:<10} p50={:<8} p99={:<8} errors={}", + line["rps"], line["latency_ms"]["p50"], line["latency_ms"]["p99"], line["errors"] + ); + Ok(()) +} + +// Database benchmark + +async fn db_step(pool: &PgPool, scenario: &str, rng: &mut Rng, users: u64) -> Result<()> { + let i = rng.user(users); + let uid = user_id(i); + let cutoff = OffsetDateTime::now_utc() - time::Duration::minutes(15); + match scenario { + "user_by_email" => { + user_repo::find_by_identifier(pool, &email(i)).await?; + } + "session_by_token" => { + let hash = crypto::sha256(format!("perf-rt-{i}-1").as_bytes()); + session_repo::find_by_token_hash(pool, &hash).await?; + } + "session_validation" => { + session_repo::find_validation_by_id(pool, perf_uuid(&format!("perf-session-{i}-1"))) + .await?; + } + "active_sessions" => { + session_repo::find_active_summary_by_user(pool, uid).await?; + } + "failures_by_identifier" => { + login_attempt::count_recent_failures_by_identifier(pool, &email(i), cutoff, 10).await?; + } + "failures_by_ip" => { + let ip: IpNetwork = format!("10.{}.{}.20/32", i % 250, (i / 250) % 250).parse()?; + login_attempt::count_recent_failures_by_ip(pool, ip, cutoff, 30).await?; + } + "consecutive_failures" => { + login_attempt::count_consecutive_failures_by_user(pool, uid, 10).await?; + } + "rbac" => { + role::find_rbac_names(pool, uid).await?; + } + "audit_page" => { + audit::find_page_by_user(pool, uid, None, 51).await?; + } + "two_factor_overview" => { + tokio::try_join!( + tf_repo::find_by_user(pool, uid), + recovery_code::count_usable_by_user(pool, uid), + )?; + } + "sign_in_write" => { + // The four writes of a completed sign-in, in one transaction. + let token_hash = crypto::sha256(Uuid::new_v4().as_bytes()); + let ip: IpNetwork = IpAddr::from([100, 64, (i % 256) as u8, 1]).into(); + let mut tx = pool.begin().await?; + session_repo::create( + &mut *tx, + &NewSession { + user_id: uid, + session_family_id: Uuid::new_v4(), + expires_at: OffsetDateTime::now_utc() + time::Duration::days(1), + ip_address: Some(ip), + device_name: Some("perf"), + remember_me: false, + token_hash: &token_hash, + user_agent: Some("perf_load"), + session_type: SessionType::Web, + client_id: None, + family_created_at: None, + scopes: None, + }, + ) + .await?; + user_repo::record_sign_in(&mut *tx, uid).await?; + login_attempt::record( + &mut *tx, + &NewLoginAttempt { + user_id: Some(uid), + attempted_identifier: &email(i), + was_successful: true, + failure_reason: None, + request_ip: Some(ip), + request_user_agent: None, + }, + ) + .await?; + audit::append( + &mut *tx, + &NewAuditEntry { + user_id: Some(uid), + request_id: None, + action: AuditAction::Login, + ip_address: Some(ip), + metadata: json!({}), + }, + ) + .await?; + tx.commit().await?; + } + other => bail!("unknown db scenario {other}"), + } + Ok(()) +} + +async fn run_db(args: &Args) -> Result<()> { + let scenario = args.str("scenario")?.to_owned(); + let concurrency: usize = args.num("concurrency")?; + let duration = Duration::from_secs(args.num("duration")?); + let warmup = Duration::from_secs(args.num("warmup")?); + let users: u64 = args.num("users")?; + + let pool = PgPoolOptions::new() + .min_connections(concurrency as u32) + .max_connections(concurrency as u32) + .connect(&env("DATABASE_URL")?) + .await?; + let stats = stats_pool().await?; + + let start = Instant::now(); + let measure_from = start + warmup; + let end = measure_from + duration; + let handles: Vec<_> = (0..concurrency) + .map(|id| { + let pool = pool.clone(); + let scenario = scenario.clone(); + tokio::spawn(async move { + let mut rng = Rng::new(id as u64 + 99); + let mut recorder = Recorder::new(measure_from); + while Instant::now() < end { + let started = Instant::now(); + let outcome = match db_step(&pool, &scenario, &mut rng, users).await { + Ok(()) => Outcome::Ok, + Err(_) => Outcome::Failed, + }; + recorder.record("query", started, outcome); + } + recorder + }) + }) + .collect(); + + tokio::time::sleep_until(measure_from.into()).await; + let cpu_before = cpu_times()?; + let db_before = db_counters(&stats).await?; + tokio::time::sleep_until(end.into()).await; + let cpu_after = cpu_times()?; + let db_after = db_counters(&stats).await?; + + let mut recorder = Recorder::new(measure_from); + for handle in handles { + recorder.merge(handle.await?); + } + let mut line = recorder.report(duration); + let obj = line.as_object_mut().expect("object"); + obj.insert("kind".into(), json!("db")); + obj.insert("label".into(), json!(args.str("label")?)); + obj.insert("users".into(), json!(users)); + obj.insert("scenario".into(), json!(scenario)); + obj.insert("concurrency".into(), json!(concurrency)); + obj.insert("duration_secs".into(), json!(duration.as_secs())); + obj.insert("cpu".into(), cpu_report(&cpu_before, &cpu_after)); + obj.insert("db".into(), db_report(db_before, db_after, duration)); + append_line(args.str("out")?, &line)?; + println!( + "db {scenario:<22} c={concurrency:<3} qps={:<10} p50={:<8} p99={:<8} errors={}", + line["rps"], line["latency_ms"]["p50"], line["latency_ms"]["p99"], line["errors"] + ); + Ok(()) +} + +// Plans, sizes, retention + +fn walk_plan(node: &Value, out: &mut Vec) { + let mut label = node["Node Type"].as_str().unwrap_or("?").to_owned(); + if let Some(index) = node["Index Name"].as_str() { + label.push_str(&format!(" using {index}")); + } else if let Some(relation) = node["Relation Name"].as_str() { + label.push_str(&format!(" on {relation}")); + } + out.push(label); + if let Some(children) = node["Plans"].as_array() { + for child in children { + walk_plan(child, out); + } + } +} + +async fn explain_query(pool: &PgPool, name: &str, sql: &str, i: u64) -> Result { + let uid = user_id(i); + let cutoff = OffsetDateTime::now_utc() - time::Duration::minutes(15); + let explain = format!("EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) {sql}"); + let mut timings = Vec::new(); + let mut last = Value::Null; + for _ in 0..7 { + let q = sqlx::query_scalar::<_, Value>(&explain); + let q = match name { + "user_by_email" | "failures_by_identifier" => { + let q = q.bind(email(i)); + if name == "failures_by_identifier" { + q.bind(cutoff).bind(10i64) + } else { + q + } + } + "session_by_token" => { + q.bind(crypto::sha256(format!("perf-rt-{i}-1").as_bytes()).to_vec()) + } + "session_validation" => q.bind(perf_uuid(&format!("perf-session-{i}-1"))), + "failures_by_ip" => { + let ip: IpNetwork = format!("10.{}.{}.20/32", i % 250, (i / 250) % 250).parse()?; + q.bind(ip).bind(cutoff).bind(30i64) + } + "consecutive_failures" => q.bind(uid).bind(10i64), + "audit_page" => q.bind(uid).bind(51i64), + _ => q.bind(uid), + }; + last = q + .fetch_one(pool) + .await + .with_context(|| format!("explain {name}"))?; + timings.push(last[0]["Execution Time"].as_f64().unwrap_or(0.0)); + } + timings.sort_by(|a, b| a.partial_cmp(b).expect("finite")); + let root = &last[0]; + let mut nodes = Vec::new(); + walk_plan(&root["Plan"], &mut nodes); + Ok(json!({ + "query": name, + "execution_ms_median": round3(timings[timings.len() / 2]), + "planning_ms": root["Planning Time"], + "shared_hit_blocks": root["Plan"]["Shared Hit Blocks"], + "shared_read_blocks": root["Plan"]["Shared Read Blocks"], + "rows": root["Plan"]["Actual Rows"], + "nodes": nodes, + })) +} + +async fn run_explain(args: &Args) -> Result<()> { + let users: u64 = args.num("users")?; + let pool = stats_pool().await?; + // A single-factor user in the middle of the index range. + let mut i = users / 2; + while !is_single_factor(i) { + i += 1; + } + let queries: [(&str, String); 11] = [ + ("user_by_email", user_repo::FIND_BY_EMAIL_SQL.into()), + ("session_by_token", session_repo::FIND_BY_TOKEN_HASH_SQL.into()), + ("session_validation", session_repo::FIND_VALIDATION_BY_ID_SQL.into()), + ("active_sessions", session_repo::FIND_ACTIVE_SUMMARY_BY_USER_SQL.into()), + ("failures_by_identifier", login_attempt::COUNT_RECENT_FAILURES_BY_IDENTIFIER_SQL.into()), + ("failures_by_ip", login_attempt::COUNT_RECENT_FAILURES_BY_IP_SQL.into()), + ("consecutive_failures", login_attempt::COUNT_CONSECUTIVE_FAILURES_BY_USER_SQL.into()), + ( + "rbac", + "SELECT COALESCE(ARRAY(SELECT r.name::TEXT FROM user_roles ur JOIN roles r ON r.id = ur.role_id WHERE ur.user_id = $1 ORDER BY r.name), '{}'), COALESCE(ARRAY(SELECT DISTINCT p.name FROM user_roles ur JOIN role_permissions rp ON rp.role_id = ur.role_id JOIN permissions p ON p.id = rp.permission_id WHERE ur.user_id = $1 ORDER BY p.name), '{}')".into(), + ), + ( + "audit_page", + "SELECT * FROM audit_log WHERE user_id = $1 AND created_at <= NOW() ORDER BY created_at DESC, id DESC LIMIT $2".into(), + ), + ( + "two_factor_methods", + "SELECT * FROM two_factor_methods WHERE user_id = $1 ORDER BY created_at".into(), + ), + ( + "recovery_codes_usable", + "SELECT COUNT(*) FROM recovery_codes WHERE user_id = $1 AND used_at IS NULL AND (expires_at IS NULL OR expires_at > NOW())".into(), + ), + ]; + let mut plans = Vec::new(); + for (name, sql) in &queries { + plans.push(explain_query(&pool, name, sql, i).await?); + } + + let relations: Vec = sqlx::query( + "SELECT c.relname, + CASE WHEN c.relkind = 'p' + THEN (SELECT COALESCE(SUM(s.n_live_tup), 0) FROM pg_partition_tree(c.oid) t + JOIN pg_stat_user_tables s ON s.relid = t.relid) + ELSE (SELECT n_live_tup FROM pg_stat_user_tables WHERE relid = c.oid) END::bigint, + CASE WHEN c.relkind = 'p' + THEN (SELECT SUM(pg_relation_size(t.relid)) FROM pg_partition_tree(c.oid) t) + ELSE pg_relation_size(c.oid) END::bigint, + CASE WHEN c.relkind = 'p' + THEN (SELECT SUM(pg_indexes_size(t.relid)) FROM pg_partition_tree(c.oid) t) + ELSE pg_indexes_size(c.oid) END::bigint + FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' AND c.relkind IN ('r', 'p') AND NOT c.relispartition", + ) + .fetch_all(&pool) + .await? + .into_iter() + .map(|r| { + json!({ + "relation": r.get::(0), + "rows": r.get::, _>(1), + "table_bytes": r.get::, _>(2), + "index_bytes": r.get::, _>(3), + }) + }) + .collect(); + + let indexes: Vec = sqlx::query( + "SELECT indexrelname::text, relname::text, pg_relation_size(indexrelid), idx_scan + FROM pg_stat_user_indexes ORDER BY pg_relation_size(indexrelid) DESC LIMIT 25", + ) + .fetch_all(&pool) + .await? + .into_iter() + .map(|r| { + json!({ + "index": r.get::(0), + "relation": r.get::(1), + "bytes": r.get::(2), + "scans": r.get::(3), + }) + }) + .collect(); + + let database_bytes: i64 = sqlx::query_scalar("SELECT pg_database_size(current_database())") + .fetch_one(&pool) + .await?; + let settings: Vec = sqlx::query( + "SELECT name, setting, unit FROM pg_settings WHERE name IN + ('shared_buffers','effective_cache_size','work_mem','fsync','synchronous_commit', + 'full_page_writes','max_wal_size','random_page_cost','max_connections','server_version')", + ) + .fetch_all(&pool) + .await? + .into_iter() + .map(|r| json!({ "name": r.get::(0), "setting": r.get::(1), "unit": r.get::, _>(2) })) + .collect(); + + let line = json!({ + "kind": "explain", + "label": args.str("label")?, + "users": users, + "sample_user": i, + "database_bytes": database_bytes, + "plans": plans, + "relations": relations, + "indexes": indexes, + "settings": settings, + }); + append_line(args.str("out")?, &line)?; + println!( + "explain users={users} database={} MB", + database_bytes / 1_048_576 + ); + Ok(()) +} + +async fn run_cleanup(args: &Args) -> Result<()> { + let pool = stats_pool().await?; + let jobs = [ + ( + "sessions", + "SELECT cleanup_expired_sessions('7 days'::interval, 5000)", + ), + ( + "login_attempts", + "SELECT cleanup_old_login_attempts('90 days'::interval, 5000)", + ), + ( + "email_verification_tokens", + "SELECT cleanup_expired_email_verification_tokens('1 day'::interval, 5000)", + ), + ( + "password_reset_tokens", + "SELECT cleanup_expired_password_reset_tokens('1 day'::interval, 5000)", + ), + ]; + let mut results = Vec::new(); + for (name, sql) in jobs { + for batch in 1..=3 { + let started = Instant::now(); + let deleted: i32 = sqlx::query_scalar(sql).fetch_one(&pool).await?; + results.push(json!({ + "job": name, + "batch": batch, + "deleted": deleted, + "ms": round3(started.elapsed().as_secs_f64() * 1_000.0), + })); + } + } + let started = Instant::now(); + sqlx::query("SELECT rotate_audit_log_partitions(12)") + .execute(&pool) + .await?; + results.push(json!({ + "job": "rotate_audit_log_partitions", + "batch": 1, + "deleted": 0, + "ms": round3(started.elapsed().as_secs_f64() * 1_000.0), + })); + append_line( + args.str("out")?, + &json!({ "kind": "cleanup", "label": args.str("label")?, "users": args.num::("users")?, "batches": results }), + )?; + println!("cleanup done"); + Ok(()) +} + +async fn run_statements(action: &str, args: &Args) -> Result<()> { + let pool = stats_pool().await?; + match action { + "reset" => { + sqlx::query("SELECT pg_stat_statements_reset()") + .execute(&pool) + .await?; + } + "dump" => { + let rows: Vec = sqlx::query( + "SELECT left(regexp_replace(query, '\\s+', ' ', 'g'), 240), calls, + total_exec_time, mean_exec_time, stddev_exec_time, rows, + shared_blks_hit, shared_blks_read + FROM pg_stat_statements + WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database()) + ORDER BY total_exec_time DESC LIMIT 20", + ) + .fetch_all(&pool) + .await? + .into_iter() + .map(|r| { + json!({ + "query": r.get::(0), + "calls": r.get::(1), + "total_ms": round3(r.get::(2)), + "mean_ms": round3(r.get::(3)), + "stddev_ms": round3(r.get::(4)), + "rows": r.get::(5), + "shared_hit": r.get::(6), + "shared_read": r.get::(7), + }) + }) + .collect(); + append_line( + args.str("out")?, + &json!({ + "kind": "statements", + "label": args.str("label")?, + "users": args.num::("users")?, + "context": args.str("context")?, + "statements": rows, + }), + )?; + } + other => bail!("statements {other}: expected reset or dump"), + } + Ok(()) +} + +// Entry point + +#[tokio::main] +async fn main() -> Result<()> { + let argv: Vec = std::env::args().skip(1).collect(); + let Some(command) = argv.first() else { + bail!("usage: perf_load hash|migrate|http|db|explain|cleanup|statements ..."); + }; + match command.as_str() { + "hash" => { + let crypto_cfg = CryptoConfig { + argon2_memory_kib: 65_536, + argon2_iterations: 3, + argon2_parallelism: 4, + argon2_max_concurrency: 1, + totp_issuer: "perf".into(), + encryption_key: String::new(), + previous_encryption_key: None, + totp_skew: 1, + recovery_code_expiry_days: 365, + }; + println!("{}", password::hash(&env("PERF_PASSWORD")?, &crypto_cfg)?); + } + "migrate" => { + let pool = stats_pool().await?; + sqlx::migrate::Migrator::new(Path::new("migrations")) + .await? + .run(&pool) + .await?; + println!("migrations applied"); + } + "http" => run_http(&Args::parse(&argv[1..])?).await?, + "db" => run_db(&Args::parse(&argv[1..])?).await?, + "explain" => run_explain(&Args::parse(&argv[1..])?).await?, + "cleanup" => run_cleanup(&Args::parse(&argv[1..])?).await?, + "statements" => { + let action = argv + .get(1) + .ok_or_else(|| anyhow!("statements reset|dump"))?; + run_statements(action, &Args::parse(&argv[2..])?).await? + } + other => bail!("unknown command {other}"), + } + Ok(()) +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..ba4df32 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,252 @@ +//! Operational commands handled by the binary instead of starting the server. + +use serde_json::json; +use sqlx::PgPool; + +use crate::{ + domain::audit::AuditAction, + repositories::{ + audit::{self, NewAuditEntry}, + registered_client::NewRegisteredClient, + role, user, + }, +}; + +/// A client registration requested on the command line: +/// +/// ```text +/// auth-api --register-client --name +/// [--primary] [--scopes a:b,c:d] [--redirect-uri ]... +/// [--loopback-redirect] [--max-sessions ] +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClientRegistration { + pub client_id: String, + pub display_name: String, + pub is_primary: bool, + pub scopes: Vec, + pub redirect_uris: Vec, + pub allows_loopback_redirect: bool, + pub default_max_sessions: i16, +} + +impl ClientRegistration { + pub fn as_new(&self) -> NewRegisteredClient<'_> { + NewRegisteredClient { + client_id: &self.client_id, + display_name: &self.display_name, + is_primary: self.is_primary, + scopes: &self.scopes, + redirect_uris: &self.redirect_uris, + allows_loopback_redirect: self.allows_loopback_redirect, + default_max_sessions: self.default_max_sessions, + } + } +} + +/// Parse `--register-client` and its options. `Ok(None)` when the flag is absent. +pub fn parse_client_registration(args: &[String]) -> Result, String> { + let Some(position) = args.iter().position(|a| a == "--register-client") else { + return Ok(None); + }; + + let value = |index: usize, flag: &str| -> Result { + args.get(index) + .filter(|v| !v.starts_with("--")) + .cloned() + .ok_or_else(|| format!("{flag} needs a value")) + }; + + let client_id = value(position + 1, "--register-client")?; + if !crate::domain::registered_client::is_valid_client_id(&client_id) { + return Err("client id must be 1 to 100 of [A-Za-z0-9._-]".into()); + } + + let mut registration = ClientRegistration { + client_id, + display_name: String::new(), + is_primary: false, + scopes: Vec::new(), + redirect_uris: Vec::new(), + allows_loopback_redirect: false, + default_max_sessions: 5, + }; + + let mut index = position + 2; + while index < args.len() { + match args[index].as_str() { + "--name" => { + registration.display_name = value(index + 1, "--name")?; + index += 2; + } + "--primary" => { + registration.is_primary = true; + index += 1; + } + "--scopes" => { + registration.scopes = value(index + 1, "--scopes")? + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .collect(); + index += 2; + } + "--redirect-uri" => { + let uri = value(index + 1, "--redirect-uri")?; + reqwest::Url::parse(&uri) + .map_err(|e| format!("invalid redirect uri {uri}: {e}"))?; + registration.redirect_uris.push(uri); + index += 2; + } + "--loopback-redirect" => { + registration.allows_loopback_redirect = true; + index += 1; + } + "--max-sessions" => { + registration.default_max_sessions = value(index + 1, "--max-sessions")? + .parse::() + .ok() + .filter(|n| *n > 0) + .ok_or("--max-sessions must be a positive number")?; + index += 2; + } + other => return Err(format!("unknown option for --register-client: {other}")), + } + } + + if registration.display_name.trim().is_empty() { + return Err("--name is required".into()); + } + Ok(Some(registration)) +} + +/// A role granted on the command line, typically the first administrator: +/// +/// ```text +/// auth-api --grant-role --user +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RoleGrant { + pub role: String, + pub email: String, +} + +/// Parse `--grant-role` and `--user`. `Ok(None)` when `--grant-role` is absent. +pub fn parse_role_grant(args: &[String]) -> Result, String> { + let Some(position) = args.iter().position(|a| a == "--grant-role") else { + return Ok(None); + }; + let value_of = |flag: &str| -> Result { + let index = args + .iter() + .position(|a| a == flag) + .ok_or_else(|| format!("{flag} is required"))?; + args.get(index + 1) + .filter(|v| !v.starts_with("--") && !v.is_empty()) + .cloned() + .ok_or_else(|| format!("{flag} needs a value")) + }; + let role = args + .get(position + 1) + .filter(|v| !v.starts_with("--") && !v.is_empty()) + .cloned() + .ok_or("--grant-role needs a role name")?; + Ok(Some(RoleGrant { + role, + email: value_of("--user")?, + })) +} + +/// Grant the role to the account, audited. Granting a role the account already +/// holds changes nothing. +pub async fn grant_role(pool: &PgPool, grant: &RoleGrant) -> Result<(), String> { + let account = user::find_by_email(pool, &grant.email) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("no account with the address {}", grant.email))?; + let granted = role::find_by_name(pool, &grant.role) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("no role named {}", grant.role))?; + + match role::assign_to_user(pool, account.id, granted.id, None).await { + Ok(_) => {} + // ON CONFLICT DO NOTHING returns no row: the role was already held. + Err(sqlx::Error::RowNotFound) => return Ok(()), + Err(e) => return Err(e.to_string()), + } + audit::append( + pool, + &NewAuditEntry { + user_id: Some(account.id), + request_id: None, + action: AuditAction::RoleAssigned, + ip_address: None, + metadata: json!({ "role": granted.name, "by": "command_line" }), + }, + ) + .await + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(line: &str) -> Vec { + line.split(' ').map(str::to_owned).collect() + } + + #[test] + fn absent_flag_is_not_a_command() { + assert_eq!(parse_client_registration(&args("auth-api")), Ok(None)); + } + + #[test] + fn full_registration_is_parsed() { + let parsed = parse_client_registration(&args( + "auth-api --register-client cli-app --name CLI --primary --scopes users:read,users:manage \ + --redirect-uri http://127.0.0.1/callback --loopback-redirect --max-sessions 3", + )) + .unwrap() + .unwrap(); + + assert_eq!(parsed.client_id, "cli-app"); + assert_eq!(parsed.display_name, "CLI"); + assert!(parsed.is_primary && parsed.allows_loopback_redirect); + assert_eq!(parsed.scopes, vec!["users:read", "users:manage"]); + assert_eq!(parsed.redirect_uris, vec!["http://127.0.0.1/callback"]); + assert_eq!(parsed.default_max_sessions, 3); + } + + #[test] + fn invalid_registrations_are_refused() { + for line in [ + "auth-api --register-client", + "auth-api --register-client bad/id --name X", + "auth-api --register-client ok", + "auth-api --register-client ok --name X --max-sessions 0", + "auth-api --register-client ok --name X --redirect-uri not-a-url", + "auth-api --register-client ok --name X --bogus", + ] { + assert!(parse_client_registration(&args(line)).is_err(), "{line}"); + } + } + + #[test] + fn a_role_grant_names_the_role_and_the_account() { + assert_eq!( + parse_role_grant(&args("auth-api --grant-role admin --user a@example.com")), + Ok(Some(RoleGrant { + role: "admin".into(), + email: "a@example.com".into() + })) + ); + assert_eq!(parse_role_grant(&args("auth-api")), Ok(None)); + assert!(parse_role_grant(&args("auth-api --grant-role --user a@example.com")).is_err()); + assert!(parse_role_grant(&args("auth-api --grant-role admin")).is_err()); + assert!(parse_role_grant(&args("auth-api --grant-role admin --user")).is_err()); + } +} diff --git a/src/config.rs b/src/config.rs deleted file mode 100644 index 22cf54c..0000000 --- a/src/config.rs +++ /dev/null @@ -1,1453 +0,0 @@ -//! Application configuration. -//! -//! Loads all settings from environment variables at startup. -//! Required variables cause an early, explicit error if missing. -//! Optional variables fall back to safe, documented defaults. -//! Use `.env.dev` and `config.prod.env` as a reference for all available variables. - -use std::{env, path::Path, str::FromStr}; - -use base64::{Engine, engine::general_purpose::STANDARD}; -use ipnetwork::IpNetwork; - -// Error - -#[derive(Debug, thiserror::Error)] -pub enum ConfigError { - #[error("missing required env var: {0}")] - Missing(String), - #[error("invalid value for '{key}': {reason}")] - Invalid { key: String, reason: String }, -} - -// Environment - -#[derive(Debug, Clone, PartialEq)] -pub enum Environment { - Development, - Production, - Test, -} - -impl FromStr for Environment { - type Err = String; - - fn from_str(s: &str) -> Result { - match s.to_ascii_lowercase().as_str() { - "development" | "dev" => Ok(Self::Development), - "production" | "prod" => Ok(Self::Production), - "test" => Ok(Self::Test), - _ => Err(format!("unknown environment: {s}")), - } - } -} - -// Sub-configs - -#[derive(Debug, Clone)] -pub struct ServerConfig { - pub host: String, - pub port: u16, - /// Public-facing base URL used to build links in emails (e.g. "https://api.example.com"). - pub public_url: String, - /// Reverse-proxy CIDRs allowed to supply X-Forwarded-For / X-Real-IP. - /// Requests coming from other peers use the socket address directly. - pub trusted_proxy_cidrs: Vec, -} - -#[derive(Debug, Clone)] -pub struct DatabaseConfig { - pub url: String, - /// Maximum number of connections in the pool. - pub max_connections: u32, - /// Minimum idle connections kept alive. - pub min_connections: u32, - /// Seconds before a pending acquire is aborted. - pub acquire_timeout_secs: u64, -} - -#[derive(Debug, Clone)] -pub struct RedisConfig { - pub url: String, - pub pool_size: u32, - /// Maximum time in milliseconds to wait for a connection from the pool. - /// Prevents unbounded queue buildup under Redis pressure. Default: 2000ms. - pub wait_timeout_ms: u64, -} - -#[derive(Debug, Clone)] -pub struct NatsConfig { - pub url: String, -} - -#[derive(Debug, Clone)] -pub struct JwtConfig { - /// PEM-encoded ECDSA P-256 private key used to sign access tokens. - pub private_key: String, - /// PEM-encoded ECDSA P-256 public key used to verify access tokens. - pub public_key: String, - /// Previous public key, accepted for verification during key rotation. - pub previous_public_key: Option, - /// Short-lived access token lifetime (default: 15 min). - pub access_expiry_secs: u64, - /// Long-lived refresh token lifetime used when remember_me is true (default: 30 days). - pub refresh_expiry_secs: u64, - /// Short-lived refresh token lifetime used when remember_me is false (default: 24 h). - pub short_session_expiry_secs: u64, - /// When true, the refresh endpoint rejects requests whose IP differs from the - /// one recorded at session creation. Useful for high-security deployments but - /// breaks clients that roam between networks (e.g. mobile). - pub strict_session_binding: bool, - /// Hard upper bound on session lifetime regardless of refresh activity (default: 90 days). - pub max_session_lifetime_secs: u64, - /// Audience values stamped into the `aud` claim of issued access tokens. - /// Each entry is the public URL of a downstream resource server (core-api, - /// billing-api, ...). Loaded from the `JWT_AUDIENCE` env var as a CSV. - /// Required in production: an empty audience would emit tokens that - /// downstream services pinning `aud` could not accept. - pub audience: Vec, -} - -#[derive(Debug, Clone)] -pub struct CryptoConfig { - // Argon2id parameters, tune for your hardware - pub argon2_memory_kib: u32, - pub argon2_iterations: u32, - pub argon2_parallelism: u32, - /// Maximum number of Argon2id operations allowed to run concurrently. - /// Bounds worst-case memory usage (max_concurrency x argon2_memory_kib) - /// and keeps the blocking threadpool from being flooded during a login - /// storm; excess requests queue on a semaphore instead. Defaults to the - /// number of available CPU cores. - pub argon2_max_concurrency: u32, - /// Issuer name shown in authenticator apps. - pub totp_issuer: String, - /// Base64-encoded 32-byte key used to encrypt TOTP secrets at rest with AES-256-GCM. - pub encryption_key: String, - /// Previous encryption key used only during key rotation (`--rotate-totp-keys`). - /// Set this to the old key value before running the rotation command, then remove it afterward. - pub previous_encryption_key: Option, - /// Number of 30-second steps to accept before and after the current one. - /// 1 = accept codes within +/- 30 seconds (recommended for clock skew tolerance). - pub totp_skew: u8, - /// Lifetime of recovery codes in days. 0 = no expiration. - pub recovery_code_expiry_days: u32, -} - -#[derive(Debug, Clone)] -pub struct RateLimitConfig { - /// Max requests per 1-minute window per IP for general routes. - pub requests_per_minute: u64, - /// Stricter limit for authentication routes (login, register, forgot-password, 2FA). - /// Defaults to 20 requests per minute. - pub auth_requests_per_minute: u64, - /// When true, Redis outages do not block traffic and the request is allowed through. - pub fail_open_on_redis_error: bool, - /// When true, requests with no resolved client IP are allowed through. - pub allow_requests_without_ip: bool, -} - -#[derive(Debug, Clone)] -pub struct SecurityConfig { - /// Number of consecutive login failures before the account is temporarily locked. - pub lockout_threshold: u32, - /// Duration of the account lockout in seconds (default: 1800 = 30 minutes). - pub lockout_duration_secs: u64, - /// TTL of the "recent re-authentication" window for sensitive actions. - pub sensitive_action_reauth_secs: u64, -} - -// Log - -#[derive(Debug, Clone, PartialEq)] -pub enum LogFormat { - /// Human-readable, coloured output for development. - Pretty, - /// Structured JSON output for production log aggregators. - Json, -} - -impl FromStr for LogFormat { - type Err = String; - - fn from_str(s: &str) -> Result { - match s.to_ascii_lowercase().as_str() { - "pretty" => Ok(Self::Pretty), - "json" => Ok(Self::Json), - _ => Err(format!("unknown log format: {s}")), - } - } -} - -#[derive(Debug, Clone)] -pub struct LogConfig { - /// Directive passed to EnvFilter, e.g. "info" or "auth_api=debug,tower_http=info". - pub level: String, - pub format: LogFormat, -} - -// Mail - -#[derive(Debug, Clone)] -pub struct SmtpConfig { - pub host: String, - pub port: u16, - pub username: String, - pub password: String, - /// Display name used in the From header. - pub from_name: String, - /// Email address used in the From header. - pub from_address: String, -} - -#[derive(Debug, Clone)] -pub struct MailConfig { - pub smtp: SmtpConfig, - /// Path to the templates directory, e.g. "templates". - pub templates_dir: String, - /// Locale used when no match is found for the user's preferred locale. - pub default_locale: String, -} - -// Risk scoring - -#[derive(Debug, Clone)] -pub struct RiskConfig { - /// Path to the MaxMind GeoLite2-City.mmdb file. - /// If empty or the file is absent, geolocation signals are skipped (fail open). - pub geoip_db_path: String, - /// When true, the API refuses to start unless the GeoIP database is available. - pub geoip_required: bool, - /// Score threshold above which an alert email is sent to the user (default: 30). - pub alert_threshold: u32, - /// Score threshold above which 2FA is enforced even without TOTP configured (default: 60). - pub challenge_threshold: u32, - /// Score threshold above which the login is blocked entirely (default: 80). - pub block_threshold: u32, - /// Number of days of location history to consider when computing "new country/city" (default: 90). - pub history_days: u32, -} - -// Cleanup - -#[derive(Debug, Clone)] -pub struct CleanupConfig { - /// Interval in seconds between application-side cleanup runs (fallback when pg_cron is unavailable). Default: 3600. - pub interval_secs: u64, - /// Grace period in days after session expiry/revocation before deletion. Default: 7. - pub sessions_grace_days: u32, - /// Grace period in days after token expiry before deletion - /// (email_2fa_codes, password_reset_tokens, email_verification_tokens). Default: 1. - pub tokens_grace_days: u32, - /// Retention period in days for login_attempts records. Default: 90. - pub login_attempts_retention_days: u32, - /// Grace period in days after recovery code expiry before deletion. Default: 7. - pub recovery_codes_grace_days: u32, -} - -// Audit - -#[derive(Debug, Clone)] -pub struct AuditConfig { - /// Number of months of audit log data to retain. Older monthly partitions are dropped. - /// The database function rotate_audit_log_partitions() enforces this at startup and - /// nightly via pg_cron (if available). 0 = keep forever. - pub retention_months: u32, -} - -// CAPTCHA - -#[derive(Debug, Clone)] -pub struct CaptchaConfig { - /// hCaptcha secret key. If empty, captcha verification is skipped (development/test mode). - pub secret: Option, - /// hCaptcha verify endpoint. - pub verify_url: String, - /// Request timeout for the verification call. - pub request_timeout_secs: u64, - /// When true, network/5xx errors from the CAPTCHA provider allow the request through. - pub fail_open_on_error: bool, -} - -// CORS - -#[derive(Debug, Clone)] -pub struct CorsConfig { - /// Comma-separated list of allowed origins, e.g. "https://app.example.com,https://admin.example.com". - /// Use "*" to allow all origins (not recommended in production). - pub allowed_origins: Vec, - /// Whether to allow credentials (cookies, Authorization header). - pub allow_credentials: bool, -} - -// Metrics - -#[derive(Debug, Clone)] -pub struct MetricsConfig { - /// When true, Prometheus metrics are collected and served on `port`. - pub enabled: bool, - /// Port of the internal metrics listener (`/metrics`). Conventionally 9464 - /// (Prometheus exporter range). Must never be exposed publicly: publish it - /// on loopback only in docker-compose, never through the reverse proxy. - pub port: u16, -} - -// Device authorization (RFC 8628) - -#[derive(Debug, Clone)] -pub struct DeviceAuthConfig { - /// How long a device authorization request remains valid (seconds). - pub ttl_secs: u64, - /// Recommended polling interval for clients (seconds). - pub poll_interval_secs: u64, - /// Base URL of the verification page shown to the user (auth frontend). - pub verification_uri: String, -} - -// Root config - -#[derive(Debug, Clone)] -pub struct Config { - pub env: Environment, - pub server: ServerConfig, - pub database: DatabaseConfig, - pub redis: RedisConfig, - pub nats: NatsConfig, - pub jwt: JwtConfig, - pub crypto: CryptoConfig, - pub rate_limit: RateLimitConfig, - pub security: SecurityConfig, - pub mail: MailConfig, - pub cors: CorsConfig, - pub captcha: CaptchaConfig, - pub cleanup: CleanupConfig, - pub audit: AuditConfig, - pub risk: RiskConfig, - pub log: LogConfig, - pub device_auth: DeviceAuthConfig, - pub metrics: MetricsConfig, -} - -impl Config { - /// Load configuration from environment variables. - /// Silently ignores a missing `.env` file; production relies on real env vars. - pub fn from_env() -> Result { - dotenvy::dotenv().ok(); - - let env = env_parse("APP_ENV").unwrap_or(Environment::Development); - - let is_production = matches!(env, Environment::Production); - - let config = Self { - env: env.clone(), - server: ServerConfig { - host: env_string("SERVER_HOST").unwrap_or_else(|| "0.0.0.0".into()), - port: env_parse("SERVER_PORT").unwrap_or(3000u16), - public_url: env_string("APP_PUBLIC_URL") - .unwrap_or_else(|| "http://localhost:3000".into()), - trusted_proxy_cidrs: env_ip_network_list("TRUSTED_PROXY_CIDRS")?, - }, - database: DatabaseConfig { - url: env_require("DATABASE_URL")?, - max_connections: env_parse("DB_MAX_CONNECTIONS").unwrap_or(20), - min_connections: env_parse("DB_MIN_CONNECTIONS").unwrap_or(2), - acquire_timeout_secs: env_parse("DB_ACQUIRE_TIMEOUT_SECS").unwrap_or(30), - }, - redis: RedisConfig { - url: env_require("REDIS_URL")?, - pool_size: env_parse("REDIS_POOL_SIZE").unwrap_or(10), - wait_timeout_ms: env_parse("REDIS_WAIT_TIMEOUT_MS").unwrap_or(2000), - }, - nats: NatsConfig { - url: env_string("NATS_URL").unwrap_or_else(|| "nats://nats:4222".into()), - }, - jwt: JwtConfig { - private_key: env_require("JWT_PRIVATE_KEY")?.replace("\\n", "\n"), - public_key: env_require("JWT_PUBLIC_KEY")?.replace("\\n", "\n"), - previous_public_key: env_string("JWT_PREVIOUS_PUBLIC_KEY") - .map(|s| s.replace("\\n", "\n")), - access_expiry_secs: env_parse("JWT_ACCESS_EXPIRY_SECS").unwrap_or(900), - refresh_expiry_secs: env_parse("JWT_REFRESH_EXPIRY_SECS") - .unwrap_or(60 * 60 * 24 * 30), - short_session_expiry_secs: env_parse("JWT_SHORT_SESSION_EXPIRY_SECS") - .unwrap_or(60 * 60 * 24), - strict_session_binding: env_parse("JWT_STRICT_SESSION_BINDING").unwrap_or(false), - max_session_lifetime_secs: env_parse("JWT_MAX_SESSION_LIFETIME_SECS") - .unwrap_or(60 * 60 * 24 * 90), - audience: env_csv("JWT_AUDIENCE").unwrap_or_default(), - }, - crypto: CryptoConfig { - argon2_memory_kib: env_parse("ARGON2_MEMORY_KIB").unwrap_or(65_536), // 64 MB - argon2_iterations: env_parse("ARGON2_ITERATIONS").unwrap_or(3), - argon2_parallelism: env_parse("ARGON2_PARALLELISM").unwrap_or(4), - argon2_max_concurrency: env_parse("ARGON2_MAX_CONCURRENCY") - .unwrap_or_else(default_argon2_max_concurrency), - totp_issuer: env_string("TOTP_ISSUER").unwrap_or_else(|| "auth-api".into()), - encryption_key: env_require("ENCRYPTION_KEY")?, - previous_encryption_key: env_string("PREVIOUS_ENCRYPTION_KEY"), - totp_skew: env_parse("TOTP_SKEW").unwrap_or(1), - recovery_code_expiry_days: env_parse("RECOVERY_CODE_EXPIRY_DAYS").unwrap_or(365), // 0 = never - }, - rate_limit: RateLimitConfig { - requests_per_minute: env_parse("RATE_LIMIT_RPM").unwrap_or(300), - auth_requests_per_minute: env_parse("RATE_LIMIT_AUTH_RPM").unwrap_or(20), - fail_open_on_redis_error: env_parse("RATE_LIMIT_FAIL_OPEN") - .unwrap_or(!is_production), - allow_requests_without_ip: env_parse("RATE_LIMIT_ALLOW_MISSING_IP") - .unwrap_or(!is_production), - }, - security: SecurityConfig { - lockout_threshold: env_parse("LOCKOUT_THRESHOLD").unwrap_or(10), - lockout_duration_secs: env_parse("LOCKOUT_DURATION_SECS").unwrap_or(1800), - sensitive_action_reauth_secs: env_parse("SENSITIVE_ACTION_REAUTH_SECS") - .unwrap_or(600), - }, - mail: MailConfig { - smtp: SmtpConfig { - host: env_require("SMTP_HOST")?, - port: env_parse("SMTP_PORT").unwrap_or(587), - username: env_require("SMTP_USERNAME")?, - password: env_require("SMTP_PASSWORD")?, - from_name: env_string("SMTP_FROM_NAME").unwrap_or_else(|| "auth-api".into()), - from_address: env_require("SMTP_FROM_ADDRESS")?, - }, - templates_dir: env_string("MAIL_TEMPLATES_DIR") - .unwrap_or_else(|| "templates".into()), - default_locale: env_string("MAIL_DEFAULT_LOCALE").unwrap_or_else(|| "en".into()), - }, - captcha: CaptchaConfig { - secret: env_string("CAPTCHA_SECRET"), - verify_url: env_string("CAPTCHA_VERIFY_URL") - .unwrap_or_else(|| "https://hcaptcha.com/siteverify".into()), - request_timeout_secs: env_parse("CAPTCHA_TIMEOUT_SECS").unwrap_or(5), - fail_open_on_error: env_parse("CAPTCHA_FAIL_OPEN").unwrap_or(!is_production), - }, - cors: CorsConfig { - allowed_origins: env_string("CORS_ALLOWED_ORIGINS") - .unwrap_or_else(|| "http://localhost:3000".into()) - .split(',') - .map(|s| s.trim().to_owned()) - .collect(), - allow_credentials: env_parse("CORS_ALLOW_CREDENTIALS").unwrap_or(true), - }, - cleanup: CleanupConfig { - interval_secs: env_parse("CLEANUP_INTERVAL_SECS").unwrap_or(3600), - sessions_grace_days: env_parse("CLEANUP_SESSIONS_GRACE_DAYS").unwrap_or(7), - tokens_grace_days: env_parse("CLEANUP_TOKENS_GRACE_DAYS").unwrap_or(1), - login_attempts_retention_days: env_parse("CLEANUP_LOGIN_ATTEMPTS_RETENTION_DAYS") - .unwrap_or(90), - recovery_codes_grace_days: env_parse("CLEANUP_RECOVERY_CODES_GRACE_DAYS") - .unwrap_or(7), - }, - audit: AuditConfig { - retention_months: env_parse("AUDIT_LOG_RETENTION_MONTHS").unwrap_or(12), - }, - risk: RiskConfig { - geoip_db_path: env_string("GEOIP_DB_PATH").unwrap_or_default(), - geoip_required: env_parse("GEOIP_REQUIRED").unwrap_or(false), - alert_threshold: env_parse("RISK_ALERT_THRESHOLD").unwrap_or(30), - challenge_threshold: env_parse("RISK_CHALLENGE_THRESHOLD").unwrap_or(60), - block_threshold: env_parse("RISK_BLOCK_THRESHOLD").unwrap_or(80), - history_days: env_parse("RISK_HISTORY_DAYS").unwrap_or(90), - }, - log: LogConfig { - level: env_string("LOG_LEVEL").unwrap_or_else(|| "info".into()), - format: env_parse("LOG_FORMAT").unwrap_or(LogFormat::Pretty), - }, - device_auth: DeviceAuthConfig { - ttl_secs: env_parse("DEVICE_AUTH_TTL_SECS").unwrap_or(300), - poll_interval_secs: env_parse("DEVICE_AUTH_POLL_INTERVAL_SECS").unwrap_or(5), - verification_uri: env_require("DEVICE_AUTH_VERIFICATION_URI")?, - }, - metrics: MetricsConfig { - enabled: env_parse("METRICS_ENABLED").unwrap_or(true), - port: env_parse("METRICS_PORT").unwrap_or(9464), - }, - }; - - config.validate()?; - - Ok(config) - } - - pub fn is_production(&self) -> bool { - self.env == Environment::Production - } - - pub fn is_test(&self) -> bool { - self.env == Environment::Test - } - - pub fn validate(&self) -> Result<(), ConfigError> { - validate_jwt_keys(&self.jwt)?; - validate_encryption_key("ENCRYPTION_KEY", &self.crypto.encryption_key)?; - validate_optional_encryption_key( - "PREVIOUS_ENCRYPTION_KEY", - self.crypto.previous_encryption_key.as_deref(), - )?; - validate_cors(&self.cors, self.is_production())?; - validate_risk(&self.risk)?; - validate_security(&self.security)?; - validate_crypto(&self.crypto)?; - - validate_jwt_audience(&self.jwt.audience, self.is_production())?; - - if self.is_production() { - // The development key pair is committed in `.env.dev` and therefore - // public: anyone can mint valid tokens for a deployment that uses - // it. Refuse to boot rather than run with a known-compromised key. - if self - .jwt - .public_key - .replace(['\n', ' ', '\t'], "") - .contains(DEV_JWT_PUBLIC_KEY_MARKER) - { - return Err(ConfigError::Invalid { - key: "JWT_PUBLIC_KEY".into(), - reason: "this is the committed development key from .env.dev -- it is public and must never be used in production".into(), - }); - } - - validate_https_url("APP_PUBLIC_URL", &self.server.public_url)?; - - if self.captcha.secret.is_some() { - validate_https_url("CAPTCHA_VERIFY_URL", &self.captcha.verify_url)?; - } else { - return Err(ConfigError::Invalid { - key: "CAPTCHA_SECRET".into(), - reason: "must be set in production -- CAPTCHA protection cannot be disabled in production".into(), - }); - } - - if self.mail.smtp.username.is_empty() { - return Err(ConfigError::Invalid { - key: "SMTP_USERNAME".into(), - reason: "must not be empty in production (unauthenticated/unencrypted SMTP is not allowed)".into(), - }); - } - - // Hardened-default switches: in production these MUST be set to the - // secure value, even if an env override re-enables the permissive - // behaviour. Refuse to boot rather than start in a degraded state. - if self.rate_limit.fail_open_on_redis_error { - return Err(ConfigError::Invalid { - key: "RATE_LIMIT_FAIL_OPEN".into(), - reason: "must be false in production -- a Redis outage would otherwise disable rate limiting entirely".into(), - }); - } - - if self.rate_limit.allow_requests_without_ip { - return Err(ConfigError::Invalid { - key: "RATE_LIMIT_ALLOW_MISSING_IP".into(), - reason: "must be false in production -- requests without a resolved client IP must be rejected, not let through".into(), - }); - } - - if self.captcha.fail_open_on_error { - return Err(ConfigError::Invalid { - key: "CAPTCHA_FAIL_OPEN".into(), - reason: "must be false in production -- CAPTCHA upstream errors must not let traffic through".into(), - }); - } - - if !self.jwt.strict_session_binding { - return Err(ConfigError::Invalid { - key: "JWT_STRICT_SESSION_BINDING".into(), - reason: "must be true in production -- refresh tokens must be bound to the originating IP".into(), - }); - } - } - - Ok(()) - } -} - -/// Unique fragment of the development JWT public key committed in `.env.dev`. -/// Used to refuse that key in production (the pair is public by definition). -const DEV_JWT_PUBLIC_KEY_MARKER: &str = "MEjIGO1563lSVOpDzgW6Y9aI20lH"; - -// Helpers - -fn env_require(key: &str) -> Result { - env::var(key).map_err(|_| ConfigError::Missing(key.into())) -} - -fn env_string(key: &str) -> Option { - env::var(key).ok() -} - -fn env_csv(key: &str) -> Option> { - let value = env::var(key).ok()?; - let values = value - .split(',') - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(str::to_owned) - .collect::>(); - - Some(values) -} - -fn env_ip_network_list(key: &str) -> Result, ConfigError> { - env_csv(key) - .unwrap_or_default() - .into_iter() - .map(|raw| { - raw.parse::().map_err(|e| ConfigError::Invalid { - key: key.into(), - reason: format!("invalid CIDR '{raw}': {e}"), - }) - }) - .collect() -} - -// Parse an env var into any type that implements FromStr; returns None on missing or parse failure. -fn env_parse(key: &str) -> Option { - env::var(key).ok()?.parse().ok() -} - -fn validate_jwt_keys(jwt: &JwtConfig) -> Result<(), ConfigError> { - use crate::utils::jwt as jwt_util; - - let signing_key = - jwt_util::parse_signing_key(&jwt.private_key).map_err(|e| ConfigError::Invalid { - key: "JWT_PRIVATE_KEY".into(), - reason: e.to_string(), - })?; - - let verifying_key = - jwt_util::parse_p256_verifying_key(&jwt.public_key).map_err(|e| ConfigError::Invalid { - key: "JWT_PUBLIC_KEY".into(), - reason: e.to_string(), - })?; - - // Verify that the public key matches the private key. - let derived = p256::ecdsa::VerifyingKey::from(&signing_key); - if derived != verifying_key { - return Err(ConfigError::Invalid { - key: "JWT_PUBLIC_KEY".into(), - reason: "public key does not match the private key".into(), - }); - } - - if let Some(ref prev_pub) = jwt.previous_public_key { - jwt_util::parse_p256_verifying_key(prev_pub).map_err(|e| ConfigError::Invalid { - key: "JWT_PREVIOUS_PUBLIC_KEY".into(), - reason: e.to_string(), - })?; - } - - Ok(()) -} - -fn validate_encryption_key(key_name: &str, value: &str) -> Result<(), ConfigError> { - let decoded = STANDARD.decode(value).map_err(|e| ConfigError::Invalid { - key: key_name.into(), - reason: format!("must be valid base64: {e}"), - })?; - - if decoded.len() != 32 { - return Err(ConfigError::Invalid { - key: key_name.into(), - reason: "must decode to exactly 32 bytes".into(), - }); - } - - // Reject low-entropy keys using Shannon entropy over byte distribution. - // A truly random 32-byte key typically has >= 3.5 bits of entropy per byte. - let mut counts = [0u32; 256]; - for &b in &decoded { - counts[b as usize] += 1; - } - let len = decoded.len() as f64; - let shannon: f64 = counts - .iter() - .filter(|&&c| c > 0) - .map(|&c| { - let p = c as f64 / len; - -p * p.log2() - }) - .sum(); - if shannon < 3.0 { - return Err(ConfigError::Invalid { - key: key_name.into(), - reason: format!( - "key has insufficient entropy ({shannon:.2} bits/byte, minimum 3.0): \ - use a cryptographically random key (e.g. openssl rand -base64 32)" - ), - }); - } - - Ok(()) -} - -fn validate_optional_encryption_key( - key_name: &str, - value: Option<&str>, -) -> Result<(), ConfigError> { - if let Some(value) = value { - validate_encryption_key(key_name, value)?; - } - - Ok(()) -} - -fn validate_https_url(key: &str, value: &str) -> Result<(), ConfigError> { - if !value.starts_with("https://") { - return Err(ConfigError::Invalid { - key: key.into(), - reason: "must use https in production".into(), - }); - } - - Ok(()) -} - -/// `JWT_AUDIENCE` validation. In production we refuse to start without at -/// least one audience: emitting tokens with an empty `aud` would silently -/// break every downstream service that pins the audience claim. In dev we -/// only warn so local stacks (no resource server, scratch tests) keep -/// working. -fn validate_jwt_audience(audience: &[String], is_production: bool) -> Result<(), ConfigError> { - if audience.is_empty() { - if is_production { - return Err(ConfigError::Invalid { - key: "JWT_AUDIENCE".into(), - reason: "must not be empty in production -- downstream services that pin `aud` would reject all tokens".into(), - }); - } - - tracing::warn!( - "JWT_AUDIENCE is empty: issued access tokens will not carry an `aud` claim, downstream services pinning audience will reject them" - ); - return Ok(()); - } - - for value in audience { - if value.trim().is_empty() { - return Err(ConfigError::Invalid { - key: "JWT_AUDIENCE".into(), - reason: "entries must not be empty or whitespace-only".into(), - }); - } - } - - Ok(()) -} - -fn default_argon2_max_concurrency() -> u32 { - std::thread::available_parallelism() - .map(|n| n.get() as u32) - .unwrap_or(4) -} - -fn validate_crypto(crypto: &CryptoConfig) -> Result<(), ConfigError> { - if crypto.argon2_max_concurrency == 0 { - return Err(ConfigError::Invalid { - key: "ARGON2_MAX_CONCURRENCY".into(), - reason: "must be greater than 0".into(), - }); - } - - Ok(()) -} - -fn validate_security(security: &SecurityConfig) -> Result<(), ConfigError> { - if security.sensitive_action_reauth_secs == 0 { - return Err(ConfigError::Invalid { - key: "SENSITIVE_ACTION_REAUTH_SECS".into(), - reason: "must be greater than 0".into(), - }); - } - - Ok(()) -} - -fn validate_cors(cors: &CorsConfig, is_production: bool) -> Result<(), ConfigError> { - if cors.allowed_origins.is_empty() { - return Err(ConfigError::Invalid { - key: "CORS_ALLOWED_ORIGINS".into(), - reason: "must not be empty".into(), - }); - } - - let has_wildcard = cors.allowed_origins.iter().any(|origin| origin == "*"); - if has_wildcard && cors.allow_credentials { - return Err(ConfigError::Invalid { - key: "CORS_ALLOWED_ORIGINS".into(), - reason: "cannot use '*' when CORS_ALLOW_CREDENTIALS=true".into(), - }); - } - - if is_production && has_wildcard { - return Err(ConfigError::Invalid { - key: "CORS_ALLOWED_ORIGINS".into(), - reason: "cannot use '*' in production".into(), - }); - } - - for origin in cors - .allowed_origins - .iter() - .filter(|origin| origin.as_str() != "*") - { - let parsed = reqwest::Url::parse(origin).map_err(|e| ConfigError::Invalid { - key: "CORS_ALLOWED_ORIGINS".into(), - reason: format!("invalid origin '{origin}': {e}"), - })?; - - if is_production && parsed.scheme() != "https" { - return Err(ConfigError::Invalid { - key: "CORS_ALLOWED_ORIGINS".into(), - reason: format!("origin '{origin}' must use https in production"), - }); - } - } - - Ok(()) -} - -fn validate_risk(risk: &RiskConfig) -> Result<(), ConfigError> { - if !(risk.alert_threshold <= risk.challenge_threshold - && risk.challenge_threshold <= risk.block_threshold) - { - return Err(ConfigError::Invalid { - key: "RISK_*_THRESHOLD".into(), - reason: "must satisfy alert <= challenge <= block".into(), - }); - } - - if risk.geoip_required { - if risk.geoip_db_path.is_empty() { - return Err(ConfigError::Invalid { - key: "GEOIP_DB_PATH".into(), - reason: "is required when GEOIP_REQUIRED=true".into(), - }); - } - - if !Path::new(&risk.geoip_db_path).exists() { - return Err(ConfigError::Invalid { - key: "GEOIP_DB_PATH".into(), - reason: "file does not exist".into(), - }); - } - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - const TEST_PRIVATE_KEY_PEM: &str = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgL+1qOaZ7C+H1mGbV\njUP83/W450N4GfOnZSrQ7P//4Y2hRANCAAR4BApTJy8Anvp+O7YNVlTeCbBZ+1YJ\nk+r5ELHGFIXciAEGSrCTOkCm3yChSYroYWLE3ZN4reh6JDbIMX/QnBGx\n-----END PRIVATE KEY-----"; - const TEST_PUBLIC_KEY_PEM: &str = "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEeAQKUycvAJ76fju2DVZU3gmwWftW\nCZPq+RCxxhSF3IgBBkqwkzpApt8goUmK6GFixN2TeK3oeiQ2yDF/0JwRsQ==\n-----END PUBLIC KEY-----"; - - fn valid_config() -> Config { - Config { - env: Environment::Production, - server: ServerConfig { - host: "127.0.0.1".into(), - port: 3000, - public_url: "https://api.example.com".into(), - trusted_proxy_cidrs: vec![], - }, - database: DatabaseConfig { - url: "postgres://user:pass@localhost/db".into(), - max_connections: 10, - min_connections: 1, - acquire_timeout_secs: 5, - }, - redis: RedisConfig { - url: "redis://127.0.0.1:6379".into(), - pool_size: 5, - wait_timeout_ms: 2000, - }, - nats: NatsConfig { - url: "nats://127.0.0.1:4222".into(), - }, - jwt: JwtConfig { - private_key: TEST_PRIVATE_KEY_PEM.into(), - public_key: TEST_PUBLIC_KEY_PEM.into(), - previous_public_key: None, - access_expiry_secs: 900, - refresh_expiry_secs: 3600, - short_session_expiry_secs: 3600, - strict_session_binding: true, - max_session_lifetime_secs: 86400, - audience: vec!["https://core.example.com".into()], - }, - crypto: CryptoConfig { - argon2_memory_kib: 8192, - argon2_iterations: 1, - argon2_parallelism: 1, - argon2_max_concurrency: 4, - totp_issuer: "test".into(), - encryption_key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=".into(), - previous_encryption_key: None, - totp_skew: 1, - recovery_code_expiry_days: 365, - }, - rate_limit: RateLimitConfig { - requests_per_minute: 100, - auth_requests_per_minute: 20, - fail_open_on_redis_error: false, - allow_requests_without_ip: false, - }, - security: SecurityConfig { - lockout_threshold: 5, - lockout_duration_secs: 1800, - sensitive_action_reauth_secs: 600, - }, - mail: MailConfig { - smtp: SmtpConfig { - host: "smtp.example.com".into(), - port: 587, - username: "user".into(), - password: "pass".into(), - from_name: "Example".into(), - from_address: "no-reply@example.com".into(), - }, - templates_dir: "templates".into(), - default_locale: "en".into(), - }, - cors: CorsConfig { - allowed_origins: vec!["https://app.example.com".into()], - allow_credentials: true, - }, - captcha: CaptchaConfig { - secret: Some("captcha-secret".into()), - verify_url: "https://hcaptcha.com/siteverify".into(), - request_timeout_secs: 5, - fail_open_on_error: false, - }, - cleanup: CleanupConfig { - interval_secs: 3600, - sessions_grace_days: 7, - tokens_grace_days: 1, - login_attempts_retention_days: 90, - recovery_codes_grace_days: 7, - }, - audit: AuditConfig { - retention_months: 6, - }, - risk: RiskConfig { - geoip_db_path: String::new(), - geoip_required: false, - alert_threshold: 30, - challenge_threshold: 60, - block_threshold: 80, - history_days: 90, - }, - log: LogConfig { - level: "info".into(), - format: LogFormat::Pretty, - }, - device_auth: DeviceAuthConfig { - ttl_secs: 300, - poll_interval_secs: 5, - verification_uri: "https://auth.example.com/device".into(), - }, - metrics: MetricsConfig { - enabled: true, - port: 9464, - }, - } - } - - #[test] - fn validate_accepts_hardened_production_config() { - assert!(valid_config().validate().is_ok()); - } - - #[test] - fn validate_rejects_wildcard_cors_with_credentials() { - let mut config = valid_config(); - config.cors.allowed_origins = vec!["*".into()]; - - let err = config - .validate() - .expect_err("wildcard CORS with credentials should fail"); - assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "CORS_ALLOWED_ORIGINS")); - } - - #[test] - fn validate_rejects_non_https_public_url_in_production() { - let mut config = valid_config(); - config.server.public_url = "http://api.example.com".into(); - - let err = config - .validate() - .expect_err("http public URL should fail in production"); - assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "APP_PUBLIC_URL")); - } - - #[test] - fn validate_rejects_invalid_jwt_private_key() { - let mut config = valid_config(); - config.jwt.private_key = "not-a-valid-pem".into(); - - let err = config - .validate() - .expect_err("invalid JWT private key should fail"); - assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "JWT_PRIVATE_KEY")); - } - - #[test] - fn validate_rejects_invalid_encryption_key() { - let mut config = valid_config(); - config.crypto.encryption_key = "not-base64".into(); - - let err = config - .validate() - .expect_err("invalid encryption key should fail"); - assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "ENCRYPTION_KEY")); - } - - #[test] - fn validate_rejects_missing_geoip_database_when_required() { - let mut config = valid_config(); - config.risk.geoip_required = true; - - let err = config - .validate() - .expect_err("missing GeoIP database should fail"); - assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "GEOIP_DB_PATH")); - } - - #[test] - fn validate_rejects_zero_sensitive_reauth_window() { - let mut config = valid_config(); - config.security.sensitive_action_reauth_secs = 0; - - let err = config - .validate() - .expect_err("zero recent reauth window should fail"); - assert!( - matches!(err, ConfigError::Invalid { key, .. } if key == "SENSITIVE_ACTION_REAUTH_SECS") - ); - } - - #[test] - fn validate_rejects_mismatched_jwt_keys() { - let mut config = valid_config(); - // Use a different public key that doesn't match the private key. - config.jwt.public_key = "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEMEjIGO1563lSVOpDzgW6Y9aI20lH\nSejuoGIZ4JxZldRlZnWft8qZWJ9CUqlfKW88z3sHs6WEbAWNxl0fqn+SYg==\n-----END PUBLIC KEY-----".into(); - - let err = config - .validate() - .expect_err("mismatched JWT keys should fail"); - assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "JWT_PUBLIC_KEY")); - } - - #[test] - fn validate_rejects_committed_dev_key_in_production() { - // The exact key pair committed in .env.dev: valid, matching, but public. - let mut config = valid_config(); - config.jwt.private_key = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg2R2G2WSdQAzqkVz/\n03JHEWNczskciWsiIKpONSbyHs2hRANCAAQwSMgY7XnreVJU6kPOBbpj1ojbSUdJ\n6O6gYhngnFmV1GVmdZ+3yplYn0JSqV8pbzzPewezpYRsBY3GXR+qf5Ji\n-----END PRIVATE KEY-----".into(); - config.jwt.public_key = "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEMEjIGO1563lSVOpDzgW6Y9aI20lH\nSejuoGIZ4JxZldRlZnWft8qZWJ9CUqlfKW88z3sHs6WEbAWNxl0fqn+SYg==\n-----END PUBLIC KEY-----".into(); - - let err = config - .validate() - .expect_err("committed dev key in production must be rejected"); - match err { - ConfigError::Invalid { key, reason } => { - assert_eq!(key, "JWT_PUBLIC_KEY"); - assert!(reason.contains("development"), "reason: {reason}"); - } - other => panic!("unexpected error: {other:?}"), - } - } - - #[test] - fn validate_accepts_committed_dev_key_outside_production() { - let mut config = valid_config(); - config.env = Environment::Development; - config.mail.smtp.username = String::new(); - config.server.public_url = "http://localhost:3000".into(); - config.cors.allowed_origins = vec!["http://localhost:5173".into()]; - config.cors.allow_credentials = false; - config.captcha.secret = None; - config.jwt.private_key = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg2R2G2WSdQAzqkVz/\n03JHEWNczskciWsiIKpONSbyHs2hRANCAAQwSMgY7XnreVJU6kPOBbpj1ojbSUdJ\n6O6gYhngnFmV1GVmdZ+3yplYn0JSqV8pbzzPewezpYRsBY3GXR+qf5Ji\n-----END PRIVATE KEY-----".into(); - config.jwt.public_key = "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEMEjIGO1563lSVOpDzgW6Y9aI20lH\nSejuoGIZ4JxZldRlZnWft8qZWJ9CUqlfKW88z3sHs6WEbAWNxl0fqn+SYg==\n-----END PUBLIC KEY-----".into(); - - assert!( - config.validate().is_ok(), - "dev key must remain usable in development" - ); - } - - #[test] - fn validate_rejects_encryption_key_wrong_decoded_length() { - let mut config = valid_config(); - // Valid base64 but decodes to 16 bytes, not 32. - config.crypto.encryption_key = "AAAAAAAAAAAAAAAAAAAAAA==".into(); // 16 bytes - - let err = config - .validate() - .expect_err("wrong-length encryption key should fail"); - assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "ENCRYPTION_KEY")); - } - - #[test] - fn validate_rejects_empty_cors_origins() { - let mut config = valid_config(); - config.cors.allowed_origins = vec![]; - - let err = config - .validate() - .expect_err("empty CORS origins should fail"); - assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "CORS_ALLOWED_ORIGINS")); - } - - #[test] - fn validate_rejects_wildcard_cors_in_production() { - let mut config = valid_config(); - config.cors.allow_credentials = false; - config.cors.allowed_origins = vec!["*".into()]; - - let err = config - .validate() - .expect_err("wildcard CORS in production should fail"); - assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "CORS_ALLOWED_ORIGINS")); - } - - #[test] - fn validate_rejects_non_https_cors_origin_in_production() { - let mut config = valid_config(); - config.cors.allow_credentials = false; - config.cors.allowed_origins = vec!["http://app.example.com".into()]; - - let err = config - .validate() - .expect_err("http CORS origin in production should fail"); - assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "CORS_ALLOWED_ORIGINS")); - } - - #[test] - fn validate_rejects_invalid_cors_url() { - let mut config = valid_config(); - config.env = Environment::Development; - config.cors.allowed_origins = vec!["not-a-url".into()]; - - let err = config.validate().expect_err("invalid CORS URL should fail"); - assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "CORS_ALLOWED_ORIGINS")); - } - - #[test] - fn validate_rejects_inverted_risk_thresholds_alert_above_challenge() { - let mut config = valid_config(); - config.risk.alert_threshold = 50; - config.risk.challenge_threshold = 30; // alert > challenge -- invalid - config.risk.block_threshold = 80; - - let err = config - .validate() - .expect_err("alert > challenge threshold should fail"); - assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "RISK_*_THRESHOLD")); - } - - #[test] - fn validate_rejects_inverted_risk_thresholds_challenge_above_block() { - let mut config = valid_config(); - config.risk.alert_threshold = 10; - config.risk.challenge_threshold = 90; - config.risk.block_threshold = 50; // challenge > block -- invalid - - let err = config - .validate() - .expect_err("challenge > block threshold should fail"); - assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "RISK_*_THRESHOLD")); - } - - #[test] - fn validate_rejects_empty_smtp_username_in_production() { - let mut config = valid_config(); - config.mail.smtp.username = String::new(); - - let err = config - .validate() - .expect_err("empty SMTP username in production should fail"); - assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "SMTP_USERNAME")); - } - - #[test] - fn validate_rejects_non_https_captcha_url_in_production() { - let mut config = valid_config(); - config.captcha.verify_url = "http://hcaptcha.com/siteverify".into(); - - let err = config - .validate() - .expect_err("http captcha URL in production should fail"); - assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "CAPTCHA_VERIFY_URL")); - } - - #[test] - fn validate_accepts_development_config_without_smtp() { - let mut config = valid_config(); - config.env = Environment::Development; - config.mail.smtp.username = String::new(); - config.server.public_url = "http://localhost:3000".into(); - config.cors.allowed_origins = vec!["http://localhost:5173".into()]; - config.cors.allow_credentials = false; - config.captcha.secret = None; - - assert!( - config.validate().is_ok(), - "development config without SMTP must be accepted" - ); - } - - #[test] - fn validate_accepts_valid_previous_public_key() { - let mut config = valid_config(); - // Use the mismatched public key from dev as a valid "previous" key. - config.jwt.previous_public_key = Some("-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEMEjIGO1563lSVOpDzgW6Y9aI20lH\nSejuoGIZ4JxZldRlZnWft8qZWJ9CUqlfKW88z3sHs6WEbAWNxl0fqn+SYg==\n-----END PUBLIC KEY-----".into()); - - assert!( - config.validate().is_ok(), - "valid previous public key must be accepted" - ); - } - - #[test] - fn validate_rejects_invalid_previous_public_key() { - let mut config = valid_config(); - config.jwt.previous_public_key = Some("not-a-valid-pem".into()); - - let err = config - .validate() - .expect_err("invalid previous public key should fail"); - assert!( - matches!(err, ConfigError::Invalid { key, .. } if key == "JWT_PREVIOUS_PUBLIC_KEY") - ); - } - - #[test] - fn validate_accepts_valid_previous_encryption_key() { - let mut config = valid_config(); - config.crypto.previous_encryption_key = - Some("AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=".into()); - - assert!( - config.validate().is_ok(), - "valid previous encryption key must be accepted" - ); - } - - #[test] - fn validate_rejects_invalid_previous_encryption_key() { - let mut config = valid_config(); - config.crypto.previous_encryption_key = Some("not-base64!".into()); - - let err = config - .validate() - .expect_err("invalid previous encryption key should fail"); - assert!( - matches!(err, ConfigError::Invalid { key, .. } if key == "PREVIOUS_ENCRYPTION_KEY") - ); - } - - #[test] - fn validate_rejects_geoip_required_when_file_missing() { - let mut config = valid_config(); - config.risk.geoip_required = true; - config.risk.geoip_db_path = "/nonexistent/path/to/GeoIP.mmdb".into(); - - let err = config - .validate() - .expect_err("non-existent GeoIP file should fail when required"); - assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "GEOIP_DB_PATH")); - } - - // Environment / LogFormat FromStr - - #[test] - fn environment_from_str_accepts_known_variants() { - assert_eq!( - "development".parse::().unwrap(), - Environment::Development - ); - assert_eq!( - "dev".parse::().unwrap(), - Environment::Development - ); - assert_eq!( - "production".parse::().unwrap(), - Environment::Production - ); - assert_eq!( - "prod".parse::().unwrap(), - Environment::Production - ); - assert_eq!("test".parse::().unwrap(), Environment::Test); - } - - #[test] - fn environment_from_str_rejects_unknown_value() { - let err = "staging".parse::(); - assert!(err.is_err(), "unknown environment should return Err"); - assert!(err.unwrap_err().contains("staging")); - } - - #[test] - fn log_format_from_str_accepts_known_variants() { - assert_eq!("pretty".parse::().unwrap(), LogFormat::Pretty); - assert_eq!("json".parse::().unwrap(), LogFormat::Json); - } - - #[test] - fn log_format_from_str_rejects_unknown_value() { - let err = "xml".parse::(); - assert!(err.is_err(), "unknown log format should return Err"); - assert!(err.unwrap_err().contains("xml")); - } - - // is_production / is_test - - #[test] - fn is_production_returns_true_only_for_production_env() { - let mut config = valid_config(); - assert!(config.is_production()); - config.env = Environment::Development; - assert!(!config.is_production()); - config.env = Environment::Test; - assert!(!config.is_production()); - } - - #[test] - fn is_test_returns_true_only_for_test_env() { - let mut config = valid_config(); - config.env = Environment::Test; - assert!(config.is_test()); - config.env = Environment::Production; - assert!(!config.is_test()); - config.env = Environment::Development; - assert!(!config.is_test()); - } - - // JWT_AUDIENCE: required in production, optional (warn-only) in dev. - - #[test] - fn validate_rejects_production_config_with_empty_jwt_audience() { - let mut config = valid_config(); - config.jwt.audience = vec![]; - - let err = config - .validate() - .expect_err("empty JWT_AUDIENCE in production should fail"); - assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "JWT_AUDIENCE")); - } - - #[test] - fn validate_accepts_development_config_with_empty_jwt_audience() { - let mut config = valid_config(); - config.env = Environment::Development; - config.mail.smtp.username = String::new(); - config.server.public_url = "http://localhost:3000".into(); - config.cors.allowed_origins = vec!["http://localhost:5173".into()]; - config.cors.allow_credentials = false; - config.captcha.secret = None; - config.jwt.audience = vec![]; - - assert!( - config.validate().is_ok(), - "development config with empty JWT_AUDIENCE must be accepted (warn-only)" - ); - } - - #[test] - fn validate_rejects_jwt_audience_with_blank_entry() { - let mut config = valid_config(); - config.jwt.audience = vec!["https://core.example.com".into(), " ".into()]; - - let err = config - .validate() - .expect_err("blank JWT_AUDIENCE entry should fail"); - assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "JWT_AUDIENCE")); - } - - // Production captcha: secret absent must be rejected - - #[test] - fn validate_rejects_production_config_without_captcha_secret() { - let mut config = valid_config(); - config.captcha.secret = None; - let err = config.validate().unwrap_err(); - assert!( - err.to_string().contains("CAPTCHA_SECRET"), - "production config without CAPTCHA_SECRET must be rejected: {err}" - ); - } - - // Hardened-default switches: production must refuse permissive overrides. - - #[test] - fn validate_rejects_production_config_with_rate_limit_fail_open() { - let mut config = valid_config(); - config.rate_limit.fail_open_on_redis_error = true; - - let err = config - .validate() - .expect_err("RATE_LIMIT_FAIL_OPEN=true in production should fail"); - assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "RATE_LIMIT_FAIL_OPEN")); - } - - #[test] - fn validate_rejects_production_config_with_rate_limit_allow_missing_ip() { - let mut config = valid_config(); - config.rate_limit.allow_requests_without_ip = true; - - let err = config - .validate() - .expect_err("RATE_LIMIT_ALLOW_MISSING_IP=true in production should fail"); - assert!( - matches!(err, ConfigError::Invalid { key, .. } if key == "RATE_LIMIT_ALLOW_MISSING_IP") - ); - } - - #[test] - fn validate_rejects_production_config_with_captcha_fail_open() { - let mut config = valid_config(); - config.captcha.fail_open_on_error = true; - - let err = config - .validate() - .expect_err("CAPTCHA_FAIL_OPEN=true in production should fail"); - assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "CAPTCHA_FAIL_OPEN")); - } - - #[test] - fn validate_rejects_production_config_without_strict_session_binding() { - let mut config = valid_config(); - config.jwt.strict_session_binding = false; - - let err = config - .validate() - .expect_err("JWT_STRICT_SESSION_BINDING=false in production should fail"); - assert!( - matches!(err, ConfigError::Invalid { key, .. } if key == "JWT_STRICT_SESSION_BINDING") - ); - } - - #[test] - fn validate_accepts_development_config_with_permissive_switches() { - let mut config = valid_config(); - config.env = Environment::Development; - config.mail.smtp.username = String::new(); - config.server.public_url = "http://localhost:3000".into(); - config.cors.allowed_origins = vec!["http://localhost:5173".into()]; - config.cors.allow_credentials = false; - config.captcha.secret = None; - // Permissive defaults must remain allowed in development. - config.rate_limit.fail_open_on_redis_error = true; - config.rate_limit.allow_requests_without_ip = true; - config.captcha.fail_open_on_error = true; - config.jwt.strict_session_binding = false; - - assert!( - config.validate().is_ok(), - "development config with permissive switches must be accepted" - ); - } -} diff --git a/src/config/env_vars.rs b/src/config/env_vars.rs new file mode 100644 index 0000000..00706e8 --- /dev/null +++ b/src/config/env_vars.rs @@ -0,0 +1,81 @@ +//! Reading environment variables: strict parsing, blank values treated as unset. +//! +//! Variables come from a lookup function: the process environment in +//! production, a map in tests, so loading is tested without mutating the +//! environment of a running process. + +use std::str::FromStr; + +use ipnetwork::IpNetwork; + +use super::ConfigError; + +pub(super) struct Env { + lookup: L, +} + +impl Option> Env { + pub(super) fn new(lookup: L) -> Self { + Self { lookup } + } + + pub(super) fn require(&self, key: &str) -> Result { + (self.lookup)(key).ok_or_else(|| ConfigError::Missing(key.into())) + } + + /// Optional string variable. A blank value counts as unset: a secret that + /// survives as `Some("")` satisfies every "must be set" check while the code + /// using it treats blank as "not configured" and skips the protection. + pub(super) fn string(&self, key: &str) -> Option { + (self.lookup)(key).filter(|value| !value.trim().is_empty()) + } + + pub(super) fn csv(&self, key: &str) -> Option> { + let value = (self.lookup)(key)?; + let values = value + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .collect::>(); + + Some(values) + } + + pub(super) fn ip_network_list(&self, key: &str) -> Result, ConfigError> { + self.csv(key) + .unwrap_or_default() + .into_iter() + .map(|raw| { + raw.parse::().map_err(|e| ConfigError::Invalid { + key: key.into(), + reason: format!("invalid CIDR '{raw}': {e}"), + }) + }) + .collect() + } + + /// Parse an optional variable. Absent or blank yields `None`; a value that + /// is present but does not parse is an error, never a silent fallback to + /// the default (`LOCKOUT_THRESHOLD=1O` must not quietly become 10). + pub(super) fn parse(&self, key: &str) -> Result, ConfigError> + where + T: FromStr, + T::Err: std::fmt::Display, + { + self.string(key) + .map(|raw| { + raw.trim().parse::().map_err(|e| ConfigError::Invalid { + key: key.into(), + reason: format!("cannot parse '{raw}': {e}"), + }) + }) + .transpose() + } +} + +pub(super) fn default_argon2_max_concurrency() -> u32 { + std::thread::available_parallelism() + .map(|n| n.get() as u32) + .unwrap_or(4) +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..3ea9c2e --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,900 @@ +//! Application configuration. +//! +//! Loads all settings from environment variables at startup. +//! Required variables cause an early, explicit error if missing. +//! Optional variables fall back to safe, documented defaults. +//! Use `.env.dev` and `config.prod.env` as a reference for all available variables. + +use std::str::FromStr; + +use ipnetwork::IpNetwork; + +mod env_vars; +#[cfg(test)] +mod tests; +mod validate; + +use env_vars::*; + +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + #[error("missing required env var: {0}")] + Missing(String), + #[error("invalid value for '{key}': {reason}")] + Invalid { key: String, reason: String }, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Environment { + Development, + Production, + Test, +} + +impl FromStr for Environment { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_ascii_lowercase().as_str() { + "development" | "dev" => Ok(Self::Development), + "production" | "prod" => Ok(Self::Production), + "test" => Ok(Self::Test), + _ => Err(format!("unknown environment: {s}")), + } + } +} + +#[derive(Debug, Clone)] +pub struct ServerConfig { + pub host: String, + pub port: u16, + /// Public base URL of this API (e.g. "https://api.example.com"): token + /// issuer, audience, and the JWKS location. + pub public_url: String, + /// Base URL of the web application whose pages emails link to + /// (`/verify-email`, `/reset-password`). `FRONTEND_URL`, defaulting to + /// `APP_PUBLIC_URL` when the API and the application share an origin. + pub frontend_url: String, + /// Reverse-proxy CIDRs allowed to supply X-Forwarded-For / X-Real-IP. + /// Requests coming from other peers use the socket address directly. + pub trusted_proxy_cidrs: Vec, +} + +#[derive(Clone)] +pub struct DatabaseConfig { + pub url: String, + /// Maximum number of connections in the pool. + pub max_connections: u32, + /// Minimum idle connections kept alive. + pub min_connections: u32, + /// Seconds before a pending acquire is aborted. Short (5 s by default): + /// an exhausted pool must answer fast and show in the metrics, not wait out + /// the 30-second request timeout. + pub acquire_timeout_secs: u64, + /// A read replica (`DATABASE_READ_URL`) for reads that tolerate a few + /// seconds of lag: security histories, the admin audit log and account + /// search. Unset: they read the primary. + pub read_url: Option, +} + +#[derive(Clone)] +pub struct RedisConfig { + pub url: String, + pub pool_size: u32, + /// Maximum time in milliseconds to wait for a connection from the pool. + /// Prevents unbounded queue buildup under Redis pressure. Default: 2000ms. + pub wait_timeout_ms: u64, +} + +#[derive(Clone)] +pub struct NatsConfig { + pub url: String, + /// Copies of the event stream kept by a JetStream cluster (1, 3 or 5). + pub stream_replicas: usize, +} + +#[derive(Clone)] +pub struct JwtConfig { + /// PEM-encoded ECDSA P-256 private key used to sign access tokens. + pub private_key: String, + /// PEM-encoded ECDSA P-256 public key used to verify access tokens. + pub public_key: String, + /// Previous public key, accepted for verification during key rotation. + pub previous_public_key: Option, + /// Public key of the next signing key, published in the JWKS and accepted + /// ahead of a rotation, so resource servers know it before any token is + /// signed with it. + pub next_public_key: Option, + /// Short-lived access token lifetime (default: 15 min). + pub access_expiry_secs: u64, + /// Long-lived refresh token lifetime used when remember_me is true (default: 30 days). + pub refresh_expiry_secs: u64, + /// Short-lived refresh token lifetime used when remember_me is false (default: 24 h). + pub short_session_expiry_secs: u64, + /// When true, the refresh endpoint rejects requests whose IP differs from the + /// one recorded at session creation. Useful for high-security deployments but + /// breaks clients that roam between networks (e.g. mobile). + pub strict_session_binding: bool, + /// Hard upper bound on session lifetime regardless of refresh activity (default: 90 days). + pub max_session_lifetime_secs: u64, + /// Audience values stamped into the `aud` claim of issued access tokens. + /// Each entry is the public URL of a downstream resource server that accepts + /// these tokens. Loaded from the `JWT_AUDIENCE` env var as a CSV. + /// Required in production: an empty audience would emit tokens that + /// downstream services pinning `aud` could not accept. + pub audience: Vec, +} + +impl JwtConfig { + /// Lifetime of a refresh token: long with "remember me", short otherwise. + pub fn session_ttl_secs(&self, remember_me: bool) -> u64 { + if remember_me { + self.refresh_expiry_secs + } else { + self.short_session_expiry_secs + } + } +} + +#[derive(Clone)] +pub struct CryptoConfig { + // Argon2id parameters, tune for your hardware + pub argon2_memory_kib: u32, + pub argon2_iterations: u32, + pub argon2_parallelism: u32, + /// Maximum number of Argon2id operations allowed to run concurrently. + /// Bounds worst-case memory usage (max_concurrency x argon2_memory_kib) + /// and keeps the blocking threadpool from being flooded during a login + /// storm; excess requests queue on a semaphore instead. Defaults to the + /// number of available CPU cores. + pub argon2_max_concurrency: u32, + /// Issuer name shown in authenticator apps. + pub totp_issuer: String, + /// Base64-encoded 32-byte key used to encrypt TOTP secrets at rest with AES-256-GCM. + pub encryption_key: String, + /// Previous encryption key used only during key rotation (`--rotate-totp-keys`). + /// Set this to the old key value before running the rotation command, then remove it afterward. + pub previous_encryption_key: Option, + /// Number of 30-second steps to accept before and after the current one. + /// 1 = accept codes within +/- 30 seconds (recommended for clock skew tolerance). + pub totp_skew: u8, + /// Lifetime of recovery codes in days. 0 = no expiration. + pub recovery_code_expiry_days: u32, +} + +#[derive(Debug, Clone)] +pub struct RateLimitConfig { + /// Max requests per 1-minute window per IP for general routes. + pub requests_per_minute: u64, + /// Stricter limit for authentication routes (login, register, forgot-password, 2FA). + /// Defaults to 20 requests per minute. + pub auth_requests_per_minute: u64, + /// When true, Redis outages do not block traffic and the request is allowed through. + pub fail_open_on_redis_error: bool, + /// When true, requests with no resolved client IP are allowed through. + pub allow_requests_without_ip: bool, +} + +#[derive(Debug, Clone)] +pub struct SecurityConfig { + /// Number of consecutive login failures before the account is temporarily locked. + pub lockout_threshold: u32, + /// Duration of the account lockout in seconds (default: 1800 = 30 minutes). + pub lockout_duration_secs: u64, + /// TTL of the "recent re-authentication" window for sensitive actions. + pub sensitive_action_reauth_secs: u64, + /// E-mail the owner when an account signs in from a device it never used. + /// Default: true. + pub new_device_alerts: bool, + /// Offer sign-in links by email (`/auth/magic-link`). Whoever reads the + /// mailbox can then sign in without the password, so it is off by default. + pub magic_links: bool, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum LogFormat { + /// Human-readable, coloured output for development. + Pretty, + /// Structured JSON output for production log aggregators. + Json, +} + +impl FromStr for LogFormat { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_ascii_lowercase().as_str() { + "pretty" => Ok(Self::Pretty), + "json" => Ok(Self::Json), + _ => Err(format!("unknown log format: {s}")), + } + } +} + +#[derive(Debug, Clone)] +pub struct LogConfig { + /// Directive passed to EnvFilter, e.g. "info" or "auth_api=debug,tower_http=info". + pub level: String, + pub format: LogFormat, +} + +#[derive(Clone)] +pub struct SmtpConfig { + pub host: String, + pub port: u16, + pub username: String, + pub password: String, + /// Display name used in the From header. + pub from_name: String, + /// Email address used in the From header. + pub from_address: String, +} + +#[derive(Debug, Clone)] +pub struct MailConfig { + pub smtp: SmtpConfig, + /// Path to the templates directory, e.g. "templates". + pub templates_dir: String, + /// Locale used when no match is found for the user's preferred locale. + pub default_locale: String, +} + +#[derive(Debug, Clone)] +pub struct CleanupConfig { + /// Interval in seconds between cleanup runs (retention sweeps and audit partition rotation). Default: 3600. + pub interval_secs: u64, + /// Grace period in days after session expiry/revocation before deletion. Default: 7. + pub sessions_grace_days: u32, + /// Grace period in days after token expiry before deletion + /// (email_2fa_codes, password_reset_tokens, email_verification_tokens). Default: 1. + pub tokens_grace_days: u32, + /// Retention period in days for login_attempts records. Default: 90. + pub login_attempts_retention_days: u32, + /// Grace period in days after recovery code expiry before deletion. Default: 7. + pub recovery_codes_grace_days: u32, + /// Age in days after which an account whose address was never verified is + /// deleted; 0 keeps them. Default: 7. + pub unverified_accounts_retention_days: u32, + /// Days after which a device unseen is forgotten (a sign-in from it alerts + /// again). Default: 90. + pub known_devices_retention_days: u32, + /// Days finished webhook deliveries are kept for inspection. Default: 7. + pub webhook_deliveries_retention_days: u32, +} + +#[derive(Debug, Clone)] +pub struct AuditConfig { + /// Number of months of audit log data to retain. Older monthly partitions are dropped. + /// The database function rotate_audit_log_partitions() enforces this at startup and + /// on every cleanup run. 0 = keep forever. + pub retention_months: u32, + /// Age in days after which the client address of an audit entry keeps only + /// its network (/24, /48). 0 keeps full addresses. Default: 90. + pub ip_retention_days: u32, +} + +#[derive(Debug, Clone)] +pub struct PwnedPasswordsConfig { + /// Refuse passwords found in known breaches. Default: true. + pub enabled: bool, + /// Base URL of the Pwned Passwords range API. + pub api_url: String, + /// Timeout of the range query, in milliseconds. Default: 1500. + pub timeout_ms: u64, + /// Accept the password when the API gives no answer. Default: true. + pub fail_open: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IdentityProviderKind { + /// OpenID Connect, discovered from its issuer (Google is one). + Oidc, + /// GitHub's OAuth apps: no ID token, the user API names the person. + Github, +} + +#[derive(Clone)] +pub struct IdentityProviderConfig { + /// Identifier in routes (`/auth/external/{name}`): lower-case letters, + /// digits, `-` and `_`. + pub name: String, + pub kind: IdentityProviderKind, + pub display_name: String, + pub client_id: String, + pub client_secret: String, + /// OpenID Connect issuer, discovered at `{issuer}/.well-known/openid-configuration`. + pub issuer: String, + pub scopes: Vec, + /// GitHub endpoints (overridable for GitHub Enterprise). + pub authorization_url: String, + pub token_url: String, + pub user_url: String, +} + +#[derive(Debug, Clone)] +pub struct TelemetryConfig { + /// OTLP/HTTP collector base URL (`OTEL_EXPORTER_OTLP_ENDPOINT`, such as + /// `http://otel-collector:4318`). Unset: no trace is exported. + pub otlp_endpoint: Option, + /// `OTEL_SERVICE_NAME`. Default: `auth-api`. + pub service_name: String, + /// Share of new traces recorded (`OTEL_TRACES_SAMPLER_ARG`, 0 to 1); a + /// request carrying a sampled `traceparent` is always recorded. Default: 0.1. + pub sample_ratio: f64, +} + +#[derive(Debug, Clone)] +pub struct WebAuthnConfig { + /// Relying party id: the registrable domain passkeys are bound to. Default: + /// the host of `FRONTEND_URL`. + pub rp_id: String, + /// Name shown by the authenticator. Default: the mail sender name. + pub rp_name: String, + /// Origins allowed to run ceremonies. Default: the origin of `FRONTEND_URL`. + pub origins: Vec, +} + +#[derive(Debug, Clone)] +pub struct WebhookConfig { + /// Accept `http://` endpoints. Default: outside production only. + pub allow_http: bool, + /// Deliver to loopback, private and other internal addresses. Default: + /// false; refused in production. + pub allow_private_networks: bool, + /// Timeout of one delivery, in milliseconds. Default: 5000. + pub timeout_ms: u64, +} + +#[derive(Clone)] +pub struct CaptchaConfig { + /// hCaptcha secret key. If empty, captcha verification is skipped (development/test mode). + pub secret: Option, + /// hCaptcha verify endpoint. + pub verify_url: String, + /// Request timeout for the verification call. + pub request_timeout_secs: u64, + /// When true, network/5xx errors from the CAPTCHA provider allow the request through. + pub fail_open_on_error: bool, +} + +#[derive(Debug, Clone)] +pub struct CorsConfig { + /// Comma-separated list of allowed origins, e.g. "https://app.example.com,https://admin.example.com". + /// Use "*" to allow all origins (not recommended in production). + pub allowed_origins: Vec, + /// Whether to allow credentials (cookies, Authorization header). + pub allow_credentials: bool, +} + +#[derive(Debug, Clone)] +pub struct MetricsConfig { + /// When true, Prometheus metrics are collected and served on `port`. + pub enabled: bool, + /// Port of the internal metrics listener (`/metrics`). Conventionally 9464 + /// (Prometheus exporter range). Must never be exposed publicly: publish it + /// on loopback only in docker-compose, never through the reverse proxy. + pub port: u16, +} + +#[derive(Debug, Clone)] +pub struct DeviceAuthConfig { + /// How long a device authorization request remains valid (seconds). + pub ttl_secs: u64, + /// Recommended polling interval for clients (seconds). + pub poll_interval_secs: u64, + /// Base URL of the verification page shown to the user (auth frontend). + pub verification_uri: String, + /// Page of the auth frontend where a signed-in user approves an + /// authorization request; `GET /oauth/authorize` redirects there with + /// `request_id`. Default: `{FRONTEND_URL}/authorize`. + pub consent_uri: String, +} + +#[derive(Debug, Clone)] +pub struct Config { + pub env: Environment, + pub server: ServerConfig, + pub database: DatabaseConfig, + pub redis: RedisConfig, + pub nats: NatsConfig, + pub jwt: JwtConfig, + pub crypto: CryptoConfig, + pub rate_limit: RateLimitConfig, + pub security: SecurityConfig, + pub mail: MailConfig, + pub cors: CorsConfig, + pub captcha: CaptchaConfig, + pub pwned_passwords: PwnedPasswordsConfig, + pub webhooks: WebhookConfig, + pub webauthn: WebAuthnConfig, + /// External identity providers, in `IDENTITY_PROVIDERS` order. + pub identity_providers: Vec, + /// Frontend page receiving the outcome of an external sign-in or link, as + /// `?code=`; default `{FRONTEND_URL}/external-login`. + pub external_login_uri: String, + pub cleanup: CleanupConfig, + pub audit: AuditConfig, + pub log: LogConfig, + pub telemetry: TelemetryConfig, + pub device_auth: DeviceAuthConfig, + pub metrics: MetricsConfig, +} + +impl Config { + /// Load configuration from environment variables. + /// Silently ignores a missing `.env` file; production relies on real env vars. + pub fn from_env() -> Result { + dotenvy::dotenv().ok(); + Self::from_lookup(|key| std::env::var(key).ok()) + } + + /// Load configuration from `lookup`, which returns the value of a variable: + /// the process environment for `from_env`, a map in tests. + pub(crate) fn from_lookup( + lookup: impl Fn(&str) -> Option, + ) -> Result { + let vars = Env::new(lookup); + + // Required: a typo such as `APP_ENV=prd` must not silently start a + // deployment with every development relaxation enabled. + let env: Environment = vars + .parse("APP_ENV")? + .ok_or_else(|| ConfigError::Missing("APP_ENV".into()))?; + + let is_production = matches!(env, Environment::Production); + + let config = Self { + env: env.clone(), + server: ServerConfig { + host: vars + .string("SERVER_HOST") + .unwrap_or_else(|| "0.0.0.0".into()), + port: vars.parse("SERVER_PORT")?.unwrap_or(3000u16), + // The issuer of every token and of the published metadata: + // one spelling, without a trailing slash. + public_url: vars + .string("APP_PUBLIC_URL") + .unwrap_or_else(|| "http://localhost:3000".into()) + .trim_end_matches('/') + .to_owned(), + frontend_url: vars + .string("FRONTEND_URL") + .or_else(|| vars.string("APP_PUBLIC_URL")) + .unwrap_or_else(|| "http://localhost:3000".into()) + .trim_end_matches('/') + .to_owned(), + trusted_proxy_cidrs: vars.ip_network_list("TRUSTED_PROXY_CIDRS")?, + }, + database: DatabaseConfig { + url: vars.require("DATABASE_URL")?, + max_connections: vars.parse("DB_MAX_CONNECTIONS")?.unwrap_or(20), + min_connections: vars.parse("DB_MIN_CONNECTIONS")?.unwrap_or(2), + acquire_timeout_secs: vars.parse("DB_ACQUIRE_TIMEOUT_SECS")?.unwrap_or(5), + read_url: vars.string("DATABASE_READ_URL"), + }, + redis: RedisConfig { + url: vars.require("REDIS_URL")?, + pool_size: vars.parse("REDIS_POOL_SIZE")?.unwrap_or(10), + wait_timeout_ms: vars.parse("REDIS_WAIT_TIMEOUT_MS")?.unwrap_or(2000), + }, + nats: NatsConfig { + url: vars + .string("NATS_URL") + .unwrap_or_else(|| "nats://nats:4222".into()), + stream_replicas: vars.parse("NATS_STREAM_REPLICAS")?.unwrap_or(1), + }, + jwt: JwtConfig { + private_key: vars.require("JWT_PRIVATE_KEY")?.replace("\\n", "\n"), + public_key: vars.require("JWT_PUBLIC_KEY")?.replace("\\n", "\n"), + previous_public_key: vars + .string("JWT_PREVIOUS_PUBLIC_KEY") + .map(|s| s.replace("\\n", "\n")), + next_public_key: vars + .string("JWT_NEXT_PUBLIC_KEY") + .map(|s| s.replace("\\n", "\n")), + access_expiry_secs: vars.parse("JWT_ACCESS_EXPIRY_SECS")?.unwrap_or(900), + refresh_expiry_secs: vars + .parse("JWT_REFRESH_EXPIRY_SECS")? + .unwrap_or(60 * 60 * 24 * 30), + short_session_expiry_secs: vars + .parse("JWT_SHORT_SESSION_EXPIRY_SECS")? + .unwrap_or(60 * 60 * 24), + strict_session_binding: vars.parse("JWT_STRICT_SESSION_BINDING")?.unwrap_or(false), + max_session_lifetime_secs: vars + .parse("JWT_MAX_SESSION_LIFETIME_SECS")? + .unwrap_or(60 * 60 * 24 * 90), + audience: vars.csv("JWT_AUDIENCE").unwrap_or_default(), + }, + crypto: CryptoConfig { + argon2_memory_kib: vars.parse("ARGON2_MEMORY_KIB")?.unwrap_or(65_536), // 64 MB + argon2_iterations: vars.parse("ARGON2_ITERATIONS")?.unwrap_or(3), + argon2_parallelism: vars.parse("ARGON2_PARALLELISM")?.unwrap_or(4), + argon2_max_concurrency: vars + .parse("ARGON2_MAX_CONCURRENCY")? + .unwrap_or_else(default_argon2_max_concurrency), + totp_issuer: vars + .string("TOTP_ISSUER") + .unwrap_or_else(|| "auth-api".into()), + encryption_key: vars.require("ENCRYPTION_KEY")?, + previous_encryption_key: vars.string("PREVIOUS_ENCRYPTION_KEY"), + totp_skew: vars.parse("TOTP_SKEW")?.unwrap_or(1), + recovery_code_expiry_days: vars.parse("RECOVERY_CODE_EXPIRY_DAYS")?.unwrap_or(365), // 0 = never + }, + rate_limit: RateLimitConfig { + requests_per_minute: vars.parse("RATE_LIMIT_RPM")?.unwrap_or(300), + auth_requests_per_minute: vars.parse("RATE_LIMIT_AUTH_RPM")?.unwrap_or(20), + fail_open_on_redis_error: vars + .parse("RATE_LIMIT_FAIL_OPEN")? + .unwrap_or(!is_production), + allow_requests_without_ip: vars + .parse("RATE_LIMIT_ALLOW_MISSING_IP")? + .unwrap_or(!is_production), + }, + security: SecurityConfig { + lockout_threshold: vars.parse("LOCKOUT_THRESHOLD")?.unwrap_or(10), + lockout_duration_secs: vars.parse("LOCKOUT_DURATION_SECS")?.unwrap_or(1800), + sensitive_action_reauth_secs: vars + .parse("SENSITIVE_ACTION_REAUTH_SECS")? + .unwrap_or(600), + new_device_alerts: vars.parse("NEW_DEVICE_ALERTS_ENABLED")?.unwrap_or(true), + magic_links: vars.parse("MAGIC_LINK_ENABLED")?.unwrap_or(false), + }, + mail: MailConfig { + smtp: SmtpConfig { + host: vars.require("SMTP_HOST")?, + port: vars.parse("SMTP_PORT")?.unwrap_or(587), + username: vars.require("SMTP_USERNAME")?, + password: vars.require("SMTP_PASSWORD")?, + from_name: vars + .string("SMTP_FROM_NAME") + .unwrap_or_else(|| "auth-api".into()), + from_address: vars.require("SMTP_FROM_ADDRESS")?, + }, + templates_dir: vars + .string("MAIL_TEMPLATES_DIR") + .unwrap_or_else(|| "templates".into()), + default_locale: vars + .string("MAIL_DEFAULT_LOCALE") + .unwrap_or_else(|| "en".into()), + }, + captcha: CaptchaConfig { + secret: vars.string("CAPTCHA_SECRET"), + verify_url: vars + .string("CAPTCHA_VERIFY_URL") + .unwrap_or_else(|| "https://hcaptcha.com/siteverify".into()), + request_timeout_secs: vars.parse("CAPTCHA_TIMEOUT_SECS")?.unwrap_or(5), + fail_open_on_error: vars.parse("CAPTCHA_FAIL_OPEN")?.unwrap_or(!is_production), + }, + pwned_passwords: PwnedPasswordsConfig { + enabled: vars.parse("PWNED_PASSWORDS_ENABLED")?.unwrap_or(true), + api_url: vars + .string("PWNED_PASSWORDS_URL") + .unwrap_or_else(|| "https://api.pwnedpasswords.com".into()), + timeout_ms: vars.parse("PWNED_PASSWORDS_TIMEOUT_MS")?.unwrap_or(1500), + fail_open: vars.parse("PWNED_PASSWORDS_FAIL_OPEN")?.unwrap_or(true), + }, + webauthn: { + let frontend = vars + .string("FRONTEND_URL") + .or_else(|| vars.string("APP_PUBLIC_URL")) + .unwrap_or_else(|| "http://localhost:3000".into()); + let frontend = reqwest::Url::parse(&frontend).ok(); + WebAuthnConfig { + rp_id: vars.string("WEBAUTHN_RP_ID").unwrap_or_else(|| { + frontend + .as_ref() + .and_then(|url| url.host_str().map(str::to_owned)) + .unwrap_or_else(|| "localhost".into()) + }), + rp_name: vars + .string("WEBAUTHN_RP_NAME") + .or_else(|| vars.string("SMTP_FROM_NAME")) + .unwrap_or_else(|| "auth-api".into()), + origins: match vars.string("WEBAUTHN_ORIGINS") { + Some(list) => list + .split(',') + .map(|o| o.trim().trim_end_matches('/').to_owned()) + .filter(|o| !o.is_empty()) + .collect(), + None => frontend + .map(|url| url.origin().ascii_serialization()) + .into_iter() + .collect(), + }, + } + }, + identity_providers: identity_providers(&vars)?, + external_login_uri: vars.string("EXTERNAL_LOGIN_URI").unwrap_or_else(|| { + format!( + "{}/external-login", + vars.string("FRONTEND_URL") + .or_else(|| vars.string("APP_PUBLIC_URL")) + .unwrap_or_else(|| "http://localhost:3000".into()) + .trim_end_matches('/') + ) + }), + webhooks: WebhookConfig { + allow_http: vars.parse("WEBHOOK_ALLOW_HTTP")?.unwrap_or(!is_production), + allow_private_networks: vars + .parse("WEBHOOK_ALLOW_PRIVATE_NETWORKS")? + .unwrap_or(false), + timeout_ms: vars.parse("WEBHOOK_TIMEOUT_MS")?.unwrap_or(5000), + }, + cors: CorsConfig { + allowed_origins: vars + .string("CORS_ALLOWED_ORIGINS") + .unwrap_or_else(|| "http://localhost:3000".into()) + .split(',') + .map(|s| s.trim().to_owned()) + .collect(), + allow_credentials: vars.parse("CORS_ALLOW_CREDENTIALS")?.unwrap_or(true), + }, + cleanup: CleanupConfig { + interval_secs: vars.parse("CLEANUP_INTERVAL_SECS")?.unwrap_or(3600), + sessions_grace_days: vars.parse("CLEANUP_SESSIONS_GRACE_DAYS")?.unwrap_or(7), + tokens_grace_days: vars.parse("CLEANUP_TOKENS_GRACE_DAYS")?.unwrap_or(1), + login_attempts_retention_days: vars + .parse("CLEANUP_LOGIN_ATTEMPTS_RETENTION_DAYS")? + .unwrap_or(90), + recovery_codes_grace_days: vars + .parse("CLEANUP_RECOVERY_CODES_GRACE_DAYS")? + .unwrap_or(7), + unverified_accounts_retention_days: vars + .parse("CLEANUP_UNVERIFIED_ACCOUNT_DAYS")? + .unwrap_or(7), + known_devices_retention_days: vars + .parse("CLEANUP_KNOWN_DEVICE_DAYS")? + .unwrap_or(90), + webhook_deliveries_retention_days: vars + .parse("CLEANUP_WEBHOOK_DELIVERY_DAYS")? + .unwrap_or(7), + }, + audit: AuditConfig { + retention_months: vars.parse("AUDIT_LOG_RETENTION_MONTHS")?.unwrap_or(12), + ip_retention_days: vars.parse("AUDIT_IP_RETENTION_DAYS")?.unwrap_or(90), + }, + telemetry: TelemetryConfig { + otlp_endpoint: vars + .string("OTEL_EXPORTER_OTLP_ENDPOINT") + .map(|url| url.trim_end_matches('/').to_owned()), + service_name: vars + .string("OTEL_SERVICE_NAME") + .unwrap_or_else(|| "auth-api".into()), + sample_ratio: vars + .parse::("OTEL_TRACES_SAMPLER_ARG")? + .unwrap_or(0.1) + .clamp(0.0, 1.0), + }, + log: LogConfig { + level: vars.string("LOG_LEVEL").unwrap_or_else(|| "info".into()), + format: vars.parse("LOG_FORMAT")?.unwrap_or(LogFormat::Pretty), + }, + device_auth: DeviceAuthConfig { + ttl_secs: vars.parse("DEVICE_AUTH_TTL_SECS")?.unwrap_or(300), + poll_interval_secs: vars.parse("DEVICE_AUTH_POLL_INTERVAL_SECS")?.unwrap_or(5), + verification_uri: vars.require("DEVICE_AUTH_VERIFICATION_URI")?, + consent_uri: vars.string("OAUTH_CONSENT_URI").unwrap_or_else(|| { + format!( + "{}/authorize", + vars.string("FRONTEND_URL") + .or_else(|| vars.string("APP_PUBLIC_URL")) + .unwrap_or_else(|| "http://localhost:3000".into()) + .trim_end_matches('/') + ) + }), + }, + metrics: MetricsConfig { + enabled: vars.parse("METRICS_ENABLED")?.unwrap_or(true), + port: vars.parse("METRICS_PORT")?.unwrap_or(9464), + }, + }; + + // Validation runs once, in `AppState::from_config`, after derived values + // such as the self audience are in place. + Ok(config) + } + + pub fn is_production(&self) -> bool { + self.env == Environment::Production + } + + pub fn is_test(&self) -> bool { + self.env == Environment::Test + } + + /// Make sure auth-api's own `public_url` is part of the JWT audience list. + /// + /// Tokens are addressed to downstream resource servers, but auth-api also + /// consumes its own tokens for `/users/me/*` and pins `aud == public_url` + /// in the `AuthUser` extractor. Idempotent. + pub fn ensure_self_in_audience(&mut self) { + let self_url = self.server.public_url.clone(); + if self_url.is_empty() || self.jwt.audience.iter().any(|a| a == &self_url) { + return; + } + self.jwt.audience.push(self_url); + } +} + +/// Unique fragment of the development JWT public key committed in `.env.dev`. +/// Used to refuse that key in production (the pair is public by definition). +const DEV_JWT_PUBLIC_KEY_MARKER: &str = "MEjIGO1563lSVOpDzgW6Y9aI20lH"; + +// Debug output of the settings that hold secrets. `Config` derives `Debug`, and a +// derived implementation would print URLs with their passwords, the signing key +// and the encryption keys into any log or panic message that formats it. + +/// Printed in place of a secret. +const REDACTED: &str = ""; + +impl std::fmt::Debug for DatabaseConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DatabaseConfig") + .field("url", &REDACTED) + .field("max_connections", &self.max_connections) + .field("min_connections", &self.min_connections) + .field("acquire_timeout_secs", &self.acquire_timeout_secs) + .field("read_url", &self.read_url.as_ref().map(|_| REDACTED)) + .finish() + } +} + +impl std::fmt::Debug for RedisConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RedisConfig") + .field("url", &REDACTED) + .field("pool_size", &self.pool_size) + .field("wait_timeout_ms", &self.wait_timeout_ms) + .finish() + } +} + +impl std::fmt::Debug for NatsConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("NatsConfig") + .field("url", &REDACTED) + .finish() + } +} + +impl std::fmt::Debug for JwtConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("JwtConfig") + .field("private_key", &REDACTED) + .field("public_key", &self.public_key) + .field("previous_public_key", &self.previous_public_key) + .field("next_public_key", &self.next_public_key) + .field("access_expiry_secs", &self.access_expiry_secs) + .field("refresh_expiry_secs", &self.refresh_expiry_secs) + .field("short_session_expiry_secs", &self.short_session_expiry_secs) + .field("strict_session_binding", &self.strict_session_binding) + .field("max_session_lifetime_secs", &self.max_session_lifetime_secs) + .field("audience", &self.audience) + .finish() + } +} + +impl std::fmt::Debug for CryptoConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CryptoConfig") + .field("argon2_memory_kib", &self.argon2_memory_kib) + .field("argon2_iterations", &self.argon2_iterations) + .field("argon2_parallelism", &self.argon2_parallelism) + .field("argon2_max_concurrency", &self.argon2_max_concurrency) + .field("totp_issuer", &self.totp_issuer) + .field("encryption_key", &REDACTED) + .field( + "previous_encryption_key", + &self.previous_encryption_key.as_ref().map(|_| REDACTED), + ) + .field("totp_skew", &self.totp_skew) + .field("recovery_code_expiry_days", &self.recovery_code_expiry_days) + .finish() + } +} + +impl std::fmt::Debug for SmtpConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SmtpConfig") + .field("host", &self.host) + .field("port", &self.port) + .field("username", &self.username) + .field("password", &REDACTED) + .field("from_name", &self.from_name) + .field("from_address", &self.from_address) + .finish() + } +} + +impl std::fmt::Debug for IdentityProviderConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("IdentityProviderConfig") + .field("name", &self.name) + .field("kind", &self.kind) + .field("client_id", &self.client_id) + .field("client_secret", &REDACTED) + .field("issuer", &self.issuer) + .finish_non_exhaustive() + } +} + +/// `IDENTITY_PROVIDERS=google,github,corp`, each configured by `IDP_{NAME}_*`. +fn identity_providers Option>( + vars: &Env, +) -> Result, ConfigError> { + let mut providers: Vec = Vec::new(); + for name in vars.csv("IDENTITY_PROVIDERS").unwrap_or_default() { + if !crate::domain::external_identity::is_valid_provider_name(&name) + || providers.iter().any(|p| p.name == name) + { + return Err(ConfigError::Invalid { + key: "IDENTITY_PROVIDERS".into(), + reason: format!("'{name}' is not a unique name of [a-z0-9_-]"), + }); + } + let key = |suffix: &str| { + format!( + "IDP_{}_{suffix}", + name.to_ascii_uppercase().replace('-', "_") + ) + }; + let kind_name = vars.string(&key("KIND")).unwrap_or_else(|| name.clone()); + let (kind, issuer, scopes) = match kind_name.as_str() { + "google" => ( + IdentityProviderKind::Oidc, + "https://accounts.google.com".to_owned(), + "openid", + ), + "github" => (IdentityProviderKind::Github, String::new(), "read:user"), + "oidc" => ( + IdentityProviderKind::Oidc, + vars.require(&key("ISSUER"))?, + "openid", + ), + other => { + return Err(ConfigError::Invalid { + key: key("KIND"), + reason: format!("'{other}' is not google, github or oidc"), + }); + } + }; + providers.push(IdentityProviderConfig { + display_name: vars + .string(&key("DISPLAY_NAME")) + .unwrap_or_else(|| name.clone()), + kind, + client_id: vars.require(&key("CLIENT_ID"))?, + client_secret: vars.require(&key("CLIENT_SECRET"))?, + issuer: vars + .string(&key("ISSUER")) + .unwrap_or(issuer) + .trim_end_matches('/') + .to_owned(), + scopes: vars + .csv(&key("SCOPES")) + .unwrap_or_else(|| vec![scopes.to_owned()]), + authorization_url: vars + .string(&key("AUTHORIZATION_URL")) + .unwrap_or_else(|| "https://github.com/login/oauth/authorize".into()), + token_url: vars + .string(&key("TOKEN_URL")) + .unwrap_or_else(|| "https://github.com/login/oauth/access_token".into()), + user_url: vars + .string(&key("USER_URL")) + .unwrap_or_else(|| "https://api.github.com/user".into()), + name, + }); + } + Ok(providers) +} + +impl std::fmt::Debug for CaptchaConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CaptchaConfig") + .field("secret", &self.secret.as_ref().map(|_| REDACTED)) + .field("verify_url", &self.verify_url) + .field("request_timeout_secs", &self.request_timeout_secs) + .field("fail_open_on_error", &self.fail_open_on_error) + .finish() + } +} diff --git a/src/config/tests.rs b/src/config/tests.rs new file mode 100644 index 0000000..9a16993 --- /dev/null +++ b/src/config/tests.rs @@ -0,0 +1,1091 @@ +//! Configuration loading and validation tests. + +use base64::{Engine, engine::general_purpose::STANDARD}; + +use super::*; +#[allow(unused_imports)] +use super::{env_vars::*, validate::*}; + +const TEST_PRIVATE_KEY_PEM: &str = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgL+1qOaZ7C+H1mGbV\njUP83/W450N4GfOnZSrQ7P//4Y2hRANCAAR4BApTJy8Anvp+O7YNVlTeCbBZ+1YJ\nk+r5ELHGFIXciAEGSrCTOkCm3yChSYroYWLE3ZN4reh6JDbIMX/QnBGx\n-----END PRIVATE KEY-----"; +const TEST_PUBLIC_KEY_PEM: &str = "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEeAQKUycvAJ76fju2DVZU3gmwWftW\nCZPq+RCxxhSF3IgBBkqwkzpApt8goUmK6GFixN2TeK3oeiQ2yDF/0JwRsQ==\n-----END PUBLIC KEY-----"; + +fn valid_config() -> Config { + Config { + env: Environment::Production, + server: ServerConfig { + host: "127.0.0.1".into(), + port: 3000, + public_url: "https://api.example.com".into(), + frontend_url: "https://api.example.com".into(), + trusted_proxy_cidrs: vec!["10.0.0.0/8".parse().unwrap()], + }, + database: DatabaseConfig { + url: "postgres://user:pass@localhost/db".into(), + max_connections: 10, + min_connections: 1, + acquire_timeout_secs: 5, + read_url: None, + }, + redis: RedisConfig { + url: "redis://127.0.0.1:6379".into(), + pool_size: 5, + wait_timeout_ms: 2000, + }, + nats: NatsConfig { + url: "nats://broker-token@127.0.0.1:4222".into(), + stream_replicas: 1, + }, + jwt: JwtConfig { + private_key: TEST_PRIVATE_KEY_PEM.into(), + public_key: TEST_PUBLIC_KEY_PEM.into(), + previous_public_key: None, + next_public_key: None, + access_expiry_secs: 900, + refresh_expiry_secs: 3600, + short_session_expiry_secs: 3600, + strict_session_binding: true, + max_session_lifetime_secs: 86400, + audience: vec!["https://core.example.com".into()], + }, + crypto: CryptoConfig { + argon2_memory_kib: 8192, + argon2_iterations: 1, + argon2_parallelism: 1, + argon2_max_concurrency: 4, + totp_issuer: "test".into(), + encryption_key: "VVKGNsojoT/vVMlGypXnqcCcJIbrPKbn/8DGfEs496k=".into(), + previous_encryption_key: None, + totp_skew: 1, + recovery_code_expiry_days: 365, + }, + rate_limit: RateLimitConfig { + requests_per_minute: 100, + auth_requests_per_minute: 20, + fail_open_on_redis_error: false, + allow_requests_without_ip: false, + }, + security: SecurityConfig { + lockout_threshold: 5, + lockout_duration_secs: 1800, + sensitive_action_reauth_secs: 600, + new_device_alerts: true, + magic_links: false, + }, + mail: MailConfig { + smtp: SmtpConfig { + host: "smtp.example.com".into(), + port: 587, + username: "user".into(), + password: "pass".into(), + from_name: "Example".into(), + from_address: "no-reply@example.com".into(), + }, + templates_dir: "templates".into(), + default_locale: "en".into(), + }, + cors: CorsConfig { + allowed_origins: vec!["https://app.example.com".into()], + allow_credentials: true, + }, + captcha: CaptchaConfig { + secret: Some("captcha-secret".into()), + verify_url: "https://hcaptcha.com/siteverify".into(), + request_timeout_secs: 5, + fail_open_on_error: false, + }, + pwned_passwords: PwnedPasswordsConfig { + enabled: true, + api_url: "https://api.pwnedpasswords.com".into(), + timeout_ms: 1500, + fail_open: true, + }, + webauthn: WebAuthnConfig { + rp_id: "auth.example.com".into(), + rp_name: "Auth API".into(), + origins: vec!["https://auth.example.com".into()], + }, + identity_providers: Vec::new(), + external_login_uri: "https://auth.example.com/external-login".into(), + webhooks: WebhookConfig { + allow_http: false, + allow_private_networks: false, + 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: "info".into(), + format: LogFormat::Pretty, + }, + device_auth: DeviceAuthConfig { + ttl_secs: 300, + poll_interval_secs: 5, + verification_uri: "https://auth.example.com/device".into(), + consent_uri: "https://auth.example.com/authorize".into(), + }, + metrics: MetricsConfig { + enabled: true, + port: 9464, + }, + } +} + +#[test] +fn validate_accepts_hardened_production_config() { + assert!(valid_config().validate().is_ok()); +} + +#[test] +fn validate_rejects_a_production_broker_without_credentials() { + let mut config = valid_config(); + config.nats.url = "nats://127.0.0.1:4222".into(); + + let err = config + .validate() + .expect_err("an unauthenticated broker must be refused in production"); + assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "NATS_URL")); + + config.env = Environment::Development; + assert!( + config.validate().is_ok(), + "development may use an open broker" + ); +} + +#[test] +fn validate_rejects_an_unreadable_broker_url() { + let mut config = valid_config(); + config.env = Environment::Development; + config.nats.url = "nats://:password-without-user@127.0.0.1:4222".into(); + + let err = config + .validate() + .expect_err("an unreadable NATS_URL must be refused"); + assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "NATS_URL")); +} + +#[test] +fn validate_rejects_wildcard_cors_with_credentials() { + let mut config = valid_config(); + config.cors.allowed_origins = vec!["*".into()]; + + let err = config + .validate() + .expect_err("wildcard CORS with credentials should fail"); + assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "CORS_ALLOWED_ORIGINS")); +} + +#[test] +fn validate_rejects_non_https_public_url_in_production() { + let mut config = valid_config(); + config.server.public_url = "http://api.example.com".into(); + + let err = config + .validate() + .expect_err("http public URL should fail in production"); + assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "APP_PUBLIC_URL")); +} + +#[test] +fn validate_rejects_invalid_jwt_private_key() { + let mut config = valid_config(); + config.jwt.private_key = "not-a-valid-pem".into(); + + let err = config + .validate() + .expect_err("invalid JWT private key should fail"); + assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "JWT_PRIVATE_KEY")); +} + +#[test] +fn validate_rejects_invalid_encryption_key() { + let mut config = valid_config(); + config.crypto.encryption_key = "not-base64".into(); + + let err = config + .validate() + .expect_err("invalid encryption key should fail"); + assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "ENCRYPTION_KEY")); +} + +#[test] +fn validate_rejects_zero_sensitive_reauth_window() { + let mut config = valid_config(); + config.security.sensitive_action_reauth_secs = 0; + + let err = config + .validate() + .expect_err("zero recent reauth window should fail"); + assert!( + matches!(err, ConfigError::Invalid { key, .. } if key == "SENSITIVE_ACTION_REAUTH_SECS") + ); +} + +#[test] +fn validate_rejects_mismatched_jwt_keys() { + let mut config = valid_config(); + // Use a different public key that doesn't match the private key. + config.jwt.public_key = "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEMEjIGO1563lSVOpDzgW6Y9aI20lH\nSejuoGIZ4JxZldRlZnWft8qZWJ9CUqlfKW88z3sHs6WEbAWNxl0fqn+SYg==\n-----END PUBLIC KEY-----".into(); + + let err = config + .validate() + .expect_err("mismatched JWT keys should fail"); + assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "JWT_PUBLIC_KEY")); +} + +#[test] +fn validate_rejects_committed_dev_key_in_production() { + // The exact key pair committed in .env.dev: valid, matching, but public. + let mut config = valid_config(); + config.jwt.private_key = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg2R2G2WSdQAzqkVz/\n03JHEWNczskciWsiIKpONSbyHs2hRANCAAQwSMgY7XnreVJU6kPOBbpj1ojbSUdJ\n6O6gYhngnFmV1GVmdZ+3yplYn0JSqV8pbzzPewezpYRsBY3GXR+qf5Ji\n-----END PRIVATE KEY-----".into(); + config.jwt.public_key = "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEMEjIGO1563lSVOpDzgW6Y9aI20lH\nSejuoGIZ4JxZldRlZnWft8qZWJ9CUqlfKW88z3sHs6WEbAWNxl0fqn+SYg==\n-----END PUBLIC KEY-----".into(); + + let err = config + .validate() + .expect_err("committed dev key in production must be rejected"); + match err { + ConfigError::Invalid { key, reason } => { + assert_eq!(key, "JWT_PUBLIC_KEY"); + assert!(reason.contains("development"), "reason: {reason}"); + } + other => panic!("unexpected error: {other:?}"), + } +} + +#[test] +fn validate_accepts_committed_dev_key_outside_production() { + let mut config = valid_config(); + config.env = Environment::Development; + config.mail.smtp.username = String::new(); + config.server.public_url = "http://localhost:3000".into(); + config.cors.allowed_origins = vec!["http://localhost:5173".into()]; + config.cors.allow_credentials = false; + config.captcha.secret = None; + config.jwt.private_key = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg2R2G2WSdQAzqkVz/\n03JHEWNczskciWsiIKpONSbyHs2hRANCAAQwSMgY7XnreVJU6kPOBbpj1ojbSUdJ\n6O6gYhngnFmV1GVmdZ+3yplYn0JSqV8pbzzPewezpYRsBY3GXR+qf5Ji\n-----END PRIVATE KEY-----".into(); + config.jwt.public_key = "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEMEjIGO1563lSVOpDzgW6Y9aI20lH\nSejuoGIZ4JxZldRlZnWft8qZWJ9CUqlfKW88z3sHs6WEbAWNxl0fqn+SYg==\n-----END PUBLIC KEY-----".into(); + + assert!( + config.validate().is_ok(), + "dev key must remain usable in development" + ); +} + +#[test] +fn validate_rejects_encryption_key_wrong_decoded_length() { + let mut config = valid_config(); + // Valid base64 but decodes to 16 bytes, not 32. + config.crypto.encryption_key = "AAAAAAAAAAAAAAAAAAAAAA==".into(); // 16 bytes + + let err = config + .validate() + .expect_err("wrong-length encryption key should fail"); + assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "ENCRYPTION_KEY")); +} + +#[test] +fn validate_rejects_empty_cors_origins() { + let mut config = valid_config(); + config.cors.allowed_origins = vec![]; + + let err = config + .validate() + .expect_err("empty CORS origins should fail"); + assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "CORS_ALLOWED_ORIGINS")); +} + +#[test] +fn validate_rejects_wildcard_cors_in_production() { + let mut config = valid_config(); + config.cors.allow_credentials = false; + config.cors.allowed_origins = vec!["*".into()]; + + let err = config + .validate() + .expect_err("wildcard CORS in production should fail"); + assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "CORS_ALLOWED_ORIGINS")); +} + +#[test] +fn validate_rejects_non_https_cors_origin_in_production() { + let mut config = valid_config(); + config.cors.allow_credentials = false; + config.cors.allowed_origins = vec!["http://app.example.com".into()]; + + let err = config + .validate() + .expect_err("http CORS origin in production should fail"); + assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "CORS_ALLOWED_ORIGINS")); +} + +#[test] +fn validate_rejects_invalid_cors_url() { + let mut config = valid_config(); + config.env = Environment::Development; + config.cors.allowed_origins = vec!["not-a-url".into()]; + + let err = config.validate().expect_err("invalid CORS URL should fail"); + assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "CORS_ALLOWED_ORIGINS")); +} + +#[test] +fn validate_rejects_empty_smtp_username_in_production() { + let mut config = valid_config(); + config.mail.smtp.username = String::new(); + + let err = config + .validate() + .expect_err("empty SMTP username in production should fail"); + assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "SMTP_USERNAME")); +} + +#[test] +fn validate_rejects_non_https_captcha_url_in_production() { + let mut config = valid_config(); + config.captcha.verify_url = "http://hcaptcha.com/siteverify".into(); + + let err = config + .validate() + .expect_err("http captcha URL in production should fail"); + assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "CAPTCHA_VERIFY_URL")); +} + +#[test] +fn validate_accepts_development_config_without_smtp() { + let mut config = valid_config(); + config.env = Environment::Development; + config.mail.smtp.username = String::new(); + config.server.public_url = "http://localhost:3000".into(); + config.cors.allowed_origins = vec!["http://localhost:5173".into()]; + config.cors.allow_credentials = false; + config.captcha.secret = None; + + assert!( + config.validate().is_ok(), + "development config without SMTP must be accepted" + ); +} + +#[test] +fn validate_accepts_valid_previous_public_key() { + let mut config = valid_config(); + // Use the mismatched public key from dev as a valid "previous" key. + config.jwt.previous_public_key = Some("-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEMEjIGO1563lSVOpDzgW6Y9aI20lH\nSejuoGIZ4JxZldRlZnWft8qZWJ9CUqlfKW88z3sHs6WEbAWNxl0fqn+SYg==\n-----END PUBLIC KEY-----".into()); + + assert!( + config.validate().is_ok(), + "valid previous public key must be accepted" + ); +} + +#[test] +fn validate_rejects_invalid_next_public_key() { + let mut config = valid_config(); + config.jwt.next_public_key = Some("not-a-valid-pem".into()); + + let err = config + .validate() + .expect_err("an unreadable next key must be refused"); + assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "JWT_NEXT_PUBLIC_KEY")); +} + +#[test] +fn validate_rejects_invalid_previous_public_key() { + let mut config = valid_config(); + config.jwt.previous_public_key = Some("not-a-valid-pem".into()); + + let err = config + .validate() + .expect_err("invalid previous public key should fail"); + assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "JWT_PREVIOUS_PUBLIC_KEY")); +} + +#[test] +fn validate_accepts_valid_previous_encryption_key() { + let mut config = valid_config(); + config.crypto.previous_encryption_key = + Some("6QoHPPjm9EnjsuRmj7OXQrYh98XIvrWYbI5KQyglMNc=".into()); + + assert!( + config.validate().is_ok(), + "valid previous encryption key must be accepted" + ); +} + +#[test] +fn validate_rejects_invalid_previous_encryption_key() { + let mut config = valid_config(); + config.crypto.previous_encryption_key = Some("not-base64!".into()); + + let err = config + .validate() + .expect_err("invalid previous encryption key should fail"); + assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "PREVIOUS_ENCRYPTION_KEY")); +} + +#[test] +fn environment_from_str_accepts_known_variants() { + assert_eq!( + "development".parse::().unwrap(), + Environment::Development + ); + assert_eq!( + "dev".parse::().unwrap(), + Environment::Development + ); + assert_eq!( + "production".parse::().unwrap(), + Environment::Production + ); + assert_eq!( + "prod".parse::().unwrap(), + Environment::Production + ); + assert_eq!("test".parse::().unwrap(), Environment::Test); +} + +#[test] +fn environment_from_str_rejects_unknown_value() { + let err = "staging".parse::(); + assert!(err.is_err(), "unknown environment should return Err"); + assert!(err.unwrap_err().contains("staging")); +} + +#[test] +fn log_format_from_str_accepts_known_variants() { + assert_eq!("pretty".parse::().unwrap(), LogFormat::Pretty); + assert_eq!("json".parse::().unwrap(), LogFormat::Json); +} + +#[test] +fn log_format_from_str_rejects_unknown_value() { + let err = "xml".parse::(); + assert!(err.is_err(), "unknown log format should return Err"); + assert!(err.unwrap_err().contains("xml")); +} + +// is_production / is_test + +#[test] +fn is_production_returns_true_only_for_production_env() { + let mut config = valid_config(); + assert!(config.is_production()); + config.env = Environment::Development; + assert!(!config.is_production()); + config.env = Environment::Test; + assert!(!config.is_production()); +} + +#[test] +fn is_test_returns_true_only_for_test_env() { + let mut config = valid_config(); + config.env = Environment::Test; + assert!(config.is_test()); + config.env = Environment::Production; + assert!(!config.is_test()); + config.env = Environment::Development; + assert!(!config.is_test()); +} + +// JWT_AUDIENCE: required in production, optional (warn-only) in dev. + +#[test] +fn validate_rejects_production_config_with_empty_jwt_audience() { + let mut config = valid_config(); + config.jwt.audience = vec![]; + + let err = config + .validate() + .expect_err("empty JWT_AUDIENCE in production should fail"); + assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "JWT_AUDIENCE")); +} + +#[test] +fn validate_accepts_development_config_with_empty_jwt_audience() { + let mut config = valid_config(); + config.env = Environment::Development; + config.mail.smtp.username = String::new(); + config.server.public_url = "http://localhost:3000".into(); + config.cors.allowed_origins = vec!["http://localhost:5173".into()]; + config.cors.allow_credentials = false; + config.captcha.secret = None; + config.jwt.audience = vec![]; + + assert!( + config.validate().is_ok(), + "development config with empty JWT_AUDIENCE must be accepted (warn-only)" + ); +} + +#[test] +fn validate_rejects_jwt_audience_with_blank_entry() { + let mut config = valid_config(); + config.jwt.audience = vec!["https://core.example.com".into(), " ".into()]; + + let err = config + .validate() + .expect_err("blank JWT_AUDIENCE entry should fail"); + assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "JWT_AUDIENCE")); +} + +// Production captcha: secret absent must be rejected + +#[test] +fn validate_rejects_production_config_without_captcha_secret() { + let mut config = valid_config(); + config.captcha.secret = None; + let err = config.validate().unwrap_err(); + assert!( + err.to_string().contains("CAPTCHA_SECRET"), + "production config without CAPTCHA_SECRET must be rejected: {err}" + ); +} + +// Hardened-default switches: production must refuse permissive overrides. + +#[test] +fn validate_rejects_production_config_with_rate_limit_fail_open() { + let mut config = valid_config(); + config.rate_limit.fail_open_on_redis_error = true; + + let err = config + .validate() + .expect_err("RATE_LIMIT_FAIL_OPEN=true in production should fail"); + assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "RATE_LIMIT_FAIL_OPEN")); +} + +#[test] +fn validate_rejects_production_config_with_rate_limit_allow_missing_ip() { + let mut config = valid_config(); + config.rate_limit.allow_requests_without_ip = true; + + let err = config + .validate() + .expect_err("RATE_LIMIT_ALLOW_MISSING_IP=true in production should fail"); + assert!( + matches!(err, ConfigError::Invalid { key, .. } if key == "RATE_LIMIT_ALLOW_MISSING_IP") + ); +} + +#[test] +fn validate_rejects_production_config_with_captcha_fail_open() { + let mut config = valid_config(); + config.captcha.fail_open_on_error = true; + + let err = config + .validate() + .expect_err("CAPTCHA_FAIL_OPEN=true in production should fail"); + assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "CAPTCHA_FAIL_OPEN")); +} + +#[test] +fn validate_rejects_production_config_without_strict_session_binding() { + let mut config = valid_config(); + config.jwt.strict_session_binding = false; + + let err = config + .validate() + .expect_err("JWT_STRICT_SESSION_BINDING=false in production should fail"); + assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "JWT_STRICT_SESSION_BINDING")); +} + +#[test] +fn validate_accepts_development_config_with_permissive_switches() { + let mut config = valid_config(); + config.env = Environment::Development; + config.mail.smtp.username = String::new(); + config.server.public_url = "http://localhost:3000".into(); + config.cors.allowed_origins = vec!["http://localhost:5173".into()]; + config.cors.allow_credentials = false; + config.captcha.secret = None; + // Permissive defaults must remain allowed in development. + config.rate_limit.fail_open_on_redis_error = true; + config.rate_limit.allow_requests_without_ip = true; + config.captcha.fail_open_on_error = true; + config.jwt.strict_session_binding = false; + + assert!( + config.validate().is_ok(), + "development config with permissive switches must be accepted" + ); +} + +// Production hardening added with strict configuration loading. + +#[test] +fn validate_rejects_empty_trusted_proxies_in_production() { + let mut config = valid_config(); + config.server.trusted_proxy_cidrs = vec![]; + + let err = config.validate().expect_err("empty proxies must fail"); + assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "TRUSTED_PROXY_CIDRS")); +} + +#[test] +fn validate_rejects_committed_dev_encryption_key_in_production() { + let mut config = valid_config(); + config.crypto.encryption_key = DEV_ENCRYPTION_KEYS[0].into(); + + let err = config.validate().expect_err("dev AES key must fail"); + assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "ENCRYPTION_KEY")); +} + +#[test] +fn validate_rejects_arithmetic_encryption_key_in_production() { + let mut config = valid_config(); + let counted: Vec = (0u8..32).map(|i| i.wrapping_mul(3)).collect(); + config.crypto.previous_encryption_key = Some(STANDARD.encode(counted)); + + let err = config.validate().expect_err("counted key must fail"); + assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "PREVIOUS_ENCRYPTION_KEY")); +} + +#[test] +fn validate_accepts_dev_encryption_key_outside_production() { + let mut config = valid_config(); + config.env = Environment::Development; + config.crypto.encryption_key = DEV_ENCRYPTION_KEYS[0].into(); + + assert!(config.validate().is_ok()); +} + +fn lookup(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option + use<> { + let map: std::collections::HashMap = pairs + .iter() + .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) + .collect(); + move |key| map.get(key).cloned() +} + +#[test] +fn env_string_treats_blank_as_unset() { + let env = Env::new(lookup(&[("BLANK", " "), ("SET", " x ")])); + assert_eq!(env.string("BLANK"), None); + assert_eq!(env.string("ABSENT"), None); + assert_eq!(env.string("SET").as_deref(), Some(" x ")); +} + +#[test] +fn env_parse_rejects_unparsable_values() { + let env = Env::new(lookup(&[("BAD_NUMBER", "1O")])); + let parsed: Result, _> = env.parse("BAD_NUMBER"); + + assert!(matches!(parsed, Err(ConfigError::Invalid { key, .. }) if key == "BAD_NUMBER")); +} + +#[test] +fn env_parse_accepts_absent_and_valid_values() { + let env = Env::new(lookup(&[("GOOD_NUMBER", " 42 ")])); + assert_eq!(env.parse::("ABSENT").unwrap(), None); + assert_eq!(env.parse::("GOOD_NUMBER").unwrap(), Some(42)); +} + +/// Load a configuration from the variables every deployment sets, with +/// `overrides` applied and `removed` unset. +fn load(overrides: &[(&str, &str)], removed: &[&str]) -> Result { + let private_key = TEST_PRIVATE_KEY_PEM.replace('\n', "\\n"); + let public_key = TEST_PUBLIC_KEY_PEM.replace('\n', "\\n"); + let mut vars: Vec<(&str, &str)> = vec![ + ("APP_ENV", "development"), + ("DATABASE_URL", "postgres://db/auth"), + ("REDIS_URL", "redis://redis"), + ("JWT_PRIVATE_KEY", &private_key), + ("JWT_PUBLIC_KEY", &public_key), + ("ENCRYPTION_KEY", "key"), + ("SMTP_HOST", "smtp.example.com"), + ("SMTP_USERNAME", "user"), + ("SMTP_PASSWORD", "password"), + ("SMTP_FROM_ADDRESS", "no-reply@example.com"), + ("DEVICE_AUTH_VERIFICATION_URI", "https://example.com/device"), + ]; + vars.retain(|(key, _)| !removed.contains(key) && !overrides.iter().any(|(k, _)| k == key)); + vars.extend_from_slice(overrides); + Config::from_lookup(lookup(&vars)) +} + +#[test] +fn loading_applies_the_documented_defaults() { + let config = load(&[], &[]).unwrap(); + + assert_eq!(config.env, Environment::Development); + assert_eq!(config.server.port, 3000); + assert_eq!(config.server.public_url, "http://localhost:3000"); + assert_eq!(config.server.frontend_url, "http://localhost:3000"); + assert!(config.server.trusted_proxy_cidrs.is_empty()); + assert_eq!(config.nats.url, "nats://nats:4222"); + assert_eq!(config.jwt.access_expiry_secs, 900); + assert_eq!(config.jwt.refresh_expiry_secs, 30 * 86_400); + assert_eq!(config.jwt.short_session_expiry_secs, 86_400); + assert_eq!(config.jwt.max_session_lifetime_secs, 90 * 86_400); + assert!(!config.jwt.strict_session_binding); + assert_eq!( + config.jwt.private_key, TEST_PRIVATE_KEY_PEM, + "escaped newlines are restored" + ); + assert_eq!(config.security.lockout_threshold, 10); + assert_eq!(config.security.lockout_duration_secs, 1800); + assert_eq!(config.cors.allowed_origins, vec!["http://localhost:3000"]); + assert_eq!(config.captcha.secret, None); + assert_eq!(config.mail.default_locale, "en"); + assert_eq!(config.device_auth.poll_interval_secs, 5); + assert_eq!(config.database.acquire_timeout_secs, 5); + assert_eq!(config.jwt.next_public_key, None); + assert!( + config.rate_limit.fail_open_on_redis_error, + "development fails open" + ); + assert!(config.rate_limit.allow_requests_without_ip); + assert!(config.captcha.fail_open_on_error); +} + +#[test] +fn debug_output_never_prints_a_secret() { + let mut config = valid_config(); + config.crypto.previous_encryption_key = Some(config.crypto.encryption_key.clone()); + config.captcha.secret = Some("captcha-secret-value".into()); + config.mail.smtp.password = "smtp-password-value".into(); + let printed = format!("{config:?}"); + + for secret in [ + config.database.url.as_str(), + config.redis.url.as_str(), + config.nats.url.as_str(), + config.jwt.private_key.as_str(), + config.crypto.encryption_key.as_str(), + "captcha-secret-value", + "smtp-password-value", + ] { + assert!(!printed.contains(secret), "Debug printed {secret:?}"); + } + assert!(printed.contains("")); + assert!( + printed.contains("acquire_timeout_secs"), + "non-secret settings stay visible" + ); +} + +#[test] +fn production_defaults_fail_closed() { + let config = load(&[("APP_ENV", "production")], &[]).unwrap(); + + assert!(config.is_production()); + assert!(!config.rate_limit.fail_open_on_redis_error); + assert!(!config.rate_limit.allow_requests_without_ip); + assert!(!config.captcha.fail_open_on_error); +} + +#[test] +fn loading_reads_lists_flags_and_urls() { + let config = load( + &[ + ("SERVER_PORT", " 8080 "), + ("APP_PUBLIC_URL", "https://auth.example.com"), + ("TRUSTED_PROXY_CIDRS", "10.0.0.0/8, 192.168.1.1/32"), + ( + "JWT_AUDIENCE", + "https://api.example.com, ,https://files.example.com", + ), + ("JWT_STRICT_SESSION_BINDING", "true"), + ( + "CORS_ALLOWED_ORIGINS", + "https://a.example.com, https://b.example.com", + ), + ("CAPTCHA_SECRET", " "), + ], + &[], + ) + .unwrap(); + + assert_eq!(config.server.port, 8080); + assert_eq!( + config.server.frontend_url, "https://auth.example.com", + "the frontend defaults to the public URL" + ); + assert_eq!(config.server.trusted_proxy_cidrs.len(), 2); + assert_eq!( + config.jwt.audience, + vec!["https://api.example.com", "https://files.example.com"] + ); + assert!(config.jwt.strict_session_binding); + assert_eq!( + config.cors.allowed_origins, + vec!["https://a.example.com", "https://b.example.com"] + ); + assert_eq!(config.captcha.secret, None, "a blank secret is no secret"); + + let config = load(&[("FRONTEND_URL", "https://app.example.com/")], &[]).unwrap(); + assert_eq!(config.server.frontend_url, "https://app.example.com"); +} + +#[test] +fn loading_names_the_variable_at_fault() { + for key in [ + "APP_ENV", + "DATABASE_URL", + "REDIS_URL", + "JWT_PRIVATE_KEY", + "JWT_PUBLIC_KEY", + "ENCRYPTION_KEY", + "SMTP_HOST", + "SMTP_FROM_ADDRESS", + "DEVICE_AUTH_VERIFICATION_URI", + ] { + assert!( + matches!(load(&[], &[key]), Err(ConfigError::Missing(missing)) if missing == key), + "{key} must be required" + ); + } + + for (key, value) in [ + ("APP_ENV", "prd"), + ("SERVER_PORT", "70000"), + ("LOCKOUT_THRESHOLD", "1O"), + ("TRUSTED_PROXY_CIDRS", "10.0.0.0/33"), + ("JWT_STRICT_SESSION_BINDING", "yes"), + ] { + assert!( + matches!(load(&[(key, value)], &[]), Err(ConfigError::Invalid { key: invalid, .. }) if invalid == key), + "{key}={value} must be refused" + ); + } +} + +#[test] +fn ensure_self_in_audience_is_idempotent() { + let mut config = valid_config(); + config.ensure_self_in_audience(); + config.ensure_self_in_audience(); + + let own = config.server.public_url.clone(); + assert_eq!(config.jwt.audience.iter().filter(|a| **a == own).count(), 1); +} + +#[test] +fn validate_rejects_non_https_frontend_url_in_production() { + let mut config = valid_config(); + config.server.frontend_url = "http://app.example.com".into(); + + let err = config.validate().unwrap_err(); + + assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "FRONTEND_URL")); +} + +#[test] +fn validate_rejects_zero_lockout_threshold() { + let mut config = valid_config(); + config.security.lockout_threshold = 0; + + let err = config + .validate() + .expect_err("a zero lockout threshold should fail"); + assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "LOCKOUT_THRESHOLD")); +} + +#[test] +fn validate_rejects_zero_device_poll_interval() { + let mut config = valid_config(); + config.device_auth.poll_interval_secs = 0; + + let err = config + .validate() + .expect_err("a zero poll interval should fail"); + assert!( + matches!(err, ConfigError::Invalid { key, .. } if key == "DEVICE_AUTH_POLL_INTERVAL_SECS") + ); +} + +#[test] +fn validate_rejects_poll_interval_not_below_device_ttl() { + let mut config = valid_config(); + config.device_auth.ttl_secs = 60; + config.device_auth.poll_interval_secs = 60; + + let err = config + .validate() + .expect_err("a poll interval as long as the code lifetime should fail"); + assert!( + matches!(err, ConfigError::Invalid { key, .. } if key == "DEVICE_AUTH_POLL_INTERVAL_SECS") + ); +} + +#[test] +fn validate_rejects_zero_session_lifetime() { + let mut config = valid_config(); + config.jwt.max_session_lifetime_secs = 0; + + let err = config + .validate() + .expect_err("a zero absolute session lifetime should fail"); + assert!( + matches!(err, ConfigError::Invalid { key, .. } if key == "JWT_MAX_SESSION_LIFETIME_SECS") + ); +} + +/// The production checks also reject most malformed keys, which hid this +/// validator from the tests: it is exercised on its own here. +#[test] +fn encryption_keys_are_checked_for_shape_in_every_environment() { + let reason = |value: &str| match validate_encryption_key("ENCRYPTION_KEY", value) { + Err(ConfigError::Invalid { key, reason }) => { + assert_eq!(key, "ENCRYPTION_KEY"); + reason + } + other => panic!("{value:?} was not refused: {other:?}"), + }; + + assert!(reason("not-base64").contains("base64")); + assert!(reason(&STANDARD.encode([0x5a_u8; 16])).contains("32 bytes")); + assert!( + validate_encryption_key( + "ENCRYPTION_KEY", + "VVKGNsojoT/vVMlGypXnqcCcJIbrPKbn/8DGfEs496k=" + ) + .is_ok() + ); +} + +#[test] +fn a_well_formed_key_with_little_entropy_is_refused() { + // 32 bytes alternating two values: not an arithmetic sequence, so only the + // entropy floor (1 bit per byte here) can refuse it. + let weak = STANDARD.encode([0xab_u8, 0x13].repeat(16)); + match validate_encryption_key("ENCRYPTION_KEY", &weak) { + Err(ConfigError::Invalid { reason, .. }) => assert!(reason.contains("entropy"), "{reason}"), + other => panic!("a low-entropy key was accepted: {other:?}"), + } +} + +#[test] +fn a_key_at_exactly_the_entropy_floor_is_accepted() { + // Eight distinct bytes, four times each: exactly 3.0 bits per byte. + let bytes = [0x10_u8, 0x9c, 0x2e, 0xf1, 0x47, 0x83, 0x5d, 0xb6].repeat(4); + assert!(validate_encryption_key("ENCRYPTION_KEY", &STANDARD.encode(bytes)).is_ok()); +} + +#[test] +fn the_previous_encryption_key_is_optional_but_checked_when_set() { + assert!(validate_optional_encryption_key("PREVIOUS_ENCRYPTION_KEY", None).is_ok()); + let err = validate_optional_encryption_key("PREVIOUS_ENCRYPTION_KEY", Some("not-base64")) + .expect_err("a malformed previous key should fail"); + assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "PREVIOUS_ENCRYPTION_KEY")); +} + +#[test] +fn argon2_needs_at_least_one_concurrent_hash() { + let mut crypto = valid_config().crypto; + crypto.argon2_max_concurrency = 0; + let err = validate_crypto(&crypto).expect_err("zero concurrency should fail"); + assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "ARGON2_MAX_CONCURRENCY")); + crypto.argon2_max_concurrency = 1; + assert!(validate_crypto(&crypto).is_ok()); +} + +#[test] +fn remember_me_selects_the_long_session_lifetime() { + let mut jwt = valid_config().jwt; + jwt.refresh_expiry_secs = 30; + jwt.short_session_expiry_secs = 1; + assert_eq!(jwt.session_ttl_secs(true), 30); + assert_eq!(jwt.session_ttl_secs(false), 1); +} + +#[test] +fn validate_rejects_production_webhooks_to_http_or_internal_addresses() { + let mut config = valid_config(); + config.webhooks.allow_http = true; + let err = config + .validate() + .expect_err("WEBHOOK_ALLOW_HTTP in production"); + assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "WEBHOOK_ALLOW_HTTP")); + + let mut config = valid_config(); + config.webhooks.allow_private_networks = true; + let err = config + .validate() + .expect_err("WEBHOOK_ALLOW_PRIVATE_NETWORKS in production"); + assert!( + matches!(err, ConfigError::Invalid { key, .. } if key == "WEBHOOK_ALLOW_PRIVATE_NETWORKS") + ); +} + +#[test] +fn validate_rejects_production_passkey_origins_outside_the_relying_party() { + for origins in [ + vec![], + vec!["http://auth.example.com".to_owned()], + vec!["https://evil.example.org".to_owned()], + ] { + let mut config = valid_config(); + config.webauthn.origins = origins.clone(); + let err = config + .validate() + .expect_err("bad passkey origins in production"); + assert!( + matches!(err, ConfigError::Invalid { ref key, .. } if key == "WEBAUTHN_ORIGINS"), + "{origins:?}" + ); + } +} + +#[test] +fn identity_providers_are_read_from_their_variables() { + let vars: std::collections::HashMap<&str, &str> = [ + ("IDENTITY_PROVIDERS", "google,corp-sso,github"), + ("IDP_GOOGLE_CLIENT_ID", "google-id"), + ("IDP_GOOGLE_CLIENT_SECRET", "google-secret"), + ("IDP_CORP_SSO_KIND", "oidc"), + ("IDP_CORP_SSO_ISSUER", "https://sso.example.com/"), + ("IDP_CORP_SSO_CLIENT_ID", "corp"), + ("IDP_CORP_SSO_CLIENT_SECRET", "corp-secret"), + ("IDP_CORP_SSO_DISPLAY_NAME", "Corporate SSO"), + ("IDP_GITHUB_CLIENT_ID", "gh"), + ("IDP_GITHUB_CLIENT_SECRET", "gh-secret"), + ] + .into_iter() + .collect(); + let env = super::env_vars::Env::new(|key: &str| vars.get(key).map(|v| (*v).to_owned())); + let providers = super::identity_providers(&env).unwrap(); + assert_eq!(providers.len(), 3); + assert_eq!(providers[0].issuer, "https://accounts.google.com"); + assert_eq!(providers[1].issuer, "https://sso.example.com"); + assert_eq!(providers[1].display_name, "Corporate SSO"); + assert_eq!(providers[2].kind, IdentityProviderKind::Github); + assert!(!format!("{:?}", providers[0]).contains("google-secret")); + + let missing: std::collections::HashMap<&str, &str> = + [("IDENTITY_PROVIDERS", "corp"), ("IDP_CORP_KIND", "oidc")] + .into_iter() + .collect(); + let env = super::env_vars::Env::new(|key: &str| missing.get(key).map(|v| (*v).to_owned())); + assert!(super::identity_providers(&env).is_err()); + let bad: std::collections::HashMap<&str, &str> = + [("IDENTITY_PROVIDERS", "Bad Name")].into_iter().collect(); + let env = super::env_vars::Env::new(|key: &str| bad.get(key).map(|v| (*v).to_owned())); + assert!(super::identity_providers(&env).is_err()); +} + +#[test] +fn validate_rejects_an_even_number_of_stream_replicas() { + let mut config = valid_config(); + config.nats.stream_replicas = 2; + let err = config + .validate() + .expect_err("2 replicas cannot hold a quorum"); + assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "NATS_STREAM_REPLICAS")); +} diff --git a/src/config/validate.rs b/src/config/validate.rs new file mode 100644 index 0000000..79cdc7e --- /dev/null +++ b/src/config/validate.rs @@ -0,0 +1,476 @@ +//! Startup validation: refuse a configuration that is unsafe for its environment. + +use base64::{Engine, engine::general_purpose::STANDARD}; + +use super::*; + +impl Config { + pub fn validate(&self) -> Result<(), ConfigError> { + if ![1, 3, 5].contains(&self.nats.stream_replicas) { + return Err(ConfigError::Invalid { + key: "NATS_STREAM_REPLICAS".into(), + reason: "must be 1, 3 or 5".into(), + }); + } + validate_jwt_keys(&self.jwt)?; + validate_encryption_key("ENCRYPTION_KEY", &self.crypto.encryption_key)?; + validate_optional_encryption_key( + "PREVIOUS_ENCRYPTION_KEY", + self.crypto.previous_encryption_key.as_deref(), + )?; + validate_cors(&self.cors, self.is_production())?; + validate_security(&self.security)?; + validate_device_auth(&self.device_auth)?; + validate_session_lifetime(&self.jwt)?; + validate_crypto(&self.crypto)?; + + validate_jwt_audience(&self.jwt.audience, self.is_production())?; + + let (_, nats_credentials) = + crate::utils::nats::split_credentials(&self.nats.url).map_err(|reason| { + ConfigError::Invalid { + key: "NATS_URL".into(), + reason, + } + })?; + + if self.is_production() { + // The development key pair is committed in `.env.dev` and therefore + // public: anyone can mint valid tokens for a deployment that uses + // it. Refuse to boot rather than run with a known-compromised key. + if self + .jwt + .public_key + .replace(['\n', ' ', '\t'], "") + .contains(DEV_JWT_PUBLIC_KEY_MARKER) + { + return Err(ConfigError::Invalid { + key: "JWT_PUBLIC_KEY".into(), + reason: "this is the committed development key from .env.dev -- it is public and must never be used in production".into(), + }); + } + + validate_https_url("APP_PUBLIC_URL", &self.server.public_url)?; + validate_https_url("FRONTEND_URL", &self.server.frontend_url)?; + validate_https_url("OAUTH_CONSENT_URI", &self.device_auth.consent_uri)?; + + // TLS terminates at a reverse proxy in production. With no trusted + // CIDR every request resolves to the proxy's address: one rate-limit + // bucket for the whole internet and one IP in every audit row. + if self.server.trusted_proxy_cidrs.is_empty() { + return Err(ConfigError::Invalid { + key: "TRUSTED_PROXY_CIDRS".into(), + reason: "must not be empty in production -- without it every client is rate-limited and audited as the reverse proxy".into(), + }); + } + + validate_production_encryption_key("ENCRYPTION_KEY", &self.crypto.encryption_key)?; + if let Some(previous) = self.crypto.previous_encryption_key.as_deref() { + validate_production_encryption_key("PREVIOUS_ENCRYPTION_KEY", previous)?; + } + + if self.webauthn.origins.is_empty() + || self.webauthn.origins.iter().any(|origin| { + !origin.starts_with("https://") + || !crate::domain::webauthn::origin_matches_rp(origin, &self.webauthn.rp_id) + }) + { + return Err(ConfigError::Invalid { + key: "WEBAUTHN_ORIGINS".into(), + reason: + "must list HTTPS origins on WEBAUTHN_RP_ID or its subdomains in production" + .into(), + }); + } + + validate_https_url("EXTERNAL_LOGIN_URI", &self.external_login_uri)?; + for provider in &self.identity_providers { + let urls = match provider.kind { + crate::config::IdentityProviderKind::Oidc => vec![("ISSUER", &provider.issuer)], + crate::config::IdentityProviderKind::Github => vec![ + ("AUTHORIZATION_URL", &provider.authorization_url), + ("TOKEN_URL", &provider.token_url), + ("USER_URL", &provider.user_url), + ], + }; + for (suffix, url) in urls { + validate_https_url( + &format!("IDP_{}_{suffix}", provider.name.to_ascii_uppercase()), + url, + )?; + } + } + + if self.webhooks.allow_http { + return Err(ConfigError::Invalid { + key: "WEBHOOK_ALLOW_HTTP".into(), + reason: "must be false in production -- deliveries carry account events and their signature".into(), + }); + } + if self.webhooks.allow_private_networks { + return Err(ConfigError::Invalid { + key: "WEBHOOK_ALLOW_PRIVATE_NETWORKS".into(), + reason: "must be false in production -- a webhook must not reach the internal network".into(), + }); + } + + if self.pwned_passwords.enabled { + validate_https_url("PWNED_PASSWORDS_URL", &self.pwned_passwords.api_url)?; + } + + if self.captcha.secret.is_some() { + validate_https_url("CAPTCHA_VERIFY_URL", &self.captcha.verify_url)?; + } else { + return Err(ConfigError::Invalid { + key: "CAPTCHA_SECRET".into(), + reason: "must be set in production -- CAPTCHA protection cannot be disabled in production".into(), + }); + } + + if self.mail.smtp.username.is_empty() { + return Err(ConfigError::Invalid { + key: "SMTP_USERNAME".into(), + reason: "must not be empty in production (unauthenticated/unencrypted SMTP is not allowed)".into(), + }); + } + + // The broker carries the erasure events: anything that reaches it + // must not be able to publish or read them. + if nats_credentials == crate::utils::nats::NatsCredentials::None { + return Err(ConfigError::Invalid { + key: "NATS_URL".into(), + reason: + "must carry the broker credentials in production (nats://@host:port)" + .into(), + }); + } + + // Hardened-default switches: in production these MUST be set to the + // secure value, even if an env override re-enables the permissive + // behaviour. Refuse to boot rather than start in a degraded state. + if self.rate_limit.fail_open_on_redis_error { + return Err(ConfigError::Invalid { + key: "RATE_LIMIT_FAIL_OPEN".into(), + reason: "must be false in production -- a Redis outage would otherwise disable rate limiting entirely".into(), + }); + } + + if self.rate_limit.allow_requests_without_ip { + return Err(ConfigError::Invalid { + key: "RATE_LIMIT_ALLOW_MISSING_IP".into(), + reason: "must be false in production -- requests without a resolved client IP must be rejected, not let through".into(), + }); + } + + if self.captcha.fail_open_on_error { + return Err(ConfigError::Invalid { + key: "CAPTCHA_FAIL_OPEN".into(), + reason: "must be false in production -- CAPTCHA upstream errors must not let traffic through".into(), + }); + } + + if !self.jwt.strict_session_binding { + return Err(ConfigError::Invalid { + key: "JWT_STRICT_SESSION_BINDING".into(), + reason: "must be true in production -- refresh tokens must be bound to the originating IP".into(), + }); + } + } + + Ok(()) + } +} + +/// Symmetric keys committed in `.env.dev` or used as examples: public by +/// definition, refused in production. +pub(super) const DEV_ENCRYPTION_KEYS: [&str; 2] = [ + "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=", +]; + +pub(super) fn validate_jwt_keys(jwt: &JwtConfig) -> Result<(), ConfigError> { + use crate::utils::jwt as jwt_util; + + let signing_key = + jwt_util::parse_signing_key(&jwt.private_key).map_err(|e| ConfigError::Invalid { + key: "JWT_PRIVATE_KEY".into(), + reason: e.to_string(), + })?; + + let verifying_key = + jwt_util::parse_p256_verifying_key(&jwt.public_key).map_err(|e| ConfigError::Invalid { + key: "JWT_PUBLIC_KEY".into(), + reason: e.to_string(), + })?; + + // Verify that the public key matches the private key. + let derived = p256::ecdsa::VerifyingKey::from(&signing_key); + if derived != verifying_key { + return Err(ConfigError::Invalid { + key: "JWT_PUBLIC_KEY".into(), + reason: "public key does not match the private key".into(), + }); + } + + if let Some(ref prev_pub) = jwt.previous_public_key { + jwt_util::parse_p256_verifying_key(prev_pub).map_err(|e| ConfigError::Invalid { + key: "JWT_PREVIOUS_PUBLIC_KEY".into(), + reason: e.to_string(), + })?; + } + + if let Some(ref next_pub) = jwt.next_public_key { + jwt_util::parse_p256_verifying_key(next_pub).map_err(|e| ConfigError::Invalid { + key: "JWT_NEXT_PUBLIC_KEY".into(), + reason: e.to_string(), + })?; + } + + Ok(()) +} + +pub(super) fn validate_encryption_key(key_name: &str, value: &str) -> Result<(), ConfigError> { + let decoded = STANDARD.decode(value).map_err(|e| ConfigError::Invalid { + key: key_name.into(), + reason: format!("must be valid base64: {e}"), + })?; + + if decoded.len() != 32 { + return Err(ConfigError::Invalid { + key: key_name.into(), + reason: "must decode to exactly 32 bytes".into(), + }); + } + + // Reject low-entropy keys using Shannon entropy over byte distribution. + // A truly random 32-byte key typically has >= 3.5 bits of entropy per byte. + let mut counts = [0u32; 256]; + for &b in &decoded { + counts[b as usize] += 1; + } + let len = decoded.len() as f64; + let shannon: f64 = counts + .iter() + .filter(|&&c| c > 0) + .map(|&c| { + let p = c as f64 / len; + -p * p.log2() + }) + .sum(); + if shannon < 3.0 { + return Err(ConfigError::Invalid { + key: key_name.into(), + reason: format!( + "key has insufficient entropy ({shannon:.2} bits/byte, minimum 3.0): \ + use a cryptographically random key (e.g. openssl rand -base64 32)" + ), + }); + } + + Ok(()) +} + +/// Production-only checks on a symmetric key, on top of the entropy floor. +/// +/// Shannon entropy counts byte frequencies, so any 32 distinct bytes score a +/// perfect 5 bits/byte -- including the counted sequence `00 01 .. 1f` from +/// `.env.dev`. A constant stride between bytes gives such keys away. +pub(super) fn validate_production_encryption_key( + key_name: &str, + value: &str, +) -> Result<(), ConfigError> { + if DEV_ENCRYPTION_KEYS.contains(&value) { + return Err(ConfigError::Invalid { + key: key_name.into(), + reason: "this is a committed development key -- it is public and must never be used in production".into(), + }); + } + + let decoded = STANDARD.decode(value).map_err(|e| ConfigError::Invalid { + key: key_name.into(), + reason: format!("must be valid base64: {e}"), + })?; + + let stride = decoded + .windows(2) + .next() + .map(|w| w[1].wrapping_sub(w[0])) + .unwrap_or_default(); + if decoded + .windows(2) + .all(|w| w[1].wrapping_sub(w[0]) == stride) + { + return Err(ConfigError::Invalid { + key: key_name.into(), + reason: "key bytes form an arithmetic sequence: use a cryptographically random key (e.g. openssl rand -base64 32)".into(), + }); + } + + Ok(()) +} + +pub(super) fn validate_optional_encryption_key( + key_name: &str, + value: Option<&str>, +) -> Result<(), ConfigError> { + if let Some(value) = value { + validate_encryption_key(key_name, value)?; + } + + Ok(()) +} + +pub(super) fn validate_https_url(key: &str, value: &str) -> Result<(), ConfigError> { + if !value.starts_with("https://") { + return Err(ConfigError::Invalid { + key: key.into(), + reason: "must use https in production".into(), + }); + } + + Ok(()) +} + +/// `JWT_AUDIENCE` validation. In production we refuse to start without at +/// least one audience: emitting tokens with an empty `aud` would silently +/// break every downstream service that pins the audience claim. In dev we +/// only warn so local stacks (no resource server, scratch tests) keep +/// working. +pub(super) fn validate_jwt_audience( + audience: &[String], + is_production: bool, +) -> Result<(), ConfigError> { + if audience.is_empty() { + if is_production { + return Err(ConfigError::Invalid { + key: "JWT_AUDIENCE".into(), + reason: "must not be empty in production -- downstream services that pin `aud` would reject all tokens".into(), + }); + } + + tracing::warn!( + "JWT_AUDIENCE is empty: issued access tokens will not carry an `aud` claim, downstream services pinning audience will reject them" + ); + return Ok(()); + } + + for value in audience { + if value.trim().is_empty() { + return Err(ConfigError::Invalid { + key: "JWT_AUDIENCE".into(), + reason: "entries must not be empty or whitespace-only".into(), + }); + } + } + + Ok(()) +} + +pub(super) fn validate_crypto(crypto: &CryptoConfig) -> Result<(), ConfigError> { + if crypto.argon2_max_concurrency == 0 { + return Err(ConfigError::Invalid { + key: "ARGON2_MAX_CONCURRENCY".into(), + reason: "must be greater than 0".into(), + }); + } + + Ok(()) +} + +pub(super) fn validate_security(security: &SecurityConfig) -> Result<(), ConfigError> { + // 0 would not disable the lockout: every wrong password would lock the account. + if security.lockout_threshold == 0 { + return Err(ConfigError::Invalid { + key: "LOCKOUT_THRESHOLD".into(), + reason: "must be at least 1".into(), + }); + } + + if security.sensitive_action_reauth_secs == 0 { + return Err(ConfigError::Invalid { + key: "SENSITIVE_ACTION_REAUTH_SECS".into(), + reason: "must be greater than 0".into(), + }); + } + + Ok(()) +} + +/// A device flow needs a code that lives long enough to be polled at least once. +pub(super) fn validate_device_auth(device: &DeviceAuthConfig) -> Result<(), ConfigError> { + if device.ttl_secs == 0 { + return Err(ConfigError::Invalid { + key: "DEVICE_AUTH_TTL_SECS".into(), + reason: "must be greater than 0".into(), + }); + } + // 0 was advertised to clients as is, while polling was paced at one second: + // a client following the advertised interval was always told to slow down. + if device.poll_interval_secs == 0 { + return Err(ConfigError::Invalid { + key: "DEVICE_AUTH_POLL_INTERVAL_SECS".into(), + reason: "must be at least 1".into(), + }); + } + if device.poll_interval_secs >= device.ttl_secs { + return Err(ConfigError::Invalid { + key: "DEVICE_AUTH_POLL_INTERVAL_SECS".into(), + reason: "must be shorter than DEVICE_AUTH_TTL_SECS, or no poll can succeed".into(), + }); + } + Ok(()) +} + +/// A zero absolute lifetime would refuse every refresh. +pub(super) fn validate_session_lifetime(jwt: &JwtConfig) -> Result<(), ConfigError> { + if jwt.max_session_lifetime_secs == 0 { + return Err(ConfigError::Invalid { + key: "JWT_MAX_SESSION_LIFETIME_SECS".into(), + reason: "must be greater than 0".into(), + }); + } + Ok(()) +} +pub(super) fn validate_cors(cors: &CorsConfig, is_production: bool) -> Result<(), ConfigError> { + if cors.allowed_origins.is_empty() { + return Err(ConfigError::Invalid { + key: "CORS_ALLOWED_ORIGINS".into(), + reason: "must not be empty".into(), + }); + } + + let has_wildcard = cors.allowed_origins.iter().any(|origin| origin == "*"); + if has_wildcard && cors.allow_credentials { + return Err(ConfigError::Invalid { + key: "CORS_ALLOWED_ORIGINS".into(), + reason: "cannot use '*' when CORS_ALLOW_CREDENTIALS=true".into(), + }); + } + + if is_production && has_wildcard { + return Err(ConfigError::Invalid { + key: "CORS_ALLOWED_ORIGINS".into(), + reason: "cannot use '*' in production".into(), + }); + } + + for origin in cors + .allowed_origins + .iter() + .filter(|origin| origin.as_str() != "*") + { + let parsed = reqwest::Url::parse(origin).map_err(|e| ConfigError::Invalid { + key: "CORS_ALLOWED_ORIGINS".into(), + reason: format!("invalid origin '{origin}': {e}"), + })?; + + if is_production && parsed.scheme() != "https" { + return Err(ConfigError::Invalid { + key: "CORS_ALLOWED_ORIGINS".into(), + reason: format!("origin '{origin}' must use https in production"), + }); + } + } + + Ok(()) +} diff --git a/src/domain/audit.rs b/src/domain/audit.rs index 3921396..5142b18 100644 --- a/src/domain/audit.rs +++ b/src/domain/audit.rs @@ -40,6 +40,28 @@ pub enum AuditAction { UsernameChanged, RecoveryCodeUsed, EmailChanged, + EncryptionKeyRotated, + AccountUnlocked, + PasswordResetForced, + RoleCreated, + RoleDeleted, + RolePermissionsChanged, + ClientRegistered, + ClientUpdated, + ClientDeleted, + DataExported, + MagicLinkSent, + PersonalAccessTokenCreated, + PersonalAccessTokenRevoked, + WebhookCreated, + WebhookUpdated, + WebhookDeleted, + WebhookSecretRotated, + ClientSecretRotated, + PasskeyRegistered, + PasskeyRemoved, + ExternalIdentityLinked, + ExternalIdentityUnlinked, } #[derive(Debug, Clone, sqlx::FromRow)] diff --git a/src/domain/captcha.rs b/src/domain/captcha.rs new file mode 100644 index 0000000..cc70092 --- /dev/null +++ b/src/domain/captcha.rs @@ -0,0 +1,69 @@ +//! CAPTCHA verdicts, apart from the HTTP call that obtains an answer. + +/// What the verification endpoint gave back. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CaptchaUpstream { + /// No response: connection failure or timeout. + Unreachable, + /// A response with a non-success HTTP status. + Failed, + /// A success status with a body that does not parse. + Unreadable, + /// A readable answer. + Answered { success: bool }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CaptchaVerdict { + Accepted, + Rejected, + Unavailable, +} + +/// Judge an answer. A negative answer is a rejection whatever the fallback; +/// only a missing answer is subject to `fail_open`. +pub fn captcha_verdict(upstream: CaptchaUpstream, fail_open: bool) -> CaptchaVerdict { + match upstream { + CaptchaUpstream::Answered { success: true } => CaptchaVerdict::Accepted, + CaptchaUpstream::Answered { success: false } => CaptchaVerdict::Rejected, + _ if fail_open => CaptchaVerdict::Accepted, + _ => CaptchaVerdict::Unavailable, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const MISSING: [CaptchaUpstream; 3] = [ + CaptchaUpstream::Unreachable, + CaptchaUpstream::Failed, + CaptchaUpstream::Unreadable, + ]; + + #[test] + fn an_answer_decides_whatever_the_fallback() { + for fail_open in [false, true] { + assert_eq!( + captcha_verdict(CaptchaUpstream::Answered { success: true }, fail_open), + CaptchaVerdict::Accepted + ); + assert_eq!( + captcha_verdict(CaptchaUpstream::Answered { success: false }, fail_open), + CaptchaVerdict::Rejected, + "a negative answer is never accepted, even failing open" + ); + } + } + + #[test] + fn a_missing_answer_follows_the_fallback() { + for upstream in MISSING { + assert_eq!(captcha_verdict(upstream, true), CaptchaVerdict::Accepted); + assert_eq!( + captcha_verdict(upstream, false), + CaptchaVerdict::Unavailable + ); + } + } +} diff --git a/src/domain/device.rs b/src/domain/device.rs new file mode 100644 index 0000000..b079418 --- /dev/null +++ b/src/domain/device.rs @@ -0,0 +1,120 @@ +//! Device authorization decisions (RFC 8628), apart from their Redis storage. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Where a device request stands. Stored in Redis by name: renaming a variant +/// strands the requests in progress. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DeviceAuthStatus { + Pending, + Authorized, + Denied, +} + +impl DeviceAuthStatus { + /// Only a pending request can be approved or denied: a decision is final. + pub fn is_undecided(&self) -> bool { + *self == DeviceAuthStatus::Pending + } +} + +/// What a poll of a stored request leads to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PollOutcome<'a> { + Pending, + Denied, + Authorized { + user_id: Uuid, + client_id: &'a str, + }, + /// Approved without an approver: a corrupted or forged entry. + MissingUser, + /// Stored without a client: forged, or older than client registration. + MissingClient, +} + +/// Judge a poll from what the request stores. +pub fn poll_outcome<'a>( + status: &DeviceAuthStatus, + user_id: Option, + client_id: Option<&'a str>, +) -> PollOutcome<'a> { + match (status, user_id, client_id) { + (DeviceAuthStatus::Pending, _, _) => PollOutcome::Pending, + (DeviceAuthStatus::Denied, _, _) => PollOutcome::Denied, + (DeviceAuthStatus::Authorized, None, _) => PollOutcome::MissingUser, + (DeviceAuthStatus::Authorized, Some(_), None) => PollOutcome::MissingClient, + (DeviceAuthStatus::Authorized, Some(user_id), Some(client_id)) => { + PollOutcome::Authorized { user_id, client_id } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_a_pending_request_can_be_decided() { + assert!(DeviceAuthStatus::Pending.is_undecided()); + assert!(!DeviceAuthStatus::Authorized.is_undecided()); + assert!(!DeviceAuthStatus::Denied.is_undecided()); + } + + #[test] + fn statuses_are_stored_under_stable_names() { + for (status, name) in [ + (DeviceAuthStatus::Pending, "\"pending\""), + (DeviceAuthStatus::Authorized, "\"authorized\""), + (DeviceAuthStatus::Denied, "\"denied\""), + ] { + assert_eq!(serde_json::to_string(&status).unwrap(), name); + assert_eq!( + serde_json::from_str::(name).unwrap(), + status + ); + } + } + + #[test] + fn an_undecided_or_denied_request_ignores_what_else_it_stores() { + let user = Some(Uuid::nil()); + for (user_id, client_id) in [(None, None), (user, Some("app"))] { + assert_eq!( + poll_outcome(&DeviceAuthStatus::Pending, user_id, client_id), + PollOutcome::Pending + ); + assert_eq!( + poll_outcome(&DeviceAuthStatus::Denied, user_id, client_id), + PollOutcome::Denied + ); + } + } + + #[test] + fn an_approval_needs_both_its_approver_and_its_client() { + let user_id = Uuid::new_v4(); + assert_eq!( + poll_outcome(&DeviceAuthStatus::Authorized, Some(user_id), Some("app")), + PollOutcome::Authorized { + user_id, + client_id: "app" + } + ); + assert_eq!( + poll_outcome(&DeviceAuthStatus::Authorized, None, Some("app")), + PollOutcome::MissingUser + ); + assert_eq!( + poll_outcome(&DeviceAuthStatus::Authorized, None, None), + PollOutcome::MissingUser, + "the approver is checked first" + ); + assert_eq!( + poll_outcome(&DeviceAuthStatus::Authorized, Some(user_id), None), + PollOutcome::MissingClient + ); + } +} diff --git a/src/domain/email_change.rs b/src/domain/email_change.rs new file mode 100644 index 0000000..d94d331 --- /dev/null +++ b/src/domain/email_change.rs @@ -0,0 +1,95 @@ +//! Email change flow: the order of its steps, apart from its Redis storage. + +use serde::{Deserialize, Serialize}; + +/// Where a flow stands. Stored in Redis by name: renaming a variant strands +/// the flows in progress. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FlowStep { + /// OTP sent to the current email; waiting for the user to confirm it. + CurrentVerify, + /// Current email confirmed; waiting for the user to submit a new address. + NewSubmit, + /// OTP sent to the new email; waiting for the user to confirm it. + NewVerify, +} + +/// What the user just did. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FlowEvent { + CurrentConfirmed, + NewAddressSubmitted, + NewConfirmed, +} + +/// Where an event takes a flow. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Transition { + To(FlowStep), + Done, +} + +impl FlowStep { + /// The transition `event` makes from this step, or `None` when the event + /// does not belong to it: a step can be neither skipped nor replayed. + pub fn after(self, event: FlowEvent) -> Option { + match (self, event) { + (FlowStep::CurrentVerify, FlowEvent::CurrentConfirmed) => { + Some(Transition::To(FlowStep::NewSubmit)) + } + (FlowStep::NewSubmit, FlowEvent::NewAddressSubmitted) => { + Some(Transition::To(FlowStep::NewVerify)) + } + (FlowStep::NewVerify, FlowEvent::NewConfirmed) => Some(Transition::Done), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const STEPS: [FlowStep; 3] = [ + FlowStep::CurrentVerify, + FlowStep::NewSubmit, + FlowStep::NewVerify, + ]; + const EVENTS: [FlowEvent; 3] = [ + FlowEvent::CurrentConfirmed, + FlowEvent::NewAddressSubmitted, + FlowEvent::NewConfirmed, + ]; + + #[test] + fn each_step_accepts_exactly_its_own_event() { + let expected = [ + Some(Transition::To(FlowStep::NewSubmit)), + Some(Transition::To(FlowStep::NewVerify)), + Some(Transition::Done), + ]; + for (s, step) in STEPS.into_iter().enumerate() { + for (e, event) in EVENTS.into_iter().enumerate() { + let transition = step.after(event); + if s == e { + assert_eq!(transition, expected[s], "{step:?} on {event:?}"); + } else { + assert_eq!(transition, None, "{step:?} must refuse {event:?}"); + } + } + } + } + + #[test] + fn steps_are_stored_under_stable_names() { + for (step, name) in + STEPS + .into_iter() + .zip(["\"current_verify\"", "\"new_submit\"", "\"new_verify\""]) + { + assert_eq!(serde_json::to_string(&step).unwrap(), name); + assert_eq!(serde_json::from_str::(name).unwrap(), step); + } + } +} diff --git a/src/domain/external_identity.rs b/src/domain/external_identity.rs new file mode 100644 index 0000000..f016848 --- /dev/null +++ b/src/domain/external_identity.rs @@ -0,0 +1,151 @@ +//! Signing in through an external identity provider: which responses identify +//! a person, and how a sign-in started in one browser is finished in the same. + +use serde_json::Value; + +/// Provider names in routes and configuration. +pub fn is_valid_provider_name(name: &str) -> bool { + (1..=50).contains(&name.len()) + && name + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_') +} + +/// What an ID token must say to identify a person to this client. +pub struct IdTokenExpectations<'a> { + pub issuer: &'a str, + pub client_id: &'a str, + pub nonce: &'a str, + /// Unix seconds. + pub now: i64, +} + +/// Seconds of clock difference tolerated with the provider. +const LEEWAY_SECS: i64 = 60; + +/// The subject of ID token claims whose signature was verified, when they are +/// meant for this client, for this sign-in, and current (OIDC Core 3.1.3.7). +pub fn id_token_subject( + claims: &Value, + expected: &IdTokenExpectations<'_>, +) -> Result { + if claims["iss"].as_str().map(|iss| iss.trim_end_matches('/')) + != Some(expected.issuer.trim_end_matches('/')) + { + return Err("issuer mismatch"); + } + let audiences: Vec<&str> = match &claims["aud"] { + Value::String(aud) => vec![aud.as_str()], + Value::Array(list) => list.iter().filter_map(Value::as_str).collect(), + _ => return Err("missing audience"), + }; + if !audiences.contains(&expected.client_id) { + return Err("audience mismatch"); + } + if audiences.len() > 1 && claims["azp"].as_str() != Some(expected.client_id) { + return Err("authorized party mismatch"); + } + match claims["exp"].as_i64() { + Some(exp) if exp + LEEWAY_SECS > expected.now => {} + _ => return Err("expired"), + } + if claims["iat"] + .as_i64() + .is_some_and(|iat| iat > expected.now + LEEWAY_SECS) + { + return Err("issued in the future"); + } + if claims["nonce"].as_str() != Some(expected.nonce) { + return Err("nonce mismatch"); + } + match claims["sub"].as_str() { + Some(sub) if (1..=255).contains(&sub.len()) => Ok(sub.to_owned()), + _ => Err("missing subject"), + } +} + +/// The subject of a GitHub user: its numeric id, which survives renames. +pub fn github_subject(user: &Value) -> Option { + user["id"] + .as_u64() + .filter(|id| *id > 0) + .map(|id| id.to_string()) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn expected() -> IdTokenExpectations<'static> { + IdTokenExpectations { + issuer: "https://sso.example.com", + client_id: "auth-api", + nonce: "n-1", + now: 1_000_000, + } + } + + fn claims() -> Value { + json!({ + "iss": "https://sso.example.com/", + "aud": "auth-api", + "exp": 1_000_300, + "iat": 1_000_000, + "nonce": "n-1", + "sub": "person-42", + }) + } + + #[test] + fn a_current_token_for_this_client_and_sign_in_names_its_subject() { + assert_eq!( + id_token_subject(&claims(), &expected()), + Ok("person-42".into()) + ); + + let mut several = claims(); + several["aud"] = json!(["other", "auth-api"]); + assert_eq!( + id_token_subject(&several, &expected()), + Err("authorized party mismatch") + ); + several["azp"] = json!("auth-api"); + assert!(id_token_subject(&several, &expected()).is_ok()); + } + + #[test] + fn a_token_for_something_else_is_refused() { + for (field, value, reason) in [ + ("iss", json!("https://evil.example.com"), "issuer mismatch"), + ("aud", json!("other-client"), "audience mismatch"), + ("aud", json!(null), "missing audience"), + ("exp", json!(999_000), "expired"), + ("iat", json!(1_001_000), "issued in the future"), + ("nonce", json!("n-2"), "nonce mismatch"), + ("sub", json!(""), "missing subject"), + ] { + let mut token = claims(); + token[field] = value; + assert_eq!( + id_token_subject(&token, &expected()), + Err(reason), + "{field}" + ); + } + } + + #[test] + fn provider_names_and_github_subjects() { + assert!(is_valid_provider_name("corp-sso_2")); + assert!(!is_valid_provider_name("Corp")); + assert!(!is_valid_provider_name("")); + assert_eq!( + github_subject(&json!({ "id": 42, "login": "octo" })), + Some("42".into()) + ); + assert_eq!(github_subject(&json!({ "login": "octo" })), None); + assert_eq!(github_subject(&json!({ "id": 0 })), None); + } +} diff --git a/src/domain/known_device.rs b/src/domain/known_device.rs new file mode 100644 index 0000000..20f8394 --- /dev/null +++ b/src/domain/known_device.rs @@ -0,0 +1,124 @@ +//! What counts as "the same device" for new-device sign-in alerts: the browser +//! and operating system families of the user agent. Versions change with every +//! update and network addresses with every mobile network; neither makes a new +//! device. + +use sha2::{Digest, Sha256}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DeviceFamily { + pub browser: &'static str, + pub os: &'static str, +} + +impl DeviceFamily { + /// Readable description for the alert, such as "Firefox on Linux". + pub fn describe(&self) -> String { + format!("{} on {}", self.browser, self.os) + } + + /// Stored instead of the user agent itself. + pub fn fingerprint(&self) -> [u8; 32] { + Sha256::digest(format!("{}|{}", self.browser, self.os).as_bytes()).into() + } +} + +/// The families of `user_agent`. Order matters: many browsers carry the tokens +/// of the ones they derive from (Edge says Chrome and Safari, Chrome says Safari). +pub fn device_family(user_agent: Option<&str>) -> DeviceFamily { + let ua = user_agent.unwrap_or("").trim(); + let has = |token: &str| ua.contains(token); + + let os = if has("Android") { + "Android" + } else if has("iPhone") || has("iPad") || has("iPod") { + "iOS" + } else if has("CrOS") { + "ChromeOS" + } else if has("Windows") { + "Windows" + } else if has("Macintosh") || has("Mac OS X") { + "macOS" + } else if has("Linux") { + "Linux" + } else { + "an unknown system" + }; + + let browser = if ua.is_empty() { + "an unknown client" + } else if has("Edg/") || has("Edge/") || has("EdgiOS") || has("EdgA/") { + "Edge" + } else if has("OPR/") || has("Opera") { + "Opera" + } else if has("Firefox/") || has("FxiOS") { + "Firefox" + } else if has("SamsungBrowser") { + "Samsung Internet" + } else if has("Chrome/") || has("CriOS") { + "Chrome" + } else if has("Safari/") { + "Safari" + } else { + "another client" + }; + + DeviceFamily { browser, os } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn family(ua: &str) -> String { + device_family(Some(ua)).describe() + } + + #[test] + fn common_user_agents_get_their_families() { + for (ua, expected) in [ + ( + "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0", + "Firefox on Linux", + ), + ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36", + "Chrome on Windows", + ), + ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0", + "Edge on Windows", + ), + ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15", + "Safari on macOS", + ), + ( + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/139.0 Mobile/15E148 Safari/604.1", + "Chrome on iOS", + ), + ( + "Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/26.0 Chrome/122.0 Mobile Safari/537.36", + "Samsung Internet on Android", + ), + ("curl/8.9.1", "another client on an unknown system"), + ] { + assert_eq!(family(ua), expected, "{ua}"); + } + assert_eq!( + device_family(None).describe(), + "an unknown client on an unknown system" + ); + } + + #[test] + fn versions_do_not_make_a_new_device() { + let old = device_family(Some("Mozilla/5.0 (X11; Linux x86_64) Firefox/139.0")); + let new = device_family(Some("Mozilla/5.0 (X11; Linux x86_64) Firefox/140.0")); + assert_eq!(old.fingerprint(), new.fingerprint()); + assert_ne!( + old.fingerprint(), + device_family(Some("Mozilla/5.0 (Windows NT 10.0) Firefox/140.0")).fingerprint() + ); + } +} diff --git a/src/domain/login_attempt.rs b/src/domain/login_attempt.rs index eb865f5..ec52161 100644 --- a/src/domain/login_attempt.rs +++ b/src/domain/login_attempt.rs @@ -33,3 +33,71 @@ pub struct LoginAttempt { pub request_ip: Option, pub request_user_agent: Option, } + +/// Recent failures counted before a password is checked. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RecentFailures { + /// Failed sign-ins from the client's address (its /64 for IPv6). + pub by_ip: i64, + /// Distinct identifiers tried from that address. + pub distinct_identifiers_by_ip: i64, + /// Failed sign-ins for the identifier, from any address. + pub by_identifier: i64, +} + +/// Ceilings on [`RecentFailures`]. +#[derive(Debug, Clone, Copy)] +pub struct FailureCeilings { + pub by_ip: i64, + pub distinct_identifiers_by_ip: i64, + pub by_identifier: i64, +} + +impl RecentFailures { + /// Whether an attempt is refused before any password work. A ceiling is + /// reached at equality: a ceiling of 10 lets ten failures through, and + /// refuses the attempt after them. + pub fn reach(&self, ceilings: &FailureCeilings) -> bool { + self.by_ip >= ceilings.by_ip + || self.distinct_identifiers_by_ip >= ceilings.distinct_identifiers_by_ip + || self.by_identifier >= ceilings.by_identifier + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const CEILINGS: FailureCeilings = FailureCeilings { + by_ip: 30, + distinct_identifiers_by_ip: 50, + by_identifier: 10, + }; + + const BELOW: RecentFailures = RecentFailures { + by_ip: 29, + distinct_identifiers_by_ip: 49, + by_identifier: 9, + }; + + #[test] + fn attempts_below_every_ceiling_go_through() { + assert!(!BELOW.reach(&CEILINGS)); + } + + #[test] + fn each_ceiling_refuses_on_its_own_at_equality() { + let at_ip = RecentFailures { by_ip: 30, ..BELOW }; + let at_distinct = RecentFailures { + distinct_identifiers_by_ip: 50, + ..BELOW + }; + let at_identifier = RecentFailures { + by_identifier: 10, + ..BELOW + }; + assert!(at_ip.reach(&CEILINGS)); + assert!(at_distinct.reach(&CEILINGS)); + assert!(at_identifier.reach(&CEILINGS)); + } +} diff --git a/src/domain/login_location.rs b/src/domain/login_location.rs deleted file mode 100644 index 1865b39..0000000 --- a/src/domain/login_location.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! Login location domain type. -//! -//! Maps the `login_locations` table used for risk scoring. - -use ipnetwork::IpNetwork; -use time::OffsetDateTime; -use uuid::Uuid; - -#[derive(Debug, Clone, sqlx::FromRow)] -pub struct LoginLocation { - pub id: Uuid, - pub user_id: Uuid, - pub country: String, - pub city: String, - pub user_agent: String, - pub ip_address: IpNetwork, - pub latitude: Option, - pub longitude: Option, - pub last_seen: OffsetDateTime, - pub first_seen: OffsetDateTime, -} diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 5ac887c..fcb1c23 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -1,15 +1,31 @@ -//! Domain types that mirror the database schema. +//! Domain types that mirror the database schema, and the decisions taken about +//! them. //! //! These structs are used internally by repositories and services. //! They are never serialized directly to HTTP responses; DTOs handle that. +//! Decisions live here as plain functions of plain values (time included), so +//! unit and mutation tests reach them without Redis, PostgreSQL or HTTP; the +//! services do the I/O and map each verdict to its error. pub mod audit; +pub mod captcha; pub mod client_quota; +pub mod device; +pub mod email_change; +pub mod external_identity; +pub mod known_device; pub mod login_attempt; -pub mod login_location; +pub mod oauth; +pub mod oidc; +pub mod outbox; +pub mod personal_access_token; +pub mod pwned; +pub mod rate_limit; pub mod registered_client; pub mod role; pub mod session; pub mod token; pub mod two_factor; pub mod user; +pub mod webauthn; +pub mod webhook; diff --git a/src/domain/oauth.rs b/src/domain/oauth.rs new file mode 100644 index 0000000..86e4390 --- /dev/null +++ b/src/domain/oauth.rs @@ -0,0 +1,260 @@ +//! OAuth 2.1 vocabulary: error codes, scopes, client credentials and the +//! redirects that carry an authorization response. + +use base64::{Engine, engine::general_purpose::STANDARD as B64}; +use percent_encoding::percent_decode_str; + +/// RFC 6749 section 5.2 and 4.1.2.1, RFC 8628 section 3.5. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ErrorCode { + InvalidRequest, + InvalidClient, + InvalidGrant, + UnauthorizedClient, + UnsupportedGrantType, + UnsupportedResponseType, + InvalidScope, + AccessDenied, + AuthorizationPending, + SlowDown, + ExpiredToken, +} + +impl ErrorCode { + pub fn as_str(self) -> &'static str { + match self { + Self::InvalidRequest => "invalid_request", + Self::InvalidClient => "invalid_client", + Self::InvalidGrant => "invalid_grant", + Self::UnauthorizedClient => "unauthorized_client", + Self::UnsupportedGrantType => "unsupported_grant_type", + Self::UnsupportedResponseType => "unsupported_response_type", + Self::InvalidScope => "invalid_scope", + Self::AccessDenied => "access_denied", + Self::AuthorizationPending => "authorization_pending", + Self::SlowDown => "slow_down", + Self::ExpiredToken => "expired_token", + } + } +} + +pub const GRANT_AUTHORIZATION_CODE: &str = "authorization_code"; +pub const GRANT_REFRESH_TOKEN: &str = "refresh_token"; +pub const GRANT_DEVICE_CODE: &str = "urn:ietf:params:oauth:grant-type:device_code"; +pub const GRANT_CLIENT_CREDENTIALS: &str = "client_credentials"; + +/// The `sub` of a client credentials token: a UUID derived from the issuer and +/// the client id, stable across tokens and distinct from every user id (those +/// are random, version 4). +pub fn client_subject(issuer: &str, client_id: &str) -> uuid::Uuid { + uuid::Uuid::new_v5( + &uuid::Uuid::NAMESPACE_URL, + format!("{}/clients/{client_id}", issuer.trim_end_matches('/')).as_bytes(), + ) +} + +/// Longest `state` echoed back to a client. +pub const MAX_STATE_LEN: usize = 512; + +/// Marks a client secret, so scanners and people recognize a leaked one. +pub const CLIENT_SECRET_PREFIX: &str = "aacs_"; + +/// The parameters of a form-encoded request, refusing a repeated parameter +/// (RFC 6749 section 3.1) and dropping empty values, which count as absent. +pub fn form_parameters(body: &[u8]) -> Result, String> { + let mut parameters: Vec<(String, String)> = Vec::new(); + for (name, value) in form_urlencoded::parse(body) { + if parameters.iter().any(|(seen, _)| *seen == name) { + return Err(format!("{name} is repeated")); + } + if !value.is_empty() { + parameters.push((name.into_owned(), value.into_owned())); + } + } + Ok(parameters) +} + +/// The value of `name` among `parameters`. +pub fn parameter<'a>(parameters: &'a [(String, String)], name: &str) -> Option<&'a str> { + parameters + .iter() + .find(|(candidate, _)| candidate == name) + .map(|(_, value)| value.as_str()) +} + +/// A `scope` parameter: space-separated tokens of printable ASCII other than +/// `"` and `\` (RFC 6749 section 3.3), sorted and deduplicated. `Ok(None)` when +/// absent or blank. +pub fn parse_scope(scope: Option<&str>) -> Result>, String> { + let Some(scope) = scope.map(str::trim).filter(|s| !s.is_empty()) else { + return Ok(None); + }; + let mut scopes = Vec::new(); + for token in scope.split(' ').filter(|t| !t.is_empty()) { + if !token + .bytes() + .all(|b| b == 0x21 || (0x23..=0x5b).contains(&b) || (0x5d..=0x7e).contains(&b)) + { + return Err("scope holds a character outside RFC 6749 scope tokens".into()); + } + scopes.push(token.to_owned()); + } + scopes.sort(); + scopes.dedup(); + Ok(Some(scopes)) +} + +/// The scopes a request asks for, checked against the client's registration: +/// a restricted client may ask for a subset of its scopes only, plus the +/// OpenID Connect scopes, which any client may ask for. Without a +/// `scope` parameter the client's scopes apply (`None`: unrestricted). +pub fn requested_scopes( + requested: Option>, + client_scopes: &[String], +) -> Result>, Vec> { + match requested { + None => Ok((!client_scopes.is_empty()).then(|| client_scopes.to_vec())), + Some(requested) if client_scopes.is_empty() => Ok(Some(requested)), + Some(requested) => { + let outside: Vec = requested + .iter() + .filter(|scope| { + !super::oidc::is_oidc_scope(scope) && !client_scopes.contains(scope) + }) + .cloned() + .collect(); + if outside.is_empty() { + Ok(Some(requested)) + } else { + Err(outside) + } + } + } +} + +/// Client credentials from an `Authorization: Basic` header: the client id and +/// secret, each form-urlencoded before being joined (RFC 6749 section 2.3.1). +pub fn basic_credentials(header: &str) -> Option<(String, String)> { + let encoded = header + .strip_prefix("Basic ") + .or_else(|| header.strip_prefix("basic "))?; + let decoded = String::from_utf8(B64.decode(encoded.trim()).ok()?).ok()?; + let (id, secret) = decoded.split_once(':')?; + let unescape = |s: &str| { + percent_decode_str(&s.replace('+', " ")) + .decode_utf8() + .ok() + .map(|v| v.into_owned()) + }; + let (id, secret) = (unescape(id)?, unescape(secret)?); + (!id.is_empty()).then_some((id, secret)) +} + +/// A new client secret as handed to the administrator. +pub fn format_client_secret(random: &str) -> String { + format!("{CLIENT_SECRET_PREFIX}{random}") +} + +/// The redirect carrying an authorization response or error: the parameters +/// appended after any query the registered redirect URI already has. +pub fn redirect_with(redirect_uri: &str, parameters: &[(&str, &str)]) -> Option { + let mut url = reqwest::Url::parse(redirect_uri).ok()?; + { + let mut query = url.query_pairs_mut(); + for (name, value) in parameters { + query.append_pair(name, value); + } + } + Some(url.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn form_parameters_refuse_repetition_and_drop_empty_values() { + assert_eq!( + form_parameters(b"grant_type=refresh_token&scope=a+b&empty="), + Ok(vec![ + ("grant_type".to_owned(), "refresh_token".to_owned()), + ("scope".to_owned(), "a b".to_owned()), + ]) + ); + assert!(form_parameters(b"code=1&code=2").is_err()); + } + + #[test] + fn scopes_are_rfc_6749_tokens() { + assert_eq!( + parse_scope(Some(" docs:read audit:read docs:read ")), + Ok(Some(vec!["audit:read".to_owned(), "docs:read".to_owned()])) + ); + assert_eq!(parse_scope(Some(" ")), Ok(None)); + assert_eq!(parse_scope(None), Ok(None)); + assert!(parse_scope(Some("docs:\"read\"")).is_err()); + assert!(parse_scope(Some("caf\u{e9}")).is_err()); + } + + #[test] + fn a_restricted_client_asks_for_a_subset_of_its_scopes() { + let client = vec!["audit:read".to_owned(), "docs:read".to_owned()]; + assert_eq!(requested_scopes(None, &client), Ok(Some(client.clone()))); + assert_eq!(requested_scopes(None, &[]), Ok(None)); + assert_eq!( + requested_scopes(Some(vec!["docs:read".into()]), &client), + Ok(Some(vec!["docs:read".to_owned()])) + ); + assert_eq!( + requested_scopes(Some(vec!["users:manage".into()]), &client), + Err(vec!["users:manage".to_owned()]) + ); + assert_eq!( + requested_scopes(Some(vec!["openid".into(), "docs:read".into()]), &client), + Ok(Some(vec!["openid".to_owned(), "docs:read".to_owned()])) + ); + assert_eq!( + requested_scopes(Some(vec!["users:manage".into()]), &[]), + Ok(Some(vec!["users:manage".to_owned()])) + ); + } + + #[test] + fn basic_credentials_are_form_decoded() { + let header = format!("Basic {}", B64.encode("my%20app:s%3Acret+x")); + assert_eq!( + basic_credentials(&header), + Some(("my app".to_owned(), "s:cret x".to_owned())) + ); + assert_eq!(basic_credentials("Bearer abc"), None); + assert_eq!(basic_credentials("Basic !!!"), None); + assert_eq!( + basic_credentials(&format!("Basic {}", B64.encode(":secret"))), + None + ); + } + + #[test] + fn a_client_subject_is_stable_and_never_a_user_id() { + let subject = client_subject("https://auth.example.com/", "backend"); + assert_eq!( + subject, + client_subject("https://auth.example.com", "backend") + ); + assert_ne!(subject, client_subject("https://auth.example.com", "other")); + assert_eq!(subject.get_version_num(), 5); + } + + #[test] + fn redirects_keep_the_registered_query() { + assert_eq!( + redirect_with( + "https://app.example.com/cb?tenant=acme", + &[("code", "a b"), ("state", "x&y")] + ) + .as_deref(), + Some("https://app.example.com/cb?tenant=acme&code=a+b&state=x%26y") + ); + assert_eq!(redirect_with("not a url", &[]), None); + } +} diff --git a/src/domain/oidc.rs b/src/domain/oidc.rs new file mode 100644 index 0000000..10eb0cb --- /dev/null +++ b/src/domain/oidc.rs @@ -0,0 +1,132 @@ +//! OpenID Connect: the scopes that are about identity rather than permissions, +//! and the claims an ID token and the UserInfo endpoint carry. + +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD as B64URL}; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use super::user::User; + +pub const SCOPE_OPENID: &str = "openid"; +pub const SCOPE_PROFILE: &str = "profile"; +pub const SCOPE_EMAIL: &str = "email"; + +/// Scopes defined by OpenID Connect. They are not permissions: any client may +/// ask for them, and they never grant access to a resource. +pub const SCOPES: [&str; 3] = [SCOPE_OPENID, SCOPE_PROFILE, SCOPE_EMAIL]; + +pub fn is_oidc_scope(scope: &str) -> bool { + SCOPES.contains(&scope) +} + +/// Whether a session's scopes make it an OpenID Connect session. +pub fn requests_identity(scopes: Option<&[String]>) -> bool { + scopes.is_some_and(|scopes| scopes.iter().any(|s| s == SCOPE_OPENID)) +} + +/// OIDC Core 3.1.3.6: the base64url of the left half of the SHA-256 of the +/// access token. +pub fn at_hash(access_token: &str) -> String { + let digest = Sha256::digest(access_token.as_bytes()); + B64URL.encode(&digest[..16]) +} + +/// Claims of an ID token. +#[derive(Debug, Serialize)] +pub struct IdTokenClaims { + pub iss: String, + pub sub: Uuid, + pub aud: String, + pub azp: String, + pub exp: i64, + pub iat: i64, + pub auth_time: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub nonce: Option, + pub at_hash: String, + #[serde(flatten)] + pub profile: UserClaims, +} + +/// The claims the granted scopes release about the user. +#[derive(Debug, Default, Serialize, PartialEq)] +pub struct UserClaims { + #[serde(skip_serializing_if = "Option::is_none")] + pub preferred_username: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub locale: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub email_verified: Option, +} + +pub fn user_claims(user: &User, scopes: &[String]) -> UserClaims { + let has = |scope: &str| scopes.iter().any(|s| s == scope); + let mut claims = UserClaims::default(); + if has(SCOPE_PROFILE) { + claims.preferred_username = Some(user.username.clone()); + claims.locale = Some(user.preferred_locale.clone()); + claims.updated_at = Some(user.updated_at.unix_timestamp()); + } + if has(SCOPE_EMAIL) { + claims.email = Some(user.email.clone()); + claims.email_verified = Some(user.email_verified_at.is_some()); + } + claims +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::user::UserStatus; + use time::OffsetDateTime; + + fn user() -> User { + User { + id: Uuid::nil(), + created_at: OffsetDateTime::UNIX_EPOCH, + updated_at: OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(10), + email_verified_at: Some(OffsetDateTime::UNIX_EPOCH), + last_login_at: None, + locked_until: None, + status: UserStatus::Active, + preferred_locale: "fr".into(), + username: "jane".into(), + email: "jane@example.com".into(), + password_hash: String::new(), + } + } + + #[test] + fn scopes_release_their_claims_only() { + let scopes = |list: &[&str]| list.iter().map(|s| s.to_string()).collect::>(); + assert_eq!( + user_claims(&user(), &scopes(&["openid"])), + UserClaims::default() + ); + let profile = user_claims(&user(), &scopes(&["openid", "profile"])); + assert_eq!(profile.preferred_username.as_deref(), Some("jane")); + assert_eq!(profile.updated_at, Some(10)); + assert!(profile.email.is_none()); + let email = user_claims(&user(), &scopes(&["email"])); + assert_eq!(email.email.as_deref(), Some("jane@example.com")); + assert_eq!(email.email_verified, Some(true)); + assert!(email.preferred_username.is_none()); + } + + #[test] + fn at_hash_is_the_left_half_of_the_sha256() { + // OIDC Core example, recomputed: 16 bytes, base64url without padding. + let hash = at_hash("jHkWEdUXMU1BwAsC4vtUsZwnNvTIxEl0z9K3vx5KF0Y"); + assert_eq!(hash.len(), 22); + assert!(!hash.contains('=')); + assert!(requests_identity(Some(&["openid".to_owned()]))); + assert!(!requests_identity(Some(&["docs:read".to_owned()]))); + assert!(!requests_identity(None)); + assert!(is_oidc_scope("email") && !is_oidc_scope("users:read")); + } +} diff --git a/src/domain/outbox.rs b/src/domain/outbox.rs new file mode 100644 index 0000000..de362d7 --- /dev/null +++ b/src/domain/outbox.rs @@ -0,0 +1,96 @@ +//! Decisions of the event outbox relay: when to retry a publication, and the +//! message an event becomes. + +use std::time::Duration; + +use serde_json::Value; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; +use uuid::Uuid; + +/// Delay before the second attempt. +pub const FIRST_RETRY: Duration = Duration::from_secs(1); +/// Longest delay between two attempts. +pub const MAX_RETRY: Duration = Duration::from_secs(60); + +/// Delay before the next attempt after `failed_attempts` failures: one second, +/// doubling up to a minute. The broker is usually back within seconds; a longer +/// outage is retried every minute rather than hammered. +pub fn retry_delay(failed_attempts: i32) -> Duration { + if failed_attempts <= 0 { + return Duration::ZERO; + } + let doublings = (failed_attempts - 1).min(6).unsigned_abs(); + FIRST_RETRY + .saturating_mul(2u32.pow(doublings)) + .min(MAX_RETRY) +} + +/// The published message: the event's payload with its identity, so consumers +/// can deduplicate (`event_id`) and date what they receive (`occurred_at`, when +/// the transaction of the change started). A payload that is not an object is +/// wrapped under `data`. +pub fn envelope(payload: Value, event_id: Uuid, occurred_at: OffsetDateTime) -> Value { + let mut object = match payload { + Value::Object(object) => object, + other => { + let mut object = serde_json::Map::new(); + object.insert("data".to_owned(), other); + object + } + }; + object.insert("event_id".to_owned(), Value::String(event_id.to_string())); + object.insert( + "occurred_at".to_owned(), + Value::String(occurred_at.format(&Rfc3339).unwrap_or_default()), + ); + Value::Object(object) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn retries_double_from_one_second_up_to_a_minute() { + for (failures, secs) in [ + (i32::MIN, 0), + (0, 0), + (1, 1), + (2, 2), + (3, 4), + (6, 32), + (7, 60), + (i32::MAX, 60), + ] { + assert_eq!( + retry_delay(failures), + Duration::from_secs(secs), + "{failures} failures" + ); + } + } + + #[test] + fn the_envelope_adds_the_event_identity_to_the_payload() { + let id = Uuid::nil(); + let at = OffsetDateTime::UNIX_EPOCH; + let message = envelope(json!({ "user_id": "u" }), id, at); + assert_eq!( + message, + json!({ + "user_id": "u", + "event_id": "00000000-0000-0000-0000-000000000000", + "occurred_at": "1970-01-01T00:00:00Z", + }) + ); + } + + #[test] + fn a_payload_that_is_not_an_object_is_wrapped() { + let message = envelope(json!([1, 2]), Uuid::nil(), OffsetDateTime::UNIX_EPOCH); + assert_eq!(message["data"], json!([1, 2])); + assert!(message["event_id"].is_string()); + } +} diff --git a/src/domain/personal_access_token.rs b/src/domain/personal_access_token.rs new file mode 100644 index 0000000..58ffe66 --- /dev/null +++ b/src/domain/personal_access_token.rs @@ -0,0 +1,108 @@ +//! Personal access tokens: what a token looks like, and what an account may put +//! in one. + +use time::OffsetDateTime; +use uuid::Uuid; + +/// Marks the secret as this service's personal access token, so secret +/// scanners and people recognize it in a leaked file. +pub const PREFIX: &str = "aapat_"; + +/// Longest lifetime an account may give a token. +pub const MAX_LIFETIME_DAYS: i64 = 365; + +/// Lifetime when the request names none. +pub const DEFAULT_LIFETIME_DAYS: i64 = 90; + +/// Active tokens per account. +pub const MAX_ACTIVE_PER_ACCOUNT: i64 = 20; + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct PersonalAccessToken { + pub id: Uuid, + pub user_id: Uuid, + pub session_id: Uuid, + pub name: String, + pub scopes: Vec, + pub created_at: OffsetDateTime, + pub expires_at: OffsetDateTime, + pub last_used_at: Option, +} + +/// The secret handed to the account: the prefix and 32 random bytes. +pub fn format_token(random: &str) -> String { + format!("{PREFIX}{random}") +} + +/// The random part of a presented token, when it has the shape of one: the +/// prefix, then 43 URL-safe base64 characters. +pub fn random_part(presented: &str) -> Option<&str> { + let random = presented.strip_prefix(PREFIX)?; + (random.len() == 43 + && random + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')) + .then_some(random) +} + +/// The requested scopes, sorted and deduplicated, when the account holds every +/// one of them; otherwise the ones it does not hold. +pub fn grantable_scopes(requested: &[String], held: &[String]) -> Result, Vec> { + let mut scopes = requested.to_vec(); + scopes.sort(); + scopes.dedup(); + let missing: Vec = scopes + .iter() + .filter(|scope| !held.contains(scope)) + .cloned() + .collect(); + if missing.is_empty() { + Ok(scopes) + } else { + Err(missing) + } +} + +/// A token name: 1 to 100 characters once trimmed, no control character. +pub fn valid_name(name: &str) -> Option<&str> { + let name = name.trim(); + ((1..=100).contains(&name.chars().count()) && !name.chars().any(char::is_control)) + .then_some(name) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_token_is_the_prefix_and_its_random_part() { + let random = "a".repeat(43); + let token = format_token(&random); + assert_eq!(random_part(&token), Some(random.as_str())); + assert_eq!(random_part(&random), None); + assert_eq!(random_part(&format!("{PREFIX}{}", "a".repeat(42))), None); + assert_eq!(random_part(&format!("{PREFIX}{}!", "a".repeat(42))), None); + } + + #[test] + fn scopes_are_limited_to_the_permissions_held() { + let held = vec!["profile:read".to_owned(), "users:read".to_owned()]; + assert_eq!( + grantable_scopes(&["users:read".into(), "users:read".into()], &held), + Ok(vec!["users:read".to_owned()]) + ); + assert_eq!( + grantable_scopes(&["users:manage".into()], &held), + Err(vec!["users:manage".to_owned()]) + ); + assert_eq!(grantable_scopes(&[], &held), Ok(vec![])); + } + + #[test] + fn names_are_trimmed_and_bounded() { + assert_eq!(valid_name(" deploy script "), Some("deploy script")); + assert_eq!(valid_name(" "), None); + assert_eq!(valid_name(&"n".repeat(101)), None); + assert_eq!(valid_name("tab\there"), None); + } +} diff --git a/src/domain/pwned.rs b/src/domain/pwned.rs new file mode 100644 index 0000000..403d5dc --- /dev/null +++ b/src/domain/pwned.rs @@ -0,0 +1,100 @@ +//! Breached-password check through the Pwned Passwords range API, with +//! k-anonymity: only the first five hexadecimal characters of the password's +//! SHA-1 leave the service, and the match happens here. + +use sha1::{Digest, Sha1}; + +/// The prefix sent to the range API and the suffix looked up in its answer: +/// the uppercase hexadecimal SHA-1 of the password, split after five +/// characters. +pub fn range_key(password: &str) -> (String, String) { + let hex: String = Sha1::digest(password.as_bytes()) + .iter() + .map(|byte| format!("{byte:02X}")) + .collect(); + let (prefix, suffix) = hex.split_at(5); + (prefix.to_owned(), suffix.to_owned()) +} + +/// How many known breaches contain the password, read from a range answer +/// (`SUFFIX:COUNT` per line). Padding entries (count 0), malformed lines and an +/// absent suffix all count as 0. +pub fn breach_count(range: &str, suffix: &str) -> u64 { + range + .lines() + .filter_map(|line| line.trim().split_once(':')) + .find(|(candidate, _)| candidate.eq_ignore_ascii_case(suffix)) + .and_then(|(_, count)| count.trim().parse::().ok()) + .unwrap_or(0) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Verdict { + Accepted, + /// The password appears in at least one known breach. + Compromised, + /// The range API gave no usable answer and the check does not fail open. + Unavailable, +} + +/// `count` is `None` when the range API could not be asked. +pub fn verdict(count: Option, fail_open: bool) -> Verdict { + match count { + Some(0) => Verdict::Accepted, + Some(_) => Verdict::Compromised, + None if fail_open => Verdict::Accepted, + None => Verdict::Unavailable, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_range_key_splits_the_uppercase_sha1() { + // SHA-1("password") = 5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8 + assert_eq!( + range_key("password"), + ( + "5BAA6".to_owned(), + "1E4C9B93F3F0682250B6CF8331B7EE68FD8".to_owned() + ) + ); + } + + #[test] + fn the_count_of_the_matching_suffix_is_read() { + let range = "0018A45C4D1DEF81644B54AB7F969B88D65:1\r\n\ + 1E4C9B93F3F0682250B6CF8331B7EE68FD8:9659365\r\n\ + 011053FD0102E94D6AE2F8B83D76FAF94F6:0\r\n"; + assert_eq!( + breach_count(range, "1E4C9B93F3F0682250B6CF8331B7EE68FD8"), + 9_659_365 + ); + assert_eq!( + breach_count(range, "1e4c9b93f3f0682250b6cf8331b7ee68fd8"), + 9_659_365 + ); + } + + #[test] + fn padding_malformed_lines_and_absent_suffixes_count_as_zero() { + let range = "011053FD0102E94D6AE2F8B83D76FAF94F6:0\nnot a line\nABC:many\n"; + assert_eq!( + breach_count(range, "011053FD0102E94D6AE2F8B83D76FAF94F6"), + 0 + ); + assert_eq!(breach_count(range, "ABC"), 0); + assert_eq!(breach_count(range, "FFFFF"), 0); + assert_eq!(breach_count("", "FFFFF"), 0); + } + + #[test] + fn an_unavailable_range_api_fails_open_only_when_configured() { + assert_eq!(verdict(Some(0), false), Verdict::Accepted); + assert_eq!(verdict(Some(3), true), Verdict::Compromised); + assert_eq!(verdict(None, true), Verdict::Accepted); + assert_eq!(verdict(None, false), Verdict::Unavailable); + } +} diff --git a/src/domain/rate_limit.rs b/src/domain/rate_limit.rs new file mode 100644 index 0000000..e7463d2 --- /dev/null +++ b/src/domain/rate_limit.rs @@ -0,0 +1,81 @@ +//! Rate limiter verdicts, apart from the Redis script that counts requests. + +/// What the limiter answered for a request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LimiterAnswer { + /// Under every limit. + Clear, + /// Over a limit, which frees a slot in `ms` milliseconds. + Wait { ms: u64 }, + /// The limiter could not be asked. + Unreachable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RateLimitVerdict { + Allow, + Refuse { retry_after_secs: u64 }, + Unavailable, +} + +pub fn rate_limit_verdict(answer: LimiterAnswer, fail_open: bool) -> RateLimitVerdict { + match answer { + LimiterAnswer::Clear => RateLimitVerdict::Allow, + LimiterAnswer::Wait { ms } => RateLimitVerdict::Refuse { + retry_after_secs: retry_after_secs(ms), + }, + LimiterAnswer::Unreachable if fail_open => RateLimitVerdict::Allow, + LimiterAnswer::Unreachable => RateLimitVerdict::Unavailable, + } +} + +/// `Retry-After` in whole seconds: rounded up, so a client waiting that long +/// finds a free slot, and never 0, which would invite an immediate retry. +pub fn retry_after_secs(wait_ms: u64) -> u64 { + wait_ms.div_ceil(1000).max(1) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn retry_after_rounds_up_to_a_whole_second() { + for (wait_ms, secs) in [ + (0, 1), + (1, 1), + (999, 1), + (1000, 1), + (1001, 2), + (59_999, 60), + (u64::MAX, u64::MAX.div_ceil(1000)), + ] { + assert_eq!(retry_after_secs(wait_ms), secs, "{wait_ms} ms"); + } + } + + #[test] + fn an_answer_decides_and_only_an_outage_follows_the_fallback() { + for fail_open in [false, true] { + assert_eq!( + rate_limit_verdict(LimiterAnswer::Clear, fail_open), + RateLimitVerdict::Allow + ); + assert_eq!( + rate_limit_verdict(LimiterAnswer::Wait { ms: 1500 }, fail_open), + RateLimitVerdict::Refuse { + retry_after_secs: 2 + }, + "a refusal is never waived" + ); + } + assert_eq!( + rate_limit_verdict(LimiterAnswer::Unreachable, true), + RateLimitVerdict::Allow + ); + assert_eq!( + rate_limit_verdict(LimiterAnswer::Unreachable, false), + RateLimitVerdict::Unavailable + ); + } +} diff --git a/src/domain/registered_client.rs b/src/domain/registered_client.rs index d9cafca..a42c2df 100644 --- a/src/domain/registered_client.rs +++ b/src/domain/registered_client.rs @@ -1,14 +1,205 @@ //! Registered client domain type. //! -//! Maps the `registered_clients` table. Represents a known client application -//! that can authenticate users via the device authorization flow. +//! Maps the `registered_clients` table: a known client application allowed to +//! sign users in through the device authorization flow or the authorization +//! code flow. Requests naming an unregistered client are refused. use time::OffsetDateTime; +use super::client_quota::UserClientQuota; + #[derive(Debug, Clone, sqlx::FromRow)] pub struct RegisteredClient { pub client_id: String, pub display_name: String, + /// The application this instance owns; used when a device flow names no client. pub is_primary: bool, pub created_at: OffsetDateTime, + /// Permissions a token issued for this client may carry, by `resource:action` + /// name. Empty means unrestricted. + pub scopes: Vec, + /// Exact redirect URIs accepted for the authorization code flow. + pub redirect_uris: Vec, + /// Whether a loopback redirect on any port is accepted for a registered path. + pub allows_loopback_redirect: bool, + /// Concurrent device sessions per user when no quota row overrides it. + pub default_max_sessions: i16, + /// SHA-256 of the client secret of a confidential client; `None` for a + /// public client. + pub client_secret_hash: Option>, + /// May obtain tokens for itself with the client credentials grant. + pub allows_client_credentials: bool, +} + +impl RegisteredClient { + /// Whether the client authenticates with a secret at the token endpoint. + pub fn is_confidential(&self) -> bool { + self.client_secret_hash.is_some() + } + + /// Concurrent device sessions a user may hold for this client. + /// + /// A per-user quota row always applies. Without one, a non-primary client is + /// capped by its default, while the primary client (the application this + /// instance owns) is unlimited. + pub fn session_limit(&self, quota: Option<&UserClientQuota>) -> Option { + match quota { + Some(quota) => Some(i64::from(quota.max_sessions)), + None if self.is_primary => None, + None => Some(i64::from(self.default_max_sessions)), + } + } + + /// Permissions a token for this client carries, given the user's own: the + /// intersection with the client's scopes, or everything the user holds when + /// the client is unrestricted. + pub fn granted(&self, user_permissions: &[String]) -> Vec { + if self.scopes.is_empty() { + return user_permissions.to_vec(); + } + user_permissions + .iter() + .filter(|permission| self.scopes.contains(permission)) + .cloned() + .collect() + } + + /// The scopes an approval freezes: what the user holds among the client's + /// scopes, or `None` for an unrestricted client. `Some` of an empty list is + /// a real answer, a token carrying no permission, never "unrestricted". + pub fn consented_scopes(&self, user_permissions: &[String]) -> Option> { + (!self.scopes.is_empty()).then(|| self.granted(user_permissions)) + } +} + +/// The claims of a token: the user's roles and permissions, restricted to +/// `consent` when its session was issued to a client. Roles are dropped then: +/// a resource server authorizing by role would grant more than the consent +/// covered. `Some(&[])` consents to nothing. +pub fn restrict_to_consent( + roles: Vec, + mut permissions: Vec, + consent: Option<&[String]>, +) -> (Vec, Vec) { + match consent { + None => (roles, permissions), + Some(consent) => { + permissions.retain(|permission| consent.contains(permission)); + (Vec::new(), permissions) + } + } +} + +/// Whether `client_id` has the shape the `registered_clients` table accepts. +pub fn is_valid_client_id(client_id: &str) -> bool { + (1..=100).contains(&client_id.len()) + && client_id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b)) +} + +/// Check client settings before they are stored, with a message for the caller. +pub fn check_settings( + client_id: &str, + display_name: &str, + redirect_uris: &[String], + default_max_sessions: i16, +) -> Result<(), String> { + if !is_valid_client_id(client_id) { + return Err("client id must be 1 to 100 of [A-Za-z0-9._-]".into()); + } + if display_name.trim().is_empty() || display_name.chars().count() > 200 { + return Err("name must be 1 to 200 characters".into()); + } + for uri in redirect_uris { + reqwest::Url::parse(uri).map_err(|e| format!("invalid redirect uri {uri}: {e}"))?; + } + if default_max_sessions <= 0 { + return Err("max sessions must be a positive number".into()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn client(is_primary: bool, scopes: &[&str]) -> RegisteredClient { + RegisteredClient { + client_id: "app".into(), + display_name: "App".into(), + is_primary, + created_at: OffsetDateTime::now_utc(), + scopes: scopes.iter().map(|s| s.to_string()).collect(), + redirect_uris: vec![], + allows_loopback_redirect: false, + default_max_sessions: 2, + client_secret_hash: None, + allows_client_credentials: false, + } + } + + #[test] + fn session_limit_prefers_quota_then_default_and_leaves_primary_unlimited() { + let quota = UserClientQuota { + id: uuid::Uuid::new_v4(), + user_id: uuid::Uuid::new_v4(), + client_id: "app".into(), + max_sessions: 7, + created_at: OffsetDateTime::now_utc(), + updated_at: OffsetDateTime::now_utc(), + }; + assert_eq!(client(false, &[]).session_limit(Some("a)), Some(7)); + assert_eq!(client(false, &[]).session_limit(None), Some(2)); + assert_eq!(client(true, &[]).session_limit(None), None); + assert_eq!(client(true, &[]).session_limit(Some("a)), Some(7)); + } + + #[test] + fn granted_is_an_intersection_unless_unrestricted() { + let user = vec!["users:read".to_string(), "users:manage".to_string()]; + assert_eq!( + client(false, &["users:read", "other:x"]).granted(&user), + vec!["users:read"] + ); + assert_eq!(client(false, &[]).granted(&user), user); + } + + #[test] + fn only_a_restricted_client_freezes_scopes() { + let user = vec!["users:read".to_string(), "users:manage".to_string()]; + assert_eq!(client(false, &[]).consented_scopes(&user), None); + assert_eq!( + client(false, &["users:read"]).consented_scopes(&user), + Some(vec!["users:read".to_string()]) + ); + assert_eq!( + client(false, &["other:x"]).consented_scopes(&user), + Some(vec![]), + "consenting to scopes the user lacks grants nothing, not everything" + ); + } + + #[test] + fn a_consent_keeps_its_permissions_and_drops_every_role() { + let roles = vec!["admin".to_string()]; + let permissions = vec!["users:read".to_string(), "users:manage".to_string()]; + + assert_eq!( + restrict_to_consent(roles.clone(), permissions.clone(), None), + (roles.clone(), permissions.clone()) + ); + assert_eq!( + restrict_to_consent( + roles.clone(), + permissions.clone(), + Some(&["users:read".to_string()]) + ), + (vec![], vec!["users:read".to_string()]) + ); + assert_eq!( + restrict_to_consent(roles, permissions, Some(&[])), + (vec![], vec![]) + ); + } } diff --git a/src/domain/role.rs b/src/domain/role.rs index e6db955..f754b05 100644 --- a/src/domain/role.rs +++ b/src/domain/role.rs @@ -34,3 +34,53 @@ pub struct UserRole { pub granted_by: Option, pub granted_at: OffsetDateTime, } + +/// Permissions of the administration routes. A token carrying none of them +/// cannot reach `/admin`. +pub const ADMIN_PERMISSIONS: [&str; 6] = [ + "users:read", + "users:manage", + "roles:manage", + "clients:manage", + "audit:read", + "webhooks:manage", +]; + +pub fn is_admin_permission(permission: &str) -> bool { + ADMIN_PERMISSIONS.contains(&permission) +} + +/// The permission that manages roles: at least one account must keep it, or +/// nobody could grant anything again without the command line. +pub const ROLES_MANAGE: &str = "roles:manage"; + +/// Role names: lower-case ASCII letters, digits and underscores, starting with a +/// letter, 2 to 50 characters. +pub fn is_valid_role_name(name: &str) -> bool { + (2..=50).contains(&name.len()) + && name.as_bytes()[0].is_ascii_lowercase() + && name + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_') +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn role_names_are_lower_case_identifiers() { + for valid in ["admin", "support_2", "ab"] { + assert!(is_valid_role_name(valid), "{valid}"); + } + for invalid in ["", "a", "Admin", "2fa", "_x", "sup-port", &"a".repeat(51)] { + assert!(!is_valid_role_name(invalid), "{invalid}"); + } + } + + #[test] + fn administrative_permissions_are_recognized() { + assert!(is_admin_permission("users:read")); + assert!(!is_admin_permission("profile:read")); + } +} diff --git a/src/domain/session.rs b/src/domain/session.rs index 5e4bb6d..5eb71a7 100644 --- a/src/domain/session.rs +++ b/src/domain/session.rs @@ -3,17 +3,49 @@ //! Maps the `sessions` table. token_hash is a SHA-256 digest of the raw //! token; the plaintext is never persisted. +use std::net::IpAddr; + use ipnetwork::IpNetwork; use time::OffsetDateTime; use uuid::Uuid; -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, sqlx::Type)] +/// Longest device label the sessions table stores (`VARCHAR(100)`). +pub const DEVICE_NAME_MAX_CHARS: usize = 100; + +/// Normalize a client-supplied device name: control characters removed, +/// whitespace trimmed, at most `DEVICE_NAME_MAX_CHARS` characters. `None` when +/// nothing is left, so an unusable label never reaches the database. +pub fn device_label(raw: &str) -> Option { + let cleaned: String = raw.chars().filter(|c| !c.is_control()).collect(); + let label: String = cleaned.trim().chars().take(DEVICE_NAME_MAX_CHARS).collect(); + let label = label.trim_end(); + (!label.is_empty()).then(|| label.to_owned()) +} + +/// When a session issued or rotated at `now` expires: its refresh lifetime, +/// cut short by the absolute lifetime of the sign-in that started at +/// `family_started_at`. A rotation never dates a session past the moment the +/// refresh would refuse it anyway, so session listings and revocation TTLs +/// stay exact. +pub fn capped_expiry( + now: OffsetDateTime, + ttl_secs: u64, + family_started_at: OffsetDateTime, + max_lifetime_secs: u64, +) -> OffsetDateTime { + let seconds = |secs: u64| time::Duration::seconds(i64::try_from(secs).unwrap_or(i64::MAX)); + now.saturating_add(seconds(ttl_secs)) + .min(family_started_at.saturating_add(seconds(max_lifetime_secs))) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, sqlx::Type, utoipa::ToSchema)] #[sqlx(type_name = "session_type", rename_all = "snake_case")] #[serde(rename_all = "snake_case")] pub enum SessionType { Web, Device, + PersonalAccessToken, } #[derive(Debug, Clone, PartialEq, sqlx::Type)] @@ -32,6 +64,13 @@ pub struct Session { pub last_used_at: OffsetDateTime, pub expires_at: OffsetDateTime, pub created_at: OffsetDateTime, + /// When the family's first session was created: the start of the sign-in + /// that every rotation inherits. The absolute lifetime is measured from it. + pub family_created_at: OffsetDateTime, + /// Permissions consented for the client this session was issued to. Tokens + /// carry the user's permissions restricted to these; `None` is unrestricted + /// (password sign-in, or a client registered without scopes). + pub scopes: Option>, pub revoked_at: Option, pub rotated_at: Option, pub compromised_at: Option, @@ -48,21 +87,118 @@ pub struct Session { } impl Session { - pub fn is_active(&self) -> bool { - self.revoked_at.is_none() && self.expires_at > OffsetDateTime::now_utc() + pub fn is_active(&self, now: OffsetDateTime) -> bool { + self.revoked_at.is_none() && self.expires_at > now } pub fn is_compromised(&self) -> bool { self.compromised_at.is_some() } + + /// True when this session was rotated within `grace` and not for a + /// compromise: presenting its token again is then a concurrent refresh + /// from the same client (two tabs, a retried request), not a replay. + pub fn rotated_within(&self, grace: time::Duration, now: OffsetDateTime) -> bool { + self.compromised_at.is_none() + && self + .rotated_at + .is_some_and(|rotated| now - rotated <= grace) + } +} + +/// What a refresh token presented for a session leads to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RefreshVerdict { + /// Rotate the session. + Rotate, + /// Revoked by a rotation moments ago: the same client refreshing twice. + /// Refused, the family left alive. + ConcurrentRefresh, + /// Revoked earlier: the token leaked, and the family is revoked. + Replay, + /// Past its expiry, or past the absolute lifetime of its sign-in. + Expired, + /// Presented from another address while sessions are bound to theirs. + AddressMismatch, +} + +/// The rules a refresh is judged by. +#[derive(Debug, Clone, Copy)] +pub struct RefreshPolicy { + /// How long after a rotation a second use reads as concurrent. + pub reuse_grace: time::Duration, + pub max_lifetime_secs: u64, + pub strict_binding: bool, +} + +impl Session { + /// Judge a refresh presented at `now` from `request_ip`. + /// + /// Revocation comes first, so a replayed token is detected even once its + /// session has expired; then the expiry, the absolute lifetime counted from + /// the family's first sign-in, and the address binding. + pub fn refresh_verdict( + &self, + now: OffsetDateTime, + request_ip: Option, + policy: &RefreshPolicy, + ) -> RefreshVerdict { + if self.revoked_at.is_some() { + return if self.rotated_within(policy.reuse_grace, now) { + RefreshVerdict::ConcurrentRefresh + } else { + RefreshVerdict::Replay + }; + } + if !self.is_active(now) { + return RefreshVerdict::Expired; + } + let max_lifetime = i64::try_from(policy.max_lifetime_secs).unwrap_or(i64::MAX); + if (now - self.family_created_at).whole_seconds() >= max_lifetime { + return RefreshVerdict::Expired; + } + if policy.strict_binding && self.ip_address.map(|network| network.ip()) != request_ip { + return RefreshVerdict::AddressMismatch; + } + RefreshVerdict::Rotate + } +} + +/// What the per-request token check learned from Redis. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TokenState { + /// The token's id is on the logout blocklist. + Revoked, + /// The session is cached as active. + Active, + /// The session is cached as ended (revoked or expired). + Ended, + /// Nothing cached: the database decides. + Unknown, +} + +/// Combine the blocklist and the session cache. The blocklist wins: a logout +/// takes effect before the cached validity expires. Only a cached `1` means +/// active; any other cached value reads as ended. +pub fn token_state(blocklisted: bool, cached: Option) -> TokenState { + match (blocklisted, cached) { + (true, _) => TokenState::Revoked, + (false, None) => TokenState::Unknown, + (false, Some(1)) => TokenState::Active, + (false, Some(_)) => TokenState::Ended, + } } #[cfg(test)] mod tests { use super::*; + fn now() -> OffsetDateTime { + OffsetDateTime::UNIX_EPOCH + time::Duration::days(20_000) + } + fn make_session(revoked: bool, expires_in_secs: i64, compromised: bool) -> Session { - let now = OffsetDateTime::now_utc(); + let now = now(); Session { id: uuid::Uuid::new_v4(), user_id: uuid::Uuid::new_v4(), @@ -70,6 +206,8 @@ mod tests { last_used_at: now, expires_at: now + time::Duration::seconds(expires_in_secs), created_at: now, + family_created_at: now, + scopes: None, revoked_at: if revoked { Some(now) } else { None }, rotated_at: None, compromised_at: if compromised { Some(now) } else { None }, @@ -87,17 +225,183 @@ mod tests { #[test] fn is_active_true_when_not_revoked_and_not_expired() { - assert!(make_session(false, 3600, false).is_active()); + assert!(make_session(false, 3600, false).is_active(now())); } #[test] fn is_active_false_when_revoked() { - assert!(!make_session(true, 3600, false).is_active()); + assert!(!make_session(true, 3600, false).is_active(now())); } #[test] fn is_active_false_when_expired() { - assert!(!make_session(false, -1, false).is_active()); + assert!(!make_session(false, -1, false).is_active(now())); + } + + #[test] + fn is_active_ends_at_the_expiry_instant() { + let session = make_session(false, 60, false); + assert!(session.is_active(session.expires_at - time::Duration::nanoseconds(1))); + assert!(!session.is_active(session.expires_at)); + } + + fn policy(strict_binding: bool) -> RefreshPolicy { + RefreshPolicy { + reuse_grace: time::Duration::seconds(2), + max_lifetime_secs: 86_400, + strict_binding, + } + } + + #[test] + fn a_revoked_session_reads_as_concurrent_only_within_the_grace() { + let mut session = make_session(true, 3600, false); + session.rotated_at = Some(now() - time::Duration::seconds(1)); + assert_eq!( + session.refresh_verdict(now(), None, &policy(false)), + RefreshVerdict::ConcurrentRefresh + ); + session.rotated_at = Some(now() - time::Duration::seconds(10)); + assert_eq!( + session.refresh_verdict(now(), None, &policy(false)), + RefreshVerdict::Replay + ); + session.rotated_at = None; + assert_eq!( + session.refresh_verdict(now(), None, &policy(false)), + RefreshVerdict::Replay, + "a logout is not a rotation" + ); + } + + #[test] + fn revocation_is_judged_before_expiry() { + let session = make_session(true, -60, false); + assert_eq!( + session.refresh_verdict(now(), None, &policy(false)), + RefreshVerdict::Replay + ); + } + + #[test] + fn an_expired_or_outlived_session_is_not_rotated() { + assert_eq!( + make_session(false, -1, false).refresh_verdict(now(), None, &policy(false)), + RefreshVerdict::Expired + ); + + let mut session = make_session(false, 3600, false); + session.family_created_at = now() - time::Duration::seconds(86_400); + assert_eq!( + session.refresh_verdict(now(), None, &policy(false)), + RefreshVerdict::Expired, + "the lifetime ends at its last second" + ); + session.family_created_at = now() - time::Duration::seconds(86_399); + assert_eq!( + session.refresh_verdict(now(), None, &policy(false)), + RefreshVerdict::Rotate + ); + } + + #[test] + fn a_bound_session_refreshes_only_from_its_address() { + let mut session = make_session(false, 3600, false); + session.family_created_at = now(); + let home: IpAddr = "203.0.113.7".parse().unwrap(); + let away: IpAddr = "198.51.100.1".parse().unwrap(); + session.ip_address = Some(IpNetwork::from(home)); + + assert_eq!( + session.refresh_verdict(now(), Some(home), &policy(true)), + RefreshVerdict::Rotate + ); + assert_eq!( + session.refresh_verdict(now(), Some(away), &policy(true)), + RefreshVerdict::AddressMismatch + ); + assert_eq!( + session.refresh_verdict(now(), None, &policy(true)), + RefreshVerdict::AddressMismatch + ); + assert_eq!( + session.refresh_verdict(now(), Some(away), &policy(false)), + RefreshVerdict::Rotate + ); + } + + #[test] + fn the_blocklist_wins_over_the_session_cache() { + assert_eq!(token_state(true, Some(1)), TokenState::Revoked); + assert_eq!(token_state(true, None), TokenState::Revoked); + assert_eq!(token_state(false, Some(1)), TokenState::Active); + assert_eq!(token_state(false, Some(0)), TokenState::Ended); + assert_eq!(token_state(false, Some(2)), TokenState::Ended); + assert_eq!(token_state(false, None), TokenState::Unknown); + } + + #[test] + fn a_new_sign_in_expires_with_its_refresh_lifetime() { + assert_eq!( + capped_expiry(now(), 3600, now(), 86_400), + now() + time::Duration::hours(1) + ); + } + + #[test] + fn rotations_never_outlive_the_absolute_lifetime() { + let started = now() - time::Duration::days(2); + let end = started + time::Duration::days(3); + assert_eq!(capped_expiry(now(), 30 * 86_400, started, 3 * 86_400), end); + assert_eq!(capped_expiry(now(), 86_400, started, 3 * 86_400), end); + assert_eq!( + capped_expiry(now(), 86_399, started, 3 * 86_400), + end - time::Duration::seconds(1) + ); + } + + #[test] + fn huge_lifetimes_saturate_instead_of_overflowing() { + assert_eq!( + capped_expiry(now(), u64::MAX, now(), 60), + now() + time::Duration::minutes(1) + ); + assert!(capped_expiry(now(), u64::MAX, now(), u64::MAX) > now()); + } + + #[test] + fn rotated_within_accepts_the_grace_boundary_only() { + let grace = time::Duration::seconds(2); + let mut session = make_session(true, 3600, false); + session.rotated_at = Some(now()); + + assert!(session.rotated_within(grace, now())); + assert!(session.rotated_within(grace, now() + grace)); + assert!(!session.rotated_within(grace, now() + grace + time::Duration::nanoseconds(1))); + } + + #[test] + fn rotated_within_is_false_without_rotation_or_after_compromise() { + let grace = time::Duration::seconds(2); + assert!(!make_session(true, 3600, false).rotated_within(grace, now())); + + let mut compromised = make_session(true, 3600, true); + compromised.rotated_at = Some(now()); + assert!(!compromised.rotated_within(grace, now())); + } + + #[test] + fn device_label_strips_controls_and_bounds_length() { + assert_eq!( + device_label(" My\u{7}Phone \n").as_deref(), + Some("MyPhone") + ); + assert_eq!(device_label("\u{0}\t "), None); + let long = "\u{e9}".repeat(DEVICE_NAME_MAX_CHARS + 20); + assert_eq!( + device_label(&long).unwrap().chars().count(), + DEVICE_NAME_MAX_CHARS + ); } #[test] @@ -109,4 +413,25 @@ mod tests { fn is_compromised_false_when_not_compromised() { assert!(!make_session(false, 3600, false).is_compromised()); } + + mod properties { + use proptest::prelude::*; + + use super::*; + + proptest! { + #![proptest_config(ProptestConfig::with_cases(512))] + + #[test] + fn a_device_label_is_bounded_clean_and_stable(raw in "(\\PC|[\\x00-\\x1f\\x7f])*") { + if let Some(label) = device_label(&raw) { + prop_assert!(!label.is_empty()); + prop_assert!(label.chars().count() <= DEVICE_NAME_MAX_CHARS); + prop_assert!(!label.chars().any(char::is_control)); + prop_assert_eq!(label.trim(), label.as_str()); + prop_assert_eq!(device_label(&label), Some(label.clone()), "not idempotent"); + } + } + } + } } diff --git a/src/domain/token.rs b/src/domain/token.rs index 52be4aa..19cfb42 100644 --- a/src/domain/token.rs +++ b/src/domain/token.rs @@ -33,7 +33,28 @@ pub struct PasswordResetToken { pub request_user_agent: Option, } -// Shared helpers for both token types +/// A sign-in link sent by email. +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct MagicLinkToken { + pub id: Uuid, + pub user_id: Uuid, + pub token_hash: Vec, + pub created_at: OffsetDateTime, + pub expires_at: OffsetDateTime, + pub used_at: Option, + pub request_ip: Option, + pub request_user_agent: Option, +} + +// Shared helpers for every token type + +/// What a submitted one-time token turned out to be. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TokenVerdict { + Valid, + Expired, + Used, +} pub trait OneTimeToken { fn used_at(&self) -> Option; @@ -43,12 +64,23 @@ pub trait OneTimeToken { self.used_at().is_some() } - fn is_expired(&self) -> bool { - self.expires_at() < OffsetDateTime::now_utc() + fn is_expired(&self, now: OffsetDateTime) -> bool { + self.expires_at() < now } - fn is_valid(&self) -> bool { - !self.is_used() && !self.is_expired() + fn is_valid(&self, now: OffsetDateTime) -> bool { + !self.is_used() && !self.is_expired(now) + } + + /// Expiry is reported first: an expired link says so, used or not. + fn verdict(&self, now: OffsetDateTime) -> TokenVerdict { + if self.is_expired(now) { + TokenVerdict::Expired + } else if self.is_used() { + TokenVerdict::Used + } else { + TokenVerdict::Valid + } } } @@ -72,12 +104,26 @@ impl OneTimeToken for PasswordResetToken { } } +impl OneTimeToken for MagicLinkToken { + fn used_at(&self) -> Option { + self.used_at + } + + fn expires_at(&self) -> OffsetDateTime { + self.expires_at + } +} + #[cfg(test)] mod tests { use super::*; + fn now() -> OffsetDateTime { + OffsetDateTime::UNIX_EPOCH + time::Duration::days(20_000) + } + fn make_reset_token(used: bool, expires_in_secs: i64) -> PasswordResetToken { - let now = OffsetDateTime::now_utc(); + let now = now(); PasswordResetToken { id: uuid::Uuid::new_v4(), user_id: uuid::Uuid::new_v4(), @@ -102,26 +148,74 @@ mod tests { #[test] fn is_expired_true_when_past() { - assert!(make_reset_token(false, -1).is_expired()); + assert!(make_reset_token(false, -1).is_expired(now())); } #[test] fn is_expired_false_when_future() { - assert!(!make_reset_token(false, 3600).is_expired()); + assert!(!make_reset_token(false, 3600).is_expired(now())); + } + + #[test] + fn is_expired_only_after_the_expiry_instant() { + let token = make_reset_token(false, 60); + assert!(!token.is_expired(token.expires_at)); + assert!(token.is_expired(token.expires_at + time::Duration::nanoseconds(1))); } #[test] fn is_valid_true_when_unused_and_not_expired() { - assert!(make_reset_token(false, 3600).is_valid()); + assert!(make_reset_token(false, 3600).is_valid(now())); } #[test] fn is_valid_false_when_used() { - assert!(!make_reset_token(true, 3600).is_valid()); + assert!(!make_reset_token(true, 3600).is_valid(now())); } #[test] fn is_valid_false_when_expired() { - assert!(!make_reset_token(false, -1).is_valid()); + assert!(!make_reset_token(false, -1).is_valid(now())); + } + + #[test] + fn verdict_reports_expiry_before_use() { + assert_eq!( + make_reset_token(false, 3600).verdict(now()), + TokenVerdict::Valid + ); + assert_eq!( + make_reset_token(true, 3600).verdict(now()), + TokenVerdict::Used + ); + assert_eq!( + make_reset_token(false, -1).verdict(now()), + TokenVerdict::Expired + ); + assert_eq!( + make_reset_token(true, -1).verdict(now()), + TokenVerdict::Expired + ); + let token = make_reset_token(false, 60); + assert_eq!(token.verdict(token.expires_at), TokenVerdict::Valid); + } + + #[test] + fn email_verification_tokens_follow_the_same_rules() { + let now = now(); + let token = |used: bool| EmailVerificationToken { + id: uuid::Uuid::new_v4(), + user_id: uuid::Uuid::new_v4(), + token_hash: vec![0u8; 32], + created_at: now, + expires_at: now + time::Duration::hours(24), + used_at: used.then_some(now), + request_ip: None, + request_user_agent: None, + target_email: "jane@example.com".into(), + }; + assert!(token(false).is_valid(now)); + assert!(token(true).is_used()); + assert!(!token(true).is_valid(now)); } } diff --git a/src/domain/user.rs b/src/domain/user.rs index 1ef29fc..d272ecc 100644 --- a/src/domain/user.rs +++ b/src/domain/user.rs @@ -31,9 +31,8 @@ pub struct User { } impl User { - pub fn is_locked(&self) -> bool { - self.locked_until - .is_some_and(|t| t > OffsetDateTime::now_utc()) + pub fn is_locked(&self, now: OffsetDateTime) -> bool { + self.locked_until.is_some_and(|t| t > now) } } @@ -47,12 +46,72 @@ impl User { } } +/// Usernames as the `users_username_format` constraint accepts them: ASCII +/// letters, digits and underscores only. Length is checked by the caller. +pub fn is_valid_username(username: &str) -> bool { + !username.is_empty() + && username + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_') +} + +/// Addresses the `users` table can store: ASCII, at most 254 bytes, in the +/// shape of the `users_email_format` constraint (`local@host.tld`). Stricter +/// than RFC 5322 on purpose: whatever passes here must also pass the database +/// CHECK, or the request fails with a 500 instead of a 422. +pub fn is_storable_email(email: &str) -> bool { + if email.len() > 254 || !email.is_ascii() { + return false; + } + let Some((local, domain)) = email.split_once('@') else { + return false; + }; + let Some((host, tld)) = domain.rsplit_once('.') else { + return false; + }; + !local.is_empty() + && local + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"._%+-".contains(&b)) + && !host.is_empty() + && host + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b".-".contains(&b)) + && tld.len() >= 2 + && tld.bytes().all(|b| b.is_ascii_alphabetic()) +} + +/// The `LIKE` pattern matching values that start with `query`, compared in +/// lower case. `%`, `_` and the escape character itself match literally. +pub fn prefix_pattern(query: &str) -> String { + let mut pattern = String::with_capacity(query.len() + 1); + for c in query.trim().to_lowercase().chars() { + if matches!(c, '%' | '_' | '\\') { + pattern.push('\\'); + } + pattern.push(c); + } + pattern.push('%'); + pattern +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn a_search_prefix_matches_wildcards_literally() { + assert_eq!(prefix_pattern(" Alice "), "alice%"); + assert_eq!(prefix_pattern("a_b%c\\"), "a\\_b\\%c\\\\%"); + assert_eq!(prefix_pattern(""), "%"); + } + + fn now() -> OffsetDateTime { + OffsetDateTime::UNIX_EPOCH + time::Duration::days(20_000) + } + fn make_user(status: UserStatus, locked_secs: Option, verified: bool) -> User { - let now = time::OffsetDateTime::now_utc(); + let now = now(); User { id: uuid::Uuid::new_v4(), created_at: now, @@ -70,17 +129,25 @@ mod tests { #[test] fn is_locked_true_when_locked_until_is_in_future() { - assert!(make_user(UserStatus::Active, Some(3600), false).is_locked()); + assert!(make_user(UserStatus::Active, Some(3600), false).is_locked(now())); } #[test] fn is_locked_false_when_locked_until_is_in_past() { - assert!(!make_user(UserStatus::Active, Some(-1), false).is_locked()); + assert!(!make_user(UserStatus::Active, Some(-1), false).is_locked(now())); } #[test] fn is_locked_false_when_no_lockout() { - assert!(!make_user(UserStatus::Active, None, false).is_locked()); + assert!(!make_user(UserStatus::Active, None, false).is_locked(now())); + } + + #[test] + fn lockout_ends_at_locked_until() { + let user = make_user(UserStatus::Active, Some(1800), false); + let until = user.locked_until.unwrap(); + assert!(user.is_locked(until - time::Duration::nanoseconds(1))); + assert!(!user.is_locked(until)); } #[test] @@ -102,4 +169,33 @@ mod tests { fn is_email_verified_false_when_timestamp_missing() { assert!(!make_user(UserStatus::Active, None, false).is_email_verified()); } + + #[test] + fn usernames_must_match_the_database_constraint() { + assert!(is_valid_username("alice_42")); + assert!(!is_valid_username("Jos\u{e9}_1")); + assert!(!is_valid_username("bad-name")); + assert!(!is_valid_username("")); + } + + #[test] + fn storable_emails_match_the_database_constraint() { + assert!(is_storable_email("first.last+tag@mail.example.org")); + assert!(!is_storable_email("user@localhost")); + assert!(!is_storable_email("us\u{e9}r@example.com")); + assert!(!is_storable_email("a@b@example.com")); + assert!(!is_storable_email("user@example.c0m")); + assert!(!is_storable_email(&format!( + "{}@example.com", + "a".repeat(250) + ))); + } + + #[test] + fn storable_emails_stop_at_254_bytes() { + let at_limit = format!("{}@example.com", "a".repeat(242)); + assert_eq!(at_limit.len(), 254); + assert!(is_storable_email(&at_limit)); + assert!(!is_storable_email(&format!("a{at_limit}"))); + } } diff --git a/src/domain/webauthn.rs b/src/domain/webauthn.rs new file mode 100644 index 0000000..86700da --- /dev/null +++ b/src/domain/webauthn.rs @@ -0,0 +1,555 @@ +//! WebAuthn (Level 2) verification for passkeys: client data, authenticator +//! data, COSE public keys and assertion signatures. +//! +//! Attestation statements are not verified: the relying party asks for `none` +//! and trusts a new passkey because the account registering it re-authenticated, +//! not because of the authenticator's make. What is verified is what protects +//! the account: the challenge, the origin, the relying party, user presence and +//! verification, and every assertion signature against the stored key. + +use std::io::Cursor; + +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD as B64URL}; +use ciborium::Value; +use serde::Deserialize; +use sha2::{Digest, Sha256}; + +pub const ALG_ES256: i64 = -7; +pub const ALG_EDDSA: i64 = -8; +pub const ALG_RS256: i64 = -257; + +/// Algorithms offered at registration, preferred first. +pub const ALGORITHMS: [i64; 3] = [ALG_ES256, ALG_EDDSA, ALG_RS256]; + +const FLAG_USER_PRESENT: u8 = 0x01; +const FLAG_USER_VERIFIED: u8 = 0x04; +const FLAG_BACKUP_ELIGIBLE: u8 = 0x08; +const FLAG_BACKED_UP: u8 = 0x10; +const FLAG_ATTESTED: u8 = 0x40; +const FLAG_EXTENSIONS: u8 = 0x80; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WebAuthnError(pub &'static str); + +type Result = std::result::Result; + +fn fail(reason: &'static str) -> Result { + Err(WebAuthnError(reason)) +} + +/// `clientDataJSON`, the part the relying party checks. +#[derive(Debug, Deserialize)] +pub struct ClientData { + #[serde(rename = "type")] + pub kind: String, + pub challenge: String, + pub origin: String, + #[serde(default, rename = "crossOrigin")] + pub cross_origin: bool, +} + +/// Parse `clientDataJSON` and check it answers `challenge` for a ceremony of +/// `kind` (`webauthn.create` or `webauthn.get`) from an allowed origin. +pub fn check_client_data( + json: &[u8], + kind: &str, + challenge: &[u8], + origins: &[String], +) -> Result { + let data: ClientData = + serde_json::from_slice(json).map_err(|_| WebAuthnError("malformed client data"))?; + if data.kind != kind { + return fail("wrong ceremony type"); + } + if B64URL.decode(&data.challenge).ok().as_deref() != Some(challenge) { + return fail("challenge mismatch"); + } + if !origins.contains(&data.origin) { + return fail("origin not allowed"); + } + if data.cross_origin { + return fail("cross-origin ceremony"); + } + Ok(data) +} + +/// The challenge a `clientDataJSON` carries, before anything is checked: it +/// names the ceremony the response belongs to. +pub fn challenge_of(json: &[u8]) -> Result> { + let data: ClientData = + serde_json::from_slice(json).map_err(|_| WebAuthnError("malformed client data"))?; + B64URL + .decode(&data.challenge) + .map_err(|_| WebAuthnError("malformed challenge")) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AttestedCredential { + pub aaguid: [u8; 16], + pub credential_id: Vec, + /// The COSE key, as encoded by the authenticator. + pub public_key: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthenticatorData { + pub rp_id_hash: [u8; 32], + pub flags: u8, + pub sign_count: u32, + pub attested: Option, +} + +impl AuthenticatorData { + pub fn user_present(&self) -> bool { + self.flags & FLAG_USER_PRESENT != 0 + } + pub fn user_verified(&self) -> bool { + self.flags & FLAG_USER_VERIFIED != 0 + } + pub fn backup_eligible(&self) -> bool { + self.flags & FLAG_BACKUP_ELIGIBLE != 0 + } + pub fn backed_up(&self) -> bool { + self.flags & FLAG_BACKED_UP != 0 + } + + /// The checks every ceremony shares: this relying party, a present and + /// verified user. + pub fn check(&self, rp_id: &str) -> Result<()> { + if self.rp_id_hash[..] != Sha256::digest(rp_id.as_bytes())[..] { + return fail("relying party mismatch"); + } + if !self.user_present() { + return fail("user not present"); + } + if !self.user_verified() { + return fail("user not verified"); + } + Ok(()) + } +} + +/// WebAuthn section 6.1. +pub fn parse_authenticator_data(bytes: &[u8]) -> Result { + if bytes.len() < 37 { + return fail("authenticator data too short"); + } + let mut rp_id_hash = [0u8; 32]; + rp_id_hash.copy_from_slice(&bytes[..32]); + let flags = bytes[32]; + let sign_count = u32::from_be_bytes([bytes[33], bytes[34], bytes[35], bytes[36]]); + let mut rest = &bytes[37..]; + + let attested = if flags & FLAG_ATTESTED != 0 { + if rest.len() < 18 { + return fail("attested credential data too short"); + } + let mut aaguid = [0u8; 16]; + aaguid.copy_from_slice(&rest[..16]); + let id_len = usize::from(u16::from_be_bytes([rest[16], rest[17]])); + rest = &rest[18..]; + if id_len == 0 || id_len > 1023 || rest.len() < id_len { + return fail("malformed credential id"); + } + let credential_id = rest[..id_len].to_vec(); + rest = &rest[id_len..]; + let key_len = cbor_item_len(rest)?; + let public_key = rest[..key_len].to_vec(); + rest = &rest[key_len..]; + Some(AttestedCredential { + aaguid, + credential_id, + public_key, + }) + } else { + None + }; + if flags & FLAG_EXTENSIONS != 0 { + let len = cbor_item_len(rest)?; + rest = &rest[len..]; + } + if !rest.is_empty() { + return fail("trailing bytes in authenticator data"); + } + Ok(AuthenticatorData { + rp_id_hash, + flags, + sign_count, + attested, + }) +} + +/// Length of the CBOR item at the start of `bytes`. +fn cbor_item_len(bytes: &[u8]) -> Result { + let mut cursor = Cursor::new(bytes); + let _: Value = + ciborium::de::from_reader(&mut cursor).map_err(|_| WebAuthnError("malformed CBOR"))?; + usize::try_from(cursor.position()).map_err(|_| WebAuthnError("malformed CBOR")) +} + +/// The authenticator data of an `attestationObject`. +pub fn attestation_auth_data(attestation_object: &[u8]) -> Result> { + let value: Value = ciborium::de::from_reader(attestation_object) + .map_err(|_| WebAuthnError("malformed attestation object"))?; + let Value::Map(entries) = value else { + return fail("malformed attestation object"); + }; + entries + .into_iter() + .find_map(|(key, value)| match (key, value) { + (Value::Text(key), Value::Bytes(data)) if key == "authData" => Some(data), + _ => None, + }) + .ok_or(WebAuthnError("attestation object without authData")) +} + +/// A credential public key in the algorithms offered. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CoseKey { + Es256 { x: Vec, y: Vec }, + EdDsa { x: Vec }, + Rs256 { n: Vec, e: Vec }, +} + +impl CoseKey { + pub fn algorithm(&self) -> i64 { + match self { + Self::Es256 { .. } => ALG_ES256, + Self::EdDsa { .. } => ALG_EDDSA, + Self::Rs256 { .. } => ALG_RS256, + } + } +} + +/// RFC 9053 key parameters for EC2 P-256, OKP Ed25519 and RSA keys. +pub fn parse_cose_key(bytes: &[u8]) -> Result { + let value: Value = + ciborium::de::from_reader(bytes).map_err(|_| WebAuthnError("malformed COSE key"))?; + let Value::Map(entries) = value else { + return fail("malformed COSE key"); + }; + let int = |label: i64| { + entries.iter().find_map(|(k, v)| match (k, v) { + (Value::Integer(k), Value::Integer(v)) if i128::from(*k) == i128::from(label) => { + i64::try_from(i128::from(*v)).ok() + } + _ => None, + }) + }; + let bytes_at = |label: i64| { + entries.iter().find_map(|(k, v)| match (k, v) { + (Value::Integer(k), Value::Bytes(v)) if i128::from(*k) == i128::from(label) => { + Some(v.clone()) + } + _ => None, + }) + }; + match (int(1), int(3)) { + (Some(2), Some(ALG_ES256)) => { + let (Some(1), Some(x), Some(y)) = (int(-1), bytes_at(-2), bytes_at(-3)) else { + return fail("unsupported EC2 key"); + }; + if x.len() != 32 || y.len() != 32 { + return fail("malformed EC2 key"); + } + Ok(CoseKey::Es256 { x, y }) + } + (Some(1), Some(ALG_EDDSA)) => { + let (Some(6), Some(x)) = (int(-1), bytes_at(-2)) else { + return fail("unsupported OKP key"); + }; + if x.len() != 32 { + return fail("malformed OKP key"); + } + Ok(CoseKey::EdDsa { x }) + } + (Some(3), Some(ALG_RS256)) => { + let (Some(n), Some(e)) = (bytes_at(-1), bytes_at(-2)) else { + return fail("malformed RSA key"); + }; + if n.len() < 256 || e.is_empty() { + return fail("RSA key too small"); + } + Ok(CoseKey::Rs256 { n, e }) + } + _ => fail("unsupported key algorithm"), + } +} + +/// Verify an assertion: `signature` over `authenticatorData || SHA-256(clientDataJSON)`. +pub fn verify_assertion( + key: &CoseKey, + authenticator_data: &[u8], + client_data_json: &[u8], + signature: &[u8], +) -> bool { + let mut message = authenticator_data.to_vec(); + message.extend_from_slice(&Sha256::digest(client_data_json)); + match key { + CoseKey::Es256 { x, y } => { + use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier}; + let mut point = Vec::with_capacity(65); + point.push(0x04); + point.extend_from_slice(x); + point.extend_from_slice(y); + let (Ok(key), Ok(signature)) = ( + VerifyingKey::from_sec1_bytes(&point), + Signature::from_der(signature), + ) else { + return false; + }; + key.verify(&message, &signature).is_ok() + } + CoseKey::EdDsa { x } => jsonwebtoken::DecodingKey::from_ed_components(&B64URL.encode(x)) + .ok() + .and_then(|key| { + jsonwebtoken::crypto::verify( + &B64URL.encode(signature), + &message, + &key, + jsonwebtoken::Algorithm::EdDSA, + ) + .ok() + }) + .unwrap_or(false), + CoseKey::Rs256 { n, e } => { + let key = jsonwebtoken::DecodingKey::from_rsa_raw_components(n, e); + jsonwebtoken::crypto::verify( + &B64URL.encode(signature), + &message, + &key, + jsonwebtoken::Algorithm::RS256, + ) + .unwrap_or(false) + } + } +} + +/// WebAuthn section 7.2 step 21: a counter that does not grow, when the +/// authenticator keeps one, means the credential may have been cloned. +pub fn counter_regressed(stored: u32, presented: u32) -> bool { + (stored != 0 || presented != 0) && presented <= stored +} + +/// The relying party id of an origin: its host. +pub fn origin_host(origin: &str) -> Option { + reqwest::Url::parse(origin) + .ok()? + .host_str() + .map(str::to_owned) +} + +/// Whether `origin` may use credentials of `rp_id`: its host is the id or a +/// subdomain of it (WebAuthn section 5.1.4.1). +pub fn origin_matches_rp(origin: &str, rp_id: &str) -> bool { + origin_host(origin).is_some_and(|host| host == rp_id || host.ends_with(&format!(".{rp_id}"))) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use ciborium::Value; + use p256::ecdsa::{Signature, SigningKey, signature::Signer}; + + pub const RP_ID: &str = "auth.example.com"; + pub const ORIGIN: &str = "https://auth.example.com"; + + fn cbor(value: &Value) -> Vec { + let mut out = Vec::new(); + ciborium::ser::into_writer(value, &mut out).unwrap(); + out + } + + fn cose_es256(key: &SigningKey) -> Vec { + let point = key.verifying_key().to_encoded_point(false); + cbor(&Value::Map(vec![ + (Value::Integer(1.into()), Value::Integer(2.into())), + (Value::Integer(3.into()), Value::Integer((-7).into())), + (Value::Integer((-1).into()), Value::Integer(1.into())), + ( + Value::Integer((-2).into()), + Value::Bytes(point.x().unwrap().to_vec()), + ), + ( + Value::Integer((-3).into()), + Value::Bytes(point.y().unwrap().to_vec()), + ), + ])) + } + + fn auth_data(flags: u8, count: u32, attested: Option<(&[u8], &[u8])>) -> Vec { + let mut data = Sha256::digest(RP_ID.as_bytes()).to_vec(); + data.push(flags); + data.extend_from_slice(&count.to_be_bytes()); + if let Some((id, key)) = attested { + data.extend_from_slice(&[7u8; 16]); + data.extend_from_slice(&u16::try_from(id.len()).unwrap().to_be_bytes()); + data.extend_from_slice(id); + data.extend_from_slice(key); + } + data + } + + fn client_data(kind: &str, challenge: &[u8], origin: &str) -> Vec { + serde_json::to_vec(&serde_json::json!({ + "type": kind, + "challenge": B64URL.encode(challenge), + "origin": origin, + })) + .unwrap() + } + + #[test] + fn a_registration_yields_the_credential_and_its_key() { + let key = SigningKey::random(&mut rand_core::OsRng); + let cose = cose_es256(&key); + let data = auth_data(0x45 | 0x08, 0, Some((b"credential-1", &cose))); + let object = cbor(&Value::Map(vec![ + (Value::Text("fmt".into()), Value::Text("none".into())), + (Value::Text("attStmt".into()), Value::Map(vec![])), + (Value::Text("authData".into()), Value::Bytes(data.clone())), + ])); + + let parsed = parse_authenticator_data(&attestation_auth_data(&object).unwrap()).unwrap(); + parsed.check(RP_ID).unwrap(); + assert!(parsed.backup_eligible() && !parsed.backed_up()); + let attested = parsed.attested.unwrap(); + assert_eq!(attested.credential_id, b"credential-1"); + assert_eq!( + parse_cose_key(&attested.public_key).unwrap().algorithm(), + ALG_ES256 + ); + + assert_eq!( + parse_authenticator_data(&auth_data(0x01, 0, None)) + .unwrap() + .check(RP_ID), + Err(WebAuthnError("user not verified")) + ); + assert_eq!( + parse_authenticator_data(&auth_data(0x05, 0, None)) + .unwrap() + .check("evil.example.com"), + Err(WebAuthnError("relying party mismatch")) + ); + let mut trailing = auth_data(0x05, 0, None); + trailing.push(0); + assert!(parse_authenticator_data(&trailing).is_err()); + assert!(parse_authenticator_data(&[0u8; 36]).is_err()); + } + + #[test] + fn client_data_answers_the_challenge_from_an_allowed_origin() { + let origins = vec![ORIGIN.to_owned()]; + let good = client_data("webauthn.get", b"challenge", ORIGIN); + assert!(check_client_data(&good, "webauthn.get", b"challenge", &origins).is_ok()); + assert_eq!(challenge_of(&good).unwrap(), b"challenge"); + assert!(check_client_data(&good, "webauthn.create", b"challenge", &origins).is_err()); + assert!(check_client_data(&good, "webauthn.get", b"other", &origins).is_err()); + let foreign = client_data("webauthn.get", b"challenge", "https://evil.example.com"); + assert!(check_client_data(&foreign, "webauthn.get", b"challenge", &origins).is_err()); + let cross = serde_json::to_vec(&serde_json::json!({ + "type": "webauthn.get", "challenge": B64URL.encode(b"challenge"), + "origin": ORIGIN, "crossOrigin": true + })) + .unwrap(); + assert!(check_client_data(&cross, "webauthn.get", b"challenge", &origins).is_err()); + assert!(check_client_data(b"{", "webauthn.get", b"challenge", &origins).is_err()); + } + + #[test] + fn assertions_verify_against_the_stored_key_only() { + let key = SigningKey::random(&mut rand_core::OsRng); + let cose = parse_cose_key(&cose_es256(&key)).unwrap(); + let data = auth_data(0x05, 3, None); + let client = client_data("webauthn.get", b"c", ORIGIN); + let mut message = data.clone(); + message.extend_from_slice(&Sha256::digest(&client)); + let signature: Signature = key.sign(&message); + let der = signature.to_der(); + + assert!(verify_assertion(&cose, &data, &client, der.as_bytes())); + assert!(!verify_assertion( + &cose, + &auth_data(0x05, 4, None), + &client, + der.as_bytes() + )); + let other = + parse_cose_key(&cose_es256(&SigningKey::random(&mut rand_core::OsRng))).unwrap(); + assert!(!verify_assertion(&other, &data, &client, der.as_bytes())); + assert!(!verify_assertion(&cose, &data, &client, b"not a signature")); + } + + #[test] + fn unsupported_or_malformed_keys_are_refused() { + let key = |entries: Vec<(i64, Value)>| { + cbor(&Value::Map( + entries + .into_iter() + .map(|(k, v)| (Value::Integer(k.into()), v)) + .collect(), + )) + }; + let int = |v: i64| Value::Integer(v.into()); + // ES384. + assert!(parse_cose_key(&key(vec![(1, int(2)), (3, int(-35)), (-1, int(2))])).is_err()); + // P-256 with a short coordinate. + assert!( + parse_cose_key(&key(vec![ + (1, int(2)), + (3, int(-7)), + (-1, int(1)), + (-2, Value::Bytes(vec![1; 31])), + (-3, Value::Bytes(vec![1; 32])), + ])) + .is_err() + ); + // A 1024-bit RSA key. + assert!( + parse_cose_key(&key(vec![ + (1, int(3)), + (3, int(-257)), + (-1, Value::Bytes(vec![1; 128])), + (-2, Value::Bytes(vec![1, 0, 1])), + ])) + .is_err() + ); + let ed = parse_cose_key(&key(vec![ + (1, int(1)), + (3, int(-8)), + (-1, int(6)), + (-2, Value::Bytes(vec![9; 32])), + ])) + .unwrap(); + assert_eq!(ed.algorithm(), ALG_EDDSA); + assert!(!verify_assertion(&ed, b"data", b"{}", &[0; 64])); + assert!(parse_cose_key(b"\xff").is_err()); + } + + #[test] + fn counters_must_grow_when_kept() { + assert!(!counter_regressed(0, 0)); + assert!(!counter_regressed(4, 5)); + assert!(counter_regressed(5, 5)); + assert!(counter_regressed(5, 0)); + assert!(!counter_regressed(0, 1)); + } + + #[test] + fn origins_belong_to_the_relying_party_or_its_subdomains() { + assert!(origin_matches_rp( + "https://auth.example.com", + "auth.example.com" + )); + assert!(origin_matches_rp( + "https://login.auth.example.com:8443", + "auth.example.com" + )); + assert!(!origin_matches_rp( + "https://evilauth.example.com", + "auth.example.com" + )); + assert!(!origin_matches_rp("not a url", "auth.example.com")); + } +} diff --git a/src/domain/webhook.rs b/src/domain/webhook.rs new file mode 100644 index 0000000..df44ff2 --- /dev/null +++ b/src/domain/webhook.rs @@ -0,0 +1,297 @@ +//! Webhooks: which endpoints may be called, what they subscribe to, when a +//! failed delivery is retried, and how a delivery is signed. +//! +//! Signatures follow Standard Webhooks: the secret is `whsec_` and base64 +//! bytes, the signed content is `{id}.{timestamp}.{body}`, and the header value +//! is `v1,` and the base64 HMAC-SHA256. + +use std::{ + net::{IpAddr, Ipv4Addr, Ipv6Addr}, + time::Duration, +}; + +use base64::{Engine, engine::general_purpose::STANDARD as B64}; +use hmac::{Hmac, KeyInit, Mac}; +use reqwest::Url; +use sha2::Sha256; + +/// Events an endpoint can subscribe to, by name. +pub const EVENT_NAMES: [&str; 8] = [ + "user.created", + "user.email_verified", + "user.email_changed", + "user.password_changed", + "user.sessions_revoked", + "user.suspended", + "user.reactivated", + "user.deleted", +]; + +/// Attempts before a delivery is given up. +pub const MAX_ATTEMPTS: i32 = 12; + +const FIRST_RETRY: Duration = Duration::from_secs(30); +const MAX_RETRY: Duration = Duration::from_secs(6 * 3600); + +const SECRET_PREFIX: &str = "whsec_"; + +/// Delay before the next attempt after `failed_attempts` failures: 30 seconds, +/// doubling up to six hours. The twelve attempts span about fourteen hours. +pub fn retry_delay(failed_attempts: i32) -> Duration { + if failed_attempts <= 0 { + return Duration::ZERO; + } + let doublings = (failed_attempts - 1).min(16).unsigned_abs(); + FIRST_RETRY + .saturating_mul(2u32.saturating_pow(doublings)) + .min(MAX_RETRY) +} + +/// The subscriptions, deduplicated and sorted, when each is a known event or +/// `*`; otherwise a message naming the others. +pub fn check_events(events: &[String]) -> Result, String> { + if events.is_empty() { + return Err("subscribe to at least one event, or `*`".into()); + } + let unknown: Vec<&str> = events + .iter() + .map(String::as_str) + .filter(|event| *event != "*" && !EVENT_NAMES.contains(event)) + .collect(); + if !unknown.is_empty() { + return Err(format!("unknown events: {}", unknown.join(", "))); + } + let mut events = events.to_vec(); + events.sort(); + events.dedup(); + Ok(events) +} + +/// Whether an endpoint with these subscriptions receives `event`. +pub fn subscribes(events: &[String], event: &str) -> bool { + events.iter().any(|e| e == "*" || e == event) +} + +/// An endpoint URL an administrator may register: HTTPS (HTTP only when +/// allowed), a host, no credentials, no fragment, at most 2 000 characters. +/// Addresses are checked again at each delivery, after resolution. +pub fn check_url(url: &str, allow_http: bool) -> Result { + if url.len() > 2000 { + return Err("url must be at most 2000 characters".into()); + } + let parsed = Url::parse(url).map_err(|e| format!("invalid url: {e}"))?; + match parsed.scheme() { + "https" => {} + "http" if allow_http => {} + _ => return Err("url must use https".into()), + } + if parsed.host_str().is_none_or(str::is_empty) { + return Err("url must name a host".into()); + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err("url must not carry credentials".into()); + } + if parsed.fragment().is_some() { + return Err("url must not carry a fragment".into()); + } + Ok(parsed) +} + +/// Whether a delivery may connect to `ip`: never to loopback, private, +/// link-local, shared, documentation, multicast or reserved ranges, nor to an +/// IPv6 address embedding one of them. A webhook must not become a way to reach +/// the internal network. +pub fn is_public_address(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => is_public_v4(v4), + IpAddr::V6(v6) => is_public_v6(v6), + } +} + +fn is_public_v4(ip: Ipv4Addr) -> bool { + let [a, b, c, _] = ip.octets(); + !(ip.is_unspecified() + || ip.is_loopback() + || ip.is_private() + || ip.is_link_local() + || ip.is_broadcast() + || ip.is_documentation() + || ip.is_multicast() + || a == 0 + || (a == 100 && (64..=127).contains(&b)) + || (a == 192 && b == 0 && c == 0) + || (a == 198 && (18..=19).contains(&b)) + || a >= 240) +} + +fn is_public_v6(ip: Ipv6Addr) -> bool { + if let Some(v4) = ip.to_ipv4_mapped() { + return is_public_v4(v4); + } + let segments = ip.segments(); + // NAT64 (64:ff9b::/96) and 6to4 (2002::/16) reach IPv4 addresses. + if segments[0] == 0x64 && segments[1] == 0xff9b && segments[2..6] == [0, 0, 0, 0] { + return is_public_v4(Ipv4Addr::from( + (u32::from(segments[6]) << 16) | u32::from(segments[7]), + )); + } + if segments[0] == 0x2002 { + return is_public_v4(Ipv4Addr::from( + (u32::from(segments[1]) << 16) | u32::from(segments[2]), + )); + } + !(ip.is_unspecified() + || ip.is_loopback() + || ip.is_multicast() + // IPv4-compatible (deprecated) addresses. + || segments[0..6] == [0, 0, 0, 0, 0, 0] + // Unique local fc00::/7, link-local fe80::/10, site-local fec0::/10. + || (segments[0] & 0xfe00) == 0xfc00 + || (segments[0] & 0xffc0) == 0xfe80 + || (segments[0] & 0xffc0) == 0xfec0 + // Documentation 2001:db8::/32, Teredo 2001::/32, discard 100::/64. + || (segments[0] == 0x2001 && (segments[1] == 0x0db8 || segments[1] == 0)) + || (segments[0] == 0x0100 && segments[1..4] == [0, 0, 0])) +} + +/// A new signing secret, as shown to the administrator. +pub fn format_secret(bytes: &[u8]) -> String { + format!("{SECRET_PREFIX}{}", B64.encode(bytes)) +} + +/// The key bytes of a secret written by [`format_secret`]. +pub fn secret_bytes(secret: &str) -> Option> { + B64.decode(secret.strip_prefix(SECRET_PREFIX)?).ok() +} + +/// The `webhook-signature` header value for a delivery. +pub fn signature(key: &[u8], id: &str, timestamp: i64, body: &[u8]) -> String { + let mut mac = Hmac::::new_from_slice(key).expect("HMAC accepts keys of any length"); + mac.update(id.as_bytes()); + mac.update(b"."); + mac.update(timestamp.to_string().as_bytes()); + mac.update(b"."); + mac.update(body); + format!("v1,{}", B64.encode(mac.finalize().into_bytes())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn retries_double_from_thirty_seconds_up_to_six_hours() { + assert_eq!(retry_delay(0), Duration::ZERO); + assert_eq!(retry_delay(1), Duration::from_secs(30)); + assert_eq!(retry_delay(2), Duration::from_secs(60)); + assert_eq!(retry_delay(9), Duration::from_secs(7680)); + assert_eq!(retry_delay(11), Duration::from_secs(6 * 3600)); + let span: Duration = (1..MAX_ATTEMPTS).map(retry_delay).sum(); + assert_eq!(span.as_secs() / 3600, 14); + assert_eq!(retry_delay(i32::MAX), Duration::from_secs(6 * 3600)); + } + + #[test] + fn subscriptions_name_known_events_or_everything() { + assert_eq!( + check_events(&[ + "user.deleted".into(), + "user.created".into(), + "user.deleted".into() + ]), + Ok(vec!["user.created".to_owned(), "user.deleted".to_owned()]) + ); + assert!(check_events(&[]).is_err()); + assert!(check_events(&["user.exploded".into()]).is_err()); + assert!(subscribes(&["*".into()], "user.deleted")); + assert!(subscribes(&["user.deleted".into()], "user.deleted")); + assert!(!subscribes(&["user.created".into()], "user.deleted")); + } + + #[test] + fn only_plain_https_urls_are_registered() { + assert!(check_url("https://hooks.example.com/auth?x=1", false).is_ok()); + assert!(check_url("http://hooks.example.com/auth", false).is_err()); + assert!(check_url("http://hooks.example.com/auth", true).is_ok()); + for bad in [ + "ftp://hooks.example.com/", + "https://user:pw@hooks.example.com/", + "https://hooks.example.com/#frag", + "not a url", + "file:///etc/passwd", + ] { + assert!(check_url(bad, true).is_err(), "{bad}"); + } + assert!(check_url(&format!("https://example.com/{}", "a".repeat(2000)), false).is_err()); + } + + #[test] + fn internal_addresses_are_refused() { + for internal in [ + "127.0.0.1", + "10.1.2.3", + "172.16.0.1", + "192.168.1.1", + "169.254.169.254", + "100.64.0.1", + "0.0.0.0", + "192.0.0.8", + "198.18.0.1", + "224.0.0.1", + "240.0.0.1", + "255.255.255.255", + "::1", + "::", + "fd00::1", + "fe80::1", + "fec0::1", + "ff02::1", + "::ffff:127.0.0.1", + "::127.0.0.1", + "64:ff9b::a00:1", + "2002:a00:1::", + "2001:db8::1", + "2001::1", + "100::1", + ] { + let ip: IpAddr = internal.parse().unwrap(); + assert!(!is_public_address(ip), "{internal}"); + } + for public in [ + "93.184.216.34", + "2606:2800:220:1::1", + "::ffff:93.184.216.34", + "64:ff9b::5db8:d822", + ] { + let ip: IpAddr = public.parse().unwrap(); + assert!(is_public_address(ip), "{public}"); + } + } + + #[test] + fn signatures_follow_hmac_sha256() { + // RFC 4231, test case 2, over "{id}.{timestamp}.{body}". + let key = b"Jefe"; + assert_eq!( + signature(key, "what do ya", 0, b"want for nothing?").len(), + "v1,".len() + 44 + ); + let mut mac = Hmac::::new_from_slice(key).unwrap(); + mac.update(b"what do ya want for nothing?"); + assert_eq!( + mac.finalize() + .into_bytes() + .iter() + .map(|b| format!("{b:02x}")) + .collect::(), + "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843" + ); + let secret = format_secret(&[7u8; 24]); + assert_eq!(secret_bytes(&secret), Some(vec![7u8; 24])); + assert_eq!(secret_bytes("7777"), None); + assert_ne!( + signature(&[7u8; 24], "msg_1", 1, b"{}"), + signature(&[7u8; 24], "msg_1", 2, b"{}") + ); + } +} diff --git a/src/error.rs b/src/error.rs index b9d5af0..7418445 100644 --- a/src/error.rs +++ b/src/error.rs @@ -10,74 +10,116 @@ use axum::{ http::StatusCode, response::{IntoResponse, Response}, }; +use std::borrow::Cow; + use serde::Serialize; use tracing::error; // Response body -#[derive(Serialize)] -struct ErrorBody { +/// Body of every error response. +#[derive(Serialize, utoipa::ToSchema)] +pub struct ErrorBody { + /// Stable and machine-readable: `invalid_credentials`, `reauthentication_required`, ... code: &'static str, - message: &'static str, + /// Human-readable explanation; may change between versions. + #[schema(value_type = String)] + message: Cow<'static, str>, } impl ErrorBody { - fn new(code: &'static str, message: &'static str) -> Self { - Self { code, message } + pub(crate) fn new(code: &'static str, message: impl Into>) -> Self { + Self { + code, + message: message.into(), + } } } // AppError -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] pub enum AppError { // 401 + #[error("authentication required")] Unauthorized, + #[error("invalid credentials")] InvalidCredentials, + #[error("invalid two-factor code")] TwoFactorFailed, + #[error("token expired")] TokenExpired, + #[error("token invalid")] TokenInvalid, + #[error("re-authentication failed")] + ReauthenticationFailed, // 403 + #[error("forbidden")] Forbidden, + #[error("email not verified")] EmailNotVerified, + #[error("account suspended")] AccountSuspended, + #[error("account inactive")] AccountInactive, + #[error("account locked")] AccountLocked, + #[error("two-factor authentication required")] TwoFactorRequired, - LoginBlocked, + #[error("recent re-authentication required")] ReauthenticationRequired, // 404 + #[error("resource not found")] NotFound, // 409 + #[error("conflict: {0}")] Conflict(&'static str), // 422 + #[error("validation failed: {0}")] Validation(String), // 422 - CAPTCHA + #[error("captcha verification failed")] CaptchaFailed, + // 422 - the password appears in a known data breach + #[error("password found in a data breach")] + PasswordCompromised, + // 400 - Device authorization flow (RFC 8628) + #[error("device authorization pending")] DeviceAuthPending, + #[error("device code expired")] DeviceCodeExpired, + #[error("device access denied")] DeviceAccessDenied, + #[error("device polling too fast")] DeviceSlowDown, // 403 - Device session/client restrictions + #[error("device session limit reached")] DeviceSessionLimitReached, + #[error("device client not allowed")] DeviceClientNotAllowed, + #[error("device client unknown")] DeviceClientUnknown, + #[error("invalid authorization code")] + InvalidAuthorizationCode, // 429 + #[error("rate limit exceeded")] RateLimitExceeded, // 503 + #[error("dependency unavailable: {0}")] ServiceUnavailable(&'static str), // 500 - message is logged, never sent to the caller + #[error("internal error: {0}")] Internal(anyhow::Error), } @@ -127,6 +169,13 @@ impl IntoResponse for AppError { StatusCode::UNAUTHORIZED, ErrorBody::new("token_invalid", "This token is invalid."), ), + Self::ReauthenticationFailed => ( + StatusCode::UNAUTHORIZED, + ErrorBody::new( + "reauthentication_failed", + "The current password is incorrect.", + ), + ), // 403 Self::Forbidden => ( @@ -165,13 +214,6 @@ impl IntoResponse for AppError { "Two-factor authentication is required.", ), ), - Self::LoginBlocked => ( - StatusCode::FORBIDDEN, - ErrorBody::new( - "login_blocked", - "This login attempt has been blocked due to suspicious activity.", - ), - ), Self::ReauthenticationRequired => ( StatusCode::FORBIDDEN, ErrorBody::new( @@ -187,20 +229,34 @@ impl IntoResponse for AppError { ), // 409 - Self::Conflict(field) => { - // field is a static str like "email" or "username", safe to log - let body = ErrorBody::new("conflict", "A resource with this value already exists."); - tracing::warn!(field, "conflict on unique field"); - (StatusCode::CONFLICT, body) + Self::Conflict(code) => { + // `code` is a static, stable identifier such as "email_taken": + // clients branch on it, and it is safe to log. + tracing::warn!(code, "conflict"); + let message = match code { + "last_administrator" => { + "At least one account must keep the permission to manage roles." + } + "default_role" => "The role given to every new account cannot be deleted.", + "too_many_tokens" => "Revoke a personal access token before creating another.", + "external_identity_not_linked" => { + "No account is linked to this identity: sign in, then link it from the account settings." + } + "external_identity_already_linked" => { + "This identity is already linked to an account." + } + _ => "A resource with this value already exists.", + }; + (StatusCode::CONFLICT, ErrorBody::new(code, message)) } // 422 - Self::Validation(_) => ( + // Validation messages are written by the handlers for the caller + // ("password must be at least 10 characters") and never carry + // internal state, so they are returned as-is. + Self::Validation(message) => ( StatusCode::UNPROCESSABLE_ENTITY, - ErrorBody::new( - "validation_error", - "The request body contains invalid data.", - ), + ErrorBody::new("validation_error", message), ), Self::CaptchaFailed => ( StatusCode::UNPROCESSABLE_ENTITY, @@ -209,6 +265,13 @@ impl IntoResponse for AppError { "CAPTCHA verification failed. Please try again.", ), ), + Self::PasswordCompromised => ( + StatusCode::UNPROCESSABLE_ENTITY, + ErrorBody::new( + "password_compromised", + "This password appears in a known data breach. Choose another one.", + ), + ), // 400 - Device authorization flow (RFC 8628) Self::DeviceAuthPending => ( @@ -253,6 +316,14 @@ impl IntoResponse for AppError { ), ), + Self::InvalidAuthorizationCode => ( + StatusCode::BAD_REQUEST, + ErrorBody::new( + "invalid_authorization_code", + "The authorization code is invalid, expired or already used.", + ), + ), + Self::DeviceClientUnknown => ( StatusCode::BAD_REQUEST, ErrorBody::new( @@ -282,20 +353,87 @@ impl IntoResponse for AppError { ) } - // 500 - Self::Internal(err) => { - error!(error = %err, "internal server error"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - ErrorBody::new("internal_error", "An unexpected error occurred."), - ) - } + // 500, or 503 when the cause is a dependency that cannot be reached + Self::Internal(err) => match unavailable_dependency(&err) { + Some(dependency) => { + tracing::warn!(dependency, error = %err, "dependency unavailable"); + ( + StatusCode::SERVICE_UNAVAILABLE, + ErrorBody::new( + "service_unavailable", + "A required upstream dependency is unavailable.", + ), + ) + } + None => { + error!(error = %err, "internal server error"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + ErrorBody::new("internal_error", "An unexpected error occurred."), + ) + } + }, }; (status, Json(body)).into_response() } } +/// Protocol errors sqlx reports when the connection closes while being set up. +fn connection_setup_failed(message: &str) -> bool { + ["SSLRequest", "unexpected EOF", "connection closed"] + .iter() + .any(|marker| message.contains(marker)) +} + +/// The dependency an internal error comes from, when that dependency is +/// unreachable rather than the request being wrong: an outage is a 503 the +/// client may retry, not a 500 that pages someone for a bug. +fn unavailable_dependency(err: &anyhow::Error) -> Option<&'static str> { + use deadpool_redis::redis::RedisError; + + fn redis_unreachable(e: &RedisError) -> bool { + e.is_io_error() || e.is_connection_dropped() || e.is_timeout() || e.is_unrecoverable_error() + } + + err.chain().find_map(|cause| { + if let Some(e) = cause.downcast_ref::() { + let unreachable = match e { + sqlx::Error::PoolTimedOut + | sqlx::Error::PoolClosed + | sqlx::Error::WorkerCrashed + | sqlx::Error::Io(_) + | sqlx::Error::Tls(_) => true, + // A server or proxy that drops the connection during its setup + // surfaces as a protocol error; any other protocol error is a bug. + sqlx::Error::Protocol(message) => connection_setup_failed(message), + // Connection exceptions (class 08), shutdowns and exhausted + // connection slots. + sqlx::Error::Database(db) => db.code().is_some_and(|code| { + code.starts_with("08") + || matches!(code.as_ref(), "57P01" | "57P02" | "57P03" | "53300") + }), + _ => false, + }; + return unreachable.then_some("database"); + } + if let Some(e) = cause.downcast_ref::() { + return redis_unreachable(e).then_some("redis"); + } + if let Some(e) = cause.downcast_ref::>() { + return match e { + deadpool::managed::PoolError::Backend(e) => redis_unreachable(e), + deadpool::managed::PoolError::Timeout(_) | deadpool::managed::PoolError::Closed => { + true + } + _ => false, + } + .then_some("redis"); + } + None + }) +} + // From impls for common error sources impl From for AppError { @@ -359,6 +497,15 @@ mod tests { assert_eq!(status(AppError::TokenInvalid), 401); } + #[tokio::test] + async fn reauthentication_failed_is_401_with_its_own_code() { + assert_eq!(status(AppError::ReauthenticationFailed), 401); + assert_eq!( + body_code(AppError::ReauthenticationFailed).await, + "reauthentication_failed" + ); + } + // 403 #[test] @@ -391,11 +538,6 @@ mod tests { assert_eq!(status(AppError::TwoFactorRequired), 403); } - #[test] - fn login_blocked_is_403() { - assert_eq!(status(AppError::LoginBlocked), 403); - } - #[test] fn reauthentication_required_is_403() { assert_eq!(status(AppError::ReauthenticationRequired), 403); @@ -411,9 +553,28 @@ mod tests { // 409 #[tokio::test] - async fn conflict_is_409_with_correct_code() { - assert_eq!(status(AppError::Conflict("email")), 409); - assert_eq!(body_code(AppError::Conflict("username")).await, "conflict"); + async fn conflict_is_409_with_its_specific_code() { + assert_eq!(status(AppError::Conflict("email_taken")), 409); + assert_eq!( + body_code(AppError::Conflict("username_taken")).await, + "username_taken" + ); + } + + #[tokio::test] + async fn validation_message_is_returned_to_the_caller() { + let resp = AppError::Validation("password too short".into()).into_response(); + let bytes = to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(v["message"], "password too short"); + } + + #[test] + fn app_error_implements_display() { + assert_eq!( + AppError::ServiceUnavailable("redis").to_string(), + "dependency unavailable: redis" + ); } // 422 @@ -552,4 +713,43 @@ mod tests { let err: AppError = pool_err.into(); assert_eq!(status(err), 500); } + + #[test] + fn unreachable_dependencies_are_503_not_500() { + let outage = |err: anyhow::Error| status(AppError::Internal(err)); + assert_eq!(outage(sqlx::Error::PoolTimedOut.into()), 503); + assert_eq!(outage(sqlx::Error::PoolClosed.into()), 503); + let refused = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused"); + assert_eq!(outage(sqlx::Error::Io(refused).into()), 503); + let dropped = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "dropped"); + assert_eq!( + outage(deadpool_redis::redis::RedisError::from(dropped).into()), + 503 + ); + // Wrapped with context, as the services report them. + assert_eq!( + outage(anyhow::Error::from(sqlx::Error::PoolTimedOut).context("load session")), + 503 + ); + } + + #[test] + fn other_internal_errors_stay_500() { + assert_eq!( + status(AppError::Internal(sqlx::Error::RowNotFound.into())), + 500 + ); + assert_eq!(status(AppError::Internal(anyhow::anyhow!("a bug"))), 500); + } + + #[test] + fn a_connection_dropped_during_setup_is_an_outage_but_a_protocol_bug_is_not() { + let dropped = sqlx::Error::Protocol( + "encountered unexpected or invalid data: unexpected response from SSLRequest: 0x00" + .into(), + ); + assert_eq!(status(AppError::Internal(dropped.into())), 503); + let bug = sqlx::Error::Protocol("unknown message type: \x27Z\x27".into()); + assert_eq!(status(AppError::Internal(bug.into())), 500); + } } diff --git a/src/fuzzing.rs b/src/fuzzing.rs new file mode 100644 index 0000000..891ac14 --- /dev/null +++ b/src/fuzzing.rs @@ -0,0 +1,494 @@ +//! Entry points of the fuzz targets (`fuzz/`) and of their replay on stable +//! (`tests/fuzz_corpus.rs`). +//! +//! Compiled only with the `fuzzing` feature, which no deployment enables. Each +//! function feeds raw untrusted bytes to a production parser and panics when a +//! security property does not hold: a panic is what the fuzzer reports. + +use std::{net::IpAddr, sync::OnceLock}; + +use axum::http::{HeaderMap, HeaderValue}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use ipnetwork::IpNetwork; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::{ + cli::parse_client_registration, + domain::{ + registered_client::RegisteredClient, + session::{DEVICE_NAME_MAX_CHARS, device_label}, + user::{is_storable_email, is_valid_username}, + }, + handlers::{ + audit::{decode_cursor, encode_cursor}, + auth::{validate_email, validate_username}, + oauth::validate_user_code, + user::{validate_locale, validate_password}, + }, + middleware::client_ip::resolve_client_ip, + services::{ + auth::{ChallengeMethod, parse_pre_auth_state}, + authorize::{validate_challenge, validate_redirect, verifier_matches}, + email::mask_email, + }, + utils::{ + crypto::{Keyring, sha256}, + jwt::{self, Claims}, + totp, + }, +}; + +fn text(data: &[u8]) -> Option<&str> { + std::str::from_utf8(data).ok() +} + +/// Audit history cursors: whatever is accepted re-encodes to the same position. +pub fn audit_cursor(data: &[u8]) { + let Some(input) = text(data) else { return }; + if let Ok((created_at, id)) = decode_cursor(input) { + let again = + decode_cursor(&encode_cursor(created_at, id)).expect("an encoded cursor decodes"); + assert_eq!(again, (created_at, id), "{input:?} does not round-trip"); + } +} + +/// Pre-auth state read back from Redis: a challenge completes only with the +/// method it was issued for. +pub fn pre_auth_state(data: &[u8]) { + let Some(input) = text(data) else { return }; + let Ok(state) = parse_pre_auth_state(input) else { + return; + }; + for method in [ChallengeMethod::Totp, ChallengeMethod::Email] { + assert_eq!( + state.expect_method(method).is_ok(), + state.method == Some(method), + "{input:?} completes with {method:?}" + ); + } +} + +/// Client address: an untrusted peer is always the client; behind a trusted +/// proxy, the client is the peer or an address the headers carry. +/// +/// Layout: flags (bit 0: IPv6 peer, bit 1: the peer's own network is trusted), +/// the peer address, then header values separated by 0xFF: X-Forwarded-For, +/// X-Real-IP, a second X-Forwarded-For line. +pub fn client_ip(data: &[u8]) { + let Some((&flags, rest)) = data.split_first() else { + return; + }; + let (peer, rest): (IpAddr, &[u8]) = if flags & 1 == 0 { + let Some((octets, rest)) = rest.split_first_chunk::<4>() else { + return; + }; + (IpAddr::from(*octets), rest) + } else { + let Some((octets, rest)) = rest.split_first_chunk::<16>() else { + return; + }; + (IpAddr::from(*octets), rest) + }; + let trusted: Vec = if flags & 2 != 0 { + let prefix = if peer.is_ipv4() { 24 } else { 64 }; + vec![IpNetwork::new(peer, prefix).expect("valid prefix")] + } else { + vec!["10.0.0.0/8".parse().expect("valid network")] + }; + + let mut headers = HeaderMap::new(); + let mut values = rest.split(|b| *b == 0xFF); + for name in ["x-forwarded-for", "x-real-ip", "x-forwarded-for"] { + if let Some(Ok(value)) = values.next().map(HeaderValue::from_bytes) { + headers.append(name, value); + } + } + + let resolved = resolve_client_ip(Some(peer), &headers, &trusted) + .expect("a request with a peer always has a client address"); + if !trusted.iter().any(|network| network.contains(peer)) { + assert_eq!(resolved, peer, "an untrusted peer chose its client address"); + return; + } + let carried: Vec = headers + .values() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .filter_map(|hop| hop.trim().parse().ok()) + .collect(); + assert!( + resolved == peer || carried.contains(&resolved), + "{resolved} was resolved from nowhere" + ); +} + +/// Redirect URIs: a registered URI exactly, or a loopback URI on a registered +/// loopback path for a client that allows it. Layout: a flags line (`L` allows +/// loopback), the candidate, then the registered URIs, one per line. +pub fn redirect_uri(data: &[u8]) { + let Some(input) = text(data) else { return }; + let mut lines = input.split('\n'); + let loopback = lines.next().is_some_and(|flags| flags.starts_with('L')); + let candidate = lines.next().unwrap_or_default(); + let registered: Vec = lines.map(str::to_owned).collect(); + let client = RegisteredClient { + client_id: "fuzz".into(), + display_name: "Fuzz".into(), + is_primary: false, + created_at: OffsetDateTime::UNIX_EPOCH, + scopes: Vec::new(), + redirect_uris: registered.clone(), + allows_loopback_redirect: loopback, + default_max_sessions: 1, + client_secret_hash: None, + allows_client_credentials: false, + }; + + if validate_redirect(&client, candidate).is_err() || registered.iter().any(|r| r == candidate) { + return; + } + assert!(loopback, "{candidate:?} accepted without being registered"); + let url = reqwest::Url::parse(candidate).expect("an accepted redirect parses"); + assert_eq!( + url.as_str(), + candidate, + "accepted a URI that is not in canonical form" + ); + assert_eq!(url.scheme(), "http"); + assert!( + matches!(url.host_str(), Some("127.0.0.1" | "[::1]")), + "{candidate:?} is not a loopback literal" + ); + assert!( + url.port().is_some_and(|port| port != 0), + "{candidate:?} has no usable port" + ); + assert!(url.username().is_empty() && url.password().is_none()); + assert!(url.query().is_none() && url.fragment().is_none()); + assert!( + registered + .iter() + .filter_map(|r| reqwest::Url::parse(r).ok()) + .any(|r| matches!(r.host_str(), Some("127.0.0.1" | "[::1]")) && r.path() == url.path()), + "{candidate:?} matches no registered loopback path" + ); +} + +const VERIFIER_ALPHABET: &[u8] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; + +/// PKCE: challenges are accepted only well-formed, verifiers only when they +/// hash to the challenge, and a well-formed verifier matches its own challenge. +/// Layout: challenge, newline, verifier. +pub fn pkce(data: &[u8]) { + let Some(input) = text(data) else { return }; + let (challenge, verifier) = input.split_once('\n').unwrap_or((input, "")); + + if validate_challenge(challenge, "S256").is_ok() { + assert!( + challenge.len() == 43 + && challenge + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'), + "{challenge:?} accepted as a challenge" + ); + } + if verifier_matches(challenge, verifier) { + assert!((43..=128).contains(&verifier.len())); + assert_eq!( + URL_SAFE_NO_PAD.encode(sha256(verifier.as_bytes())), + challenge + ); + } + + let derived: String = verifier + .bytes() + .take(128) + .map(|b| VERIFIER_ALPHABET[usize::from(b) % VERIFIER_ALPHABET.len()] as char) + .collect(); + if derived.len() >= 43 { + let own = URL_SAFE_NO_PAD.encode(sha256(derived.as_bytes())); + assert!(validate_challenge(&own, "S256").is_ok()); + assert!( + verifier_matches(&own, &derived), + "{derived:?} does not match its challenge" + ); + } +} + +const TOKEN_PRIVATE_KEY: &str = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgL+1qOaZ7C+H1mGbV\njUP83/W450N4GfOnZSrQ7P//4Y2hRANCAAR4BApTJy8Anvp+O7YNVlTeCbBZ+1YJ\nk+r5ELHGFIXciAEGSrCTOkCm3yChSYroYWLE3ZN4reh6JDbIMX/QnBGx\n-----END PRIVATE KEY-----"; +const TOKEN_PUBLIC_KEY: &str = "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEeAQKUycvAJ76fju2DVZU3gmwWftW\nCZPq+RCxxhSF3IgBBkqwkzpApt8goUmK6GFixN2TeK3oeiQ2yDF/0JwRsQ==\n-----END PUBLIC KEY-----"; +const TOKEN_ISSUED_AT: i64 = 1_700_000_000; + +struct TokenFixture { + verifying: jsonwebtoken::DecodingKey, + claims: Claims, + token: String, +} + +fn token_fixture() -> &'static TokenFixture { + static FIXTURE: OnceLock = OnceLock::new(); + FIXTURE.get_or_init(|| { + let mut claims = Claims::new( + Uuid::from_u128(1), + Uuid::from_u128(2), + TOKEN_ISSUED_AT, + TOKEN_ISSUED_AT + 900, + ) + .with_rbac(vec!["user".into()], vec!["users:read".into()]); + claims.jti = Uuid::from_u128(3); + claims.iss = Some("https://auth.example.com".into()); + claims.aud = vec!["https://auth.example.com".into()]; + let signing = jwt::parse_encoding_key(TOKEN_PRIVATE_KEY).expect("fixture key"); + TokenFixture { + verifying: jwt::parse_verifying_key(TOKEN_PUBLIC_KEY).expect("fixture key"), + token: jwt::encode_token(&claims, &signing, Some("fuzz")).expect("fixture token"), + claims, + } + }) +} + +fn assert_same_claims(decoded: &Claims, expected: &Claims) { + assert_eq!( + serde_json::to_value(decoded).unwrap(), + serde_json::to_value(expected).unwrap(), + "a token verified with claims it was not signed with" + ); +} + +/// Access tokens: arbitrary input never verifies, and the genuine token, +/// altered anywhere, never verifies to other claims. +/// Layout: the input itself, then read as (position: u16, byte) edits. +pub fn access_token(data: &[u8]) { + let fixture = token_fixture(); + let now = TOKEN_ISSUED_AT + 60; + + if let Some(input) = text(data) + && let Ok(claims) = jwt::decode_token(input, &fixture.verifying, now) + { + assert_same_claims(&claims, &fixture.claims); + } + + let mut token = fixture.token.clone().into_bytes(); + for [high, low, byte] in data.as_chunks::<3>().0 { + let at = usize::from(u16::from_be_bytes([*high, *low])) % token.len(); + token[at] = *byte; + } + if let Ok(tampered) = String::from_utf8(token) + && let Ok(claims) = jwt::decode_token(&tampered, &fixture.verifying, now) + { + assert_same_claims(&claims, &fixture.claims); + } +} + +struct KeyringFixture { + keyring: Keyring, + plaintext: &'static str, + ciphertext: String, +} + +fn keyring_fixture() -> &'static KeyringFixture { + static FIXTURE: OnceLock = OnceLock::new(); + FIXTURE.get_or_init(|| { + let keyring = Keyring::new([7; 32], Some([9; 32])); + let plaintext = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP"; + KeyringFixture { + ciphertext: keyring.encrypt(plaintext).expect("fixture ciphertext"), + keyring, + plaintext, + } + }) +} + +/// Secrets encrypted at rest: decryption never panics, and a ciphertext +/// altered anywhere never decrypts to another plaintext. +/// Layout: the input itself, then read as (position: u16, byte) edits. +pub fn keyring(data: &[u8]) { + let fixture = keyring_fixture(); + + if let Some(input) = text(data) { + let _ = fixture.keyring.needs_rotation(input); + if let Ok(plaintext) = fixture.keyring.decrypt(input) { + assert_eq!(plaintext, fixture.plaintext, "forged ciphertext accepted"); + } + } + + let mut ciphertext = fixture.ciphertext.clone().into_bytes(); + for [high, low, byte] in data.as_chunks::<3>().0 { + let at = usize::from(u16::from_be_bytes([*high, *low])) % ciphertext.len(); + ciphertext[at] = *byte; + } + if let Ok(tampered) = String::from_utf8(ciphertext) + && let Ok(plaintext) = fixture.keyring.decrypt(&tampered) + { + assert_eq!( + plaintext, fixture.plaintext, + "tampered ciphertext decrypted" + ); + } +} + +/// TOTP codes: only six ASCII digits can ever be accepted. +pub fn totp_code(data: &[u8]) { + let Some(code) = text(data) else { return }; + let fixture = keyring_fixture(); + let secret = fixture + .keyring + .encrypt("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ") + .expect("secret"); + for now in [0, 59, TOKEN_ISSUED_AT] { + if totp::verify_code(&secret, code, &fixture.keyring, 1, now).unwrap_or(false) { + assert!( + code.len() == 6 && code.bytes().all(|b| b.is_ascii_digit()), + "{code:?} accepted as a TOTP code" + ); + } + } +} + +/// Pwned Passwords range answers: never a panic, and a password counts as +/// breached only when a line carries its exact suffix with a positive count. +pub fn pwned_range(data: &[u8]) { + let Some(text) = text(data) else { return }; + let (suffix, range) = text.split_once('\n').unwrap_or((text, "")); + let count = crate::domain::pwned::breach_count(range, suffix); + if count > 0 { + assert!( + range.lines().any(|line| line + .trim() + .split_once(':') + .is_some_and(|(candidate, n)| candidate.eq_ignore_ascii_case(suffix) + && n.trim().parse::() == Ok(count))), + "{suffix:?} counted {count} without a matching line" + ); + } +} + +/// WebAuthn authenticator data, attestation objects and COSE keys: never a +/// panic, and parsed authenticator data accounts for every byte. +pub fn webauthn(data: &[u8]) { + use crate::domain::webauthn; + if let Ok(parsed) = webauthn::parse_authenticator_data(data) { + assert!(data.len() >= 37); + if let Some(attested) = parsed.attested { + let _ = webauthn::parse_cose_key(&attested.public_key); + assert!(!attested.credential_id.is_empty()); + } + } + let _ = webauthn::attestation_auth_data(data); + if let Ok(key) = webauthn::parse_cose_key(data) { + assert!(!webauthn::verify_assertion(&key, data, b"{}", data)); + } + let _ = webauthn::challenge_of(data); +} + +/// Webhook URLs: never a panic, and an accepted URL is http(s) with a host and +/// no credentials. +pub fn webhook_url(data: &[u8]) { + let Some(text) = text(data) else { return }; + if let Ok(url) = crate::domain::webhook::check_url(text, true) { + assert!(matches!(url.scheme(), "http" | "https"), "{text:?}"); + assert!(url.host_str().is_some_and(|h| !h.is_empty()), "{text:?}"); + assert!( + url.username().is_empty() && url.password().is_none(), + "{text:?}" + ); + } + if let Ok(ip) = text.parse::() { + let _ = crate::domain::webhook::is_public_address(ip); + } +} + +/// Input validators: they never panic, and what they accept fits the database. +pub fn validators(data: &[u8]) { + let Some(input) = text(data) else { return }; + + if is_valid_username(input) { + assert!( + !input.is_empty() + && input + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_') + ); + } + if validate_username(input).is_ok() { + assert!(is_valid_username(input) && (3..=30).contains(&input.len())); + } + if is_storable_email(input) { + assert!(input.is_ascii() && input.len() <= 254 && input.matches('@').count() == 1); + } + if validate_email(input).is_ok() { + assert!( + is_storable_email(input), + "{input:?} passes validation but not the database" + ); + } + if let Some(label) = device_label(input) { + assert!(!label.is_empty() && label.chars().count() <= DEVICE_NAME_MAX_CHARS); + assert!(!label.chars().any(char::is_control) && label.trim() == label); + } + let masked = mask_email(input); + match input.split_once('@') { + Some((local, domain)) => { + let first: String = local.chars().take(1).collect(); + assert_eq!(masked, format!("{first}***@{domain}")); + } + None => assert_eq!(masked, "***"), + } + if validate_password(input).is_ok() { + assert!( + input.chars().count() >= 10, + "{input:?}: fewer than 10 characters" + ); + assert!(input.len() <= 128); + assert!(input.chars().any(|c| c.is_ascii_digit())); + assert!(input.chars().any(|c| c.is_ascii_uppercase())); + assert!(input.chars().any(|c| c.is_ascii_punctuation())); + } + if validate_user_code(input).is_ok() { + let (letters, digits) = input.split_once('-').expect("XXXX-XXXX"); + assert!(letters.len() == 4 && letters.bytes().all(|b| b.is_ascii_uppercase())); + assert!(digits.len() == 4 && digits.bytes().all(|b| b.is_ascii_digit())); + } + if validate_locale(input).is_ok() { + assert!( + matches!(input, "en" | "fr"), + "{input:?} accepted as a locale" + ); + } +} + +/// `--register-client` arguments: parsing never panics. Arguments are +/// separated by NUL. +pub fn client_registration(data: &[u8]) { + let Some(input) = text(data) else { return }; + let args: Vec = std::iter::once("auth-api") + .chain(input.split('\0')) + .map(str::to_owned) + .collect(); + if let Ok(Some(registration)) = parse_client_registration(&args) { + let new = registration.as_new(); + assert!(!new.client_id.is_empty()); + } +} + +/// A fuzz target: the name of its binary in `fuzz/` and its entry point. +pub type Target = (&'static str, fn(&[u8])); + +/// Every target, by name, for the corpus replay. +pub const TARGETS: &[Target] = &[ + ("access_token", access_token), + ("audit_cursor", audit_cursor), + ("client_ip", client_ip), + ("client_registration", client_registration), + ("keyring", keyring), + ("pkce", pkce), + ("pre_auth_state", pre_auth_state), + ("pwned_range", pwned_range), + ("webauthn", webauthn), + ("webhook_url", webhook_url), + ("redirect_uri", redirect_uri), + ("totp_code", totp_code), + ("validators", validators), +]; diff --git a/src/handlers/admin/audit.rs b/src/handlers/admin/audit.rs new file mode 100644 index 0000000..4e7f18a --- /dev/null +++ b/src/handlers/admin/audit.rs @@ -0,0 +1,111 @@ +//! `/admin/audit`: the audit log of every account. + +use axum::{ + Json, + extract::{Query, State}, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + error::AppError, + handlers::{ + audit::{ + AuditEntryResponse, action_name, decode_cursor, encode_cursor, page_limit, + rows_to_fetch, split_page, + }, + extractors::AdminUser, + }, + repositories::audit as audit_repo, + state::AppState, +}; + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct AdminAuditParams { + /// Only this account's entries. + pub user_id: Option, + /// Only this action, such as `login_failed`. + pub action: Option, + pub limit: Option, + /// `next_cursor` of the previous page. + pub cursor: Option, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct AdminAuditEntry { + /// Absent once the account is deleted. + #[serde(skip_serializing_if = "Option::is_none")] + pub user_id: Option, + #[serde(flatten)] + pub entry: AuditEntryResponse, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct AdminAuditPage { + pub entries: Vec, + /// Pass back as `cursor` to read the next page; absent on the last one. + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, +} + +#[utoipa::path( + get, + path = "/admin/audit", + tag = "admin", + params( + ("user_id" = Option, Query, description = "Only this account's entries"), + ("action" = Option, Query, description = "Only this action"), + ("limit" = Option, Query, description = "Entries per page, 1-200 (default 50)"), + ("cursor" = Option, Query, description = "next_cursor of the previous page"), + ), + responses( + (status = 200, description = "Audit entries, newest first", body = AdminAuditPage), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Missing `audit:read`, or no second factor enrolled", body = crate::error::ErrorBody), + (status = 422, description = "Invalid cursor or account id", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn list( + admin: AdminUser, + State(state): State, + Query(params): Query, +) -> Result, AppError> { + admin.require(&state, "audit:read").await?; + let limit = page_limit(params.limit); + let before = params.cursor.as_deref().map(decode_cursor).transpose()?; + + let rows = audit_repo::find_page( + &state.db_read, + params.user_id, + params.action.as_deref(), + before, + rows_to_fetch(limit), + ) + .await?; + let (rows, more) = split_page(rows, limit); + let next_cursor = more + .then(|| { + rows.last() + .map(|last| encode_cursor(last.created_at, last.id)) + }) + .flatten(); + + Ok(Json(AdminAuditPage { + entries: rows + .into_iter() + .map(|entry| AdminAuditEntry { + user_id: entry.user_id, + entry: AuditEntryResponse { + id: entry.id, + created_at: entry.created_at.unix_timestamp(), + action: action_name(&entry.action), + ip_address: entry.ip_address.map(|net| net.ip().to_string()), + request_id: entry.request_id, + metadata: entry.metadata, + }, + }) + .collect(), + next_cursor, + })) +} diff --git a/src/handlers/admin/clients.rs b/src/handlers/admin/clients.rs new file mode 100644 index 0000000..a4e4e00 --- /dev/null +++ b/src/handlers/admin/clients.rs @@ -0,0 +1,225 @@ +//! `/admin/clients`: client applications. + +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, +}; +use serde::{Deserialize, Serialize}; + +use crate::{ + domain::registered_client::RegisteredClient, + error::AppError, + handlers::extractors::{AdminUser, ClientIp}, + repositories::registered_client::NewRegisteredClient, + services::admin::clients as admin_clients, + state::AppState, +}; + +use super::actor; + +#[derive(Serialize, utoipa::ToSchema)] +pub struct ClientResponse { + pub client_id: String, + pub display_name: String, + pub is_primary: bool, + /// Permissions its tokens may carry; empty means every permission of the user. + pub scopes: Vec, + pub redirect_uris: Vec, + pub allows_loopback_redirect: bool, + pub default_max_sessions: i16, + /// Authenticates with a secret at the token endpoint. + pub confidential: bool, + /// May obtain tokens for itself with the client credentials grant. + pub allows_client_credentials: bool, + pub created_at: i64, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct ClientSecretResponse { + /// The client secret (`aacs_...`), shown once. + pub client_secret: String, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct SaveClientRequest { + pub display_name: String, + #[serde(default)] + pub is_primary: bool, + #[serde(default)] + pub scopes: Vec, + #[serde(default)] + pub redirect_uris: Vec, + #[serde(default)] + pub allows_loopback_redirect: bool, + /// Default: 5. + pub default_max_sessions: Option, + /// Allow the client credentials grant; needs a secret and scopes. Omitted: + /// unchanged (false for a new client). + pub allows_client_credentials: Option, +} + +fn client_response(client: RegisteredClient) -> ClientResponse { + ClientResponse { + confidential: client.is_confidential(), + allows_client_credentials: client.allows_client_credentials, + created_at: client.created_at.unix_timestamp(), + client_id: client.client_id, + display_name: client.display_name, + is_primary: client.is_primary, + scopes: client.scopes, + redirect_uris: client.redirect_uris, + allows_loopback_redirect: client.allows_loopback_redirect, + default_max_sessions: client.default_max_sessions, + } +} + +#[utoipa::path( + get, + path = "/admin/clients", + tag = "admin", + responses( + (status = 200, description = "Every registered client", body = [ClientResponse]), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Missing `clients:manage`, or no second factor enrolled", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn list( + admin: AdminUser, + State(state): State, +) -> Result>, AppError> { + admin.require(&state, "clients:manage").await?; + let clients = admin_clients::list(&state).await?; + Ok(Json(clients.into_iter().map(client_response).collect())) +} + +#[utoipa::path( + put, + path = "/admin/clients/{client_id}", + tag = "admin", + params(("client_id" = String, Path, description = "Client id, 1 to 100 of [A-Za-z0-9._-]")), + request_body = SaveClientRequest, + responses( + (status = 200, description = "Client updated", body = ClientResponse), + (status = 201, description = "Client registered", body = ClientResponse), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Missing `clients:manage`, no second factor, or re-authentication required", body = crate::error::ErrorBody), + (status = 409, description = "`primary_client_exists`", body = crate::error::ErrorBody), + (status = 422, description = "Invalid settings or unknown scope", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn save( + admin: AdminUser, + State(state): State, + ClientIp(ip): ClientIp, + Path(client_id): Path, + Json(body): Json, +) -> Result<(StatusCode, Json), AppError> { + admin.require(&state, "clients:manage").await?; + let (client, created) = admin_clients::save( + &state, + &actor(&admin, ip), + &NewRegisteredClient { + client_id: &client_id, + display_name: &body.display_name, + is_primary: body.is_primary, + scopes: &body.scopes, + redirect_uris: &body.redirect_uris, + allows_loopback_redirect: body.allows_loopback_redirect, + default_max_sessions: body.default_max_sessions.unwrap_or(5), + }, + body.allows_client_credentials, + ) + .await?; + let status = if created { + StatusCode::CREATED + } else { + StatusCode::OK + }; + Ok((status, Json(client_response(client)))) +} + +#[utoipa::path( + delete, + path = "/admin/clients/{client_id}", + tag = "admin", + params(("client_id" = String, Path, description = "Client id")), + responses( + (status = 204, description = "Client removed and its sessions revoked"), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Missing `clients:manage`, or no second factor enrolled", body = crate::error::ErrorBody), + (status = 404, description = "No such client", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn delete( + admin: AdminUser, + State(state): State, + ClientIp(ip): ClientIp, + Path(client_id): Path, +) -> Result { + admin.require(&state, "clients:manage").await?; + admin_clients::delete(&state, &actor(&admin, ip), &client_id).await?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + post, + path = "/admin/clients/{client_id}/secret", + tag = "admin", + params(("client_id" = String, Path, description = "Client id")), + responses( + (status = 200, description = "A new secret; the client is confidential from now on", body = ClientSecretResponse), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Missing `clients:manage`, no second factor, or re-authentication required", body = crate::error::ErrorBody), + (status = 404, description = "No such client", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn rotate_secret( + admin: AdminUser, + State(state): State, + ClientIp(ip): ClientIp, + Path(client_id): Path, +) -> Result, AppError> { + admin.require(&state, "clients:manage").await?; + crate::services::reauth::require_recent_reauth_or_password( + &state, + admin.auth.user_id, + admin.auth.session_id, + None, + ip, + admin.auth.request_id, + "admin_client_secret", + ) + .await?; + let client_secret = + admin_clients::rotate_secret(&state, &actor(&admin, ip), &client_id).await?; + Ok(Json(ClientSecretResponse { client_secret })) +} + +#[utoipa::path( + delete, + path = "/admin/clients/{client_id}/secret", + tag = "admin", + params(("client_id" = String, Path, description = "Client id")), + responses( + (status = 204, description = "Secret removed; the client is public"), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Missing `clients:manage`, or no second factor enrolled", body = crate::error::ErrorBody), + (status = 404, description = "No such client", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn remove_secret( + admin: AdminUser, + State(state): State, + ClientIp(ip): ClientIp, + Path(client_id): Path, +) -> Result { + admin.require(&state, "clients:manage").await?; + admin_clients::remove_secret(&state, &actor(&admin, ip), &client_id).await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/src/handlers/admin/mod.rs b/src/handlers/admin/mod.rs new file mode 100644 index 0000000..40c2552 --- /dev/null +++ b/src/handlers/admin/mod.rs @@ -0,0 +1,22 @@ +//! Administration routes, under `/admin`. Each requires an access token +//! carrying the permission of its action, a second factor enrolled on the +//! administrator's account, and the permission still granted in the database. + +pub mod audit; +pub mod clients; +pub mod roles; +pub mod users; +pub mod webhooks; + +use crate::services::admin::Actor; + +use super::extractors::AdminUser; + +pub(crate) fn actor(admin: &AdminUser, ip: Option) -> Actor { + Actor { + user_id: admin.auth.user_id, + session_id: admin.auth.session_id, + ip, + request_id: admin.auth.request_id, + } +} diff --git a/src/handlers/admin/roles.rs b/src/handlers/admin/roles.rs new file mode 100644 index 0000000..9764924 --- /dev/null +++ b/src/handlers/admin/roles.rs @@ -0,0 +1,260 @@ +//! `/admin/roles`, `/admin/permissions` and the roles of an account. + +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + domain::role::Role, + error::AppError, + handlers::extractors::{AdminUser, ClientIp}, + repositories::role as role_repo, + services::admin::roles as admin_roles, + state::AppState, +}; + +use super::actor; + +#[derive(Serialize, utoipa::ToSchema)] +pub struct PermissionResponse { + /// `resource:action`. + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct RoleResponse { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Given to every new account. + pub is_default: bool, + pub permissions: Vec, + pub created_at: i64, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct CreateRoleRequest { + pub name: String, + pub description: Option, + #[serde(default)] + pub permissions: Vec, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct RolePermissionsRequest { + pub permissions: Vec, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct AssignRoleRequest { + pub role: String, +} + +fn role_response(role: Role, permissions: Vec) -> RoleResponse { + RoleResponse { + name: role.name, + description: role.description, + is_default: role.is_default, + permissions, + created_at: role.created_at.unix_timestamp(), + } +} + +#[utoipa::path( + get, + path = "/admin/permissions", + tag = "admin", + responses( + (status = 200, description = "Every permission a role can grant", body = [PermissionResponse]), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Missing `roles:manage`, or no second factor enrolled", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn permissions( + admin: AdminUser, + State(state): State, +) -> Result>, AppError> { + admin.require(&state, "roles:manage").await?; + let permissions = role_repo::find_all_permissions(&state.db).await?; + Ok(Json( + permissions + .into_iter() + .map(|p| PermissionResponse { + name: p.name, + description: p.description, + }) + .collect(), + )) +} + +#[utoipa::path( + get, + path = "/admin/roles", + tag = "admin", + responses( + (status = 200, description = "Every role with its permissions", body = [RoleResponse]), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Missing `roles:manage`, or no second factor enrolled", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn list( + admin: AdminUser, + State(state): State, +) -> Result>, AppError> { + admin.require(&state, "roles:manage").await?; + let roles = admin_roles::list(&state).await?; + Ok(Json( + roles + .into_iter() + .map(|(role, permissions)| role_response(role, permissions)) + .collect(), + )) +} + +#[utoipa::path( + post, + path = "/admin/roles", + tag = "admin", + request_body = CreateRoleRequest, + responses( + (status = 201, description = "Role created", body = RoleResponse), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Missing `roles:manage`, no second factor, or re-authentication required", body = crate::error::ErrorBody), + (status = 409, description = "`role_exists`", body = crate::error::ErrorBody), + (status = 422, description = "Invalid name or unknown permission", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn create( + admin: AdminUser, + State(state): State, + ClientIp(ip): ClientIp, + Json(body): Json, +) -> Result<(StatusCode, Json), AppError> { + admin.require(&state, "roles:manage").await?; + let (role, permissions) = admin_roles::create( + &state, + &actor(&admin, ip), + &body.name, + body.description.as_deref(), + &body.permissions, + ) + .await?; + Ok((StatusCode::CREATED, Json(role_response(role, permissions)))) +} + +#[utoipa::path( + put, + path = "/admin/roles/{name}/permissions", + tag = "admin", + params(("name" = String, Path, description = "Role name")), + request_body = RolePermissionsRequest, + responses( + (status = 200, description = "The role now grants exactly these permissions", body = RoleResponse), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Missing `roles:manage`, no second factor, or re-authentication required", body = crate::error::ErrorBody), + (status = 404, description = "No such role", body = crate::error::ErrorBody), + (status = 409, description = "`last_administrator`: nobody would keep `roles:manage`", body = crate::error::ErrorBody), + (status = 422, description = "Unknown permission", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn set_permissions( + admin: AdminUser, + State(state): State, + ClientIp(ip): ClientIp, + Path(name): Path, + Json(body): Json, +) -> Result, AppError> { + admin.require(&state, "roles:manage").await?; + let (role, permissions) = + admin_roles::set_permissions(&state, &actor(&admin, ip), &name, &body.permissions).await?; + Ok(Json(role_response(role, permissions))) +} + +#[utoipa::path( + delete, + path = "/admin/roles/{name}", + tag = "admin", + params(("name" = String, Path, description = "Role name")), + responses( + (status = 204, description = "Role deleted and taken back from every account"), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Missing `roles:manage`, or no second factor enrolled", body = crate::error::ErrorBody), + (status = 404, description = "No such role", body = crate::error::ErrorBody), + (status = 409, description = "`default_role`, or `last_administrator`", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn delete( + admin: AdminUser, + State(state): State, + ClientIp(ip): ClientIp, + Path(name): Path, +) -> Result { + admin.require(&state, "roles:manage").await?; + admin_roles::delete(&state, &actor(&admin, ip), &name).await?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + post, + path = "/admin/users/{id}/roles", + tag = "admin", + params(("id" = Uuid, Path, description = "Account id")), + request_body = AssignRoleRequest, + responses( + (status = 204, description = "Role granted, or already held"), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Missing `roles:manage`, no second factor, or re-authentication required", body = crate::error::ErrorBody), + (status = 404, description = "No such account or role", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn assign( + admin: AdminUser, + State(state): State, + ClientIp(ip): ClientIp, + Path(user_id): Path, + Json(body): Json, +) -> Result { + admin.require(&state, "roles:manage").await?; + admin_roles::assign(&state, &actor(&admin, ip), user_id, &body.role).await?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + delete, + path = "/admin/users/{id}/roles/{name}", + tag = "admin", + params( + ("id" = Uuid, Path, description = "Account id"), + ("name" = String, Path, description = "Role name"), + ), + responses( + (status = 204, description = "Role taken back, or not held"), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Missing `roles:manage`, or no second factor enrolled", body = crate::error::ErrorBody), + (status = 404, description = "No such role", body = crate::error::ErrorBody), + (status = 409, description = "`last_administrator`: nobody would keep `roles:manage`", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn unassign( + admin: AdminUser, + State(state): State, + ClientIp(ip): ClientIp, + Path((user_id, name)): Path<(Uuid, String)>, +) -> Result { + admin.require(&state, "roles:manage").await?; + admin_roles::unassign(&state, &actor(&admin, ip), user_id, &name).await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/src/handlers/admin/users.rs b/src/handlers/admin/users.rs new file mode 100644 index 0000000..2fd2f69 --- /dev/null +++ b/src/handlers/admin/users.rs @@ -0,0 +1,334 @@ +//! `/admin/users`: finding an account and acting on it. + +use axum::{ + Json, + extract::{Path, Query, State}, + http::StatusCode, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + domain::user::{User, UserStatus}, + error::AppError, + handlers::{ + audit::{decode_cursor, encode_cursor, page_limit, rows_to_fetch, split_page}, + extractors::{AdminUser, ClientIp}, + user::{CurrentPasswordRequest, user_status_str}, + }, + services::admin::users as admin_users, + state::AppState, +}; + +use super::actor; + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct SearchParams { + /// Start of the address or of the username, case-insensitive. + pub query: Option, + /// `active`, `inactive`, `suspended` or `pending_verification`. + pub status: Option, + pub limit: Option, + /// `next_cursor` of the previous page. + pub cursor: Option, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct AdminUserSummary { + pub id: Uuid, + pub username: String, + pub email: String, + pub status: String, + pub preferred_locale: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub email_verified_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_login_at: Option, + /// Present while a sign-in lockout lasts. + #[serde(skip_serializing_if = "Option::is_none")] + pub locked_until: Option, + pub created_at: i64, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct AdminUserPage { + pub users: Vec, + /// Pass back as `cursor` to read the next page; absent on the last one. + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct AdminUserDetail { + #[serde(flatten)] + pub account: AdminUserSummary, + pub roles: Vec, + /// Verified second factors. + pub two_factor_methods: usize, + pub active_sessions: usize, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct RevokedSessionsResponse { + pub revoked: u64, +} + +fn summary(state: &AppState, user: User) -> AdminUserSummary { + let now = state.clock.now(); + AdminUserSummary { + id: user.id, + status: user_status_str(&user.status), + locked_until: user + .locked_until + .filter(|until| *until > now) + .map(|until| until.unix_timestamp()), + email_verified_at: user.email_verified_at.map(|t| t.unix_timestamp()), + last_login_at: user.last_login_at.map(|t| t.unix_timestamp()), + created_at: user.created_at.unix_timestamp(), + username: user.username, + email: user.email, + preferred_locale: user.preferred_locale, + } +} + +fn parse_status(status: &str) -> Result { + match status { + "active" => Ok(UserStatus::Active), + "inactive" => Ok(UserStatus::Inactive), + "suspended" => Ok(UserStatus::Suspended), + "pending_verification" => Ok(UserStatus::PendingVerification), + _ => Err(AppError::Validation("unknown status".into())), + } +} + +#[utoipa::path( + get, + path = "/admin/users", + tag = "admin", + params( + ("query" = Option, Query, description = "Start of the address or username"), + ("status" = Option, Query, description = "active, inactive, suspended or pending_verification"), + ("limit" = Option, Query, description = "Accounts per page, 1-200 (default 50)"), + ("cursor" = Option, Query, description = "next_cursor of the previous page"), + ), + responses( + (status = 200, description = "Accounts, newest first", body = AdminUserPage), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Missing `users:read`, or no second factor enrolled", body = crate::error::ErrorBody), + (status = 422, description = "Invalid status or cursor", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn search( + admin: AdminUser, + State(state): State, + Query(params): Query, +) -> Result, AppError> { + admin.require(&state, "users:read").await?; + let limit = page_limit(params.limit); + let status = params.status.as_deref().map(parse_status).transpose()?; + let before = params.cursor.as_deref().map(decode_cursor).transpose()?; + + let rows = admin_users::search( + &state, + params.query.as_deref(), + status.as_ref(), + before, + rows_to_fetch(limit), + ) + .await?; + let (rows, more) = split_page(rows, limit); + let next_cursor = more + .then(|| { + rows.last() + .map(|last| encode_cursor(last.created_at, last.id)) + }) + .flatten(); + + Ok(Json(AdminUserPage { + users: rows.into_iter().map(|user| summary(&state, user)).collect(), + next_cursor, + })) +} + +#[utoipa::path( + get, + path = "/admin/users/{id}", + tag = "admin", + params(("id" = Uuid, Path, description = "Account id")), + responses( + (status = 200, description = "The account", body = AdminUserDetail), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Missing `users:read`, or no second factor enrolled", body = crate::error::ErrorBody), + (status = 404, description = "No such account", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn detail( + admin: AdminUser, + State(state): State, + Path(user_id): Path, +) -> Result, AppError> { + admin.require(&state, "users:read").await?; + let detail = admin_users::detail(&state, user_id).await?; + Ok(Json(AdminUserDetail { + account: summary(&state, detail.user), + roles: detail.roles, + two_factor_methods: detail.two_factor_methods, + active_sessions: detail.active_sessions, + })) +} + +#[utoipa::path( + post, + path = "/admin/users/{id}/suspend", + tag = "admin", + params(("id" = Uuid, Path, description = "Account id")), + responses( + (status = 204, description = "Suspended and signed out everywhere, or already suspended"), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Missing `users:manage`, no second factor enrolled, or the administrator's own account", body = crate::error::ErrorBody), + (status = 404, description = "No such account", body = crate::error::ErrorBody), + (status = 422, description = "The account was never verified", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn suspend( + admin: AdminUser, + State(state): State, + ClientIp(ip): ClientIp, + Path(user_id): Path, +) -> Result { + admin.require(&state, "users:manage").await?; + admin_users::suspend(&state, &actor(&admin, ip), user_id).await?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + post, + path = "/admin/users/{id}/reactivate", + tag = "admin", + params(("id" = Uuid, Path, description = "Account id")), + responses( + (status = 204, description = "Active again, or already active"), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Missing `users:manage`, or no second factor enrolled", body = crate::error::ErrorBody), + (status = 404, description = "No such account", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn reactivate( + admin: AdminUser, + State(state): State, + ClientIp(ip): ClientIp, + Path(user_id): Path, +) -> Result { + admin.require(&state, "users:manage").await?; + admin_users::reactivate(&state, &actor(&admin, ip), user_id).await?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + post, + path = "/admin/users/{id}/unlock", + tag = "admin", + params(("id" = Uuid, Path, description = "Account id")), + responses( + (status = 204, description = "Lockouts ended and past failures forgiven"), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Missing `users:manage`, or no second factor enrolled", body = crate::error::ErrorBody), + (status = 404, description = "No such account", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn unlock( + admin: AdminUser, + State(state): State, + ClientIp(ip): ClientIp, + Path(user_id): Path, +) -> Result { + admin.require(&state, "users:manage").await?; + admin_users::unlock(&state, &actor(&admin, ip), user_id).await?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + delete, + path = "/admin/users/{id}/sessions", + tag = "admin", + params(("id" = Uuid, Path, description = "Account id")), + responses( + (status = 200, description = "Signed out everywhere", body = RevokedSessionsResponse), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Missing `users:manage`, no second factor enrolled, or the administrator's own account", body = crate::error::ErrorBody), + (status = 404, description = "No such account", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn revoke_sessions( + admin: AdminUser, + State(state): State, + ClientIp(ip): ClientIp, + Path(user_id): Path, +) -> Result, AppError> { + admin.require(&state, "users:manage").await?; + let revoked = admin_users::revoke_sessions(&state, &actor(&admin, ip), user_id).await?; + Ok(Json(RevokedSessionsResponse { revoked })) +} + +#[utoipa::path( + post, + path = "/admin/users/{id}/password-reset", + tag = "admin", + params(("id" = Uuid, Path, description = "Account id")), + responses( + (status = 204, description = "Signed out everywhere and a reset link mailed to the owner"), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Missing `users:manage`, no second factor enrolled, or the administrator's own account", body = crate::error::ErrorBody), + (status = 404, description = "No such account", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn force_password_reset( + admin: AdminUser, + State(state): State, + ClientIp(ip): ClientIp, + Path(user_id): Path, +) -> Result { + admin.require(&state, "users:manage").await?; + admin_users::force_password_reset(&state, &actor(&admin, ip), user_id).await?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + delete, + path = "/admin/users/{id}", + tag = "admin", + params(("id" = Uuid, Path, description = "Account id")), + request_body = Option, + responses( + (status = 204, description = "Account deleted and `user.deleted` announced"), + (status = 401, description = "Missing, invalid or revoked access token, or wrong password", body = crate::error::ErrorBody), + (status = 403, description = "Missing `users:manage`, no second factor, the administrator's own account, or re-authentication required", body = crate::error::ErrorBody), + (status = 404, description = "No such account", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn delete( + admin: AdminUser, + State(state): State, + ClientIp(ip): ClientIp, + Path(user_id): Path, + body: Option>, +) -> Result { + admin.require(&state, "users:manage").await?; + let current_password = body.and_then(|Json(b)| b.current_password); + admin_users::delete( + &state, + &actor(&admin, ip), + user_id, + current_password.as_deref(), + ) + .await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/src/handlers/admin/webhooks.rs b/src/handlers/admin/webhooks.rs new file mode 100644 index 0000000..5d1f500 --- /dev/null +++ b/src/handlers/admin/webhooks.rs @@ -0,0 +1,288 @@ +//! `/admin/webhooks`: endpoints receiving domain events, and their deliveries. + +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + error::AppError, + handlers::extractors::{AdminUser, ClientIp}, + repositories::webhook::{self as webhook_repo, WebhookDelivery, WebhookEndpoint}, + services::webhooks::{self as webhook_svc, EndpointInput}, + state::AppState, +}; + +use super::actor; + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct WebhookRequest { + /// HTTPS URL receiving the deliveries. + pub url: String, + pub description: Option, + /// Event names (`user.created`, `user.deleted`, ...) or `*`. + pub events: Vec, + /// Default: true. + pub enabled: Option, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct WebhookResponse { + pub id: Uuid, + pub url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub events: Vec, + pub enabled: bool, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct CreatedWebhookResponse { + #[serde(flatten)] + pub webhook: WebhookResponse, + /// Signing secret (`whsec_...`), shown once. + pub secret: String, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct WebhookSecretResponse { + /// The new signing secret, shown once; the previous one stops signing now. + pub secret: String, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct WebhookDeliveryResponse { + pub id: Uuid, + pub event_id: Uuid, + pub event: String, + pub attempts: i32, + pub created_at: i64, + /// Next attempt, while neither delivered nor given up. + #[serde(skip_serializing_if = "Option::is_none")] + pub next_attempt_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub delivered_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub failed_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_error: Option, +} + +fn webhook_response(endpoint: WebhookEndpoint) -> WebhookResponse { + WebhookResponse { + id: endpoint.id, + url: endpoint.url, + description: endpoint.description, + events: endpoint.events, + enabled: endpoint.enabled, + created_at: endpoint.created_at.unix_timestamp(), + updated_at: endpoint.updated_at.unix_timestamp(), + } +} + +fn delivery_response(delivery: WebhookDelivery) -> WebhookDeliveryResponse { + let finished = delivery.delivered_at.is_some() || delivery.failed_at.is_some(); + WebhookDeliveryResponse { + id: delivery.id, + event_id: delivery.event_id, + event: delivery.event_name, + attempts: delivery.attempts, + created_at: delivery.created_at.unix_timestamp(), + next_attempt_at: (!finished).then(|| delivery.next_attempt_at.unix_timestamp()), + delivered_at: delivery.delivered_at.map(|t| t.unix_timestamp()), + failed_at: delivery.failed_at.map(|t| t.unix_timestamp()), + last_status: delivery.last_status, + last_error: delivery.last_error, + } +} + +fn input(body: &WebhookRequest) -> EndpointInput<'_> { + EndpointInput { + url: &body.url, + description: body.description.as_deref(), + events: &body.events, + enabled: body.enabled.unwrap_or(true), + } +} + +#[utoipa::path( + get, + path = "/admin/webhooks", + tag = "admin", + responses( + (status = 200, description = "Every webhook endpoint", body = [WebhookResponse]), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Missing `webhooks:manage`, or no second factor enrolled", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn list( + admin: AdminUser, + State(state): State, +) -> Result>, AppError> { + admin.require(&state, "webhooks:manage").await?; + let endpoints = webhook_repo::find_all_endpoints(&state.db).await?; + Ok(Json(endpoints.into_iter().map(webhook_response).collect())) +} + +#[utoipa::path( + post, + path = "/admin/webhooks", + tag = "admin", + request_body = WebhookRequest, + responses( + (status = 201, description = "Endpoint registered; its signing secret is in this response only", body = CreatedWebhookResponse), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Missing `webhooks:manage`, or no second factor enrolled", body = crate::error::ErrorBody), + (status = 422, description = "Invalid URL or unknown event", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn create( + admin: AdminUser, + State(state): State, + ClientIp(ip): ClientIp, + Json(body): Json, +) -> Result<(StatusCode, Json), AppError> { + admin.require(&state, "webhooks:manage").await?; + let saved = webhook_svc::create(&state, &actor(&admin, ip), &input(&body)).await?; + Ok(( + StatusCode::CREATED, + Json(CreatedWebhookResponse { + webhook: webhook_response(saved.endpoint), + secret: saved.secret.unwrap_or_default(), + }), + )) +} + +#[utoipa::path( + put, + path = "/admin/webhooks/{id}", + tag = "admin", + params(("id" = Uuid, Path, description = "Webhook id")), + request_body = WebhookRequest, + responses( + (status = 200, description = "Endpoint updated; the secret is unchanged", body = WebhookResponse), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Missing `webhooks:manage`, or no second factor enrolled", body = crate::error::ErrorBody), + (status = 404, description = "No such webhook", body = crate::error::ErrorBody), + (status = 422, description = "Invalid URL or unknown event", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn update( + admin: AdminUser, + State(state): State, + ClientIp(ip): ClientIp, + Path(id): Path, + Json(body): Json, +) -> Result, AppError> { + admin.require(&state, "webhooks:manage").await?; + let endpoint = webhook_svc::update(&state, &actor(&admin, ip), id, &input(&body)).await?; + Ok(Json(webhook_response(endpoint))) +} + +#[utoipa::path( + delete, + path = "/admin/webhooks/{id}", + tag = "admin", + params(("id" = Uuid, Path, description = "Webhook id")), + responses( + (status = 204, description = "Endpoint and its pending deliveries removed"), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Missing `webhooks:manage`, or no second factor enrolled", body = crate::error::ErrorBody), + (status = 404, description = "No such webhook", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn delete( + admin: AdminUser, + State(state): State, + ClientIp(ip): ClientIp, + Path(id): Path, +) -> Result { + admin.require(&state, "webhooks:manage").await?; + webhook_svc::delete(&state, &actor(&admin, ip), id).await?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + post, + path = "/admin/webhooks/{id}/secret", + tag = "admin", + params(("id" = Uuid, Path, description = "Webhook id")), + responses( + (status = 200, description = "A new signing secret", body = WebhookSecretResponse), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Missing `webhooks:manage`, or no second factor enrolled", body = crate::error::ErrorBody), + (status = 404, description = "No such webhook", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn rotate_secret( + admin: AdminUser, + State(state): State, + ClientIp(ip): ClientIp, + Path(id): Path, +) -> Result, AppError> { + admin.require(&state, "webhooks:manage").await?; + let secret = webhook_svc::rotate_secret(&state, &actor(&admin, ip), id).await?; + Ok(Json(WebhookSecretResponse { secret })) +} + +#[utoipa::path( + get, + path = "/admin/webhooks/{id}/deliveries", + tag = "admin", + params(("id" = Uuid, Path, description = "Webhook id")), + responses( + (status = 200, description = "The latest 100 deliveries, newest first", body = [WebhookDeliveryResponse]), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Missing `webhooks:manage`, or no second factor enrolled", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn deliveries( + admin: AdminUser, + State(state): State, + Path(id): Path, +) -> Result>, AppError> { + admin.require(&state, "webhooks:manage").await?; + let deliveries = webhook_repo::find_recent_deliveries(&state.db_read, id, 100).await?; + Ok(Json( + deliveries.into_iter().map(delivery_response).collect(), + )) +} + +#[utoipa::path( + post, + path = "/admin/webhooks/{id}/deliveries/{delivery_id}/retry", + tag = "admin", + params( + ("id" = Uuid, Path, description = "Webhook id"), + ("delivery_id" = Uuid, Path, description = "Delivery id"), + ), + responses( + (status = 204, description = "Delivery queued again with a fresh attempt budget"), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Missing `webhooks:manage`, or no second factor enrolled", body = crate::error::ErrorBody), + (status = 404, description = "No such delivery for this webhook", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn retry( + admin: AdminUser, + State(state): State, + Path((id, delivery_id)): Path<(Uuid, Uuid)>, +) -> Result { + admin.require(&state, "webhooks:manage").await?; + webhook_svc::redeliver(&state, id, delivery_id).await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/src/handlers/audit.rs b/src/handlers/audit.rs new file mode 100644 index 0000000..9c94be0 --- /dev/null +++ b/src/handlers/audit.rs @@ -0,0 +1,277 @@ +//! The caller's own security history. +//! +//! Every sign-in, password change and replayed session is written to the audit +//! log; showing people their own entries is how they notice one that is not +//! theirs. Scoped to the caller by the query: there is no id to pass, and none +//! to guess. + +use axum::{ + Json, + extract::{Query, State}, +}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD as B64URL}; +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::{ + domain::audit::AuditAction, error::AppError, repositories::audit as audit_repo, state::AppState, +}; + +use super::extractors::AuthUser; + +const DEFAULT_LIMIT: i64 = 50; +/// Most entries one page may hold; larger requests are clamped, not refused. +const MAX_LIMIT: i64 = 200; + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct ListParams { + pub limit: Option, + /// `next_cursor` of the previous page. + pub cursor: Option, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct AuditEntryResponse { + pub id: Uuid, + /// Unix timestamp (seconds), like every other timestamp of this API. + pub created_at: i64, + /// Stable snake_case name: `login`, `password_changed`, ... + pub action: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub ip_address: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, + #[schema(value_type = Object)] + pub metadata: serde_json::Value, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct AuditPageResponse { + pub entries: Vec, + /// Pass back as `cursor` to read the next page; absent on the last one. + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, +} + +/// GET /users/me/audit - newest first. +#[utoipa::path( + get, + path = "/users/me/audit", + tag = "account", + params(("limit" = Option, Query, description = "Entries per page, 1-200 (default 50)"), ("cursor" = Option, Query, description = "next_cursor of the previous page")), + responses( + (status = 200, description = "The caller's security history, newest first", body = AuditPageResponse), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 422, description = "Invalid cursor", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn list( + State(state): State, + auth: AuthUser, + Query(params): Query, +) -> Result, AppError> { + let limit = page_limit(params.limit); + let before = params.cursor.as_deref().map(decode_cursor).transpose()?; + + let rows = + audit_repo::find_page_by_user(&state.db_read, auth.user_id, before, rows_to_fetch(limit)) + .await + .map_err(|e| AppError::Internal(e.into()))?; + + let (rows, more) = split_page(rows, limit); + let next_cursor = if more { + rows.last() + .map(|last| encode_cursor(last.created_at, last.id)) + } else { + None + }; + + Ok(Json(AuditPageResponse { + entries: rows + .into_iter() + .map(|entry| AuditEntryResponse { + id: entry.id, + created_at: entry.created_at.unix_timestamp(), + action: action_name(&entry.action), + // The address, not the network: every row is written from one + // address and a `/32` on each line says nothing. + ip_address: entry.ip_address.map(|net| net.ip().to_string()), + request_id: entry.request_id, + metadata: entry.metadata, + }) + .collect(), + next_cursor, + })) +} + +/// Entries per page: the requested count, bounded to 1..=MAX_LIMIT. +pub(crate) fn page_limit(requested: Option) -> i64 { + requested.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT) +} + +/// One row beyond the page tells whether another page follows. +pub(crate) fn rows_to_fetch(limit: i64) -> i64 { + limit + 1 +} + +/// The page itself, and whether another page follows it. +pub(crate) fn split_page(mut rows: Vec, limit: i64) -> (Vec, bool) { + let limit = usize::try_from(limit).unwrap_or(0); + let more = rows.len() > limit; + rows.truncate(limit); + (rows, more) +} + +/// Opaque to clients: the position of the last entry of a page. +pub(crate) fn encode_cursor(created_at: OffsetDateTime, id: Uuid) -> String { + B64URL.encode(format!("{}:{id}", created_at.unix_timestamp_nanos())) +} + +pub(crate) fn decode_cursor(cursor: &str) -> Result<(OffsetDateTime, Uuid), AppError> { + let invalid = || AppError::Validation("invalid cursor".into()); + let raw = B64URL.decode(cursor).map_err(|_| invalid())?; + let raw = std::str::from_utf8(&raw).map_err(|_| invalid())?; + let (nanos, id) = raw.split_once(':').ok_or_else(invalid)?; + let nanos: i128 = nanos.parse().map_err(|_| invalid())?; + let created_at = OffsetDateTime::from_unix_timestamp_nanos(nanos).map_err(|_| invalid())?; + let id = id.parse().map_err(|_| invalid())?; + Ok((created_at, id)) +} + +/// Wire name of an action. Written out rather than derived: these strings are +/// an API, and renaming a Rust variant must not rename them silently. +pub(crate) fn action_name(action: &AuditAction) -> &'static str { + use AuditAction as A; + match action { + A::Login => "login", + A::LoginFailed => "login_failed", + A::Logout => "logout", + A::Register => "register", + A::EmailVerificationSent => "email_verification_sent", + A::EmailVerified => "email_verified", + A::PasswordChanged => "password_changed", + A::PasswordResetRequested => "password_reset_requested", + A::PasswordResetCompleted => "password_reset_completed", + A::TwoFactorEnabled => "two_factor_enabled", + A::TwoFactorDisabled => "two_factor_disabled", + A::TwoFactorVerified => "two_factor_verified", + A::TwoFactorFailed => "two_factor_failed", + A::RoleAssigned => "role_assigned", + A::RoleRevoked => "role_revoked", + A::SessionRevoked => "session_revoked", + A::SessionReplayDetected => "session_replay_detected", + A::SessionFamilyRevoked => "session_family_revoked", + A::AccountSuspended => "account_suspended", + A::AccountReactivated => "account_reactivated", + A::RateLimitExceeded => "rate_limit_exceeded", + A::SuspiciousLogin => "suspicious_login", + A::NewDeviceLogin => "new_device_login", + A::AccountDeleted => "account_deleted", + A::Reauthenticated => "reauthenticated", + A::UsernameChanged => "username_changed", + A::RecoveryCodeUsed => "recovery_code_used", + A::EmailChanged => "email_changed", + A::EncryptionKeyRotated => "encryption_key_rotated", + A::AccountUnlocked => "account_unlocked", + A::PasswordResetForced => "password_reset_forced", + A::RoleCreated => "role_created", + A::RoleDeleted => "role_deleted", + A::RolePermissionsChanged => "role_permissions_changed", + A::ClientRegistered => "client_registered", + A::ClientUpdated => "client_updated", + A::ClientDeleted => "client_deleted", + A::DataExported => "data_exported", + A::MagicLinkSent => "magic_link_sent", + A::PersonalAccessTokenCreated => "personal_access_token_created", + A::PersonalAccessTokenRevoked => "personal_access_token_revoked", + A::WebhookCreated => "webhook_created", + A::WebhookUpdated => "webhook_updated", + A::WebhookDeleted => "webhook_deleted", + A::WebhookSecretRotated => "webhook_secret_rotated", + A::ClientSecretRotated => "client_secret_rotated", + A::PasskeyRegistered => "passkey_registered", + A::PasskeyRemoved => "passkey_removed", + A::ExternalIdentityLinked => "external_identity_linked", + A::ExternalIdentityUnlinked => "external_identity_unlinked", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_cursor_round_trips_to_the_microsecond() { + let at = OffsetDateTime::from_unix_timestamp_nanos(1_789_000_000_123_456_000).unwrap(); + let id = Uuid::new_v4(); + assert_eq!(decode_cursor(&encode_cursor(at, id)).unwrap(), (at, id)); + } + + #[test] + fn a_malformed_cursor_is_a_validation_error() { + for cursor in [ + "", + "not base64!", + &B64URL.encode("123"), + &B64URL.encode("x:y"), + ] { + assert!(matches!( + decode_cursor(cursor), + Err(AppError::Validation(_)) + )); + } + } + + #[test] + fn wire_names_are_stable() { + assert_eq!(action_name(&AuditAction::Login), "login"); + assert_eq!( + action_name(&AuditAction::SessionReplayDetected), + "session_replay_detected" + ); + } + + mod properties { + use proptest::prelude::*; + + use super::*; + + proptest! { + #![proptest_config(ProptestConfig::with_cases(512))] + + #[test] + fn cursors_round_trip_across_the_whole_timestamp_range( + nanos in -377_705_116_800_000_000_000i128..=253_402_300_799_999_999_999i128, + id in any::<[u8; 16]>(), + ) { + let at = OffsetDateTime::from_unix_timestamp_nanos(nanos).unwrap(); + let id = Uuid::from_bytes(id); + prop_assert_eq!(decode_cursor(&encode_cursor(at, id)).unwrap(), (at, id)); + } + } + } + + #[test] + fn a_page_holds_between_one_and_the_maximum_entries() { + assert_eq!(page_limit(None), DEFAULT_LIMIT); + assert_eq!(page_limit(Some(0)), 1); + assert_eq!(page_limit(Some(-7)), 1); + assert_eq!(page_limit(Some(MAX_LIMIT)), MAX_LIMIT); + assert_eq!(page_limit(Some(MAX_LIMIT + 1)), MAX_LIMIT); + assert_eq!(page_limit(Some(i64::MAX)), MAX_LIMIT); + } + + #[test] + fn one_extra_row_tells_whether_another_page_follows() { + assert_eq!(rows_to_fetch(50), 51); + let (page, more) = split_page((0..50).collect::>(), 50); + assert_eq!((page.len(), more), (50, false)); + let (page, more) = split_page((0..51).collect::>(), 50); + assert_eq!((page.len(), more), (50, true)); + assert_eq!(page.last(), Some(&49)); + let (page, more) = split_page(Vec::::new(), 50); + assert_eq!((page.len(), more), (0, false)); + } +} diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs index 3e21efb..2ea9f91 100644 --- a/src/handlers/auth.rs +++ b/src/handlers/auth.rs @@ -3,7 +3,6 @@ use axum::{Json, extract::State, http::StatusCode}; use serde::{Deserialize, Serialize}; -use uuid::Uuid; use crate::{ error::AppError, @@ -13,12 +12,12 @@ use crate::{ use super::{ extractors::{AuthUser, ClientIp, RequestId, UserAgent}, - user::{user_status_str, validate_locale, validate_password}, + user::{validate_locale, validate_password}, }; // Request types -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct RegisterRequest { pub username: String, pub email: String, @@ -28,7 +27,7 @@ pub struct RegisterRequest { pub captcha_token: Option, } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct LoginRequest { pub identifier: String, pub password: String, @@ -39,69 +38,89 @@ pub struct LoginRequest { pub captcha_token: Option, } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct RefreshRequest { pub refresh_token: String, } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct VerifyEmailRequest { pub token: String, } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] +pub struct ResendVerificationRequest { + pub email: String, + pub captcha_token: Option, +} + +#[derive(Deserialize, utoipa::ToSchema)] pub struct ForgotPasswordRequest { pub email: String, pub captcha_token: Option, } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] +pub struct MagicLinkRequest { + pub email: String, + pub captcha_token: Option, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct CompleteMagicLinkRequest { + /// The token from the link's fragment. + pub token: String, + pub device_name: Option, + /// When true, issues a long-lived refresh token. + pub remember_me: Option, +} + +#[derive(Deserialize, utoipa::ToSchema)] pub struct ResetPasswordRequest { pub token: String, pub new_password: String, } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct CompleteTwoFactorRequest { pub pre_auth_token: String, pub code: String, } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct RecoveryLoginRequest { pub pre_auth_token: String, pub recovery_code: String, } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct CompleteEmailTwoFactorRequest { pub pre_auth_token: String, pub code: String, } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct ResendEmailTwoFactorRequest { pub pre_auth_token: String, } // Response types -#[derive(Serialize)] -pub struct UserResponse { - pub id: Uuid, - pub username: String, - pub email: String, - pub status: String, - pub preferred_locale: String, +/// Registration answer. Identical whether the address was free or already had +/// an account, so it cannot be used to enumerate accounts. +#[derive(Serialize, utoipa::ToSchema)] +pub struct RegistrationAccepted { + pub status: &'static str, + pub message: &'static str, } -#[derive(Serialize)] +#[derive(Serialize, utoipa::ToSchema)] pub struct TokensResponse { pub access_token: String, pub refresh_token: String, } -#[derive(Serialize)] +#[derive(Serialize, utoipa::ToSchema)] #[serde(untagged)] pub enum LoginResponse { Complete { @@ -118,13 +137,25 @@ pub enum LoginResponse { // Handlers +#[utoipa::path( + post, + path = "/auth/register", + tag = "auth", + request_body = RegisterRequest, + responses( + (status = 202, description = "Accepted; identical whether or not the address is taken", body = RegistrationAccepted), + (status = 409, description = "Username already taken (`username_taken`); a taken address is not revealed", body = crate::error::ErrorBody), + (status = 422, description = "Invalid input", body = crate::error::ErrorBody), + (status = 429, description = "Rate limited; see Retry-After"), + ), +)] pub async fn register( State(state): State, ClientIp(ip): ClientIp, UserAgent(ua): UserAgent, RequestId(rid): RequestId, Json(body): Json, -) -> Result<(StatusCode, Json), AppError> { +) -> Result<(StatusCode, Json), AppError> { validate_email(&body.email)?; validate_password(&body.password)?; validate_username(&body.username)?; @@ -136,7 +167,7 @@ pub async fn register( let captcha_token = body.captcha_token.as_deref().unwrap_or(""); captcha_svc::verify(&state, captcha_token).await?; - let user = auth_svc::register( + auth_svc::register( &state, &body.username, &body.email, @@ -149,17 +180,27 @@ pub async fn register( .await?; Ok(( - StatusCode::CREATED, - Json(UserResponse { - id: user.id, - username: user.username, - email: user.email, - status: user_status_str(&user.status), - preferred_locale: user.preferred_locale, + StatusCode::ACCEPTED, + Json(RegistrationAccepted { + status: "pending_verification", + message: "Check your inbox to verify your email address.", }), )) } +#[utoipa::path( + post, + path = "/auth/login", + tag = "auth", + request_body = LoginRequest, + responses( + (status = 200, description = "Tokens, or a two-factor challenge", body = LoginResponse), + (status = 401, description = "Invalid credentials", body = crate::error::ErrorBody), + (status = 403, description = "Account locked, suspended or not verified", body = crate::error::ErrorBody), + (status = 422, description = "Invalid input", body = crate::error::ErrorBody), + (status = 429, description = "Rate limited; see Retry-After"), + ), +)] pub async fn login( State(state): State, ClientIp(ip): ClientIp, @@ -167,6 +208,16 @@ pub async fn login( RequestId(rid): RequestId, Json(body): Json, ) -> Result, AppError> { + // Bound client input before the database and Argon2 see it. + if body.identifier.is_empty() + || body.identifier.len() > MAX_IDENTIFIER_LEN + || body.password.len() > MAX_LOGIN_PASSWORD_LEN + { + return Err(AppError::Validation( + "identifier or password has an invalid length".into(), + )); + } + let captcha_token = body.captcha_token.as_deref().unwrap_or(""); captcha_svc::verify(&state, captcha_token).await?; @@ -182,7 +233,11 @@ pub async fn login( ) .await?; - let response = match result { + Ok(Json(login_response(result))) +} + +pub(crate) fn login_response(result: auth_svc::LoginResult) -> LoginResponse { + match result { auth_svc::LoginResult::Complete(tokens) => LoginResponse::Complete { access_token: tokens.access_token, refresh_token: tokens.refresh_token, @@ -195,11 +250,19 @@ pub async fn login( two_factor_method: method, pre_auth_token, }, - }; - - Ok(Json(response)) + } } +#[utoipa::path( + post, + path = "/auth/logout", + tag = "auth", + responses( + (status = 204, description = "Session ended"), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] pub async fn logout( State(state): State, ClientIp(ip): ClientIp, @@ -218,6 +281,18 @@ pub async fn logout( Ok(StatusCode::NO_CONTENT) } +#[utoipa::path( + post, + path = "/auth/refresh", + tag = "auth", + request_body = RefreshRequest, + responses( + (status = 200, description = "Tokens issued", body = TokensResponse), + (status = 401, description = "Invalid, expired or replayed refresh token", body = crate::error::ErrorBody), + (status = 403, description = "Account suspended or inactive", body = crate::error::ErrorBody), + (status = 429, description = "Rate limited; see Retry-After"), + ), +)] pub async fn refresh( State(state): State, ClientIp(ip): ClientIp, @@ -226,7 +301,7 @@ pub async fn refresh( Json(body): Json, ) -> Result, AppError> { let tokens = - auth_svc::refresh_token(&state, &body.refresh_token, ip, ua.as_deref(), rid).await?; + auth_svc::refresh_token(&state, &body.refresh_token, None, ip, ua.as_deref(), rid).await?; Ok(Json(TokensResponse { access_token: tokens.access_token, refresh_token: tokens.refresh_token, @@ -235,6 +310,17 @@ pub async fn refresh( // Accepts the token in the request body rather than in the URL query string so // that it is not captured in server access logs, browser history, or Referer headers. +#[utoipa::path( + post, + path = "/auth/verify-email", + tag = "auth", + request_body = VerifyEmailRequest, + responses( + (status = 200, description = "Address verified"), + (status = 401, description = "Invalid or expired token", body = crate::error::ErrorBody), + (status = 429, description = "Rate limited; see Retry-After"), + ), +)] pub async fn verify_email( State(state): State, ClientIp(ip): ClientIp, @@ -245,6 +331,98 @@ pub async fn verify_email( Ok(StatusCode::OK) } +#[utoipa::path( + post, + path = "/auth/verify-email/resend", + tag = "auth", + request_body = ResendVerificationRequest, + responses( + (status = 200, description = "Accepted; identical whether the address is unknown, pending or already verified"), + (status = 429, description = "Rate limited; see Retry-After"), + ), +)] +pub async fn resend_verification( + State(state): State, + ClientIp(ip): ClientIp, + UserAgent(ua): UserAgent, + RequestId(rid): RequestId, + Json(body): Json, +) -> Result { + let captcha_token = body.captcha_token.as_deref().unwrap_or(""); + captcha_svc::verify(&state, captcha_token).await?; + + auth_svc::resend_verification(&state, &body.email, ip, ua.as_deref(), rid).await?; + Ok(StatusCode::OK) +} + +#[utoipa::path( + post, + path = "/auth/magic-link", + tag = "auth", + request_body = MagicLinkRequest, + responses( + (status = 200, description = "Accepted; identical whether or not an account can sign in with this address"), + (status = 404, description = "Sign-in links are not enabled on this deployment", body = crate::error::ErrorBody), + (status = 429, description = "Rate limited; see Retry-After"), + ), +)] +pub async fn request_magic_link( + State(state): State, + ClientIp(ip): ClientIp, + UserAgent(ua): UserAgent, + RequestId(rid): RequestId, + Json(body): Json, +) -> Result { + let captcha_token = body.captcha_token.as_deref().unwrap_or(""); + captcha_svc::verify(&state, captcha_token).await?; + + auth_svc::request_magic_link(&state, &body.email, ip, ua.as_deref(), rid).await?; + Ok(StatusCode::OK) +} + +#[utoipa::path( + post, + path = "/auth/magic-link/complete", + tag = "auth", + request_body = CompleteMagicLinkRequest, + responses( + (status = 200, description = "Tokens, or the account's two-factor challenge", body = LoginResponse), + (status = 401, description = "Invalid, used or expired link", body = crate::error::ErrorBody), + (status = 403, description = "Account locked, suspended or inactive", body = crate::error::ErrorBody), + (status = 404, description = "Sign-in links are not enabled on this deployment", body = crate::error::ErrorBody), + (status = 429, description = "Rate limited; see Retry-After"), + ), +)] +pub async fn complete_magic_link( + State(state): State, + ClientIp(ip): ClientIp, + UserAgent(ua): UserAgent, + RequestId(rid): RequestId, + Json(body): Json, +) -> Result, AppError> { + let result = auth_svc::complete_magic_link( + &state, + &body.token, + ip, + ua.as_deref(), + body.device_name.as_deref(), + body.remember_me.unwrap_or(false), + rid, + ) + .await?; + Ok(Json(login_response(result))) +} + +#[utoipa::path( + post, + path = "/auth/forgot-password", + tag = "auth", + request_body = ForgotPasswordRequest, + responses( + (status = 200, description = "Accepted; identical whether or not the account exists"), + (status = 429, description = "Rate limited; see Retry-After"), + ), +)] pub async fn forgot_password( State(state): State, ClientIp(ip): ClientIp, @@ -259,6 +437,17 @@ pub async fn forgot_password( Ok(StatusCode::OK) } +#[utoipa::path( + post, + path = "/auth/reset-password", + tag = "auth", + request_body = ResetPasswordRequest, + responses( + (status = 200, description = "Password replaced; every session revoked"), + (status = 401, description = "Invalid or expired token", body = crate::error::ErrorBody), + (status = 422, description = "Invalid input", body = crate::error::ErrorBody), + ), +)] pub async fn reset_password( State(state): State, ClientIp(ip): ClientIp, @@ -270,6 +459,18 @@ pub async fn reset_password( Ok(StatusCode::OK) } +#[utoipa::path( + post, + path = "/auth/two-factor/complete", + tag = "auth", + request_body = CompleteTwoFactorRequest, + responses( + (status = 200, description = "Tokens issued", body = TokensResponse), + (status = 401, description = "Invalid code or pre-auth token", body = crate::error::ErrorBody), + (status = 403, description = "Account suspended or locked since the challenge", body = crate::error::ErrorBody), + (status = 429, description = "Rate limited; see Retry-After"), + ), +)] pub async fn complete_two_factor( State(state): State, ClientIp(ip): ClientIp, @@ -294,6 +495,18 @@ pub async fn complete_two_factor( })) } +#[utoipa::path( + post, + path = "/auth/two-factor/recovery", + tag = "auth", + request_body = RecoveryLoginRequest, + responses( + (status = 200, description = "Tokens issued", body = TokensResponse), + (status = 401, description = "Invalid recovery code or pre-auth token", body = crate::error::ErrorBody), + (status = 403, description = "Account suspended or locked since the challenge", body = crate::error::ErrorBody), + (status = 429, description = "Rate limited; see Retry-After"), + ), +)] pub async fn recovery_login( State(state): State, ClientIp(ip): ClientIp, @@ -317,6 +530,18 @@ pub async fn recovery_login( })) } +#[utoipa::path( + post, + path = "/auth/two-factor/email/complete", + tag = "auth", + request_body = CompleteEmailTwoFactorRequest, + responses( + (status = 200, description = "Tokens issued", body = TokensResponse), + (status = 401, description = "Invalid code or pre-auth token", body = crate::error::ErrorBody), + (status = 403, description = "Account suspended or locked since the challenge", body = crate::error::ErrorBody), + (status = 429, description = "Rate limited; see Retry-After"), + ), +)] pub async fn complete_email_two_factor( State(state): State, ClientIp(ip): ClientIp, @@ -341,14 +566,26 @@ pub async fn complete_email_two_factor( })) } +#[utoipa::path( + post, + path = "/auth/two-factor/email/resend", + tag = "auth", + request_body = ResendEmailTwoFactorRequest, + responses( + (status = 204, description = "Code sent if the challenge is an email challenge"), + (status = 401, description = "Invalid pre-auth token", body = crate::error::ErrorBody), + ), +)] pub async fn resend_email_two_factor( State(state): State, Json(body): Json, ) -> Result { // Resolve user_id from pre_auth_token without revealing whether it exists. - let user_id = auth_svc::resolve_pre_auth(&state, &body.pre_auth_token) - .await? - .user_id; + let pre_auth = auth_svc::resolve_pre_auth(&state, &body.pre_auth_token).await?; + // Only an email challenge may request an email code: resending one for a + // TOTP challenge would let a mailbox stand in for the authenticator app. + pre_auth.expect_method(auth_svc::ChallengeMethod::Email)?; + let user_id = pre_auth.user_id; // Fire-and-forget: errors are non-fatal to avoid enumeration via timing. let _ = email_2fa_svc::send_code(&state, user_id).await; @@ -357,22 +594,35 @@ pub async fn resend_email_two_factor( // Validation helpers -fn validate_email(email: &str) -> Result<(), AppError> { - if !email_address::EmailAddress::is_valid(email) { +/// Longest identifier (email or username) accepted at login. +const MAX_IDENTIFIER_LEN: usize = 254; +/// Longest password accepted at login. Above the registration limit (128) so +/// no existing account is refused, but bounded before Argon2 runs. +const MAX_LOGIN_PASSWORD_LEN: usize = 256; + +/// Emails accepted for storage: syntactically valid and in the shape the +/// `users_email_format` constraint accepts, so a bad address is a 422 rather +/// than a constraint violation surfacing as a 500. +pub(crate) fn validate_email(email: &str) -> Result<(), AppError> { + if !email_address::EmailAddress::is_valid(email) + || !crate::domain::user::is_storable_email(email) + { return Err(AppError::Validation("invalid email address".into())); } Ok(()) } -fn validate_username(username: &str) -> Result<(), AppError> { - if username.len() < 3 || username.len() > 30 { +/// Usernames as the `users_username_format` constraint accepts them: 3 to 30 +/// ASCII letters, digits or underscores. +pub(crate) fn validate_username(username: &str) -> Result<(), AppError> { + if !(3..=30).contains(&username.len()) { return Err(AppError::Validation( "username must be 3 to 30 characters".into(), )); } - if !username.chars().all(|c| c.is_alphanumeric() || c == '_') { + if !crate::domain::user::is_valid_username(username) { return Err(AppError::Validation( - "username may only contain letters, digits and underscores".into(), + "username may only contain ASCII letters, digits and underscores".into(), )); } Ok(()) diff --git a/src/handlers/device.rs b/src/handlers/device.rs deleted file mode 100644 index e7ad3f8..0000000 --- a/src/handlers/device.rs +++ /dev/null @@ -1,142 +0,0 @@ -//! Device Authorization Flow handlers (RFC 8628). -//! -//! Three endpoints manage the device auth lifecycle: -//! - POST /auth/device - initiate (no auth, rate limited) -//! - POST /auth/device/token - poll for tokens (no auth, rate limited) -//! - POST /auth/device/verify - user approves/denies device (JWT required) - -use axum::{Json, extract::State, http::StatusCode}; -use serde::Deserialize; - -use crate::{error::AppError, services::device as device_svc, state::AppState}; - -use super::extractors::{AuthUser, ClientIp, UserAgent}; - -// Request types - -#[derive(Deserialize)] -pub struct DeviceAuthorizeRequest { - pub client_id: Option, -} - -#[derive(Deserialize)] -pub struct DeviceTokenRequest { - pub device_code: String, -} - -#[derive(Deserialize)] -pub struct DeviceVerifyRequest { - pub user_code: String, - #[serde(default = "default_approve")] - pub approve: bool, -} - -fn default_approve() -> bool { - true -} - -// Handlers - -/// POST /auth/device -/// Desktop app calls this to start the device authorization flow. -/// No authentication required. -pub async fn authorize( - State(state): State, - ClientIp(ip): ClientIp, - UserAgent(ua): UserAgent, - Json(body): Json, -) -> Result, AppError> { - let response = - device_svc::initiate(&state, ip, ua.as_deref(), body.client_id.as_deref()).await?; - Ok(Json(response)) -} - -/// POST /auth/device/token -/// Desktop app polls this with the device_code until tokens are available. -/// No authentication required. -pub async fn token( - State(state): State, - ClientIp(ip): ClientIp, - UserAgent(ua): UserAgent, - Json(body): Json, -) -> Result, AppError> { - if body.device_code.is_empty() { - return Err(AppError::Validation("device_code is required".into())); - } - - let result = device_svc::poll(&state, &body.device_code, ip, ua.as_deref(), None).await?; - Ok(Json(result)) -} - -/// POST /auth/device/verify -/// Authenticated user approves or denies the device authorization request. -pub async fn verify( - State(state): State, - auth: AuthUser, - Json(body): Json, -) -> Result { - validate_user_code(&body.user_code)?; - - if body.approve { - device_svc::verify(&state, auth.user_id, &body.user_code).await?; - } else { - device_svc::deny(&state, &body.user_code).await?; - } - - Ok(StatusCode::OK) -} - -// Validation - -fn validate_user_code(code: &str) -> Result<(), AppError> { - let parts: Vec<&str> = code.split('-').collect(); - if parts.len() != 2 || parts[0].len() != 4 || parts[1].len() != 4 { - return Err(AppError::Validation( - "user_code must be in XXXX-XXXX format".into(), - )); - } - if !parts[0].chars().all(|c| c.is_ascii_uppercase()) - || !parts[1].chars().all(|c| c.is_ascii_digit()) - { - return Err(AppError::Validation("invalid user_code format".into())); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn validate_user_code_accepts_valid_format() { - assert!(validate_user_code("ABCD-2345").is_ok()); - assert!(validate_user_code("WXYZ-6789").is_ok()); - } - - #[test] - fn validate_user_code_rejects_lowercase() { - assert!(validate_user_code("abcd-2345").is_err()); - } - - #[test] - fn validate_user_code_rejects_wrong_length() { - assert!(validate_user_code("ABC-2345").is_err()); - assert!(validate_user_code("ABCDE-2345").is_err()); - assert!(validate_user_code("ABCD-234").is_err()); - } - - #[test] - fn validate_user_code_rejects_missing_hyphen() { - assert!(validate_user_code("ABCD2345").is_err()); - } - - #[test] - fn validate_user_code_rejects_letters_in_digit_part() { - assert!(validate_user_code("ABCD-23AB").is_err()); - } - - #[test] - fn validate_user_code_rejects_digits_in_letter_part() { - assert!(validate_user_code("AB12-2345").is_err()); - } -} diff --git a/src/handlers/external_identity.rs b/src/handlers/external_identity.rs new file mode 100644 index 0000000..2b99a64 --- /dev/null +++ b/src/handlers/external_identity.rs @@ -0,0 +1,280 @@ +//! External identity providers: `/auth/external` and +//! `/users/me/external-identities`. + +use axum::{ + Json, + extract::{Path, RawQuery, State}, + http::StatusCode, + response::{IntoResponse, Redirect}, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + domain::oauth, + error::AppError, + repositories::external_identity::ExternalIdentity, + services::external_identity::{self as external_svc, Intent, Started}, + state::AppState, +}; + +use super::{ + auth::LoginResponse, + extractors::{AuthUser, ClientIp, RequestId, UserAgent}, + user::CurrentPasswordRequest, +}; + +#[derive(Serialize, utoipa::ToSchema)] +pub struct IdentityProviderResponse { + pub name: String, + pub display_name: String, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct ExternalStartResponse { + /// Send the browser there. + pub authorization_url: String, + /// Keep in the browser (session storage) and present at completion. + pub binding: String, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct ExternalCompleteRequest { + /// `code` given to `EXTERNAL_LOGIN_URI` after the provider. + pub code: String, + pub binding: String, + pub device_name: Option, + pub remember_me: Option, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct ExternalIdentityResponse { + pub id: Uuid, + pub provider: String, + pub created_at: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_used_at: Option, +} + +fn started(started: Started) -> Json { + Json(ExternalStartResponse { + authorization_url: started.authorization_url, + binding: started.binding, + }) +} + +fn identity_response(identity: ExternalIdentity) -> ExternalIdentityResponse { + ExternalIdentityResponse { + id: identity.id, + provider: identity.provider, + created_at: identity.created_at.unix_timestamp(), + last_used_at: identity.last_used_at.map(|t| t.unix_timestamp()), + } +} + +#[utoipa::path( + get, + path = "/auth/external/providers", + tag = "external-identities", + responses( + (status = 200, description = "Configured identity providers", body = [IdentityProviderResponse]), + ), +)] +pub async fn providers(State(state): State) -> Json> { + Json( + state + .config + .identity_providers + .iter() + .map(|p| IdentityProviderResponse { + name: p.name.clone(), + display_name: p.display_name.clone(), + }) + .collect(), + ) +} + +#[utoipa::path( + post, + path = "/auth/external/{provider}/start", + tag = "external-identities", + params(("provider" = String, Path, description = "Provider name")), + responses( + (status = 200, description = "Where to send the browser to sign in", body = ExternalStartResponse), + (status = 404, description = "No such provider", body = crate::error::ErrorBody), + (status = 503, description = "The provider's metadata is unavailable", body = crate::error::ErrorBody), + ), +)] +pub async fn start_sign_in( + State(state): State, + Path(provider): Path, +) -> Result, AppError> { + Ok(started( + external_svc::start(&state, &provider, Intent::SignIn, None).await?, + )) +} + +#[utoipa::path( + get, + path = "/auth/external/{provider}/callback", + tag = "external-identities", + params(("provider" = String, Path, description = "Provider name")), + responses( + (status = 303, description = "To `EXTERNAL_LOGIN_URI` with `code`, or `error` when the request matches no pending sign-in"), + (status = 404, description = "No such provider", body = crate::error::ErrorBody), + ), +)] +pub async fn callback( + State(state): State, + Path(provider): Path, + RawQuery(query): RawQuery, +) -> Result { + let parameters = oauth::form_parameters(query.unwrap_or_default().as_bytes()) + .map_err(AppError::Validation)?; + let location = external_svc::callback(&state, &provider, ¶meters).await?; + Ok(Redirect::to(&location)) +} + +#[utoipa::path( + post, + path = "/auth/external/complete", + tag = "external-identities", + request_body = ExternalCompleteRequest, + responses( + (status = 200, description = "Tokens, or the account's two-factor challenge", body = LoginResponse), + (status = 401, description = "Unknown, used or foreign code", body = crate::error::ErrorBody), + (status = 403, description = "Account locked, suspended or inactive", body = crate::error::ErrorBody), + (status = 409, description = "`external_identity_not_linked`", body = crate::error::ErrorBody), + (status = 503, description = "The provider could not identify the person", body = crate::error::ErrorBody), + ), +)] +pub async fn complete_sign_in( + State(state): State, + ClientIp(ip): ClientIp, + UserAgent(ua): UserAgent, + RequestId(rid): RequestId, + Json(body): Json, +) -> Result, AppError> { + let result = external_svc::complete_sign_in( + &state, + &body.code, + &body.binding, + ip, + ua.as_deref(), + body.device_name.as_deref(), + body.remember_me.unwrap_or(false), + rid, + ) + .await?; + Ok(Json(super::auth::login_response(result))) +} + +#[utoipa::path( + get, + path = "/users/me/external-identities", + tag = "external-identities", + responses( + (status = 200, description = "Identities linked to the account", body = [ExternalIdentityResponse]), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn list( + State(state): State, + auth: AuthUser, +) -> Result>, AppError> { + let identities = external_svc::list(&state, auth.user_id).await?; + Ok(Json( + identities.into_iter().map(identity_response).collect(), + )) +} + +#[utoipa::path( + post, + path = "/users/me/external-identities/{provider}/start", + tag = "external-identities", + params(("provider" = String, Path, description = "Provider name")), + responses( + (status = 200, description = "Where to send the browser to link the identity", body = ExternalStartResponse), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Recent re-authentication required", body = crate::error::ErrorBody), + (status = 404, description = "No such provider", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn start_link( + State(state): State, + ClientIp(ip): ClientIp, + auth: AuthUser, + Path(provider): Path, +) -> Result, AppError> { + Ok(started( + external_svc::start_link( + &state, + auth.user_id, + auth.session_id, + &provider, + ip, + auth.request_id, + ) + .await?, + )) +} + +#[utoipa::path( + post, + path = "/users/me/external-identities/complete", + tag = "external-identities", + request_body = ExternalCompleteRequest, + responses( + (status = 201, description = "Identity linked", body = ExternalIdentityResponse), + (status = 401, description = "Missing, invalid or revoked access token, or an unknown, used or foreign code", body = crate::error::ErrorBody), + (status = 409, description = "`external_identity_already_linked`", body = crate::error::ErrorBody), + (status = 503, description = "The provider could not identify the person", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn complete_link( + State(state): State, + auth: AuthUser, + Json(body): Json, +) -> Result<(StatusCode, Json), AppError> { + let identity = + external_svc::complete_link(&state, auth.user_id, &body.code, &body.binding).await?; + Ok((StatusCode::CREATED, Json(identity_response(identity)))) +} + +#[utoipa::path( + delete, + path = "/users/me/external-identities/{id}", + tag = "external-identities", + params(("id" = Uuid, Path, description = "Identity id")), + request_body = Option, + responses( + (status = 204, description = "Identity unlinked"), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Recent re-authentication required", body = crate::error::ErrorBody), + (status = 404, description = "No such identity on this account", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn unlink( + State(state): State, + ClientIp(ip): ClientIp, + auth: AuthUser, + Path(id): Path, + body: Option>, +) -> Result { + let current_password = body.and_then(|Json(b)| b.current_password); + external_svc::unlink( + &state, + auth.user_id, + auth.session_id, + id, + current_password.as_deref(), + ip, + auth.request_id, + ) + .await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/src/handlers/extractors.rs b/src/handlers/extractors.rs index 870e120..b68d520 100644 --- a/src/handlers/extractors.rs +++ b/src/handlers/extractors.rs @@ -2,25 +2,21 @@ //! //! AuthUser validates the Bearer token and makes user_id + session_id available //! to any handler that requires authentication. -//! ClientIp reads the real client IP from reverse-proxy headers before falling -//! back to the direct connection address. - -use std::net::{IpAddr, SocketAddr}; +//! ClientIp (defined in `middleware::client_ip`) is re-exported for handlers. use axum::{ - extract::{ConnectInfo, FromRequestParts}, + extract::FromRequestParts, http::{StatusCode, header, request::Parts}, }; -use ipnetwork::IpNetwork; use crate::{ - error::AppError, - middleware::{rate_limit::RateLimitState, request_id::X_REQUEST_ID}, - services::auth as auth_svc, - state::AppState, - utils::jwt, + error::AppError, middleware::request_id::X_REQUEST_ID, services::auth as auth_svc, + state::AppState, utils::jwt, }; +/// Re-exported: handlers keep extracting the client address from here. +pub use crate::middleware::client_ip::ClientIp; + // Authenticated user extracted from the JWT Bearer token. pub struct AuthUser { @@ -30,7 +26,7 @@ pub struct AuthUser { pub token_exp: i64, /// Role names from the JWT (e.g. ["user", "admin"]). pub roles: Vec, - /// Permission names from the JWT (e.g. ["billing:read"]). + /// Permission names from the JWT (e.g. ["users:read"]). pub permissions: Vec, /// Request ID injected by the request_id middleware; propagate to audit log entries. pub request_id: Option, @@ -53,10 +49,10 @@ impl FromRequestParts for AuthUser { .strip_prefix("Bearer ") .ok_or(AppError::Unauthorized)?; - let claims = jwt::decode_token_with_fallback( + let claims = jwt::decode_token_with_keys( token, - &state.jwt_verifying_key, - state.jwt_previous_verifying_key.as_ref(), + &state.jwt_verifying_keys, + state.clock.now().unix_timestamp(), ) .map_err(|_| AppError::TokenInvalid)?; @@ -72,21 +68,9 @@ impl FromRequestParts for AuthUser { return Err(AppError::TokenInvalid); } - // Check the JTI blocklist before touching the database. - // Fail-closed: a Redis error here is propagated as 503 - // (ServiceUnavailable) rather than silently treated as "not blocked", - // otherwise an attacker could bypass an explicit logout during a - // Redis outage (AUTH-H1). - if auth_svc::is_jti_blocked(state, claims.jti).await? { - return Err(AppError::TokenInvalid); - } - - // Verify the session is still active. - // Uses a short-lived Redis cache (SESSION_CACHE_TTL_SECS) to avoid a DB query - // on every authenticated request. Explicit logouts invalidate the cache immediately. - if !auth_svc::check_session_validity(state, claims.sid).await? { - return Err(AppError::Unauthorized); - } + // Revoked token or ended session: one Redis round trip, the database + // only on a cache miss. Fails closed when Redis is unavailable. + auth_svc::verify_token_state(state, claims.jti, claims.sid).await?; let request_id = parts .headers @@ -106,45 +90,55 @@ impl FromRequestParts for AuthUser { } } -// Client IP extracted from standard reverse-proxy headers. - -pub struct ClientIp(pub Option); - -pub trait TrustedProxySource { - fn trusted_proxy_cidrs(&self) -> &[IpNetwork]; +/// An administrator: a valid access token carrying at least one administrative +/// permission, from an account with a second factor enrolled. Each handler then +/// requires the permission of its action with [`AdminUser::require`]. +pub struct AdminUser { + pub auth: AuthUser, } -impl TrustedProxySource for AppState { - fn trusted_proxy_cidrs(&self) -> &[IpNetwork] { - &self.config.server.trusted_proxy_cidrs - } -} +impl FromRequestParts for AdminUser { + type Rejection = AppError; -impl TrustedProxySource for RateLimitState { - fn trusted_proxy_cidrs(&self) -> &[IpNetwork] { - &self.trusted_proxy_cidrs + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let auth = AuthUser::from_request_parts(parts, state).await?; + if !auth + .permissions + .iter() + .any(|permission| crate::domain::role::is_admin_permission(permission)) + { + return Err(AppError::Forbidden); + } + // An administrator's password alone must not open the administration. + if crate::repositories::two_factor::find_primary_by_user(&state.db, auth.user_id) + .await? + .is_none() + && !crate::repositories::passkey::exists_for_user(&state.db, auth.user_id).await? + { + return Err(AppError::TwoFactorRequired); + } + Ok(Self { auth }) } } -impl FromRequestParts for ClientIp { - type Rejection = (StatusCode, &'static str); - - async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { - let trusted = state.trusted_proxy_cidrs(); - let peer_ip = parts - .extensions - .get::>() - .map(|ci| ci.0.ip()); - - let ip = match peer_ip { - Some(peer) if is_trusted_proxy(peer, trusted) => { - forwarded_client_ip(parts, trusted).unwrap_or(peer) - } - Some(peer) => peer, - None => return Ok(ClientIp(None)), - }; - - Ok(ClientIp(Some(IpNetwork::from(ip)))) +impl AdminUser { + /// Require `permission`, in the token and still in the database: a role + /// revoked a minute ago stops working now, not when the token expires. + pub async fn require(&self, state: &AppState, permission: &str) -> Result<(), AppError> { + if !self.auth.permissions.iter().any(|p| p == permission) + || !crate::repositories::role::user_has_permission( + &state.db, + self.auth.user_id, + permission, + ) + .await? + { + return Err(AppError::Forbidden); + } + Ok(()) } } @@ -166,7 +160,11 @@ impl FromRequestParts for RequestId { } } -// User-Agent header as a plain string. +/// Longest user agent kept: sessions, login attempts and audit metadata store it, +/// and nothing downstream needs more. +pub const MAX_USER_AGENT_CHARS: usize = 512; + +// User-Agent header as a plain string, truncated to `MAX_USER_AGENT_CHARS`. pub struct UserAgent(pub Option); @@ -178,97 +176,8 @@ impl FromRequestParts for UserAgent { .headers .get(header::USER_AGENT) .and_then(|v| v.to_str().ok()) - .map(|s| s.to_owned()); + .map(|s| s.chars().take(MAX_USER_AGENT_CHARS).collect()); Ok(UserAgent(ua)) } } - -fn is_trusted_proxy(ip: IpAddr, trusted_proxy_cidrs: &[IpNetwork]) -> bool { - trusted_proxy_cidrs.iter().any(|cidr| cidr.contains(ip)) -} - -fn forwarded_client_ip(parts: &Parts, trusted_proxy_cidrs: &[IpNetwork]) -> Option { - if let Some(forwarded_for) = parts - .headers - .get("x-forwarded-for") - .and_then(|v| v.to_str().ok()) - { - let forwarded_chain = forwarded_for - .split(',') - .map(str::trim) - .filter_map(|raw| raw.parse::().ok()) - .collect::>(); - - for ip in forwarded_chain.iter().rev() { - if !is_trusted_proxy(*ip, trusted_proxy_cidrs) { - return Some(*ip); - } - } - - if let Some(first) = forwarded_chain.first() { - return Some(*first); - } - } - - parts - .headers - .get("x-real-ip") - .and_then(|v| v.to_str().ok()) - .and_then(|s| s.trim().parse::().ok()) -} - -#[cfg(test)] -mod tests { - use super::*; - use axum::http::{HeaderValue, Request}; - - #[derive(Default)] - struct TestState { - trusted_proxy_cidrs: Vec, - } - - impl TrustedProxySource for TestState { - fn trusted_proxy_cidrs(&self) -> &[IpNetwork] { - &self.trusted_proxy_cidrs - } - } - - #[tokio::test] - async fn direct_peer_ignores_forwarded_headers() { - let state = TestState::default(); - let mut req = Request::builder().uri("/").body(()).unwrap(); - req.headers_mut() - .insert("x-forwarded-for", HeaderValue::from_static("203.0.113.5")); - req.extensions_mut() - .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 3000)))); - - let (mut parts, _) = req.into_parts(); - let client_ip = ClientIp::from_request_parts(&mut parts, &state) - .await - .unwrap(); - - assert_eq!(client_ip.0.unwrap().ip(), IpAddr::from([127, 0, 0, 1])); - } - - #[tokio::test] - async fn trusted_proxy_uses_forwarded_client_ip() { - let state = TestState { - trusted_proxy_cidrs: vec!["10.0.0.0/8".parse().unwrap()], - }; - let mut req = Request::builder().uri("/").body(()).unwrap(); - req.headers_mut().insert( - "x-forwarded-for", - HeaderValue::from_static("198.51.100.10, 10.1.2.3"), - ); - req.extensions_mut() - .insert(ConnectInfo(SocketAddr::from(([10, 9, 8, 7], 3000)))); - - let (mut parts, _) = req.into_parts(); - let client_ip = ClientIp::from_request_parts(&mut parts, &state) - .await - .unwrap(); - - assert_eq!(client_ip.0.unwrap().ip(), IpAddr::from([198, 51, 100, 10])); - } -} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index ae3d1ca..642865f 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -10,7 +10,7 @@ use axum::{ extract::DefaultBodyLimit, http::{Method, header}, middleware, - routing::{delete, get, patch, post}, + routing::{delete, get, patch, post, put}, }; use tower_http::{ cors::{AllowOrigin, CorsLayer}, @@ -19,24 +19,131 @@ use tower_http::{ use crate::{ middleware::{ - rate_limit::{self, RateLimitState}, + access_log, error_body, + rate_limit::{self, Bucket, RateLimitState}, request_id, security_headers, }, state::AppState, }; +pub mod admin; +pub mod audit; pub mod auth; -pub mod device; +pub mod external_identity; pub mod extractors; +pub mod oauth; +pub mod passkey; +pub mod personal_access_token; pub mod session; pub mod two_factor; pub mod user; -async fn health() -> &'static str { +#[utoipa::path( + get, + path = "/health", + tag = "discovery", + responses( + (status = 200, description = "Serving", body = String, content_type = "text/plain"), + ), +)] +pub async fn health() -> &'static str { "ok" } -async fn jwks( +#[utoipa::path( + get, + path = "/live", + tag = "discovery", + responses( + (status = 200, description = "The process serves requests; dependencies are not checked", body = String, content_type = "text/plain"), + ), +)] +/// Liveness: answers as long as the process serves HTTP. Restarting the +/// container cannot fix a dependency, so this never checks one. +pub async fn live() -> &'static str { + "ok" +} + +/// What `/ready` found for each dependency. +#[derive(serde::Serialize, utoipa::ToSchema)] +pub struct ReadyResponse { + /// `ready` when every dependency answered, `unavailable` otherwise. + pub status: &'static str, + /// `up` or `down`. + pub database: &'static str, + pub redis: &'static str, + pub nats: &'static str, +} + +/// How long a readiness check waits for one dependency. +const READY_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1); + +#[utoipa::path( + get, + path = "/ready", + tag = "discovery", + responses( + (status = 200, description = "Every dependency answered", body = ReadyResponse), + (status = 503, description = "A dependency did not answer", body = ReadyResponse), + ), +)] +/// Readiness: whether this instance can serve traffic now. The reverse proxy +/// and the rolling update send traffic only to a ready instance. +pub async fn ready( + axum::extract::State(state): axum::extract::State, +) -> (axum::http::StatusCode, axum::Json) { + let database = async { + matches!( + tokio::time::timeout( + READY_PROBE_TIMEOUT, + sqlx::query("SELECT 1").execute(&state.db) + ) + .await, + Ok(Ok(_)) + ) + }; + let redis = async { + let ping = async { + let mut conn = state.redis.get().await.ok()?; + deadpool_redis::redis::cmd("PING") + .query_async::(&mut *conn) + .await + .ok() + }; + matches!( + tokio::time::timeout(READY_PROBE_TIMEOUT, ping).await, + Ok(Some(_)) + ) + }; + let (database, redis) = tokio::join!(database, redis); + let nats = state.nats.connection_state() == async_nats::connection::State::Connected; + + let up = |ok: bool| if ok { "up" } else { "down" }; + let all = database && redis && nats; + ( + if all { + axum::http::StatusCode::OK + } else { + axum::http::StatusCode::SERVICE_UNAVAILABLE + }, + axum::Json(ReadyResponse { + status: if all { "ready" } else { "unavailable" }, + database: up(database), + redis: up(redis), + nats: up(nats), + }), + ) +} + +#[utoipa::path( + get, + path = "/.well-known/jwks.json", + tag = "discovery", + responses( + (status = 200, description = "JSON Web Key Set of the current and previous signing keys", body = Object), + ), +)] +pub async fn jwks( axum::extract::State(state): axum::extract::State, ) -> impl axum::response::IntoResponse { // Public key material is safe to cache: a short max-age lets downstream @@ -81,26 +188,26 @@ fn build_router( state: AppState, prometheus_layer: Option>, ) -> Router { - let rl_general = RateLimitState { - redis: state.redis.clone(), + // Every route passes exactly one rate-limit layer, which checks all of its + // buckets in a single Redis call. Auth and reauth routes count against the + // general bucket and a stricter one of their own. + let general = Bucket { + prefix: "rl", limit: state.config.rate_limit.requests_per_minute, - trusted_proxy_cidrs: state.config.server.trusted_proxy_cidrs.clone(), - fail_open_on_redis_error: state.config.rate_limit.fail_open_on_redis_error, - allow_requests_without_ip: state.config.rate_limit.allow_requests_without_ip, - key_prefix: "rl", }; - // Auth and reauth routes use a separate, stricter bucket ("rl_auth:{ip}") so - // their limit is independent of the general bucket. If both shared "rl:{ip}", - // each auth request would consume two tokens (once per layer) and the effective - // limit would be halved. - let rl_auth = RateLimitState { - redis: state.redis.clone(), + let strict = Bucket { + prefix: "rl_auth", limit: state.config.rate_limit.auth_requests_per_minute, + }; + let limiter = |buckets: Vec| RateLimitState { + redis: state.redis.clone(), + buckets, trusted_proxy_cidrs: state.config.server.trusted_proxy_cidrs.clone(), fail_open_on_redis_error: state.config.rate_limit.fail_open_on_redis_error, allow_requests_without_ip: state.config.rate_limit.allow_requests_without_ip, - key_prefix: "rl_auth", }; + let rl_general = limiter(vec![general]); + let rl_auth = limiter(vec![general, strict]); let security_headers_state = security_headers::SecurityHeadersState { enable_hsts: state.config.is_production() && state.config.server.public_url.starts_with("https://"), @@ -117,33 +224,62 @@ fn build_router( rl_auth.clone(), rate_limit::layer_with_state, )) - .merge(me_router()); + .merge(me_router().layer(middleware::from_fn_with_state( + rl_general.clone(), + rate_limit::layer_with_state, + ))); - let router = Router::new() + // Probes skip the rate limiter: an orchestrator or the reverse proxy polls + // them, and a Redis outage must not turn every instance unhealthy at once. + let probes = Router::new() .route("/health", get(health)) + .route("/live", get(live)) + .route("/ready", get(ready)); + + let admin = admin_router().layer(middleware::from_fn_with_state( + rl_general.clone(), + rate_limit::layer_with_state, + )); + + let public = Router::new() .route("/.well-known/jwks.json", get(jwks)) + .route( + "/.well-known/oauth-authorization-server", + get(oauth::metadata), + ) + .route( + "/.well-known/openid-configuration", + get(oauth::openid_configuration), + ) + // Logout is authenticated (requires a valid JWT via AuthUser) but intentionally + // placed outside the auth rate-limit bucket. Exhausting that bucket during a + // brute-force attack must not prevent the legitimate user from ending their session. + .route("/auth/logout", post(auth::logout)) + .layer(middleware::from_fn_with_state( + rl_general, + rate_limit::layer_with_state, + )); + + let router = probes + .merge(public) .nest( "/auth", auth_router().layer(middleware::from_fn_with_state( + rl_auth.clone(), + rate_limit::layer_with_state, + )), + ) + .nest( + "/oauth", + oauth_router().layer(middleware::from_fn_with_state( rl_auth, rate_limit::layer_with_state, )), ) - // Logout is authenticated (requires a valid JWT via AuthUser) but intentionally - // placed outside the auth rate-limit bucket. Exhausting that bucket during a - // brute-force attack must not prevent the legitimate user from ending their session. - .route("/auth/logout", post(auth::logout)) .nest("/users/me", me_with_strict_reauth) + .nest("/admin", admin) .layer(cors) - .layer(middleware::from_fn_with_state( - rl_general, - rate_limit::layer_with_state, - )) - .layer(middleware::from_fn_with_state( - security_headers_state, - security_headers::layer, - )) - .layer(middleware::from_fn(request_id::layer)) + .layer(middleware::from_fn(access_log::layer)) // 64 KB is more than sufficient for any JSON payload this API accepts. // Overrides Axum's default 2 MB limit to reduce DoS exposure. .layer(DefaultBodyLimit::max(65_536)) @@ -156,7 +292,15 @@ fn build_router( .layer(TimeoutLayer::with_status_code( axum::http::StatusCode::SERVICE_UNAVAILABLE, std::time::Duration::from_secs(30), - )); + )) + // Outside the timeout, the body limit and the rate limiters, whose + // refusals are plain text: every error leaves with the documented body. + .layer(middleware::from_fn(error_body::layer)) + .layer(middleware::from_fn_with_state( + security_headers_state, + security_headers::layer, + )) + .layer(middleware::from_fn(request_id::layer)); // Outermost layer so HTTP metrics include time spent in every middleware. let router = match prometheus_layer { @@ -184,6 +328,7 @@ fn build_cors(cfg: &crate::config::CorsConfig) -> CorsLayer { .allow_methods([ Method::GET, Method::POST, + Method::PUT, Method::PATCH, Method::DELETE, Method::OPTIONS, @@ -208,7 +353,29 @@ fn auth_router() -> Router { // Token delivered in the body (POST) to keep it out of access logs and // browser history. Switched from GET /verify-email?token= for this reason. .route("/verify-email", post(auth::verify_email)) + .route("/verify-email/resend", post(auth::resend_verification)) .route("/forgot-password", post(auth::forgot_password)) + .route("/magic-link", post(auth::request_magic_link)) + .route("/magic-link/complete", post(auth::complete_magic_link)) + .route("/external/providers", get(external_identity::providers)) + .route( + "/external/complete", + post(external_identity::complete_sign_in), + ) + .route( + "/external/{provider}/start", + post(external_identity::start_sign_in), + ) + .route( + "/external/{provider}/callback", + get(external_identity::callback), + ) + .route("/passkeys/options", post(passkey::authentication_options)) + .route("/passkeys/sign-in", post(passkey::sign_in)) + .route( + "/personal-access-tokens/exchange", + post(personal_access_token::exchange), + ) .route("/reset-password", post(auth::reset_password)) .route("/two-factor/complete", post(auth::complete_two_factor)) .route("/two-factor/recovery", post(auth::recovery_login)) @@ -220,10 +387,89 @@ fn auth_router() -> Router { "/two-factor/email/resend", post(auth::resend_email_two_factor), ) - // Device authorization flow (RFC 8628) - .route("/device", post(device::authorize)) - .route("/device/token", post(device::token)) - .route("/device/verify", post(device::verify)) +} + +// Administration: every route checks its own permission. + +fn admin_router() -> Router { + Router::new() + .route("/users", get(admin::users::search)) + .route("/users/{id}", get(admin::users::detail)) + .route("/users/{id}", delete(admin::users::delete)) + .route("/users/{id}/suspend", post(admin::users::suspend)) + .route("/users/{id}/reactivate", post(admin::users::reactivate)) + .route("/users/{id}/unlock", post(admin::users::unlock)) + .route( + "/users/{id}/sessions", + delete(admin::users::revoke_sessions), + ) + .route( + "/users/{id}/password-reset", + post(admin::users::force_password_reset), + ) + .route("/users/{id}/roles", post(admin::roles::assign)) + .route("/users/{id}/roles/{name}", delete(admin::roles::unassign)) + .route("/permissions", get(admin::roles::permissions)) + .route("/roles", get(admin::roles::list)) + .route("/roles", post(admin::roles::create)) + .route("/roles/{name}", delete(admin::roles::delete)) + .route( + "/roles/{name}/permissions", + put(admin::roles::set_permissions), + ) + .route("/clients", get(admin::clients::list)) + .route("/clients/{client_id}", put(admin::clients::save)) + .route("/clients/{client_id}", delete(admin::clients::delete)) + .route( + "/clients/{client_id}/secret", + post(admin::clients::rotate_secret), + ) + .route( + "/clients/{client_id}/secret", + delete(admin::clients::remove_secret), + ) + .route("/audit", get(admin::audit::list)) + .route("/webhooks", get(admin::webhooks::list)) + .route("/webhooks", post(admin::webhooks::create)) + .route("/webhooks/{id}", put(admin::webhooks::update)) + .route("/webhooks/{id}", delete(admin::webhooks::delete)) + .route( + "/webhooks/{id}/secret", + post(admin::webhooks::rotate_secret), + ) + .route( + "/webhooks/{id}/deliveries", + get(admin::webhooks::deliveries), + ) + .route( + "/webhooks/{id}/deliveries/{delivery_id}/retry", + post(admin::webhooks::retry), + ) +} + +// OAuth 2.1 (RFC 6749, 7636, 8252, 8628). Every route shares the strict bucket: +// the token endpoint answers unauthenticated guesses, and the approval routes +// mint long-lived sessions. + +fn oauth_router() -> Router { + Router::new() + .route("/authorize", get(oauth::authorize)) + .route("/token", post(oauth::token)) + .route("/device_authorization", post(oauth::device_authorization)) + .route("/introspect", post(oauth::introspect)) + .route("/userinfo", get(oauth::userinfo)) + .route("/revoke", post(oauth::revoke)) + .route("/device/verify", post(oauth::verify_device)) + .route("/device/{user_code}", get(oauth::describe_device)) + .route("/authorization-requests/{id}", get(oauth::describe_request)) + .route( + "/authorization-requests/{id}/approve", + post(oauth::approve_request), + ) + .route( + "/authorization-requests/{id}/deny", + post(oauth::deny_request), + ) } // Sensitive authenticated routes placed under the strict auth rate-limit bucket. @@ -233,6 +479,8 @@ fn auth_router() -> Router { fn me_strict_router() -> Router { Router::new() .route("/reauth", post(user::reauthenticate)) + // A download of everything stored: as costly as it is sensitive. + .route("/export", get(user::export_data)) .route("/email/start", post(user::start_email_change)) .route("/email/verify-current", post(user::verify_current_email)) .route("/email/submit", post(user::submit_new_email)) @@ -246,10 +494,35 @@ fn me_router() -> Router { Router::new() // Profile .route("/", get(user::me)) + .route("/audit", get(audit::list)) + .route("/two-factor", get(two_factor::list)) .route("/username", patch(user::change_username)) .route("/password", patch(user::change_password)) .route("/locale", patch(user::change_locale)) .route("/", delete(user::delete_account)) + // External identities + .route("/external-identities", get(external_identity::list)) + .route( + "/external-identities/complete", + post(external_identity::complete_link), + ) + .route( + "/external-identities/{provider}/start", + post(external_identity::start_link), + ) + .route( + "/external-identities/{id}", + delete(external_identity::unlink), + ) + // Passkeys + .route("/passkeys", get(passkey::list)) + .route("/passkeys", post(passkey::register)) + .route("/passkeys/options", post(passkey::registration_options)) + .route("/passkeys/{id}", delete(passkey::remove)) + // Personal access tokens + .route("/tokens", get(personal_access_token::list)) + .route("/tokens", post(personal_access_token::create)) + .route("/tokens/{id}", delete(personal_access_token::revoke)) // Sessions .route("/sessions", get(session::list)) .route("/sessions", delete(session::revoke_all)) diff --git a/src/handlers/oauth.rs b/src/handlers/oauth.rs new file mode 100644 index 0000000..7a71b9e --- /dev/null +++ b/src/handlers/oauth.rs @@ -0,0 +1,652 @@ +//! OAuth 2.1 endpoints: metadata, authorization, token, device authorization, +//! and the routes the auth frontend uses to approve requests. + +use axum::{ + Json, + body::Bytes, + extract::{Path, RawQuery, State}, + http::{HeaderMap, HeaderValue, StatusCode, header}, + response::{IntoResponse, Redirect, Response}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use crate::{ + domain::oauth::{self, ErrorCode}, + error::AppError, + repositories::role as role_repo, + services::{ + device as device_svc, + oauth::{self as oauth_svc, AuthorizeOutcome, EndpointError, OAuthError}, + }, + state::AppState, +}; + +use super::extractors::{AuthUser, ClientIp, UserAgent}; + +/// RFC 6749 section 5.2. +#[derive(Serialize, utoipa::ToSchema)] +pub struct OAuthErrorBody { + /// `invalid_request`, `invalid_client`, `invalid_grant`, ... + pub error: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub error_description: Option, +} + +impl IntoResponse for EndpointError { + fn into_response(self) -> Response { + match self { + Self::App(error) => error.into_response(), + Self::OAuth(error) => { + let status = + StatusCode::from_u16(error.status()).unwrap_or(StatusCode::BAD_REQUEST); + let mut response = ( + status, + [ + (header::CACHE_CONTROL, "no-store"), + (header::PRAGMA, "no-cache"), + ], + Json(OAuthErrorBody { + error: error.code.as_str(), + error_description: error.description, + }), + ) + .into_response(); + if error.basic_challenge { + response.headers_mut().insert( + header::WWW_AUTHENTICATE, + HeaderValue::from_static("Basic realm=\"auth-api\""), + ); + } + response + } + } + } +} + +/// RFC 6749 section 5.1. +#[derive(Serialize, utoipa::ToSchema)] +pub struct OAuthTokenResponse { + pub access_token: String, + /// Always `Bearer`. + pub token_type: &'static str, + /// Seconds. + pub expires_in: u64, + /// Absent for the client credentials grant. + #[serde(skip_serializing_if = "Option::is_none")] + pub refresh_token: Option, + /// Space-separated scopes the token carries; absent when unrestricted. + #[serde(skip_serializing_if = "Option::is_none")] + pub scope: Option, + /// OpenID Connect ID token, when the `openid` scope was granted. + #[serde(skip_serializing_if = "Option::is_none")] + pub id_token: Option, +} + +/// Form fields of `POST /oauth/token`, for the document. +#[derive(Deserialize, utoipa::ToSchema)] +pub struct OAuthTokenRequest { + /// `authorization_code`, `refresh_token`, `client_credentials` or + /// `urn:ietf:params:oauth:grant-type:device_code`. + pub grant_type: String, + pub client_id: Option, + /// `client_secret_post`; or use `Authorization: Basic`. + pub client_secret: Option, + pub code: Option, + pub redirect_uri: Option, + pub code_verifier: Option, + pub refresh_token: Option, + pub device_code: Option, + /// Space-separated scopes, for `client_credentials`. + pub scope: Option, + /// Label of the new session in the account's session list. + pub device_name: Option, +} + +/// Form fields of `POST /oauth/device_authorization`, for the document. +#[derive(Deserialize, utoipa::ToSchema)] +pub struct DeviceAuthorizationRequest { + pub client_id: Option, + pub client_secret: Option, + /// Space-separated permissions; omitted, the client's registered scopes. + pub scope: Option, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct AuthorizationRequestResponse { + pub client_id: String, + pub client_name: String, + pub redirect_uri: String, + /// Permissions the approval would grant. + pub scopes: Vec, + /// The request names no scope and the client has none: the session would + /// carry every permission of the user. + pub unrestricted: bool, + /// Requested scopes the user does not hold. + pub unavailable_scopes: Vec, + pub sessions_used: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub sessions_allowed: Option, + /// Approving needs `current_password` (or a recent `POST /users/me/reauth`). + pub reauthentication_required: bool, +} + +#[derive(Deserialize, Default, utoipa::ToSchema)] +pub struct ApproveAuthorizationRequest { + pub current_password: Option, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct AuthorizationDecisionResponse { + /// Where to send the browser: the client's redirect URI with the response. + pub redirect_to: String, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct DeviceVerifyRequest { + pub user_code: String, + #[serde(default = "default_approve")] + pub approve: bool, +} + +fn default_approve() -> bool { + true +} + +fn form(headers: &HeaderMap, body: &[u8]) -> Result, EndpointError> { + let content_type = headers + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or_default(); + if !content_type + .to_ascii_lowercase() + .starts_with("application/x-www-form-urlencoded") + { + return Err(OAuthError::new( + ErrorCode::InvalidRequest, + "the body must be application/x-www-form-urlencoded", + ) + .into()); + } + oauth::form_parameters(body) + .map_err(|message| OAuthError::new(ErrorCode::InvalidRequest, message).into()) +} + +fn authorization(headers: &HeaderMap) -> Option<&str> { + headers + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) +} + +#[utoipa::path( + get, + path = "/.well-known/oauth-authorization-server", + tag = "oauth", + responses( + (status = 200, description = "Authorization server metadata (RFC 8414)", body = Object), + ), +)] +pub async fn metadata(State(state): State) -> Result { + let issuer = state + .config + .server + .public_url + .trim_end_matches('/') + .to_owned(); + let scopes: Vec = role_repo::find_all_permissions(&state.db) + .await? + .into_iter() + .map(|p| p.name) + .collect(); + Ok(( + [(header::CACHE_CONTROL, "public, max-age=300")], + Json(json!({ + "issuer": issuer, + "authorization_endpoint": format!("{issuer}/oauth/authorize"), + "token_endpoint": format!("{issuer}/oauth/token"), + "device_authorization_endpoint": format!("{issuer}/oauth/device_authorization"), + "introspection_endpoint": format!("{issuer}/oauth/introspect"), + "revocation_endpoint": format!("{issuer}/oauth/revoke"), + "introspection_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"], + "revocation_endpoint_auth_methods_supported": ["none", "client_secret_basic", "client_secret_post"], + "jwks_uri": format!("{issuer}/.well-known/jwks.json"), + "scopes_supported": scopes, + "response_types_supported": ["code"], + "response_modes_supported": ["query"], + "grant_types_supported": [ + oauth::GRANT_AUTHORIZATION_CODE, + oauth::GRANT_REFRESH_TOKEN, + oauth::GRANT_DEVICE_CODE, + oauth::GRANT_CLIENT_CREDENTIALS, + ], + "token_endpoint_auth_methods_supported": ["none", "client_secret_basic", "client_secret_post"], + "code_challenge_methods_supported": ["S256"], + "authorization_response_iss_parameter_supported": false, + })), + )) +} + +#[utoipa::path( + get, + path = "/.well-known/openid-configuration", + tag = "oauth", + responses( + (status = 200, description = "OpenID Provider metadata (OpenID Connect Discovery 1.0)", body = Object), + ), +)] +pub async fn openid_configuration( + State(state): State, +) -> Result { + let issuer = state + .config + .server + .public_url + .trim_end_matches('/') + .to_owned(); + let mut scopes: Vec = crate::domain::oidc::SCOPES + .iter() + .map(|s| (*s).to_owned()) + .collect(); + scopes.extend( + role_repo::find_all_permissions(&state.db) + .await? + .into_iter() + .map(|p| p.name), + ); + Ok(( + [(header::CACHE_CONTROL, "public, max-age=300")], + Json(json!({ + "issuer": issuer, + "authorization_endpoint": format!("{issuer}/oauth/authorize"), + "token_endpoint": format!("{issuer}/oauth/token"), + "userinfo_endpoint": format!("{issuer}/oauth/userinfo"), + "jwks_uri": format!("{issuer}/.well-known/jwks.json"), + "revocation_endpoint": format!("{issuer}/oauth/revoke"), + "introspection_endpoint": format!("{issuer}/oauth/introspect"), + "scopes_supported": scopes, + "response_types_supported": ["code"], + "response_modes_supported": ["query"], + "grant_types_supported": [ + oauth::GRANT_AUTHORIZATION_CODE, + oauth::GRANT_REFRESH_TOKEN, + oauth::GRANT_DEVICE_CODE, + oauth::GRANT_CLIENT_CREDENTIALS, + ], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["ES256"], + "token_endpoint_auth_methods_supported": ["none", "client_secret_basic", "client_secret_post"], + "code_challenge_methods_supported": ["S256"], + "claims_supported": [ + "iss", "sub", "aud", "azp", "exp", "iat", "auth_time", "nonce", "at_hash", + "preferred_username", "locale", "updated_at", "email", "email_verified" + ], + "claims_parameter_supported": false, + "request_parameter_supported": false, + "request_uri_parameter_supported": false, + })), + )) +} + +#[utoipa::path( + get, + path = "/oauth/userinfo", + tag = "oauth", + responses( + (status = 200, description = "Claims about the user released by the session's scopes (OIDC Core 5.3)", body = Object), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "The session was not granted `openid`", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn userinfo( + State(state): State, + auth: AuthUser, +) -> Result { + let claims = oauth_svc::userinfo(&state, auth.user_id, auth.session_id).await?; + Ok(([(header::CACHE_CONTROL, "no-store")], Json(claims))) +} + +#[utoipa::path( + get, + path = "/oauth/authorize", + tag = "oauth", + params( + ("response_type" = String, Query, description = "`code`"), + ("client_id" = String, Query, description = "Registered client"), + ("redirect_uri" = Option, Query, description = "A registered redirect URI; optional when the client has exactly one"), + ("code_challenge" = String, Query, description = "S256 PKCE challenge"), + ("code_challenge_method" = String, Query, description = "`S256`"), + ("scope" = Option, Query, description = "Space-separated permissions; omitted, the client's registered scopes"), + ("state" = Option, Query, description = "Echoed back, at most 512 bytes"), + ("nonce" = Option, Query, description = "OpenID Connect: echoed in the ID token"), + ), + responses( + (status = 303, description = "To the consent page (`OAUTH_CONSENT_URI?request_id=...`), or back to the client with `error`"), + (status = 400, description = "Unknown client or unregistered redirect URI: never redirected", body = OAuthErrorBody), + (status = 401, description = "Unknown client", body = OAuthErrorBody), + ), +)] +pub async fn authorize( + State(state): State, + RawQuery(query): RawQuery, +) -> Result { + let parameters = oauth::form_parameters(query.unwrap_or_default().as_bytes()) + .map_err(|message| OAuthError::new(ErrorCode::InvalidRequest, message))?; + let location = match oauth_svc::start_authorization(&state, ¶meters).await? { + AuthorizeOutcome::Consent(id) => oauth::redirect_with( + &state.config.device_auth.consent_uri, + &[("request_id", &id)], + ) + .ok_or_else(|| AppError::Internal(anyhow::anyhow!("OAUTH_CONSENT_URI does not parse")))?, + AuthorizeOutcome::Refused(location) => location, + }; + Ok(Redirect::to(&location).into_response()) +} + +#[utoipa::path( + get, + path = "/oauth/authorization-requests/{id}", + tag = "oauth", + params(("id" = String, Path, description = "`request_id` given to the consent page")), + responses( + (status = 200, description = "What the consent page shows", body = AuthorizationRequestResponse), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 404, description = "Unknown, decided or expired request", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn describe_request( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> Result, AppError> { + let described = oauth_svc::describe_request(&state, auth.user_id, auth.session_id, &id).await?; + let request = described.request; + Ok(Json(AuthorizationRequestResponse { + client_id: request.client_id, + client_name: request.client_name, + redirect_uri: described.redirect_uri, + scopes: request.scopes, + unrestricted: request.unrestricted, + unavailable_scopes: request.unavailable_scopes, + sessions_used: request.sessions_used, + sessions_allowed: request.sessions_allowed, + reauthentication_required: described.reauthentication_required, + })) +} + +#[utoipa::path( + post, + path = "/oauth/authorization-requests/{id}/approve", + tag = "oauth", + params(("id" = String, Path, description = "`request_id` given to the consent page")), + request_body = Option, + responses( + (status = 200, description = "Approved; send the browser to `redirect_to`", body = AuthorizationDecisionResponse), + (status = 401, description = "Missing, invalid or revoked access token, or wrong password", body = crate::error::ErrorBody), + (status = 403, description = "Re-authentication required, or account unusable", body = crate::error::ErrorBody), + (status = 404, description = "Unknown, decided or expired request", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn approve_request( + State(state): State, + ClientIp(ip): ClientIp, + auth: AuthUser, + Path(id): Path, + body: Option>, +) -> Result, AppError> { + let current_password = body.and_then(|Json(b)| b.current_password); + let redirect_to = oauth_svc::approve_request( + &state, + auth.user_id, + auth.session_id, + &id, + current_password.as_deref(), + ip, + auth.request_id, + ) + .await?; + Ok(Json(AuthorizationDecisionResponse { redirect_to })) +} + +#[utoipa::path( + post, + path = "/oauth/authorization-requests/{id}/deny", + tag = "oauth", + params(("id" = String, Path, description = "`request_id` given to the consent page")), + responses( + (status = 200, description = "Denied; send the browser to `redirect_to`", body = AuthorizationDecisionResponse), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 404, description = "Unknown, decided or expired request", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn deny_request( + State(state): State, + _auth: AuthUser, + Path(id): Path, +) -> Result, AppError> { + let redirect_to = oauth_svc::deny_request(&state, &id).await?; + Ok(Json(AuthorizationDecisionResponse { redirect_to })) +} + +#[utoipa::path( + post, + path = "/oauth/token", + tag = "oauth", + request_body(content = OAuthTokenRequest, content_type = "application/x-www-form-urlencoded"), + responses( + (status = 200, description = "Tokens (RFC 6749 section 5.1)", body = OAuthTokenResponse), + (status = 400, description = "`invalid_request`, `invalid_grant`, `unsupported_grant_type`, `authorization_pending`, `slow_down`, `expired_token`, `access_denied`", body = OAuthErrorBody), + (status = 401, description = "`invalid_client`", body = OAuthErrorBody), + ), +)] +pub async fn token( + State(state): State, + ClientIp(ip): ClientIp, + UserAgent(ua): UserAgent, + headers: HeaderMap, + body: Bytes, +) -> Result { + let parameters = form(&headers, &body)?; + let issued = oauth_svc::token( + &state, + authorization(&headers), + ¶meters, + ip, + ua.as_deref(), + ) + .await?; + let scope = issued.scopes.as_ref().map(|s| s.join(" ")); + Ok(( + [ + (header::CACHE_CONTROL, "no-store"), + (header::PRAGMA, "no-cache"), + ], + Json(OAuthTokenResponse { + access_token: issued.access_token, + token_type: "Bearer", + expires_in: issued.expires_in, + refresh_token: issued.refresh_token, + scope, + id_token: issued.id_token, + }), + ) + .into_response()) +} + +#[utoipa::path( + post, + path = "/oauth/device_authorization", + tag = "oauth", + request_body(content = DeviceAuthorizationRequest, content_type = "application/x-www-form-urlencoded"), + responses( + (status = 200, description = "Flow started (RFC 8628 section 3.2)", body = device_svc::DeviceInitResponse), + (status = 400, description = "`invalid_request` or `invalid_scope`", body = OAuthErrorBody), + (status = 401, description = "`invalid_client`", body = OAuthErrorBody), + ), +)] +pub async fn device_authorization( + State(state): State, + ClientIp(ip): ClientIp, + UserAgent(ua): UserAgent, + headers: HeaderMap, + body: Bytes, +) -> Result { + let parameters = form(&headers, &body)?; + let started = oauth_svc::device_authorization( + &state, + authorization(&headers), + ¶meters, + ip, + ua.as_deref(), + ) + .await?; + Ok(([(header::CACHE_CONTROL, "no-store")], Json(started)).into_response()) +} + +/// Form fields of `POST /oauth/introspect` and `POST /oauth/revoke`. +#[derive(Deserialize, utoipa::ToSchema)] +pub struct TokenOperationRequest { + pub token: String, + /// `access_token` or `refresh_token`; the token's shape decides anyway. + pub token_type_hint: Option, + pub client_id: Option, + pub client_secret: Option, +} + +#[utoipa::path( + post, + path = "/oauth/introspect", + tag = "oauth", + request_body(content = TokenOperationRequest, content_type = "application/x-www-form-urlencoded"), + responses( + (status = 200, description = "RFC 7662: `{ \"active\": false }` for anything unknown, expired or revoked", body = oauth_svc::Introspection), + (status = 400, description = "`invalid_request` or `unauthorized_client` (a public client)", body = OAuthErrorBody), + (status = 401, description = "`invalid_client`", body = OAuthErrorBody), + ), +)] +pub async fn introspect( + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> Result { + let parameters = form(&headers, &body)?; + let introspection = oauth_svc::introspect(&state, authorization(&headers), ¶meters).await?; + Ok(([(header::CACHE_CONTROL, "no-store")], Json(introspection)).into_response()) +} + +#[utoipa::path( + post, + path = "/oauth/revoke", + tag = "oauth", + request_body(content = TokenOperationRequest, content_type = "application/x-www-form-urlencoded"), + responses( + (status = 200, description = "RFC 7009: the token no longer works, if it was issued to this client; the same answer otherwise"), + (status = 400, description = "`invalid_request`", body = OAuthErrorBody), + (status = 401, description = "`invalid_client`", body = OAuthErrorBody), + ), +)] +pub async fn revoke( + State(state): State, + ClientIp(ip): ClientIp, + headers: HeaderMap, + body: Bytes, +) -> Result { + let parameters = form(&headers, &body)?; + oauth_svc::revoke(&state, authorization(&headers), ¶meters, ip).await?; + Ok(StatusCode::OK) +} + +#[utoipa::path( + get, + path = "/oauth/device/{user_code}", + tag = "oauth", + params(("user_code" = String, Path, description = "Code shown on the device, XXXX-9999")), + responses( + (status = 200, description = "What the user is about to approve", body = device_svc::DevicePreview), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 404, description = "Unknown or expired code", body = crate::error::ErrorBody), + (status = 409, description = "Already decided", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn describe_device( + State(state): State, + ClientIp(ip): ClientIp, + _auth: AuthUser, + Path(user_code): Path, +) -> Result, AppError> { + validate_user_code(&user_code)?; + Ok(Json(device_svc::describe(&state, &user_code, ip).await?)) +} + +#[utoipa::path( + post, + path = "/oauth/device/verify", + tag = "oauth", + request_body = DeviceVerifyRequest, + responses( + (status = 200, description = "Decision recorded"), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 404, description = "Unknown or expired code", body = crate::error::ErrorBody), + (status = 409, description = "Already decided", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn verify_device( + State(state): State, + ClientIp(ip): ClientIp, + auth: AuthUser, + Json(body): Json, +) -> Result { + validate_user_code(&body.user_code)?; + if body.approve { + device_svc::verify(&state, auth.user_id, &body.user_code, ip).await?; + } else { + device_svc::deny(&state, &body.user_code, ip).await?; + } + Ok(StatusCode::OK) +} + +pub(crate) fn validate_user_code(code: &str) -> Result<(), AppError> { + let parts: Vec<&str> = code.split('-').collect(); + if parts.len() != 2 || parts[0].len() != 4 || parts[1].len() != 4 { + return Err(AppError::Validation( + "user_code must be in XXXX-XXXX format".into(), + )); + } + if !parts[0].chars().all(|c| c.is_ascii_uppercase()) + || !parts[1].chars().all(|c| c.is_ascii_digit()) + { + return Err(AppError::Validation("invalid user_code format".into())); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_user_code_accepts_valid_format() { + assert!(validate_user_code("ABCD-2345").is_ok()); + assert!(validate_user_code("WXYZ-6789").is_ok()); + } + + #[test] + fn validate_user_code_rejects_malformed_codes() { + for code in [ + "abcd-2345", + "ABC-2345", + "ABCDE-2345", + "ABCD-234", + "ABCD2345", + "ABCD-23AB", + "AB12-2345", + ] { + assert!(validate_user_code(code).is_err(), "{code} must be rejected"); + } + } +} diff --git a/src/handlers/passkey.rs b/src/handlers/passkey.rs new file mode 100644 index 0000000..f88e247 --- /dev/null +++ b/src/handlers/passkey.rs @@ -0,0 +1,249 @@ +//! Passkeys: `/users/me/passkeys` and `/auth/passkeys`. + +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +use crate::{ + error::AppError, + repositories::passkey::Passkey, + services::passkey::{ + self as passkey_svc, AssertionResponse, AttestationResponse, CredentialResponse, + }, + state::AppState, +}; + +use super::{ + extractors::{AuthUser, ClientIp, UserAgent}, + user::CurrentPasswordRequest, +}; + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct RegisterPasskeyRequest { + /// Label shown in the passkey list, such as "iPhone". + pub name: String, + /// The `PublicKeyCredential` returned by `navigator.credentials.create()`. + pub credential: CredentialResponse, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct PasskeySignInRequest { + /// The `PublicKeyCredential` returned by `navigator.credentials.get()`. + pub credential: CredentialResponse, + pub device_name: Option, + pub remember_me: Option, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct PasskeyResponse { + pub id: Uuid, + pub name: String, + /// COSE algorithm: -7 (ES256), -8 (EdDSA) or -257 (RS256). + pub algorithm: i32, + /// The passkey can be synced to other devices. + pub backup_eligible: bool, + pub backed_up: bool, + pub created_at: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_used_at: Option, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct RegisteredPasskeyResponse { + pub passkey: PasskeyResponse, + /// Shown once, when the account had no recovery code left. + #[serde(skip_serializing_if = "Option::is_none")] + pub recovery_codes: Option>, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct PasskeySignInResponse { + pub access_token: String, + pub refresh_token: String, +} + +fn passkey_response(passkey: Passkey) -> PasskeyResponse { + PasskeyResponse { + id: passkey.id, + name: passkey.name, + algorithm: passkey.algorithm, + backup_eligible: passkey.backup_eligible, + backed_up: passkey.backed_up, + created_at: passkey.created_at.unix_timestamp(), + last_used_at: passkey.last_used_at.map(|t| t.unix_timestamp()), + } +} + +#[utoipa::path( + get, + path = "/users/me/passkeys", + tag = "passkeys", + responses( + (status = 200, description = "The account's passkeys", body = [PasskeyResponse]), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn list( + State(state): State, + auth: AuthUser, +) -> Result>, AppError> { + let passkeys = passkey_svc::list(&state, auth.user_id).await?; + Ok(Json(passkeys.into_iter().map(passkey_response).collect())) +} + +#[utoipa::path( + post, + path = "/users/me/passkeys/options", + tag = "passkeys", + responses( + (status = 200, description = "`PublicKeyCredentialCreationOptions` (JSON form) for `navigator.credentials.create()`; valid five minutes", body = Object), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Recent re-authentication required", body = crate::error::ErrorBody), + (status = 409, description = "`too_many_passkeys`", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn registration_options( + State(state): State, + ClientIp(ip): ClientIp, + auth: AuthUser, +) -> Result, AppError> { + Ok(Json( + passkey_svc::registration_options( + &state, + auth.user_id, + auth.session_id, + ip, + auth.request_id, + ) + .await?, + )) +} + +#[utoipa::path( + post, + path = "/users/me/passkeys", + tag = "passkeys", + request_body = RegisterPasskeyRequest, + responses( + (status = 201, description = "Passkey registered", body = RegisteredPasskeyResponse), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 409, description = "`passkey_already_registered`", body = crate::error::ErrorBody), + (status = 422, description = "No pending registration, or the response does not verify", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn register( + State(state): State, + ClientIp(ip): ClientIp, + auth: AuthUser, + Json(body): Json, +) -> Result<(StatusCode, Json), AppError> { + let registered = passkey_svc::register( + &state, + auth.user_id, + auth.session_id, + &body.name, + &body.credential, + ip, + auth.request_id, + ) + .await?; + Ok(( + StatusCode::CREATED, + Json(RegisteredPasskeyResponse { + passkey: passkey_response(registered.passkey), + recovery_codes: registered.recovery_codes, + }), + )) +} + +#[utoipa::path( + delete, + path = "/users/me/passkeys/{id}", + tag = "passkeys", + params(("id" = Uuid, Path, description = "Passkey id")), + request_body = Option, + responses( + (status = 204, description = "Passkey removed"), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Recent re-authentication required", body = crate::error::ErrorBody), + (status = 404, description = "No such passkey on this account", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn remove( + State(state): State, + ClientIp(ip): ClientIp, + auth: AuthUser, + Path(id): Path, + body: Option>, +) -> Result { + let current_password = body.and_then(|Json(b)| b.current_password); + passkey_svc::remove( + &state, + auth.user_id, + auth.session_id, + id, + current_password.as_deref(), + ip, + auth.request_id, + ) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + post, + path = "/auth/passkeys/options", + tag = "passkeys", + responses( + (status = 200, description = "`PublicKeyCredentialRequestOptions` (JSON form) for `navigator.credentials.get()`; valid five minutes, used once", body = Object), + (status = 429, description = "Rate limited; see Retry-After"), + ), +)] +pub async fn authentication_options( + State(state): State, +) -> Result, AppError> { + Ok(Json(passkey_svc::authentication_options(&state).await?)) +} + +#[utoipa::path( + post, + path = "/auth/passkeys/sign-in", + tag = "passkeys", + request_body = PasskeySignInRequest, + responses( + (status = 200, description = "Signed in: a passkey with user verification needs no second factor", body = PasskeySignInResponse), + (status = 401, description = "`invalid_credentials`: the assertion does not verify", body = crate::error::ErrorBody), + (status = 403, description = "Account locked, suspended or inactive", body = crate::error::ErrorBody), + (status = 429, description = "Rate limited; see Retry-After"), + ), +)] +pub async fn sign_in( + State(state): State, + ClientIp(ip): ClientIp, + UserAgent(ua): UserAgent, + Json(body): Json, +) -> Result, AppError> { + let tokens = passkey_svc::sign_in( + &state, + &body.credential, + ip, + ua.as_deref(), + body.device_name.as_deref(), + body.remember_me.unwrap_or(false), + None, + ) + .await?; + Ok(Json(PasskeySignInResponse { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + })) +} diff --git a/src/handlers/personal_access_token.rs b/src/handlers/personal_access_token.rs new file mode 100644 index 0000000..8126487 --- /dev/null +++ b/src/handlers/personal_access_token.rs @@ -0,0 +1,177 @@ +//! Personal access tokens: `/users/me/tokens`, and their exchange for access +//! tokens. + +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + domain::personal_access_token::PersonalAccessToken, error::AppError, + services::personal_access_token as pat_svc, state::AppState, +}; + +use super::extractors::{AuthUser, ClientIp}; + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct CreatePersonalAccessTokenRequest { + pub name: String, + /// Permissions the token's access tokens carry; each must be held by the + /// account. Empty: none. + #[serde(default)] + pub scopes: Vec, + /// 1 to 365 (default 90). + pub expires_in_days: Option, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct PersonalAccessTokenResponse { + pub id: Uuid, + pub name: String, + pub scopes: Vec, + pub created_at: i64, + pub expires_at: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_used_at: Option, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct CreatedPersonalAccessTokenResponse { + #[serde(flatten)] + pub token: PersonalAccessTokenResponse, + /// The secret, shown once. + pub secret: String, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct PersonalAccessTokenExchangeRequest { + /// A personal access token (`aapat_...`). + pub token: String, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct PersonalAccessTokenExchangeResponse { + pub access_token: String, + /// Always `Bearer`. + pub token_type: &'static str, + /// Seconds. + pub expires_in: u64, +} + +fn token_response(token: PersonalAccessToken) -> PersonalAccessTokenResponse { + PersonalAccessTokenResponse { + id: token.id, + name: token.name, + scopes: token.scopes, + created_at: token.created_at.unix_timestamp(), + expires_at: token.expires_at.unix_timestamp(), + last_used_at: token.last_used_at.map(|t| t.unix_timestamp()), + } +} + +#[utoipa::path( + get, + path = "/users/me/tokens", + tag = "account", + responses( + (status = 200, description = "Personal access tokens that still work, newest first", body = [PersonalAccessTokenResponse]), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn list( + State(state): State, + auth: AuthUser, +) -> Result>, AppError> { + let tokens = pat_svc::list(&state, auth.user_id).await?; + Ok(Json(tokens.into_iter().map(token_response).collect())) +} + +#[utoipa::path( + post, + path = "/users/me/tokens", + tag = "account", + request_body = CreatePersonalAccessTokenRequest, + responses( + (status = 201, description = "Token created; its secret is in this response only", body = CreatedPersonalAccessTokenResponse), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Recent re-authentication required", body = crate::error::ErrorBody), + (status = 409, description = "`too_many_tokens`", body = crate::error::ErrorBody), + (status = 422, description = "Invalid name or lifetime, or a scope the account does not hold", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn create( + State(state): State, + ClientIp(ip): ClientIp, + auth: AuthUser, + Json(body): Json, +) -> Result<(StatusCode, Json), AppError> { + let created = pat_svc::create( + &state, + auth.user_id, + auth.session_id, + &body.name, + &body.scopes, + body.expires_in_days, + ip, + auth.request_id, + ) + .await?; + Ok(( + StatusCode::CREATED, + Json(CreatedPersonalAccessTokenResponse { + token: token_response(created.token), + secret: created.secret, + }), + )) +} + +#[utoipa::path( + delete, + path = "/users/me/tokens/{id}", + tag = "account", + params(("id" = Uuid, Path, description = "Token id")), + responses( + (status = 204, description = "Token revoked"), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 404, description = "No such token for this account", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn revoke( + State(state): State, + ClientIp(ip): ClientIp, + auth: AuthUser, + Path(id): Path, +) -> Result { + pat_svc::revoke(&state, auth.user_id, id, ip, auth.request_id).await?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + post, + path = "/auth/personal-access-tokens/exchange", + tag = "auth", + request_body = PersonalAccessTokenExchangeRequest, + responses( + (status = 200, description = "A short-lived access token carrying the token's scopes", body = PersonalAccessTokenExchangeResponse), + (status = 401, description = "Unknown, revoked or expired token", body = crate::error::ErrorBody), + (status = 403, description = "Account locked, suspended or inactive", body = crate::error::ErrorBody), + (status = 429, description = "Rate limited; see Retry-After"), + ), +)] +pub async fn exchange( + State(state): State, + Json(body): Json, +) -> Result, AppError> { + let exchanged = pat_svc::exchange(&state, &body.token).await?; + Ok(Json(PersonalAccessTokenExchangeResponse { + access_token: exchanged.access_token, + token_type: "Bearer", + expires_in: exchanged.expires_in, + })) +} diff --git a/src/handlers/session.rs b/src/handlers/session.rs index 713f6f6..fce6cd0 100644 --- a/src/handlers/session.rs +++ b/src/handlers/session.rs @@ -17,7 +17,7 @@ use super::extractors::{AuthUser, ClientIp}; // Response types -#[derive(Serialize)] +#[derive(Serialize, utoipa::ToSchema)] pub struct SessionResponse { pub id: Uuid, pub session_type: SessionType, @@ -38,13 +38,26 @@ pub struct SessionResponse { // Request types -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct RevokeAllRequest { pub current_password: Option, + /// Keep the session making the request signed in. Default: false. + #[serde(default)] + pub keep_current_session: bool, } // Handlers +#[utoipa::path( + get, + path = "/users/me/sessions", + tag = "sessions", + responses( + (status = 200, description = "Active sessions", body = [SessionResponse]), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] pub async fn list( State(state): State, auth: AuthUser, @@ -73,17 +86,34 @@ pub async fn list( Ok(Json(response)) } +#[utoipa::path( + delete, + path = "/users/me/sessions/{id}", + tag = "sessions", + params(("id" = Uuid, Path, description = "Method or session id")), + request_body = Option, + responses( + (status = 204, description = "Session revoked"), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Recent re-authentication required", body = crate::error::ErrorBody), + (status = 404, description = "No such session", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] pub async fn revoke( State(state): State, ClientIp(ip): ClientIp, auth: AuthUser, Path(session_id): Path, + body: Option>, ) -> Result { + let current_password = body.and_then(|Json(b)| b.current_password); session_svc::revoke( &state, auth.user_id, auth.session_id, session_id, + current_password.as_deref(), ip, auth.request_id, ) @@ -91,17 +121,33 @@ pub async fn revoke( Ok(StatusCode::NO_CONTENT) } +#[utoipa::path( + delete, + path = "/users/me/sessions", + tag = "sessions", + request_body = Option, + responses( + (status = 204, description = "Every other session revoked, and the current one too unless `keep_current_session`"), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Recent re-authentication required", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] pub async fn revoke_all( State(state): State, ClientIp(ip): ClientIp, auth: AuthUser, - Json(body): Json, + body: Option>, ) -> Result { + let (current_password, keep_current_session) = body + .map(|Json(b)| (b.current_password, b.keep_current_session)) + .unwrap_or_default(); session_svc::revoke_all( &state, auth.user_id, auth.session_id, - body.current_password.as_deref(), + current_password.as_deref(), + keep_current_session, ip, auth.request_id, ) diff --git a/src/handlers/two_factor.rs b/src/handlers/two_factor.rs index 4d3d8ed..c8ba985 100644 --- a/src/handlers/two_factor.rs +++ b/src/handlers/two_factor.rs @@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::{ + domain::two_factor::TwoFactorType, error::AppError, services::{email_2fa as email_2fa_svc, two_factor as tf_svc}, state::AppState, @@ -18,39 +19,46 @@ use super::extractors::{AuthUser, ClientIp}; // Request types -#[derive(Deserialize)] +/// Body of the setup endpoints. Optional: a recent re-authentication +/// (`POST /users/me/reauth`) makes the password unnecessary. +#[derive(Deserialize, Default, utoipa::ToSchema)] +pub struct SetupTwoFactorRequest { + pub current_password: Option, +} + +#[derive(Deserialize, utoipa::ToSchema)] pub struct VerifyTotpSetupRequest { pub code: String, } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct DisableTotpRequest { pub current_password: Option, } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct RegenerateRecoveryCodesRequest { pub current_password: Option, } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct UseRecoveryCodeRequest { pub code: String, } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct VerifyEmailOtpSetupRequest { pub code: String, } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct DisableEmailOtpRequest { pub current_password: Option, } // Response types -#[derive(Serialize)] +#[derive(Serialize, utoipa::ToSchema)] pub struct TotpSetupResponse { pub method_id: Uuid, pub qr_uri: String, @@ -58,24 +66,48 @@ pub struct TotpSetupResponse { pub base32_secret: String, } -#[derive(Serialize)] +#[derive(Serialize, utoipa::ToSchema)] pub struct RecoveryCodesResponse { /// Plaintext recovery codes shown once. The user must store them securely. pub recovery_codes: Vec, } -#[derive(Serialize)] +#[derive(Serialize, utoipa::ToSchema)] pub struct EmailOtpSetupResponse { pub method_id: Uuid, } // Handlers +#[utoipa::path( + post, + path = "/users/me/two-factor/totp/setup", + tag = "two-factor", + request_body = Option, + responses( + (status = 200, description = "Secret provisioned, shown once", body = TotpSetupResponse), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Recent re-authentication required", body = crate::error::ErrorBody), + (status = 409, description = "Already enabled", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] pub async fn setup_totp( State(state): State, + ClientIp(ip): ClientIp, auth: AuthUser, + body: Option>, ) -> Result, AppError> { - let result = tf_svc::setup_totp(&state, auth.user_id).await?; + let body = body.map(|Json(b)| b).unwrap_or_default(); + let result = tf_svc::setup_totp( + &state, + auth.user_id, + auth.session_id, + body.current_password.as_deref(), + ip, + auth.request_id, + ) + .await?; Ok(Json(TotpSetupResponse { method_id: result.method_id, @@ -84,6 +116,20 @@ pub async fn setup_totp( })) } +#[utoipa::path( + post, + path = "/users/me/two-factor/totp/{id}/verify", + tag = "two-factor", + params(("id" = Uuid, Path, description = "Method or session id")), + request_body = VerifyTotpSetupRequest, + responses( + (status = 200, description = "Method enabled; recovery codes shown once", body = RecoveryCodesResponse), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 404, description = "No such method", body = crate::error::ErrorBody), + (status = 409, description = "Already verified", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] pub async fn verify_totp_setup( State(state): State, auth: AuthUser, @@ -97,19 +143,34 @@ pub async fn verify_totp_setup( })) } +#[utoipa::path( + delete, + path = "/users/me/two-factor/totp/{id}", + tag = "two-factor", + params(("id" = Uuid, Path, description = "Method or session id")), + request_body = Option, + responses( + (status = 204, description = "Method removed"), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Recent re-authentication required", body = crate::error::ErrorBody), + (status = 404, description = "No such method", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] pub async fn disable_totp( State(state): State, ClientIp(ip): ClientIp, auth: AuthUser, Path(method_id): Path, - Json(body): Json, + body: Option>, ) -> Result { + let current_password = body.and_then(|Json(b)| b.current_password); tf_svc::disable_totp( &state, auth.user_id, auth.session_id, method_id, - body.current_password.as_deref(), + current_password.as_deref(), ip, auth.request_id, ) @@ -117,6 +178,18 @@ pub async fn disable_totp( Ok(StatusCode::NO_CONTENT) } +#[utoipa::path( + post, + path = "/users/me/two-factor/recovery-codes", + tag = "two-factor", + request_body = RegenerateRecoveryCodesRequest, + responses( + (status = 200, description = "New codes, shown once", body = RecoveryCodesResponse), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Recent re-authentication required", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] pub async fn regenerate_recovery_codes( State(state): State, ClientIp(ip): ClientIp, @@ -137,6 +210,17 @@ pub async fn regenerate_recovery_codes( })) } +#[utoipa::path( + post, + path = "/users/me/two-factor/recovery-codes/use", + tag = "two-factor", + request_body = UseRecoveryCodeRequest, + responses( + (status = 204, description = "Code consumed"), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] pub async fn use_recovery_code( State(state): State, auth: AuthUser, @@ -148,16 +232,51 @@ pub async fn use_recovery_code( // Email OTP 2FA +#[utoipa::path( + post, + path = "/users/me/two-factor/email/setup", + tag = "two-factor", + request_body = Option, + responses( + (status = 200, description = "Method created; first code sent", body = EmailOtpSetupResponse), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Recent re-authentication required", body = crate::error::ErrorBody), + (status = 409, description = "Already enabled", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] pub async fn setup_email_otp( State(state): State, + ClientIp(ip): ClientIp, auth: AuthUser, + body: Option>, ) -> Result, AppError> { - let method_id = email_2fa_svc::setup(&state, auth.user_id).await?; + let body = body.map(|Json(b)| b).unwrap_or_default(); + let method_id = email_2fa_svc::setup( + &state, + auth.user_id, + auth.session_id, + body.current_password.as_deref(), + ip, + auth.request_id, + ) + .await?; // Send the first code immediately so the user can verify right away. email_2fa_svc::send_code(&state, auth.user_id).await?; Ok(Json(EmailOtpSetupResponse { method_id })) } +#[utoipa::path( + post, + path = "/users/me/two-factor/email/send", + tag = "two-factor", + responses( + (status = 204, description = "Code sent"), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 429, description = "Rate limited; see Retry-After"), + ), + security(("bearer" = [])), +)] pub async fn send_email_otp_code( State(state): State, auth: AuthUser, @@ -166,6 +285,19 @@ pub async fn send_email_otp_code( Ok(StatusCode::NO_CONTENT) } +#[utoipa::path( + post, + path = "/users/me/two-factor/email/{id}/verify", + tag = "two-factor", + params(("id" = Uuid, Path, description = "Method or session id")), + request_body = VerifyEmailOtpSetupRequest, + responses( + (status = 200, description = "Method enabled; recovery codes shown once", body = RecoveryCodesResponse), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 404, description = "No such method", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] pub async fn verify_email_otp_setup( State(state): State, auth: AuthUser, @@ -180,22 +312,99 @@ pub async fn verify_email_otp_setup( })) } +#[utoipa::path( + delete, + path = "/users/me/two-factor/email/{id}", + tag = "two-factor", + params(("id" = Uuid, Path, description = "Method or session id")), + request_body = Option, + responses( + (status = 204, description = "Method removed"), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Recent re-authentication required", body = crate::error::ErrorBody), + (status = 404, description = "No such method", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] pub async fn disable_email_otp( State(state): State, ClientIp(ip): ClientIp, auth: AuthUser, Path(method_id): Path, - Json(body): Json, + body: Option>, ) -> Result { + let current_password = body.and_then(|Json(b)| b.current_password); email_2fa_svc::disable( &state, auth.user_id, auth.session_id, method_id, - body.current_password.as_deref(), + current_password.as_deref(), ip, auth.request_id, ) .await?; Ok(StatusCode::NO_CONTENT) } + +// Overview + +#[derive(Serialize, utoipa::ToSchema)] +pub struct TwoFactorMethodResponse { + pub id: Uuid, + /// `totp` or `email`. + pub method_type: &'static str, + pub is_verified: bool, + pub is_primary: bool, + pub created_at: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_used_at: Option, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct TwoFactorOverviewResponse { + pub methods: Vec, + /// Unused and unexpired. Zero next to a verified method is worth showing: + /// losing the device would then lock the account. + pub recovery_codes_remaining: i64, +} + +/// GET /users/me/two-factor +/// +/// 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. +#[utoipa::path( + get, + path = "/users/me/two-factor", + tag = "two-factor", + responses( + (status = 200, description = "Configured methods and remaining recovery codes", body = TwoFactorOverviewResponse), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn list( + State(state): State, + auth: AuthUser, +) -> Result, AppError> { + let (methods, recovery_codes_remaining) = tf_svc::list_methods(&state, auth.user_id).await?; + + Ok(Json(TwoFactorOverviewResponse { + methods: methods + .into_iter() + .map(|method| TwoFactorMethodResponse { + id: method.id, + method_type: match method.method_type { + TwoFactorType::Totp => "totp", + TwoFactorType::Email => "email", + }, + is_verified: method.is_verified, + is_primary: method.is_primary, + created_at: method.created_at.unix_timestamp(), + last_used_at: method.last_used_at.map(|at| at.unix_timestamp()), + }) + .collect(), + recovery_codes_remaining, + })) +} diff --git a/src/handlers/user.rs b/src/handlers/user.rs index dd8444d..612656d 100644 --- a/src/handlers/user.rs +++ b/src/handlers/user.rs @@ -14,59 +14,70 @@ use super::extractors::{AuthUser, ClientIp}; // Request types -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct ChangeUsernameRequest { pub username: String, pub current_password: Option, } -#[derive(Serialize)] +/// Optional body for sensitive actions that accept the current password in +/// place of a recent re-authentication. +#[derive(Deserialize, Default, utoipa::ToSchema)] +pub struct CurrentPasswordRequest { + pub current_password: Option, +} + +#[derive(Serialize, utoipa::ToSchema)] pub struct FlowTokenResponse { pub flow_token: String, } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct VerifyCurrentEmailRequest { pub flow_token: String, pub code: String, } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct SubmitNewEmailRequest { pub flow_token: String, pub new_email: String, } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct ConfirmNewEmailRequest { pub flow_token: String, pub code: String, } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct ChangePasswordRequest { pub current_password: Option, pub new_password: String, + /// Keep the session making the request signed in; every other session is + /// revoked either way. Default: false. + #[serde(default)] + pub keep_current_session: bool, } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct ChangeLocaleRequest { pub locale: String, } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct DeleteAccountRequest { pub current_password: Option, } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct ReauthenticateRequest { pub current_password: String, } // Response types -#[derive(Serialize)] +#[derive(Serialize, utoipa::ToSchema)] pub struct UserResponse { pub id: Uuid, pub username: String, @@ -82,6 +93,16 @@ pub struct UserResponse { // Handlers +#[utoipa::path( + get, + path = "/users/me", + tag = "account", + responses( + (status = 200, description = "The caller's profile", body = UserResponse), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] pub async fn me( State(state): State, auth: AuthUser, @@ -100,26 +121,27 @@ pub async fn me( })) } +#[utoipa::path( + patch, + path = "/users/me/username", + tag = "account", + request_body = ChangeUsernameRequest, + responses( + (status = 204, description = "Username changed"), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Recent re-authentication required", body = crate::error::ErrorBody), + (status = 409, description = "Username taken", body = crate::error::ErrorBody), + (status = 422, description = "Invalid input", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] pub async fn change_username( State(state): State, ClientIp(ip): ClientIp, auth: AuthUser, Json(body): Json, ) -> Result { - if body.username.len() < 3 || body.username.len() > 30 { - return Err(AppError::Validation( - "username must be 3 to 30 characters".into(), - )); - } - if !body - .username - .chars() - .all(|c| c.is_alphanumeric() || c == '_') - { - return Err(AppError::Validation( - "username may only contain letters, digits and underscores".into(), - )); - } + super::auth::validate_username(&body.username)?; reauth_svc::require_recent_reauth_or_password( &state, @@ -136,15 +158,48 @@ pub async fn change_username( Ok(StatusCode::NO_CONTENT) } +#[utoipa::path( + post, + path = "/users/me/email/start", + tag = "email-change", + request_body = Option, + responses( + (status = 200, description = "Code sent to the current address", body = FlowTokenResponse), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Recent re-authentication required", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] pub async fn start_email_change( State(state): State, ClientIp(ip): ClientIp, auth: AuthUser, + body: Option>, ) -> Result, AppError> { - let flow_token = email_change_svc::start(&state, auth.user_id, ip, auth.request_id).await?; + let body = body.map(|Json(b)| b).unwrap_or_default(); + let flow_token = email_change_svc::start( + &state, + auth.user_id, + auth.session_id, + body.current_password.as_deref(), + ip, + auth.request_id, + ) + .await?; Ok(Json(FlowTokenResponse { flow_token })) } +#[utoipa::path( + post, + path = "/users/me/email/verify-current", + tag = "email-change", + request_body = VerifyCurrentEmailRequest, + responses( + (status = 204, description = "Current address confirmed"), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] pub async fn verify_current_email( State(state): State, auth: AuthUser, @@ -154,15 +209,26 @@ pub async fn verify_current_email( Ok(StatusCode::NO_CONTENT) } +#[utoipa::path( + post, + path = "/users/me/email/submit", + tag = "email-change", + request_body = SubmitNewEmailRequest, + responses( + (status = 204, description = "Code sent to the new address"), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 409, description = "Address taken", body = crate::error::ErrorBody), + (status = 422, description = "Invalid input", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] pub async fn submit_new_email( State(state): State, ClientIp(ip): ClientIp, auth: AuthUser, Json(body): Json, ) -> Result { - if !email_address::EmailAddress::is_valid(&body.new_email) { - return Err(AppError::Validation("invalid email address".into())); - } + super::auth::validate_email(&body.new_email)?; email_change_svc::submit_new( &state, auth.user_id, @@ -175,6 +241,18 @@ pub async fn submit_new_email( Ok(StatusCode::NO_CONTENT) } +#[utoipa::path( + post, + path = "/users/me/email/confirm", + tag = "email-change", + request_body = ConfirmNewEmailRequest, + responses( + (status = 204, description = "Address changed; other sessions revoked"), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 409, description = "Address taken", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] pub async fn confirm_new_email( State(state): State, ClientIp(ip): ClientIp, @@ -194,6 +272,19 @@ pub async fn confirm_new_email( Ok(StatusCode::NO_CONTENT) } +#[utoipa::path( + patch, + path = "/users/me/password", + tag = "account", + request_body = ChangePasswordRequest, + responses( + (status = 204, description = "Password changed; every other session revoked, and the current one too unless `keep_current_session`"), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Recent re-authentication required", body = crate::error::ErrorBody), + (status = 422, description = "Invalid input", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] pub async fn change_password( State(state): State, ClientIp(ip): ClientIp, @@ -208,6 +299,7 @@ pub async fn change_password( auth.session_id, body.current_password.as_deref(), &body.new_password, + body.keep_current_session, ip, auth.request_id, ) @@ -216,6 +308,18 @@ pub async fn change_password( Ok(StatusCode::NO_CONTENT) } +#[utoipa::path( + patch, + path = "/users/me/locale", + tag = "account", + request_body = ChangeLocaleRequest, + responses( + (status = 204, description = "Locale changed"), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 422, description = "Invalid input", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] pub async fn change_locale( State(state): State, auth: AuthUser, @@ -251,14 +355,17 @@ pub fn validate_locale(locale: &str) -> Result<(), AppError> { /// These constraints are intentionally modest; raising the minimum length is /// more effective than adding more character-class requirements. pub fn validate_password(password: &str) -> Result<(), AppError> { - if password.len() < 10 { + // Characters, not bytes: an accented password of ten bytes may hold seven. + if password.chars().count() < 10 { return Err(AppError::Validation( "password must be at least 10 characters".into(), )); } + // The upper bound stays in bytes: sign-in refuses longer input before + // hashing, so every accepted password must remain usable there. if password.len() > 128 { return Err(AppError::Validation( - "password must not exceed 128 characters".into(), + "password must not exceed 128 bytes".into(), )); } if !password.chars().any(|c| c.is_ascii_digit()) { @@ -280,17 +387,30 @@ pub fn validate_password(password: &str) -> Result<(), AppError> { Ok(()) } +#[utoipa::path( + delete, + path = "/users/me", + tag = "account", + request_body = Option, + responses( + (status = 204, description = "Account deleted"), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Recent re-authentication required", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] pub async fn delete_account( State(state): State, ClientIp(ip): ClientIp, auth: AuthUser, - Json(body): Json, + body: Option>, ) -> Result { + let current_password = body.and_then(|Json(b)| b.current_password); user_svc::delete_account( &state, auth.user_id, auth.session_id, - body.current_password.as_deref(), + current_password.as_deref(), ip, auth.request_id, ) @@ -298,6 +418,19 @@ pub async fn delete_account( Ok(StatusCode::NO_CONTENT) } +#[utoipa::path( + post, + path = "/users/me/reauth", + tag = "account", + request_body = ReauthenticateRequest, + responses( + (status = 204, description = "Re-authenticated for SENSITIVE_ACTION_REAUTH_SECS"), + (status = 401, description = "Wrong password or invalid token", body = crate::error::ErrorBody), + (status = 403, description = "Too many wrong passwords: `account_locked` until the window ends", body = crate::error::ErrorBody), + (status = 429, description = "Rate limited; see Retry-After"), + ), + security(("bearer" = [])), +)] pub async fn reauthenticate( State(state): State, ClientIp(ip): ClientIp, @@ -316,6 +449,34 @@ pub async fn reauthenticate( Ok(StatusCode::NO_CONTENT) } +#[utoipa::path( + get, + path = "/users/me/export", + tag = "account", + responses( + (status = 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", body = Object), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Recent re-authentication required", body = crate::error::ErrorBody), + (status = 429, description = "Rate limited; see Retry-After"), + ), + security(("bearer" = [])), +)] +pub async fn export_data( + State(state): State, + ClientIp(ip): ClientIp, + auth: AuthUser, +) -> Result { + let document = + user_svc::export_data(&state, auth.user_id, auth.session_id, ip, auth.request_id).await?; + Ok(( + [( + axum::http::header::CONTENT_DISPOSITION, + "attachment; filename=\"account-data.json\"", + )], + Json(document), + )) +} + pub fn user_status_str(status: &crate::domain::user::UserStatus) -> String { use crate::domain::user::UserStatus; match status { @@ -326,3 +487,54 @@ pub fn user_status_str(status: &crate::domain::user::UserStatus) -> String { } .to_owned() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn password_minimum_counts_characters_not_bytes() { + // Found by the fuzzer: ten bytes, seven characters. + assert!(validate_password("n@My\u{202E}e 1").is_err()); + assert!(validate_password(&format!("\u{c9}{}1!A", "\u{e9}".repeat(6))).is_ok()); + } + + #[test] + fn password_maximum_counts_bytes() { + let longest = format!("A1!{}", "a".repeat(125)); + assert!(validate_password(&longest).is_ok()); + assert!(validate_password(&format!("{longest}a")).is_err()); + assert!(validate_password(&format!("A1!{}", "\u{e9}".repeat(63))).is_err()); + } + + #[test] + fn password_needs_every_character_class() { + assert!(validate_password("Password1!").is_ok()); + assert!(validate_password("password1!").is_err()); + assert!(validate_password("Password!!").is_err()); + assert!(validate_password("Password11").is_err()); + assert!(validate_password("Password\u{0663}!").is_err()); + } + + mod properties { + use proptest::prelude::*; + + use super::super::validate_password; + + proptest! { + #![proptest_config(ProptestConfig::with_cases(1024))] + + #[test] + fn the_password_policy_is_exactly_its_definition( + password in prop_oneof!["\\PC{0,140}", "[A-Za-z0-9!?#\u{e9}]{8,12}", "[a-z]{120,135}A1!"], + ) { + let expected = password.chars().count() >= 10 + && password.len() <= 128 + && password.chars().any(|c| c.is_ascii_digit()) + && password.chars().any(|c| c.is_ascii_uppercase()) + && password.chars().any(|c| c.is_ascii_punctuation()); + prop_assert_eq!(validate_password(&password).is_ok(), expected, "{:?}", password); + } + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 2279b60..8288ddf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,9 +1,14 @@ +pub mod cli; pub mod config; pub mod domain; pub mod error; +#[cfg(feature = "fuzzing")] +pub mod fuzzing; pub mod handlers; pub mod middleware; +pub mod openapi; pub mod repositories; pub mod services; pub mod state; +pub mod telemetry; pub mod utils; diff --git a/src/main.rs b/src/main.rs index 1f602ec..1f7c68f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,5 @@ +use std::{future::IntoFuture, time::Duration}; + use auth_api::{ config::{Config, LogFormat}, handlers, @@ -5,9 +7,16 @@ use auth_api::{ state::AppState, }; +/// Shutdown budget, inside the container's 40-second stop grace period: requests +/// get slightly more than the 30-second request timeout, then the background +/// tasks (notifications, cache invalidations), then the buffered NATS events. +const REQUEST_DRAIN_TIMEOUT: Duration = Duration::from_secs(32); +const BACKGROUND_DRAIN_TIMEOUT: Duration = Duration::from_secs(5); +const NATS_FLUSH_TIMEOUT: Duration = Duration::from_secs(2); + #[tokio::main] async fn main() -> anyhow::Result<()> { - // Healthcheck mode: hit the local /health endpoint and exit 0/1. + // Healthcheck mode: hit the local /live endpoint and exit 0/1. // Designed for `HEALTHCHECK CMD ["./auth-api", "--healthcheck"]` in the // runtime image so we don't have to ship `curl`/`wget` in the slim base. // Handled BEFORE Config::from_env so a misconfigured env doesn't make the @@ -18,17 +27,18 @@ async fn main() -> anyhow::Result<()> { let config = Config::from_env().expect("failed to load config"); - init_tracing(&config.log); + let tracer_provider = auth_api::telemetry::tracer_provider(&config.telemetry)?; + init_tracing(&config.log, tracer_provider.as_ref()); + auth_api::utils::password::log_capacity(&config.crypto); // One-off command: re-encrypt all TOTP secrets with the new key. // Set PREVIOUS_ENCRYPTION_KEY= ENCRYPTION_KEY=, run, then remove PREVIOUS_ENCRYPTION_KEY. if std::env::args().any(|a| a == "--rotate-totp-keys") { let state = AppState::from_config(config).await?; - let result = key_rotation::rotate_totp_encryption_key(&state) - .await - .map_err(|e| anyhow::anyhow!("{:?}", e))?; + let result = key_rotation::rotate_totp_encryption_key(&state).await?; tracing::info!( rotated = result.rotated, + skipped = result.skipped, failed = result.failed, "TOTP key rotation complete" ); @@ -38,17 +48,69 @@ async fn main() -> anyhow::Result<()> { return Ok(()); } + // One-off command: create or update a registered client (device and + // authorization code flows refuse unregistered clients). Needs only the + // database, not Redis, NATS or SMTP. + let args: Vec = std::env::args().collect(); + + // One-off command: grant a role, such as the first administrator. + match auth_api::cli::parse_role_grant(&args) { + Ok(Some(grant)) => { + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect(&config.database.url) + .await?; + auth_api::cli::grant_role(&pool, &grant) + .await + .map_err(|message| anyhow::anyhow!("--grant-role: {message}"))?; + tracing::info!(role = grant.role, "role granted"); + return Ok(()); + } + Ok(None) => {} + Err(message) => anyhow::bail!("--grant-role: {message}"), + } + + match auth_api::cli::parse_client_registration(&args) { + Ok(Some(registration)) => { + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect(&config.database.url) + .await?; + let client = + auth_api::repositories::registered_client::upsert(&pool, ®istration.as_new()) + .await?; + tracing::info!( + client_id = client.client_id, + primary = client.is_primary, + "registered client saved" + ); + return Ok(()); + } + Ok(None) => {} + Err(message) => anyhow::bail!("--register-client: {message}"), + } + let addr = format!("{}:{}", config.server.host, config.server.port); let state = AppState::from_config(config).await?; // Rotate audit log partitions at startup: creates upcoming monthly partitions // and drops partitions older than retention_months. - if let Err(e) = rotate_audit_log(&state.db, state.config.audit.retention_months).await { + if let Err(e) = cleanup::rotate_audit_log(&state.db, state.config.audit.retention_months).await + { tracing::warn!(error = ?e, "audit log partition rotation failed at startup"); } cleanup::spawn_cleanup_task(state.db.clone(), state.config.clone()); + let _relay = auth_api::services::events::spawn_relay(state.db.clone(), state.nats.clone()); + let _webhooks = auth_api::services::webhooks::spawn_dispatcher(state.clone()); + let nats = state.nats.clone(); + auth_api::utils::container_metrics::spawn(); + auth_api::utils::pool_metrics::spawn( + state.db.clone(), + state.config.database.max_connections, + state.redis.clone(), + ); // Serve Prometheus metrics on a separate internal listener so the // exposition endpoint never sits behind the public reverse proxy. @@ -73,22 +135,54 @@ async fn main() -> anyhow::Result<()> { let listener = tokio::net::TcpListener::bind(&addr).await?; tracing::info!("listening on {}", addr); - axum::serve( + let (stop_tx, stop_rx) = tokio::sync::watch::channel(false); + tokio::spawn(async move { + shutdown_signal().await; + let _ = stop_tx.send(true); + }); + let stopped = |mut rx: tokio::sync::watch::Receiver| async move { + let _ = rx.wait_for(|stopped| *stopped).await; + }; + + let server = axum::serve( listener, app.into_make_service_with_connect_info::(), ) - .with_graceful_shutdown(shutdown_signal()) - .await?; + .with_graceful_shutdown(stopped(stop_rx.clone())); - tracing::info!("shutdown complete"); - Ok(()) -} + // Phase 1: in-flight requests, bounded. Axum alone would wait forever. + let deadline_rx = stop_rx; + tokio::select! { + result = server.into_future() => result?, + () = async move { + stopped(deadline_rx).await; + tokio::time::sleep(REQUEST_DRAIN_TIMEOUT).await; + } => { + tracing::warn!(timeout = ?REQUEST_DRAIN_TIMEOUT, "requests still running at the shutdown deadline"); + } + } + + // Phase 2: notifications and cache invalidations started by requests. + let left = auth_api::utils::background::drain(BACKGROUND_DRAIN_TIMEOUT).await; + if left > 0 { + tracing::warn!(left, "background tasks cut off at shutdown"); + } -async fn rotate_audit_log(db: &sqlx::PgPool, retention_months: u32) -> Result<(), sqlx::Error> { - sqlx::query("SELECT rotate_audit_log_partitions($1)") - .bind(retention_months as i32) - .execute(db) - .await?; + // Phase 3: events the NATS client still buffers. + match tokio::time::timeout(NATS_FLUSH_TIMEOUT, nats.flush()).await { + Ok(Ok(())) => {} + Ok(Err(e)) => tracing::warn!(error = %e, "NATS events not flushed at shutdown"), + Err(_) => tracing::warn!("flushing NATS events timed out at shutdown"), + } + + // Phase 4: the last spans. + if let Some(provider) = tracer_provider + && let Err(e) = provider.shutdown() + { + tracing::warn!(error = %e, "traces not flushed at shutdown"); + } + + tracing::info!("shutdown complete"); Ok(()) } @@ -116,7 +210,7 @@ async fn shutdown_signal() { } } -/// Hit the local `/health` endpoint and return a process exit code. +/// Hit the local `/live` endpoint and return a process exit code. /// Reads `SERVER_PORT` (defaults to 3000) so the healthcheck honours /// custom port overrides without requiring a full Config load. /// Returns 0 on a 2xx response, 1 otherwise (including timeouts and @@ -126,7 +220,7 @@ async fn run_healthcheck() -> i32 { .ok() .and_then(|v| v.parse::().ok()) .unwrap_or(3000); - let url = format!("http://127.0.0.1:{port}/health"); + let url = format!("http://127.0.0.1:{port}/live"); let client = match reqwest::Client::builder() .timeout(std::time::Duration::from_secs(5)) @@ -142,12 +236,17 @@ async fn run_healthcheck() -> i32 { } } -fn init_tracing(cfg: &auth_api::config::LogConfig) { +fn init_tracing( + cfg: &auth_api::config::LogConfig, + tracer_provider: Option<&opentelemetry_sdk::trace::SdkTracerProvider>, +) { use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt}; let filter = EnvFilter::try_new(&cfg.level).unwrap_or_else(|_| EnvFilter::new("info")); - let registry = tracing_subscriber::registry().with(filter); + let registry = tracing_subscriber::registry() + .with(filter) + .with(tracer_provider.map(auth_api::telemetry::layer)); match cfg.format { LogFormat::Json => registry diff --git a/src/middleware/access_log.rs b/src/middleware/access_log.rs new file mode 100644 index 0000000..8db184b --- /dev/null +++ b/src/middleware/access_log.rs @@ -0,0 +1,50 @@ +//! Access log: one structured line per request. +//! +//! Logs the route template (`/oauth/device/{user_code}`), never the raw path, +//! so codes and identifiers carried in URLs stay out of the logs. Health checks +//! log at debug to keep probes from drowning real traffic. + +use std::time::Instant; + +use tracing::Instrument; + +use axum::{ + extract::{MatchedPath, Request}, + middleware::Next, + response::Response, +}; + +use super::request_id::X_REQUEST_ID; + +pub async fn layer(req: Request, next: Next) -> Response { + let started = Instant::now(); + let method = req.method().clone(); + let route = req + .extensions() + .get::() + .map(|path| path.as_str().to_owned()); + let request_id = req + .headers() + .get(&X_REQUEST_ID) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let route = route.as_deref().unwrap_or(""); + let span = crate::telemetry::request_span(&method, route, req.headers()); + + let res = next.run(req).instrument(span.clone()).await; + + let status = res.status().as_u16(); + span.record("http.response.status_code", status); + let _entered = span.enter(); + let latency_ms = started.elapsed().as_secs_f64() * 1000.0; + let request_id = request_id.as_deref().unwrap_or("-"); + + if matches!(route, "/health" | "/live" | "/ready") { + tracing::debug!(target: "access", %method, route, status, latency_ms, request_id); + } else if status >= 500 { + tracing::warn!(target: "access", %method, route, status, latency_ms, request_id); + } else { + tracing::info!(target: "access", %method, route, status, latency_ms, request_id); + } + res +} diff --git a/src/middleware/client_ip.rs b/src/middleware/client_ip.rs new file mode 100644 index 0000000..3cbee59 --- /dev/null +++ b/src/middleware/client_ip.rs @@ -0,0 +1,230 @@ +//! The client address, read through trusted reverse proxies. +//! +//! Forwarding headers are honoured only when the direct peer is a trusted +//! proxy; the rightmost untrusted hop of `X-Forwarded-For` is the client. + +use std::net::{IpAddr, SocketAddr}; + +use axum::{ + extract::{ConnectInfo, FromRequestParts}, + http::{HeaderMap, StatusCode, request::Parts}, +}; +use ipnetwork::IpNetwork; + +use crate::{middleware::rate_limit::RateLimitState, state::AppState}; + +pub struct ClientIp(pub Option); + +pub trait TrustedProxySource { + fn trusted_proxy_cidrs(&self) -> &[IpNetwork]; +} + +impl TrustedProxySource for AppState { + fn trusted_proxy_cidrs(&self) -> &[IpNetwork] { + &self.config.server.trusted_proxy_cidrs + } +} + +impl TrustedProxySource for RateLimitState { + fn trusted_proxy_cidrs(&self) -> &[IpNetwork] { + &self.trusted_proxy_cidrs + } +} + +impl FromRequestParts for ClientIp { + type Rejection = (StatusCode, &'static str); + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let peer = parts + .extensions + .get::>() + .map(|ci| ci.0.ip()); + let ip = resolve_client_ip(peer, &parts.headers, state.trusted_proxy_cidrs()); + Ok(ClientIp(ip.map(IpNetwork::from))) + } +} + +/// The client address of a request: its peer, or, when the peer is a trusted +/// proxy, the address the forwarding headers name. `None` without a peer. +pub(crate) fn resolve_client_ip( + peer: Option, + headers: &HeaderMap, + trusted_proxy_cidrs: &[IpNetwork], +) -> Option { + let peer = peer?; + if !is_trusted_proxy(peer, trusted_proxy_cidrs) { + return Some(peer); + } + Some(forwarded_client_ip(headers, trusted_proxy_cidrs).unwrap_or(peer)) +} + +fn is_trusted_proxy(ip: IpAddr, trusted_proxy_cidrs: &[IpNetwork]) -> bool { + trusted_proxy_cidrs.iter().any(|cidr| cidr.contains(ip)) +} + +/// The client named by the forwarding headers of a trusted proxy. +/// +/// `X-Forwarded-For` is read across every header line, as one list. Walking it +/// from the right, trusted proxies are skipped and the first other hop is the +/// client. A hop that is not an address ends the walk without an answer: what +/// lies to its left was written by someone no proxy vouched for. When every +/// hop is a trusted proxy, the leftmost one is the client. `X-Real-IP` counts +/// only when no `X-Forwarded-For` was sent at all. +fn forwarded_client_ip(headers: &HeaderMap, trusted_proxy_cidrs: &[IpNetwork]) -> Option { + let mut lines = headers.get_all("x-forwarded-for").iter().peekable(); + if lines.peek().is_none() { + return headers + .get("x-real-ip") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.trim().parse::().ok()); + } + + let mut hops = Vec::new(); + for line in lines { + hops.extend(line.to_str().ok()?.split(',').map(str::trim)); + } + + let mut leftmost_trusted = None; + for hop in hops.iter().rev() { + let ip = hop.parse::().ok()?; + if !is_trusted_proxy(ip, trusted_proxy_cidrs) { + return Some(ip); + } + leftmost_trusted = Some(ip); + } + leftmost_trusted +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::{HeaderValue, Request}; + + #[derive(Default)] + struct TestState { + trusted_proxy_cidrs: Vec, + } + + impl TrustedProxySource for TestState { + fn trusted_proxy_cidrs(&self) -> &[IpNetwork] { + &self.trusted_proxy_cidrs + } + } + + #[tokio::test] + async fn direct_peer_ignores_forwarded_headers() { + let state = TestState::default(); + let mut req = Request::builder().uri("/").body(()).unwrap(); + req.headers_mut() + .insert("x-forwarded-for", HeaderValue::from_static("203.0.113.5")); + req.extensions_mut() + .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 3000)))); + + let (mut parts, _) = req.into_parts(); + let client_ip = ClientIp::from_request_parts(&mut parts, &state) + .await + .unwrap(); + + assert_eq!(client_ip.0.unwrap().ip(), IpAddr::from([127, 0, 0, 1])); + } + + #[tokio::test] + async fn trusted_proxy_uses_forwarded_client_ip() { + let state = TestState { + trusted_proxy_cidrs: vec!["10.0.0.0/8".parse().unwrap()], + }; + let mut req = Request::builder().uri("/").body(()).unwrap(); + req.headers_mut().insert( + "x-forwarded-for", + HeaderValue::from_static("198.51.100.10, 10.1.2.3"), + ); + req.extensions_mut() + .insert(ConnectInfo(SocketAddr::from(([10, 9, 8, 7], 3000)))); + + let (mut parts, _) = req.into_parts(); + let client_ip = ClientIp::from_request_parts(&mut parts, &state) + .await + .unwrap(); + + assert_eq!(client_ip.0.unwrap().ip(), IpAddr::from([198, 51, 100, 10])); + } + fn forwarded(lines: &[&str]) -> HeaderMap { + let mut headers = HeaderMap::new(); + for line in lines { + headers.append("x-forwarded-for", HeaderValue::from_str(line).unwrap()); + } + headers + } + + fn trusted() -> Vec { + vec!["10.0.0.0/8".parse().unwrap()] + } + + const PROXY: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(10, 0, 0, 2)); + + #[test] + fn every_forwarded_line_counts_as_one_list() { + // A line the client sent, then the proxy's own: the proxy's hop wins. + let ip = resolve_client_ip(Some(PROXY), &forwarded(&["6.6.6.6", "1.2.3.4"]), &trusted()); + assert_eq!(ip, Some("1.2.3.4".parse().unwrap())); + } + + #[test] + fn an_unreadable_hop_stops_the_walk_at_the_proxy() { + for chain in ["6.6.6.6, unknown", "6.6.6.6, 1.2.3.4:5678", ""] { + let ip = resolve_client_ip(Some(PROXY), &forwarded(&[chain]), &trusted()); + assert_eq!(ip, Some(PROXY), "{chain:?}"); + } + } + + #[test] + fn trusted_hops_are_skipped_and_an_all_trusted_chain_names_its_leftmost() { + let ip = resolve_client_ip(Some(PROXY), &forwarded(&["1.2.3.4, 10.0.0.3"]), &trusted()); + assert_eq!(ip, Some("1.2.3.4".parse().unwrap())); + let ip = resolve_client_ip(Some(PROXY), &forwarded(&["10.0.0.5, 10.0.0.6"]), &trusted()); + assert_eq!(ip, Some("10.0.0.5".parse().unwrap())); + } + + #[test] + fn x_real_ip_counts_only_without_forwarded_for() { + let mut headers = HeaderMap::new(); + headers.insert("x-real-ip", HeaderValue::from_static("5.5.5.5")); + let ip = resolve_client_ip(Some(PROXY), &headers, &trusted()); + assert_eq!(ip, Some("5.5.5.5".parse().unwrap())); + + headers.insert("x-forwarded-for", HeaderValue::from_static("garbage")); + assert_eq!( + resolve_client_ip(Some(PROXY), &headers, &trusted()), + Some(PROXY) + ); + } + + #[test] + fn an_untrusted_peer_is_the_client_whatever_the_headers() { + let peer: IpAddr = "203.0.113.9".parse().unwrap(); + let headers = forwarded(&["1.2.3.4"]); + assert_eq!( + resolve_client_ip(Some(peer), &headers, &trusted()), + Some(peer) + ); + assert_eq!(resolve_client_ip(None, &headers, &trusted()), None); + } + + #[test] + fn the_rate_limiter_reads_its_own_trusted_proxies() { + let redis = crate::utils::redis_pool::build(&crate::config::RedisConfig { + url: "redis://127.0.0.1:1".into(), + pool_size: 1, + wait_timeout_ms: 10, + }) + .unwrap(); + let state = RateLimitState { + redis, + buckets: Vec::new(), + trusted_proxy_cidrs: trusted(), + fail_open_on_redis_error: false, + allow_requests_without_ip: false, + }; + assert_eq!(state.trusted_proxy_cidrs(), trusted().as_slice()); + } +} diff --git a/src/middleware/error_body.rs b/src/middleware/error_body.rs new file mode 100644 index 0000000..ef81604 --- /dev/null +++ b/src/middleware/error_body.rs @@ -0,0 +1,191 @@ +//! Every error response carries an `ErrorBody`. +//! +//! Handlers return `AppError`, which renders one. Responses produced before a +//! handler runs do not: the router's 404 and 405, the extractor rejections +//! (malformed JSON, missing content type, a body over the limit), the rate +//! limiter and the request timeout answer in plain text, and the JSON +//! rejections quote the parser. This layer gives those responses the +//! documented shape and keeps their status and headers (`Retry-After`, +//! `Allow`): clients parse one format, and no parser detail leaks. + +use axum::{ + body::Body, + extract::Request, + http::{ + HeaderValue, StatusCode, + header::{CONTENT_LENGTH, CONTENT_TYPE}, + }, + middleware::Next, + response::Response, +}; + +use crate::error::ErrorBody; + +pub async fn layer(req: Request, next: Next) -> Response { + normalize(next.run(req).await) +} + +fn normalize(response: Response) -> Response { + let status = response.status(); + if !(status.is_client_error() || status.is_server_error()) { + return response; + } + let is_json = response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.starts_with("application/json")); + if is_json { + return response; + } + + let (code, message) = describe(status); + let body = serde_json::to_vec(&ErrorBody::new(code, message)) + .expect("an error body always serializes"); + + let (mut parts, _) = response.into_parts(); + parts.headers.remove(CONTENT_LENGTH); + parts + .headers + .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + Response::from_parts(parts, Body::from(body)) +} + +fn describe(status: StatusCode) -> (&'static str, &'static str) { + match status { + StatusCode::BAD_REQUEST => ("invalid_request", "The request could not be read."), + StatusCode::NOT_FOUND => ("not_found", "The requested resource was not found."), + StatusCode::METHOD_NOT_ALLOWED => ( + "method_not_allowed", + "This method is not allowed on this resource.", + ), + StatusCode::PAYLOAD_TOO_LARGE => ("payload_too_large", "The request body is too large."), + StatusCode::UNSUPPORTED_MEDIA_TYPE => ( + "unsupported_media_type", + "The request body must be JSON (Content-Type: application/json).", + ), + StatusCode::UNPROCESSABLE_ENTITY => ( + "validation_error", + "The request body does not have the expected fields and types.", + ), + StatusCode::TOO_MANY_REQUESTS => ( + "rate_limit_exceeded", + "Too many requests. Please slow down.", + ), + StatusCode::SERVICE_UNAVAILABLE => ( + "service_unavailable", + "The service is temporarily unavailable.", + ), + status if status.is_server_error() => ("internal_error", "An internal error occurred."), + _ => ("request_failed", "The request could not be completed."), + } +} + +#[cfg(test)] +mod tests { + use axum::response::IntoResponse; + + use super::*; + + async fn body_of(response: Response) -> serde_json::Value { + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + serde_json::from_slice(&bytes).unwrap() + } + + #[tokio::test] + async fn plain_text_errors_become_error_bodies_with_their_headers() { + let mut plain = (StatusCode::TOO_MANY_REQUESTS, "rate limit exceeded").into_response(); + plain + .headers_mut() + .insert("retry-after", HeaderValue::from_static("7")); + + let normalized = normalize(plain); + + assert_eq!(normalized.status(), StatusCode::TOO_MANY_REQUESTS); + assert_eq!(normalized.headers()["retry-after"], "7"); + assert_eq!(normalized.headers()[CONTENT_TYPE], "application/json"); + let body = body_of(normalized).await; + assert_eq!(body["code"], "rate_limit_exceeded"); + assert!(body["message"].is_string()); + } + + #[tokio::test] + async fn parser_details_do_not_leak() { + let rejection = ( + StatusCode::UNPROCESSABLE_ENTITY, + "Failed to deserialize the JSON body into the target type: missing field `password` at line 1 column 2", + ) + .into_response(); + let body = body_of(normalize(rejection)).await; + assert_eq!(body["code"], "validation_error"); + assert!(!body.to_string().contains("password")); + } + + #[tokio::test] + async fn json_errors_and_successes_are_left_alone() { + let json = ( + StatusCode::CONFLICT, + [(CONTENT_TYPE, "application/json")], + r#"{"code":"email_taken","message":"x"}"#, + ) + .into_response(); + assert_eq!(body_of(normalize(json)).await["code"], "email_taken"); + + let ok = (StatusCode::OK, "ok").into_response(); + let bytes = axum::body::to_bytes(normalize(ok).into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!(&bytes[..], b"ok"); + } + + #[test] + fn every_error_status_has_a_stable_code() { + assert_eq!(describe(StatusCode::NOT_FOUND).0, "not_found"); + assert_eq!( + describe(StatusCode::METHOD_NOT_ALLOWED).0, + "method_not_allowed" + ); + assert_eq!( + describe(StatusCode::PAYLOAD_TOO_LARGE).0, + "payload_too_large" + ); + assert_eq!(describe(StatusCode::BAD_GATEWAY).0, "internal_error"); + assert_eq!(describe(StatusCode::IM_A_TEAPOT).0, "request_failed"); + } + + #[test] + fn plain_text_rejections_get_their_own_codes() { + assert_eq!(describe(StatusCode::BAD_REQUEST).0, "invalid_request"); + assert_eq!( + describe(StatusCode::UNSUPPORTED_MEDIA_TYPE).0, + "unsupported_media_type" + ); + assert_eq!( + describe(StatusCode::SERVICE_UNAVAILABLE).0, + "service_unavailable" + ); + } + + #[tokio::test] + async fn the_layer_rewrites_what_a_router_answers() { + use tower::ServiceExt; + + let router = axum::Router::new() + .route( + "/down", + axum::routing::get(|| async { (StatusCode::SERVICE_UNAVAILABLE, "down") }), + ) + .layer(axum::middleware::from_fn(layer)); + let request = |path: &str| axum::http::Request::get(path).body(Body::empty()).unwrap(); + + let down = router.clone().oneshot(request("/down")).await.unwrap(); + assert_eq!(down.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(body_of(down).await["code"], "service_unavailable"); + + let missing = router.oneshot(request("/nowhere")).await.unwrap(); + assert_eq!(missing.status(), StatusCode::NOT_FOUND); + assert_eq!(body_of(missing).await["code"], "not_found"); + } +} diff --git a/src/middleware/mod.rs b/src/middleware/mod.rs index a36f711..15f03a3 100644 --- a/src/middleware/mod.rs +++ b/src/middleware/mod.rs @@ -1,9 +1,15 @@ //! Tower middleware layers applied to the Axum router. //! +//! - client_ip: the client address behind trusted reverse proxies +//! - access_log: one structured line per request (route template, status, latency) +//! - error_body: the documented error body on responses produced outside the handlers //! - request_id: injects a unique x-request-id header into every request and response //! - security_headers: adds standard defensive HTTP headers to every response -//! - rate_limit: token bucket per client IP backed by Redis +//! - rate_limit: sliding-window limits per client IP backed by Redis +pub mod access_log; +pub mod client_ip; +pub mod error_body; pub mod rate_limit; pub mod request_id; pub mod security_headers; diff --git a/src/middleware/rate_limit.rs b/src/middleware/rate_limit.rs index 071fae6..e6c07ec 100644 --- a/src/middleware/rate_limit.rs +++ b/src/middleware/rate_limit.rs @@ -1,127 +1,276 @@ -//! IP-based token bucket rate limiter backed by Redis. +//! Per-client rate limiting backed by Redis. //! -//! Each client IP gets a bucket with `burst_size` tokens that refills at -//! `requests_per_second` tokens per second. The state is stored in Redis as a -//! sorted set so it survives restarts and works across multiple instances. +//! Each bucket is a sliding-window estimate over one minute, kept as one small +//! hash per client: the count of the current fixed window, the count of the +//! previous one, and which window "current" is. The estimate weights the +//! previous window by the part of it still inside the sliding minute. Memory +//! and work are O(1) per request, where a sorted set of timestamps grew with +//! the limit. //! -//! Algorithm: sliding window counter using a Redis sorted set per IP. -//! Each request adds one entry with score = now_ms. Entries older than the -//! window are pruned on every check. If the count exceeds the limit the -//! request is rejected with 429. +//! A route can be subject to several buckets (the general one and the stricter +//! auth one). They are checked in a single script call: a request is counted in +//! every bucket or in none, and a refused request consumes nothing. -use std::sync::atomic::{AtomicU64, Ordering}; +use std::{net::IpAddr, sync::LazyLock}; use axum::{ body::Body, extract::{Request, State}, - http::StatusCode, + http::{HeaderValue, StatusCode, header::RETRY_AFTER}, middleware::Next, response::{IntoResponse, Response}, }; -use deadpool_redis::{Pool as RedisPool, redis::Script}; +use deadpool_redis::redis::Script; use ipnetwork::IpNetwork; -use crate::handlers::extractors::ClientIp; +use crate::domain::rate_limit::{LimiterAnswer, RateLimitVerdict, rate_limit_verdict}; +use crate::utils::redis_pool::RedisPool; -const WINDOW_MS: u64 = 60_000; // 1 minute sliding window +use super::client_ip::ClientIp; -/// Per-process monotonic counter used to make sorted-set members unique without -/// calling the RNG on every request. -static REQUEST_COUNTER: AtomicU64 = AtomicU64::new(0); -const RATE_LIMIT_LUA: &str = r#" -local key = KEYS[1] -local now_ms = tonumber(ARGV[1]) -local window_ms = tonumber(ARGV[2]) -local limit = tonumber(ARGV[3]) -local member = ARGV[4] +/// Length of the sliding window. +const WINDOW_MS: u64 = 60_000; -redis.call('ZREMRANGEBYSCORE', key, '-inf', now_ms - window_ms) +/// KEYS: one hash per bucket. ARGV: now_ms, window_ms, then one limit per key. +/// Returns 0 when allowed, otherwise the milliseconds until the first refusing +/// bucket frees a slot (at least 1). +static SLIDING_WINDOW: LazyLock