OAuth 2.1 client implementation in Rust
|
|
пре 2 недеља | |
|---|---|---|
| config | пре 2 недеља | |
| crates | пре 2 недеља | |
| demo | пре 1 месец | |
| e2e | пре 4 недеља | |
| .codegraph | пре 1 месец | |
| .dockerignore | пре 1 месец | |
| .gitignore | пре 1 месец | |
| Cargo.lock | пре 1 месец | |
| Cargo.toml | пре 1 месец | |
| Dockerfile | пре 4 недеља | |
| LICENSE.md | пре 1 месец | |
| README.md | пре 4 недеља | |
| docker-bake.hcl | пре 4 недеља | |
| package.json | пре 1 месец | |
| rust-toolchain.toml | пре 1 месец | |
| rustfmt.toml | пре 1 месец |
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.
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):
GET /oauth/start; the BFF creates a session and redirects
to the authorization server with client_id and, when PAR is available,
request_uri.GET /callback; the BFF validates state, exchanges the
code with a DPoP proof, and rotates the session cookie.GET /api/session for identity and token metadata.POST /api/refresh and POST /api/logout are CSRF-guarded and return only
status, never tokens.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.
rust-toolchain.toml (it also declares the
rustfmt and clippy components).rustup target add wasm32-unknown-unknown.cargo install cargo-nextest --locked.cargo install trunk and cargo install wasm-pack.npx playwright install --with-deps chromium./home/efren/Repositories/d-OAuth (the authorization server the demo
provisions against; never modified from this workspace).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.: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.
: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.
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.
The dashboard is a thin view over the BFF session; none of these behaviors put a token in the browser.
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.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.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./api/session reports an active session; /dashboard redirects to /
when unauthenticated, and a successful login lands on /dashboard.localStorage["theme"], the only key the UI writes.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).
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.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.
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.
config/dpop-key.json) never leave the BFF, never enter a redirect, and
never reach the browser bundle.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.Origin header.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.Debug output and request logs;
the WASM bundle carries no secret by construction.ISC. See LICENSE.md.