Skip to content

fix(auth): OAuth error codes, statuses and challenges follow the RFCs #1735

fix(auth): OAuth error codes, statuses and challenges follow the RFCs

fix(auth): OAuth error codes, statuses and challenges follow the RFCs #1735

Workflow file for this run

# 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) }}