# 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`](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 (`/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: ```bash # 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: ```bash 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 (`/logout`, advertised as `provider_logout_url` by `/api/config`). d-OAuth ends its own session and `302`s 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: ```bash # 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`: ```bash 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)](#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: ```bash 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 --env-file \ --email demo@example.com --password-file --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 --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`](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: ```json { "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](LICENSE.md).