Repository navigation
fix(auth): OAuth error codes, statuses and challenges follow the RFCs #1735
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Continuous integration for SourceOrigin. Every job runs the same `just` recipes | |
| # as local validation: | |
| # | |
| # checks -> Rust formatting, unused Rust dependencies and the Rust dependency | |
| # audit, which compile nothing | |
| # web -> Prettier, tsc, ESLint and knip, the passkey script included, the | |
| # TypeScript SDK's package checks, tests with coverage and runtime | |
| # smoke in Node, Bun and workerd, and the web dependency audit | |
| # rust -> unit and database tests against PostgreSQL with coverage, and | |
| # the SQLx query cache checked to match the migrated schema exactly | |
| # rust-lint -> Clippy, rustdoc with warnings denied, and the doc tests | |
| # package -> the SDKs packaged as `just publish` publishes them, each crate | |
| # built from its package, without uploading anything | |
| # msrv -> the published crates built with their oldest supported Rust | |
| # e2e -> the browser suite over HTTPS against the real binary and PostgreSQL, | |
| # with the admin dashboard on its own listener and host, sourceorigin.dev's | |
| # web server on its own host, Mailpit receiving auth's email, and | |
| # the binary's CLI granting administrators and recovering accounts | |
| # docker -> the deployment images: auth, sourceorigin.dev's web server and the docs, | |
| # the Rust ones with their layers cached | |
| # infra -> the piqueld manifests, by piquelctl, and the telemetry configs, | |
| # each by the image that runs it | |
| # docs -> the documentation book, built with warnings denied, with every | |
| # link and heading anchor in it and in the repository's Markdown | |
| # checked offline | |
| # | |
| # Pull requests run only the jobs their changes can affect (see `changes`), | |
| # except `docs`: a change anywhere can break a link to it. | |
| # The merge queue, pushes to main and manual runs run everything: the queue | |
| # tests each pull request on top of the latest main before it merges, so two | |
| # pull requests that pass alone cannot break main together. `ci` is the single | |
| # status to require, and the queue waits for it: it fails if any job failed | |
| # and passes when the rest succeeded or were skipped. audit.yml audits new | |
| # advisories weekly. | |
| # | |
| # Rust caches, and the Rust images' layers, are saved from main only. Pull | |
| # requests restore main's caches, so a branch never evicts them with copies no | |
| # other branch can read. pnpm's small store cache is saved whenever its lockfile | |
| # hash is new. | |
| name: CI | |
| on: | |
| pull_request: | |
| merge_group: | |
| push: | |
| branches: [main] | |
| workflow_dispatch: | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} | |
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | |
| permissions: | |
| contents: read | |
| env: | |
| CARGO_TERM_COLOR: always | |
| # Incremental artifacts only bloat CI caches; restored dependency builds | |
| # make up for the lost reuse. | |
| CARGO_INCREMENTAL: '0' | |
| RUST_BACKTRACE: short | |
| jobs: | |
| # Decides which jobs a pull request needs from the paths it changes. A file | |
| # matches a filter only when it matches every pattern. `web` covers every | |
| # file Prettier, ESLint or tsc can see, so it excludes file types rather than | |
| # directories. `app` covers everything the running service and its images | |
| # are built from, and the TypeScript SDK the browser suite also runs. Rust | |
| # also reads files outside the crates: the committed audit verifier keys, | |
| # which auth compiles in, and the generated reference pages its tests compare | |
| # with the code, which `reference` matches since `rust` skips Markdown. | |
| changes: | |
| name: changes | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| permissions: | |
| pull-requests: read | |
| outputs: | |
| rust: ${{ github.event_name != 'pull_request' || steps.filter.outputs.rust == 'true' || steps.filter.outputs.reference == 'true' }} | |
| web: ${{ github.event_name != 'pull_request' || steps.filter.outputs.web == 'true' }} | |
| deps: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' }} | |
| app: ${{ github.event_name != 'pull_request' || steps.filter.outputs.app == 'true' }} | |
| infra: ${{ github.event_name != 'pull_request' || steps.filter.outputs.infra == 'true' }} | |
| steps: | |
| - if: github.event_name == 'pull_request' | |
| id: filter | |
| uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 | |
| with: | |
| predicate-quantifier: every | |
| filters: | | |
| rust: | |
| - '{Cargo.toml,Cargo.lock,rust-toolchain.toml,clippy.toml,.cargo/**,justfile,just/**,apps/**,crates/**,tools/**,tests/sdk-vectors/**,packages/auth/src/protocol.ts,docs/dev/security/audit-key.pub,.github/workflows/ci.yml}' | |
| - '!**/*.md' | |
| reference: | |
| - 'docs/dev/operations/{cli-commands,variables/*}.md' | |
| web: | |
| - '**' | |
| - '!**/*.{rs,sql,toml,md}' | |
| - '!Cargo.lock' | |
| - '!infra/*.{Dockerfile,conf,sh}' | |
| - '!.dockerignore' | |
| deps: | |
| - '{Cargo.toml,**/Cargo.toml,Cargo.lock,deny.toml,package.json,**/package.json,pnpm-lock.yaml,pnpm-workspace.yaml,.github/workflows/ci.yml}' | |
| app: | |
| - '{Cargo.toml,Cargo.lock,rust-toolchain.toml,.cargo/**,justfile,just/**,package.json,pnpm-lock.yaml,playwright.config.ts,apps/**,crates/**,packages/auth/**,tests/browser/**,infra/**,docs/dev/security/audit-key.pub,.dockerignore,.github/workflows/ci.yml}' | |
| - '!**/*.md' | |
| - '!infra/piqueld.toml' | |
| infra: | |
| - '{infra/piqueld.toml,infra/telemetry.toml,infra/telemetry/*.yaml,justfile,just/infra.just,.github/workflows/ci.yml}' | |
| checks: | |
| name: checks | |
| needs: changes | |
| if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.deps == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| - uses: taiki-e/install-action@3f74d7c16a4242f1c95561e98edc25d36adb4375 # v2 | |
| with: | |
| tool: just,cargo-deny,cargo-machete@0.9.2 | |
| - run: just rust fmt-check | |
| - run: just rust unused | |
| - if: needs.changes.outputs.deps == 'true' | |
| run: just rust audit | |
| web: | |
| name: web | |
| needs: changes | |
| if: needs.changes.outputs.web == 'true' || needs.changes.outputs.deps == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| - &install-just | |
| uses: taiki-e/install-action@3f74d7c16a4242f1c95561e98edc25d36adb4375 # v2 | |
| with: | |
| tool: just | |
| # pnpm's version comes from package.json's packageManager. | |
| - &setup-pnpm | |
| uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 | |
| - &setup-node | |
| uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 | |
| with: | |
| node-version: '24' | |
| cache: pnpm | |
| - run: just install | |
| - run: just web fmt-check | |
| - run: just web check | |
| - run: just web lint | |
| - run: just web unused | |
| - run: just web test | |
| - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 | |
| with: | |
| bun-version: 1.x | |
| - run: just web smoke | |
| - if: needs.changes.outputs.deps == 'true' | |
| run: just web audit | |
| # Clippy, rustdoc and the doc tests, beside `rust`: its coverage builds an | |
| # instrumented copy of everything, in target/llvm-cov-target, which they cannot | |
| # reuse. The doc tests reuse the dependencies Clippy compiled for build scripts | |
| # and proc macros. | |
| rust-lint: | |
| name: rust-lint | |
| needs: changes | |
| if: needs.changes.outputs.rust == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| - *install-just | |
| - &rust-cache | |
| uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 | |
| with: | |
| # rust-cache's key ignores this workflow, but includes the job's ID. | |
| # Bump the prefix when a job needs a new cache, as when rust began | |
| # installing sqlx-cli. | |
| prefix-key: v1-rust | |
| save-if: ${{ github.ref == 'refs/heads/main' }} | |
| - run: just rust lint | |
| - run: just doc | |
| - run: just doc-test | |
| # Everything `just publish` does except uploading: packages each crate and | |
| # builds it from its package, as `cargo install` and crates.io would, then | |
| # packs @sourceorigin/auth, whose `prepack` script builds it. | |
| package: | |
| name: package | |
| needs: changes | |
| if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.web == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| - *install-just | |
| - *setup-pnpm | |
| - *setup-node | |
| - *rust-cache | |
| - run: just install | |
| - run: just publish --dry-run | |
| # Builds the published crates with the oldest Rust their `rust-version` | |
| # promises. Warnings aren't errors here: lints differ between Rust versions, | |
| # and rust-lint lints with the pinned one. | |
| msrv: | |
| name: msrv | |
| needs: changes | |
| if: needs.changes.outputs.rust == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| # RUSTUP_TOOLCHAIN overrides rust-toolchain.toml for every later step, | |
| # the cache key included. | |
| - name: Install the oldest supported Rust | |
| run: | | |
| msrv=$(sed -n 's/^rust-version = "\(.*\)"$/\1/p' Cargo.toml) | |
| rustup toolchain install "$msrv" --profile minimal | |
| echo "RUSTUP_TOOLCHAIN=$msrv" >> "$GITHUB_ENV" | |
| - *rust-cache | |
| - run: cargo check --locked --all-features --package sourceorigin-common --package sourceorigin-crypto --package sourceorigin-auth | |
| rust: | |
| name: rust | |
| needs: changes | |
| if: needs.changes.outputs.rust == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| env: | |
| TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable | |
| # A disposable server for the database tests, and for the browser suite. | |
| services: | |
| postgres: &postgres | |
| image: postgres:18-bookworm | |
| env: | |
| POSTGRES_PASSWORD: postgres | |
| ports: ['5432:5432'] | |
| options: >- | |
| --health-cmd pg_isready --health-interval 2s --health-timeout 5s --health-retries 15 | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| - uses: taiki-e/install-action@3f74d7c16a4242f1c95561e98edc25d36adb4375 # v2 | |
| with: | |
| tool: just,cargo-nextest,cargo-llvm-cov | |
| - *rust-cache | |
| # sqlx-cli publishes no prebuilt binaries. rust-cache keeps binaries | |
| # installed after it restores, so this compiles when main's cache lacks | |
| # the latest release, until main next saves a cache. | |
| - run: just db install-sqlx | |
| # Every unit and database test, instrumented. Coverage is reported, not | |
| # enforced: per crate in the log, per line in the rust-coverage artifact. | |
| # cargo-llvm-cov adds the llvm-tools-preview component it needs. | |
| - run: just coverage | |
| - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 | |
| with: | |
| name: rust-coverage | |
| path: target/lcov.info | |
| if-no-files-found: error | |
| # The database tests migrated TEST_DATABASE_URL, so a migration that | |
| # breaks a cached query fails here even if no test runs that query, as | |
| # does a cached query the code no longer uses. | |
| - run: just db prepare-check | |
| e2e: | |
| name: e2e | |
| needs: changes | |
| if: needs.changes.outputs.app == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| services: | |
| postgres: *postgres | |
| # Receives auth's email over plain SMTP on the loopback address, and lets | |
| # the suite read it from its API. | |
| mailpit: | |
| image: axllent/mailpit:v1.27 | |
| ports: ['1025:1025', '8025:8025'] | |
| env: | |
| E2E_AUTH_URL: https://localhost:3443 | |
| E2E_ALLOW_SELF_SIGNED_TLS: '1' | |
| # migrate connects as the superuser and provisions the restricted role | |
| # that everything else connects as, as in deployments. | |
| AUTH_DATABASE_OWNER_URL: postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable | |
| AUTH_DATABASE_URL: postgres://sourceorigin_auth@localhost:5432/postgres?sslmode=disable | |
| AUTH_DATABASE_PASSWORD: env://E2E_DATABASE_PASSWORD | |
| E2E_DATABASE_PASSWORD: runtime | |
| AUTH_PUBLIC_BASE_URL: https://localhost:3443 | |
| AUTH_PUBLIC_LISTENER: 127.0.0.1:3000 | |
| # Signup needs an invitation, as in production: each signup of the suite | |
| # takes a new one from the CLI. | |
| AUTH_INVITE_REQUIRED: 'true' | |
| E2E_INVITE: target/debug/auth-service invite create | |
| AUTH_SMTP_URL: smtp://127.0.0.1:1025 | |
| E2E_MAIL_URL: http://127.0.0.1:8025 | |
| # sourceorigin.dev on its own host, whose policies auth's pages link. | |
| AUTH_WEB_ORIGIN: https://web.localhost:3443 | |
| E2E_WEB_URL: https://web.localhost:3443 | |
| WEB_LISTENER: 127.0.0.1:3002 | |
| WEB_AUTH_ORIGIN: https://localhost:3443 | |
| # The suite runs serially from one address, and spends in a minute what a | |
| # person would in an hour. | |
| AUTH_REQUESTS_PER_MINUTE: '1000' | |
| # The dashboard on an origin of its own, as admin.sourceorigin.dev is, on another | |
| # port of auth's host: its passkey dialog may use auth's RP ID there, which | |
| # admin.sourceorigin.dev does through auth's related origins. Browsers fetch those | |
| # from the RP ID on port 443, which CI does not serve. Cookies ignore the | |
| # port, so here the dashboard and auth share them; previews keep them apart. | |
| AUTH_ADMIN_LISTENER: 127.0.0.1:3001 | |
| AUTH_ADMIN_ORIGIN: https://localhost:3444 | |
| E2E_ADMIN_URL: https://localhost:3444 | |
| E2E_GRANT_ADMIN: target/debug/auth-service admin grant | |
| E2E_RECOVER: target/debug/auth-service user recover | |
| # The secret `just web browser-client` registers for the example app of | |
| # @sourceorigin/auth/server, which server.spec.ts starts. It guards nothing. | |
| E2E_SERVER_CLIENT_SECRET: YnJvd3Nlci1lMmUtc2VydmVyIHNlY3JldCBmb3IgY2k | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| - *install-just | |
| - *setup-pnpm | |
| - *setup-node | |
| - *rust-cache | |
| - run: just install | |
| # The binary embeds the passkey script and the stylesheet, as in the | |
| # deployed image. | |
| - run: just rust build | |
| # The keys reach every later step, so the commands the suite runs, such as | |
| # `user recover`, which derives its link with the token digest key, share | |
| # them with the service. | |
| - name: Generate the keys | |
| run: | | |
| just auth generate-secret "$RUNNER_TEMP/key-encryption-key" | |
| just auth generate-secret "$RUNNER_TEMP/token-digest-key" | |
| openssl rand -hex 32 > "$RUNNER_TEMP/audit-key" | |
| { | |
| echo "AUTH_KEY_ENCRYPTION_KEY=file://$RUNNER_TEMP/key-encryption-key" | |
| echo "AUTH_TOKEN_DIGEST_KEY=file://$RUNNER_TEMP/token-digest-key" | |
| echo "AUTH_AUDIT_KEY_PROVIDER=file://$RUNNER_TEMP/audit-key" | |
| } >> "$GITHUB_ENV" | |
| # The service accepts only HTTPS origins, as in production. Caddy | |
| # terminates TLS with its local CA, which the suite is told to accept. | |
| # The suite signs in through the public OIDC client from | |
| # tests/browser/README.md, whose origin has no server. | |
| - name: Start the auth service and sourceorigin.dev | |
| run: | | |
| cat > "$RUNNER_TEMP/Caddyfile" <<'EOF' | |
| { | |
| auto_https disable_redirects | |
| skip_install_trust | |
| } | |
| localhost:3443 { | |
| reverse_proxy 127.0.0.1:3000 | |
| } | |
| localhost:3444 { | |
| reverse_proxy 127.0.0.1:3001 | |
| } | |
| web.localhost:3443 { | |
| reverse_proxy 127.0.0.1:3002 | |
| } | |
| EOF | |
| docker run --detach --name caddy --network host --volume "$RUNNER_TEMP/Caddyfile:/etc/caddy/Caddyfile:ro" caddy:2-alpine | |
| target/debug/auth-service migrate | |
| just web browser-client | |
| target/debug/auth-service serve > "$RUNNER_TEMP/auth.log" 2>&1 & | |
| target/debug/sourceorigin-web > "$RUNNER_TEMP/web.log" 2>&1 & | |
| curl -kfsS --retry 30 --retry-delay 1 --retry-all-errors "$E2E_AUTH_URL/health/ready" | |
| curl -kfsS --retry 30 --retry-delay 1 --retry-all-errors "$E2E_WEB_URL/health" | |
| # The headless build matching the pinned Playwright, so runner image | |
| # updates cannot change the browser under test. | |
| - run: pnpm exec playwright install --only-shell chromium | |
| - run: just web test-browser | |
| - if: failure() | |
| run: | | |
| cat "$RUNNER_TEMP"/*.log || true | |
| docker logs caddy || true | |
| docker: | |
| name: docker | |
| needs: changes | |
| if: needs.changes.outputs.app == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| - run: docker build --file infra/docs.Dockerfile . | |
| # Both Rust targets share the `backend` stage, which builds once. A build | |
| # restores main's layers, so only what changed since main recompiles: | |
| # usually just the workspace's own crates, after cargo-chef's dependencies. | |
| - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 | |
| - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 | |
| with: | |
| context: . | |
| file: infra/rust.Dockerfile | |
| target: auth | |
| cache-from: type=gha,scope=auth | |
| cache-to: ${{ github.ref == 'refs/heads/main' && 'type=gha,mode=max,scope=auth,ignore-error=true' || '' }} | |
| - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 | |
| with: | |
| context: . | |
| file: infra/rust.Dockerfile | |
| target: web | |
| cache-from: type=gha,scope=web | |
| cache-to: ${{ github.ref == 'refs/heads/main' && 'type=gha,mode=max,scope=web,ignore-error=true' || '' }} | |
| # piquelctl publishes no prebuilt binaries either. rust-cache keeps it as it | |
| # keeps sqlx-cli in `rust`, and `cargo install` upgrades it when a newer | |
| # release is out. Docker pulls each telemetry image. | |
| infra: | |
| name: infra | |
| needs: changes | |
| if: needs.changes.outputs.infra == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| - *install-just | |
| - *rust-cache | |
| - run: cargo install --locked piquelctl | |
| - run: just infra check | |
| # mdBook and lychee at the versions `just install-tools` installs. | |
| docs: | |
| name: docs | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| - uses: taiki-e/install-action@3f74d7c16a4242f1c95561e98edc25d36adb4375 # v2 | |
| with: | |
| tool: just,mdbook@0.5.4,lychee@0.24.2 | |
| - run: just docs | |
| ci: | |
| name: ci | |
| if: always() | |
| needs: | |
| - changes | |
| - checks | |
| - web | |
| - rust | |
| - rust-lint | |
| - package | |
| - msrv | |
| - e2e | |
| - docker | |
| - infra | |
| - docs | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| steps: | |
| - uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # v1.3.0 | |
| with: | |
| allowed-skips: checks, web, rust, rust-lint, package, msrv, e2e, docker, infra | |
| jobs: ${{ toJSON(needs) }} |