OAuth 2.1 client implementation in Rust

Efren Yevale Varela a4fcc7f816 Sliding sessions 2 veckor sedan
config a4fcc7f816 Sliding sessions 2 veckor sedan
crates a4fcc7f816 Sliding sessions 2 veckor sedan
demo b0e14c9d2f Initial commit 1 månad sedan
e2e 12ce1e10fc Updated name references 4 veckor sedan
.codegraph b0e14c9d2f Initial commit 1 månad sedan
.dockerignore b0e14c9d2f Initial commit 1 månad sedan
.gitignore b0e14c9d2f Initial commit 1 månad sedan
Cargo.lock b0e14c9d2f Initial commit 1 månad sedan
Cargo.toml b0e14c9d2f Initial commit 1 månad sedan
Dockerfile 12ce1e10fc Updated name references 4 veckor sedan
LICENSE.md b0e14c9d2f Initial commit 1 månad sedan
README.md 12ce1e10fc Updated name references 4 veckor sedan
docker-bake.hcl 12ce1e10fc Updated name references 4 veckor sedan
package.json b0e14c9d2f Initial commit 1 månad sedan
rust-toolchain.toml b0e14c9d2f Initial commit 1 månad sedan
rustfmt.toml b0e14c9d2f Initial commit 1 månad sedan

README.md

d-OAuth-Client

A Rust workspace demo client for an OAuth 2.1 authorization server: a server-side BFF handles the confidential OAuth exchange while a Leptos CSR/WASM front end renders the signed-in identity, granted scopes, and token metadata.

This file is the entry point. It covers the architecture, prerequisites, the two run modes, provisioning, configuration/portability, and the security posture. The full d-OAuth provisioning runbook lives in demo/README.md.

Architecture

The client is split into two halves that never share a secret:

  • crates/server (BFF) is the confidential OAuth 2.1 client. It owns the client secret, the DPoP private key, the PKCE verifier, and the server-side session store. It pushes the authorization request (PAR when available), handles /callback, exchanges the code, introspects the token, and refreshes or revokes it.
  • crates/web (Leptos CSR/WASM) is a thin UI over the BFF JSON API. It renders /api/config and /api/session, starts sign-in, and posts refresh and logout with a CSRF token. It never sees a token or a key.

Why the split: a browser app cannot keep a client secret confidential, and storing tokens in localStorage or JS memory widens the XSS blast radius. By keeping the secret and the DPoP key on the server and giving the browser only an opaque, HttpOnly session cookie, the browser never holds credential material. In dev, Trunk serves the UI on :3000 and proxies /oauth/, /callback, and /api/ to the BFF on :3001; in demo/prod the BFF serves the built assets directly on :3000. Either way the browser sees a single origin, so there is no CORS and cookies stay same-site.

Request path (both modes):

  1. The browser hits GET /oauth/start; the BFF creates a session and redirects to the authorization server with client_id and, when PAR is available, request_uri.
  2. The AS returns to GET /callback; the BFF validates state, exchanges the code with a DPoP proof, and rotates the session cookie.
  3. The UI reads GET /api/session for identity and token metadata.
  4. POST /api/refresh and POST /api/logout are CSRF-guarded and return only status, never tokens.
  5. Log out clears the BFF session, then the browser navigates to the provider logout (<issuer>/logout) so the authorization-server session ends too.

BFF surface: /healthz, /oauth/start, /callback, /api/config, /api/session, /api/refresh, /api/logout.

Prerequisites

  • Rust stable, as pinned by rust-toolchain.toml (it also declares the rustfmt and clippy components).
  • WebAssembly target: rustup target add wasm32-unknown-unknown.
  • Next-generation test runner: cargo install cargo-nextest --locked.
  • WASM build tooling: cargo install trunk and cargo install wasm-pack.
  • Node.js (for the Playwright end-to-end tests): npx playwright install --with-deps chromium.
  • The read-only d-OAuth reference checkout at /home/efren/Repositories/d-OAuth (the authorization server the demo provisions against; never modified from this workspace).

Workspace layout

  • crates/oauth-core - OAuth 2.1 protocol (PKCE, DPoP, PAR, token, introspection) and the capability matrix.
  • crates/server - BFF HTTP server.
  • crates/web - Leptos CSR/WASM UI.
  • crates/provision - one-shot client registration and demo kit driver.
  • crates/xtask - development/build/check helper.
  • demo/ - d-OAuth provisioning runbook and config sync file.
  • e2e/ - Playwright end-to-end tests and fixtures.
  • config/ - example config plus local, gitignored secrets.

Run modes

Dev (Trunk UI on :3000 + BFF on :3001)

Two terminals:

# Terminal 1: BFF on 127.0.0.1:3001 (health: /healthz).
APP_START_URL=http://127.0.0.1:3001/oauth/start cargo run -p server

# Terminal 2: UI on 127.0.0.1:3000; Trunk proxies /oauth/, /callback, /api/ to :3001.
cd crates/web && trunk serve --port 3000

APP_START_URL is required in dev: Trunk's proxy follows the BFF's /oauth/start 302 to the authorization server and swallows it (the browser gets a 200 with an empty body and never leaves :3000), so the landing CTA navigates straight at the BFF origin instead. /api/config advertises it as the non-secret start_url (default: relative /oauth/start). Cookies are host-scoped, not port-scoped, so the top-level :3000 -> :3001 navigation keeps SameSite=Lax semantics.

Open http://127.0.0.1:3000. cargo run -p xtask -- dev prints these two commands without starting anything.

Demo / prod (BFF serves the SPA on :3000)

Build the assets once, then let the BFF serve crates/web/dist itself. No Trunk process, no proxies:

cd crates/web && trunk build
cd ../.. && SERVER_BIND_ADDR=127.0.0.1:3000 cargo run -p server

Open http://127.0.0.1:3000. SERVER_BIND_ADDR overrides the default 127.0.0.1:3001, which is what makes demo mode single-origin on :3000.

Redirect URI (fixed)

The redirect URI is exactly:

http://127.0.0.1:3000/callback

Both modes expose it on :3000: Trunk proxies /callback to the BFF in dev, and the BFF serves it directly in demo/prod. It must match config/config.example.json and the demo kit (scope demo.read, user demo@example.com). Do not change it; the AS catalog and the DCR registration both pin this exact string.

Signed-in behavior

The dashboard is a thin view over the BFF session; none of these behaviors put a token in the browser.

  • Logout is RP-initiated. Log out first clears the BFF session (POST /api/logout, best-effort token revocation), then navigates the browser to the provider logout endpoint (<issuer>/logout, advertised as provider_logout_url by /api/config). d-OAuth ends its own session and 302s back to the app root (http://127.0.0.1:3000/, the client's registered post-logout return URI), so the next sign-in can use a different account.
  • Token renewal is transparent. GET /api/session renews the access token with the refresh token whenever the token is inactive or within 30 seconds of expiry, rotating the refresh token and keeping the session. The dashboard mirrors the same 30-second threshold and re-probes as the countdown crosses it. Only an unrecoverable refresh (invalid_grant, or no refresh token) ends the session; a transient upstream failure leaves a still-active token usable.
  • A missing scope is not a failed login. If the account authenticates but is not granted the configured scope (for example demo.read), the dashboard keeps the session and shows a Signed in without the required scope notice. If the authorization server refuses the scope outright, /callback renders a Scope not granted page.
  • The dashboard is auth-gated. The header shows the Dashboard link only once /api/session reports an active session; /dashboard redirects to / when unauthenticated, and a successful login lands on /dashboard.
  • Theme. Dark is the default. The header toggle switches light/dark and persists the choice in localStorage["theme"], the only key the UI writes.

Container

The image carries the static musl server binary and the release WASM bundle; the BFF serves the SPA itself on :3000 (no Trunk process, no proxies). Config and secrets (config/config.json, config/client.json, config/dpop-key.json) are mounted at runtime and never baked into the image.

Build both architectures, or a single one to skip arm64 emulation:

# Both architectures (linux/amd64 + linux/arm64)
docker buildx bake image

# Single architecture (no arm64 QEMU emulation needed)
docker buildx bake image --set image.platform=linux/amd64

# Or a plain single-platform build
docker buildx build --load --platform linux/amd64 -t d-oauth-client:dev .

Run it, mounting the local config/ directory read-only on port 3000:

docker run --rm -p 3000:3000 \
  -v "$PWD/config:/app/config:ro" \
  d-oauth-client:latest

Open http://127.0.0.1:3000. Without the mount the server still answers /healthz with 200, but reports a degraded (server_misconfigured) profile.

The redirect URI is fixed at http://127.0.0.1:3000/callback (see Redirect URI (fixed)): the authorization server catalog and the DCR registration pin that exact string, so a container published on a different host or port cannot complete the flow without re-provisioning.

The image is non-root (65532:65532), has no shell, and ships a HEALTHCHECK that runs the binary's shell-less healthcheck subcommand against /healthz (no curl needed).

Provisioning

The workspace does not provision the AS itself; it drives the d-OAuth CLI and registers a confidential client over DCR. Copy the example config first, then run the three provision subcommands from the repo root:

cp config/config.example.json config/config.json

# 1. Drive d-OAuth: scope, user, grant, redirect URI, initial access token.
cargo run -p provision -- setup --cli <path-to-d-oauth-server> --env-file <d-OAuth/.env> \
  --email demo@example.com --password-file <path> --iat-out /tmp/demo-iat.txt

# 2. Register (or reconcile) the confidential client; writes config/client.json (0600).
cargo run -p provision -- register --issuer https://localhost:8080 --iat-file /tmp/demo-iat.txt

# 3. Write the gitignored E2E fixtures to config/e2e.json (only when absent).
cargo run -p provision -- bootstrap --password-file <path> --out config/e2e.json
  • setup runs an argv-only sequence (scope create demo.read, user create create-or-skip, user grant-scope, redirect-uri create, and initial-access-token issue). The raw IAT is written to --iat-out (mode 0600) and never printed.
  • register POSTs the RFC 7591 body with client_secret_basic and dpop_bound_access_tokens=true, then persists config/client.json (mode 0600). On rerun it reconciles drift: it probes the stored registration_client_uri, keeps a live client untouched, and mints a fresh single-use IAT to re-register a stale one.
  • A DCR invalid_redirect_uri rejection means the runtime catalog does not yet hold the URI. Provision it first with redirect-uri create, or, only outside production, relax the AS to dcr_redirect_policy=catalog_loopback. Never use open.
  • bootstrap writes {email, password} to config/e2e.json (mode 0600) only when the file is absent, so it never clobbers the Playwright-captured TOTP state.

The full manual runbook, including forced password change and one-time TOTP setup, is in demo/README.md.

Configuration and portability

config/config.example.json is the template, copied to config/config.json (or pointed at with --config / APP_CONFIG). It holds no secrets:

{
  "server": {
    "bind_addr": "127.0.0.1:3001",
    "dist_dir": "crates/web/dist",
    "cookie_secure": false,
    "session_ttl_secs": 3600
  },
  "oauth": {
    "issuer": "https://localhost:8080",
    "redirect_uri": "http://127.0.0.1:3000/callback",
    "scope": "demo.read",
    "dev": {
      "insecure_tls": true
    }
  }
}

Client credentials live in config/client.json, written by provision register (mode 0600), not in the example. Precedence is environment > client.json > config.json > built-in dev defaults. Relevant environment overrides include SERVER_BIND_ADDR, SERVER_DIST_DIR, APP_CONFIG, APP_CLIENT_FILE, APP_ISSUER / OAUTH_ISSUER, APP_REDIRECT_URI, APP_SCOPE, APP_CLIENT_ID, APP_CLIENT_SECRET, APP_INSECURE_TLS, APP_COOKIE_SECURE, APP_CA_CERT, APP_SESSION_TTL_SECS, and APP_START_URL (dev only: absolute BFF sign-in URL, e.g. http://127.0.0.1:3001/oauth/start; unset means relative /oauth/start). A missing default config file is not an error; a missing explicit --config path is.

The client discovers its target at runtime (RFC 8414 with OIDC fallback) and degrades explicitly when a capability is absent:

Capability Discovery signal When absent
DCR registration_endpoint present Require a pre-registered client_id; no dynamic registration
PAR pushed_authorization_request_endpoint Plain /authorize redirect with query params
DPoP dpop_signing_alg_values_supported has ES256 d-OAuth sender-constrained flows do not apply
Introspection introspection_endpoint present Typed "identity unavailable" state; never fabricate identity
Userinfo OIDC userinfo_endpoint present Optional enrichment only, gated on discovery
Refresh grant_types_supported has refresh_token No refresh calls; expired sessions re-authenticate

Each capability resolves through a typed helper that names the missing feature, so the UI branches instead of failing obscurely. Portability beyond d-OAuth is unverified: the matrix describes graceful degradation, not certification.

Security posture

  • Secret stays server-side. The client secret and the DPoP private key (config/dpop-key.json) never leave the BFF, never enter a redirect, and never reach the browser bundle.
  • No browser token storage. The browser holds only an opaque HttpOnly session cookie. Access and refresh tokens live in the server-side session; /api/session exposes identity and metadata (subject, scopes, exp, cnf.jkt), never raw tokens.
  • Same-origin only. The browser talks to the BFF alone. State-changing POSTs require a CSRF token and a same-origin Origin header.
  • Locked-down config. provision writes config/client.json, config/dpop-key.json, and config/e2e.json with mode 0600; all three are gitignored. config/*.local.json and .env are ignored too.
  • No secrets in artifacts or logs. The example config declares no secret fields; redaction keeps credentials out of Debug output and request logs; the WASM bundle carries no secret by construction.
  • Nothing is committed by the tooling. Provisioning writes local files only. Review and commit by hand if and when you choose.

License

ISC. See LICENSE.md.