# Demo kit — d-OAuth provisioning runbook Provisions the demo scope, user, entitlement, and redirect URI against a local d-OAuth authorization server, then registers this workspace's confidential BFF client over Dynamic Client Registration (DCR). No real secrets appear below: passwords travel via `--password-file`, the IAT is printed once by the `issue` step and handed to DCR, and nothing secret is committed. Conventions used throughout: demo scope `demo.read`, demo user `demo@example.com`, redirect URI `http://127.0.0.1:3000/callback`. The binary is `d-oauth-server` (`cargo run --bin d-oauth-server -- ` from the d-OAuth checkout). Flag names below are taken from d-OAuth `docs/OPERATIONS.md` (command reference). ## 0. Start the dependencies, then the AS ```bash # From the d-OAuth checkout: migrate once, then serve. d-oauth-server migrate D_OAUTH_ISSUER=https://localhost:8080 d-oauth-server serve ``` `serve` runs the TLS public listener on `127.0.0.1:8080` (self-signed dev certificate) plus the plaintext health listener on `127.0.0.1:8081`. It never migrates and never provisions. Readiness: ```bash curl -fsS http://127.0.0.1:8081/health/ready ``` ## 1. Scope create / enable ```bash d-oauth-server scope create demo.read --description "Demo read scope" d-oauth-server scope enable demo.read ``` `scope create` upserts (create-or-update-description) and `enable` flips the catalog flag. Stale-discovery note: if the AS was already running when the scope was created, `/metadata` `scopes_supported` may be stale (in-process cache) until restart. The OAuth flow reads Postgres directly, so either restart the AS after scope creation or do not assert scopes from discovery. ## 2. User create + grant-scope ```bash printf '%s' '' > /tmp/demo-password.txt d-oauth-server user create --email demo@example.com --password-file /tmp/demo-password.txt d-oauth-server user grant-scope --email demo@example.com --scope demo.read d-oauth-server user scopes --email demo@example.com rm /tmp/demo-password.txt ``` `user create` is a plain INSERT, not an upsert: re-running it against an existing user fails with an already-exists error (the `provision setup` step treats that as create-or-skip). `grant-scope` requires the scope to exist in the catalog (step 1 first) and upserts. A created user starts with `must_change_password = true` — see step 6. Declarative alternative (idempotent; second run reports no changes): ```bash d-oauth-server config sync --file demo/config-sync.json d-oauth-server config sync --file demo/config-sync.json ``` `demo/config-sync.json` covers scopes, redirect URIs, and entitlements. It CANNOT create clients: the sync schema has no clients section, so the confidential client always comes from step 5 (DCR via `provision register`). ## 3. Redirect-URI create ```bash d-oauth-server redirect-uri create http://127.0.0.1:3000/callback ``` The URI is a global catalog entry. After DCR mints the client id, attach it: ```bash d-oauth-server client attach-redirect-uri --client-id --uri http://127.0.0.1:3000/callback ``` (`client_redirect_uris` in `demo/config-sync.json` is left empty because the client id only exists after step 5.) ## 4. IAT issue (single-use, printed once) ```bash d-oauth-server initial-access-token issue --uses 1 --ttl 600 ``` The raw token prints once; only its keyed hash is stored. It is required when `allow_open_registration` is false. A burned IAT (even one spent on a failed registration) is never reused — issue a fresh one per attempt. Prefer the automated path instead of copy-paste: ```bash cargo run -p provision -- setup --cli --env-file \ --email demo@example.com --password-file --iat-out /tmp/demo-iat.txt cargo run -p provision -- register --issuer https://localhost:8080 --iat-file /tmp/demo-iat.txt rm /tmp/demo-iat.txt ``` ## 5. DCR (or `provision register`) `provision register` POSTs `/register` with `token_endpoint_auth_method=client_secret_basic`, `grant_types=[authorization_code,refresh_token]`, `response_types=[code]`, the demo redirect URI/scope, and `dpop_bound_access_tokens=true`, then persists `config/client.json` (mode `0600`). Catalog-policy remediation: under the default `dcr_redirect_policy=catalog`, only redirect URIs present in the runtime catalog may register. An `invalid_redirect_uri` rejection means step 3 was skipped: provision the URI first, or relax the AS to `dcr_redirect_policy=catalog_loopback` (catalog URIs plus dynamic loopback URIs, RFC 8252) — never `open` in production. ## 6. Forced password change + TOTP (one-time, browser or Playwright) The fresh demo user cannot reach `/authorize` until both complete (restricted session). The automated path is `provision bootstrap` + the Playwright spec (todo 37), which sets a new known password, captures the `otpauth://` secret from `/login/totp/setup`, and persists `{email, password, totp_secret}` to `config/e2e.json` for reuse. Manual equivalent: 1. `POST /login` (`email`, `password`, hidden `csrf`; `code` empty) → sent to `/login/change-password`. 2. `POST /login/change-password` (`new_password` of at least 12 characters, `confirm`, hidden `csrf`). 3. `GET /login/totp/setup` → register the shown secret/`otpauth://` URI in an authenticator now (shown once, with one-time recovery codes). 4. `POST /login/totp/confirm` (`code`, hidden `csrf`) → full session. 5. `POST /consent` approves with hidden `request_id`, hidden `csrf`, and `decision=approve`. ## 7. Run the BFF (+ UI in dev) ```bash # BFF (confidential client): http://127.0.0.1:3001, health at /healthz. cargo run -p server curl -fsS http://127.0.0.1:3001/healthz ``` Dev UI (Trunk owns `:3000`, proxies `/oauth/`, `/callback`, `/api/` to the BFF on `:3001`): ```bash cd crates/web && trunk serve --port 3000 ``` Browser entry point: `http://127.0.0.1:3000`. Approve with `decision=approve` at `/consent`; the flow lands back at `/callback` on the BFF. ## 8. Sign out / switch users `Log out` in the dashboard clears the BFF session, then navigates the browser to the provider logout (`/logout`). 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 a different account can sign in on the next run without restarting the authorization server. The scope `demo.read` is required. If the account authenticates without it, the dashboard keeps the session and shows a `Signed in without the required scope` notice; if the AS refuses the scope at authorization time, `/callback` renders a `Scope not granted` page. Grant the scope (step 2), then sign in again.