CLI app for developers prototyping atproto functionality
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

docs: add OAuth client conformance test design plan

Design for `atproto-devtool test oauth client`, the second member of the
`test` family. Two complementary modes (static metadata validation,
interactive fake-AS observation) sharing a promoted-from-labeler report
contract. Eight implementation phases.

Key decisions:
- Sibling subcommand `test oauth client interactive` rather than a flag,
so per-mode flag inventories stay clean.
- Reachability via localhost + bring-your-own-tunnel (cloudflared,
Tailscale Funnel, ngrok); no auto-spawn, no hosted helper.
- Promote `report.rs` to `src/common/report.rs` with `Stage` as a
`&'static str` newtype so labeler and oauth_client share one contract.
- New `src/common/oauth/relying_party.rs` is a real OAuth client used to
drive the fake AS in interactive tests today and reusable verbatim as
the probe client for the future `test oauth server` command.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

+928
+928
docs/design-plans/2026-04-16-test-oauth-client.md
··· 1 + # OAuth client conformance test design 2 + 3 + ## Summary 4 + 5 + `atproto-devtool test oauth client` is a new conformance subcommand that 6 + validates atproto OAuth clients against the atproto OAuth profile and the 7 + RFCs it draws from (PAR, PKCE, DPoP, AS metadata). It operates in two 8 + complementary modes. Static mode (the default) takes a `client_id` URL, 9 + fetches the client metadata document for HTTPS targets or synthesizes 10 + implicit metadata for loopback clients, and runs three sequential pipeline 11 + stages — discovery, metadata validation, and JWKS validation — each 12 + producing structured `CheckResult`s. Interactive mode runs the static 13 + pipeline first, then spins up an in-process fake authorization server (built 14 + with axum) that presents a synthetic atproto identity chain to the client 15 + under test and records every inbound request; scripted flow variants then 16 + assert over that request log without requiring human interaction during the 17 + check run. 18 + 19 + The two modes share a common report contract — the same five-way 20 + `CheckStatus`, the same exit-code precedence, and the same snapshot-pinned 21 + check IDs and diagnostic codes — promoted from the existing `test labeler` 22 + implementation into `src/common/report.rs`. Dependency gating runs across 23 + the static/interactive boundary: interactive checks that depend on a 24 + failing static check emit `Skipped` with a specific `blocked_by` reason 25 + rather than running blindly. The `RelyingParty` type introduced here is 26 + designed for dual use: it drives the fake AS in interactive conformance 27 + tests today, and will be reused verbatim as the probe client in the future 28 + `test oauth server` command. 29 + 30 + ## Definition of Done 31 + 32 + A new `atproto-devtool test oauth client <target>` subcommand ships, validating 33 + an atproto OAuth client across two complementary modes: 34 + 35 + - **Non-interactive (default):** static conformance checks against the client 36 + identified by its `client_id`. Day-1 coverage includes confidential web, 37 + public web, native, and loopback / development clients. Validates the client 38 + metadata document, redirect URIs (per type), JWKS (when applicable), and 39 + other spec-derived static properties drawn from the atproto OAuth profile 40 + (and the RFCs it profiles: PAR, PKCE, DPoP, AS metadata). For loopback 41 + clients (where metadata is implicit), checks that don't apply are emitted 42 + as `Skipped` with a clear reason, not silently dropped — same precedent as 43 + the labeler test. 44 + - **Interactive (`--interactive` or sibling subcommand):** spins up a fake 45 + atproto authorization server (and however much of the surrounding identity 46 + / PDS chain is needed for clients to discover it — reachability strategy 47 + chosen during brainstorming) and observes the client driving end-to-end 48 + OAuth flows. The reachability design must include same-host (client and 49 + devtool both running on the developer's machine) as a supported baseline, 50 + since that's where most pre-deployment development happens. Day-1 flow 51 + variants exercise scope handling (partial grants, downscoping) and DPoP / 52 + refresh edge cases (nonce rotation, refresh rotation, replay protection). 53 + - **Dependency-gated:** interactive checks that depend on a failing static 54 + check are skipped with a recorded reason; checks whose preconditions are 55 + met still run. 56 + - **Reporting:** matches the existing `test labeler` shape — same five-way 57 + `CheckStatus`, same exit-code precedence, snapshot-tested check IDs and 58 + diagnostic codes. 59 + 60 + **Explicitly out of scope:** App Password testing, OAuth *server* (PDS) 61 + conformance, and any operational deployment of a hosted helper service 62 + (even if brainstorming proposes one as a future option). 63 + 64 + ## Acceptance Criteria 65 + 66 + ### test-oauth-client.AC1: Static mode — target parsing and discovery 67 + 68 + - **test-oauth-client.AC1.1 Success:** An `https://...` `client_id` URL is fetched over the shared HTTP client and the response body is captured as `RawMetadata::Document`. 69 + - **test-oauth-client.AC1.2 Success:** `http://localhost[:port][/path]` is recognized as a loopback target and synthesizes `RawMetadata::Implicit` without making an HTTP request. 70 + - **test-oauth-client.AC1.3 Success:** `http://127.0.0.1[:port][/path]` is treated identically to `localhost` for loopback recognition. 71 + - **test-oauth-client.AC1.4 Failure:** A target that is neither HTTPS nor a recognized loopback form is rejected before the pipeline starts, with a `parse_target` error surfaced as a clap argument error (non-zero exit, helpful message). 72 + - **test-oauth-client.AC1.5 Failure:** An HTTPS `client_id` returning a non-2xx status produces a `NetworkError` `CheckResult` with the URL and status in the diagnostic; downstream stages emit `Skipped` rows blocked on `discovery::metadata_document_fetchable`. 73 + - **test-oauth-client.AC1.6 Failure:** An HTTPS `client_id` returning a body that is not parseable JSON produces a `SpecViolation` with the content-type and a snippet of the body in the diagnostic. 74 + - **test-oauth-client.AC1.7 Edge:** A loopback target with an empty path, an explicit port, or a non-empty path each parse identically and emit the same set of `CheckResult`s. 75 + 76 + ### test-oauth-client.AC2: Static mode — metadata document validation 77 + 78 + - **test-oauth-client.AC2.1 Success:** A confidential web client document with all required fields (`application_type=web`, `response_types=[code]`, `grant_types=[authorization_code]`, `dpop_bound_access_tokens=true`, `redirect_uris` HTTPS, `token_endpoint_auth_method=private_key_jwt`, `jwks` or `jwks_uri`, valid `scope`) passes every metadata-stage check. 79 + - **test-oauth-client.AC2.2 Success:** A public web client document (no JWKS, `token_endpoint_auth_method=none`) passes. 80 + - **test-oauth-client.AC2.3 Success:** A native client document with custom-scheme `redirect_uris` whose scheme matches the reverse-domain of the `client_id` host passes. 81 + - **test-oauth-client.AC2.4 Failure:** `dpop_bound_access_tokens` absent or `false` produces a `SpecViolation` with stable code `oauth_client::metadata::dpop_bound_required`. 82 + - **test-oauth-client.AC2.5 Failure:** A confidential client without `jwks` or `jwks_uri` produces a `SpecViolation` with code `oauth_client::metadata::confidential_requires_jwks`. 83 + - **test-oauth-client.AC2.6 Failure:** A public client whose `token_endpoint_auth_method` is anything other than `none` produces a `SpecViolation`. 84 + - **test-oauth-client.AC2.7 Failure:** A native client whose `redirect_uri` scheme does not match the reverse-domain of the `client_id` host produces a `SpecViolation`. 85 + - **test-oauth-client.AC2.8 Failure:** A `scope` field that does not parse against the atproto permission grammar produces a `SpecViolation` with the offending token highlighted via miette source span. 86 + - **test-oauth-client.AC2.9 Skip:** Every metadata-document validation check on a loopback target emits `Skipped` with reason `"metadata is implicit for loopback clients"`. 87 + 88 + ### test-oauth-client.AC3: Static mode — JWKS validation 89 + 90 + - **test-oauth-client.AC3.1 Success:** Inline `jwks` containing valid ES256 keys with unique `kid`, `alg`, and `use=sig` passes every JWKS check. 91 + - **test-oauth-client.AC3.2 Success:** External `jwks_uri` returning a valid JWKS document is fetched (over the `JwksFetcher` seam) and validated identically. 92 + - **test-oauth-client.AC3.3 Failure:** A `jwks_uri` returning a non-2xx status produces a `NetworkError` with the URL in the diagnostic. 93 + - **test-oauth-client.AC3.4 Failure:** A JWKS containing two keys with the same `kid` produces a `SpecViolation` with both `kid`s and their offsets highlighted via miette source span. 94 + - **test-oauth-client.AC3.5 Failure:** A key without an `alg` field produces a `SpecViolation`. 95 + - **test-oauth-client.AC3.6 Failure:** A key with `use` other than `sig` (or absent) produces a `SpecViolation`. 96 + - **test-oauth-client.AC3.7 Failure:** A key advertising a non-modern algorithm (RS1, MD5, etc.) produces a `SpecViolation`; ECDSA P-256 / ES256K and stronger algorithms pass. 97 + - **test-oauth-client.AC3.8 Skip:** Public-client and native-client JWKS checks emit `Skipped` rows with reason `"jwks not required for {kind} clients"` rather than running. 98 + 99 + ### test-oauth-client.AC4: Interactive mode — fake AS infrastructure 100 + 101 + - **test-oauth-client.AC4.1 Success:** `test oauth client interactive <target>` spawns the `axum`-based fake AS on a port, prints the synthetic handle / DID for the user to enter into their client, and waits for inbound requests until interrupted. 102 + - **test-oauth-client.AC4.2 Success:** Without `--public-base-url`, the fake AS binds to `127.0.0.1:<port>` and every served metadata document references `http://127.0.0.1:<port>` as the active base. 103 + - **test-oauth-client.AC4.3 Success:** With `--public-base-url <https-url>`, every URL appearing in served metadata documents (DID document `service` entries, AS metadata endpoint URLs, PRM `authorization_servers` array) is rewritten to use the public URL. 104 + - **test-oauth-client.AC4.4 Success:** The well-known endpoints (`/<did-web-path>/did.json`, `/.well-known/oauth-protected-resource`, `/.well-known/oauth-authorization-server`) return spec-shaped JSON validatable by an external client / parser. 105 + - **test-oauth-client.AC4.5 Success:** Every inbound request to PAR / authorize / token is appended to the `RequestLog` with timestamp, route, headers, and body bytes preserved verbatim. 106 + - **test-oauth-client.AC4.6 Failure:** A `--port` value that is already bound, out of range, or otherwise unbindable produces a clear startup error and a non-zero exit before the synthetic identity is printed. 107 + 108 + ### test-oauth-client.AC5: Cross-mode dependency gating 109 + 110 + - **test-oauth-client.AC5.1 Success:** When `interactive` is invoked, the static stages (discovery, metadata, JWKS) run first and their `CheckResult`s appear in the report ahead of the interactive stage's results. 111 + - **test-oauth-client.AC5.2 Success:** When all static checks pass, every interactive sub-stage runs its full check inventory. 112 + - **test-oauth-client.AC5.3 Skip:** When a static check fails AND it gates a specific interactive flow, that flow's checks emit `Skipped` with reason `"blocked by <stage>::<check_id>"` (matching the labeler `Check::blocked_by` rendering verbatim). 113 + - **test-oauth-client.AC5.4 Success:** When a static check fails but no interactive flow declares a `blocked_by` against it, the interactive flows still execute their full inventories. 114 + - **test-oauth-client.AC5.5 Success:** Every `Skipped` reason references a specific failing check ID, never a generic description like `"a prerequisite failed"`. 115 + 116 + ### test-oauth-client.AC6: Interactive — scope-variation flows 117 + 118 + - **test-oauth-client.AC6.1 Success:** A full-grant approve flow records the client correctly performing PAR (with PKCE S256 + DPoP), an authorize redirect, and a token exchange with refresh-token capture; all conformance checks emit `Pass`. 119 + - **test-oauth-client.AC6.2 Success:** A partial-grant approve flow (fake AS grants a strict subset of requested scopes) records the client correctly storing the granted (not requested) scope set; check emits `Pass` if the client surfaces or honors the actual grant. 120 + - **test-oauth-client.AC6.3 Success:** A user-denial flow (fake AS returns `error=access_denied` to the authorize redirect) records the client correctly propagating the error rather than retrying silently. 121 + - **test-oauth-client.AC6.4 Success:** A downscoped-refresh flow records the client issuing a refresh request with a subset of the originally granted scopes when its `RelyingParty`-driven script asks for one. 122 + - **test-oauth-client.AC6.5 Failure:** A recorded PAR request without `code_challenge` (PKCE) produces a `SpecViolation` with code `oauth_client::interactive::scope_variations::pkce_required`. 123 + - **test-oauth-client.AC6.6 Failure:** A recorded PAR request without a `DPoP` header produces a `SpecViolation`. 124 + 125 + ### test-oauth-client.AC7: Interactive — DPoP / refresh edge flows 126 + 127 + - **test-oauth-client.AC7.1 Success:** A DPoP-nonce rotation flow (fake AS responds 400 `use_dpop_nonce` on initial PAR with a `DPoP-Nonce` header) records the client correctly retrying with the issued nonce. 128 + - **test-oauth-client.AC7.2 Success:** A refresh-token rotation flow records the client using the new refresh token returned with the previous token response (and not retaining the old one). 129 + - **test-oauth-client.AC7.3 Success:** A replay-rejection flow (fake AS rejects a duplicate `jti`) records the client surfacing the failure rather than entering a retry loop. 130 + - **test-oauth-client.AC7.4 Failure:** A recorded sequence where the client reuses a `jti` across two requests produces a `SpecViolation` with code `oauth_client::interactive::dpop_edges::jti_reused`. 131 + - **test-oauth-client.AC7.5 Failure:** A recorded sequence where the client receives a `DPoP-Nonce` header but does not adopt it on the next request produces a `SpecViolation`. 132 + - **test-oauth-client.AC7.6 Failure:** A recorded sequence where the client reuses a single-use refresh token after rotation produces a `SpecViolation`. 133 + 134 + ### test-oauth-client.AC8: Cross-cutting CLI / report contract 135 + 136 + - **test-oauth-client.AC8.1 Success:** Exit code is `0` when no `SpecViolation` and no `NetworkError` are recorded. 137 + - **test-oauth-client.AC8.2 Success:** Exit code is `1` when at least one `SpecViolation` is recorded, regardless of any `NetworkError`s present. 138 + - **test-oauth-client.AC8.3 Success:** Exit code is `2` when at least one `NetworkError` is recorded and no `SpecViolation` is recorded. 139 + - **test-oauth-client.AC8.4 Success:** `Advisory` and `Skipped` rows never influence the exit code. 140 + - **test-oauth-client.AC8.5 Success:** `--verbose` toggles tracing to DEBUG level for both modes (verified by `oauth_client_cli` smoke test). 141 + - **test-oauth-client.AC8.6 Success:** Both `NO_COLOR` env var and `--no-color` flag suppress ANSI color in the rendered report. 142 + - **test-oauth-client.AC8.7 Success:** Every check ID follows the `oauth_client::<stage>::<check>` naming convention and appears verbatim in at least one insta snapshot under `tests/snapshots/`. 143 + - **test-oauth-client.AC8.8 Success:** Every diagnostic code follows the same naming convention and appears verbatim in at least one insta snapshot. 144 + - **test-oauth-client.AC8.9 Success:** `atproto-devtool test oauth client --help` and `atproto-devtool test oauth client interactive --help` each render distinct argument inventories with the interactive-only flags (`--public-base-url`, `--port`) appearing only on the latter. 145 + 146 + ## Glossary 147 + 148 + - **AS metadata**: The JSON document served at 149 + `/.well-known/oauth-authorization-server` that advertises an authorization 150 + server's capabilities and endpoint URLs, defined by RFC 8414. 151 + - **atproto**: The AT Protocol — a decentralized social networking protocol 152 + developed by Bluesky. It defines its own OAuth profile layered on top of 153 + standard RFCs, adding requirements such as DPoP, PAR, and PKCE S256 as 154 + mandatory, not optional. 155 + - **Authorization server (AS)**: The server that authenticates the user and 156 + issues access tokens. In atproto, the PDS acts as the authorization server 157 + for its users. 158 + - **`axum`**: A Rust web framework built on `tokio` and `tower`, used here 159 + to implement the fake authorization server that listens for inbound 160 + client requests during interactive mode. 161 + - **`client_id`**: In OAuth 2.0, the identifier for the client application. 162 + In the atproto OAuth profile, the `client_id` must be a URL that either 163 + points at a fetchable JSON metadata document (HTTPS clients) or follows 164 + the loopback form (`http://localhost[...]`) for development clients. 165 + - **Client metadata document**: A JSON document hosted at the client's 166 + `client_id` URL that declares the client's capabilities, redirect URIs, 167 + authentication method, JWKS location, and other properties required by 168 + the atproto OAuth profile. 169 + - **DID (Decentralized Identifier)**: A self-sovereign identifier used 170 + throughout atproto. Every atproto user or service has a DID. Two DID 171 + methods are relevant here: `did:plc` (a registry-backed method) and 172 + `did:web` (resolves via a `/.well-known/did.json` document on the 173 + identified host). 174 + - **did:web**: A DID method where the DID document is fetched from a URL 175 + derived from the DID string. The fake AS uses `did:web` to serve a 176 + synthetic identity without requiring a PLC registry. 177 + - **DPoP (Demonstrating Proof of Possession)**: An OAuth mechanism (RFC 178 + 9449) that binds access tokens and refresh tokens to the client's 179 + signing key. Every request to the token endpoint must carry a signed 180 + DPoP proof containing a unique `jti` claim and (after the server issues 181 + one) a `nonce` claim. 182 + - **Handle resolution**: The process of mapping an atproto human-readable 183 + handle (e.g. `alice.bsky.social`) to a DID via DNS TXT record lookup or 184 + an HTTPS well-known endpoint. The interactive mode prints a synthetic 185 + handle so the developer can enter it into their client. 186 + - **`insta`**: A Rust snapshot-testing library. Snapshots capture rendered 187 + CLI output, check IDs, and diagnostic codes; they are stored under 188 + `tests/snapshots/` and reviewed with `cargo insta review`. 189 + - **`jti` (JWT ID)**: A unique identifier claim inside a JWT, used in DPoP 190 + proofs to prevent replay attacks. Each DPoP proof must carry a fresh 191 + `jti`. 192 + - **JWKS (JSON Web Key Set)**: A JSON document containing one or more 193 + public keys in JWK format, used by confidential clients to advertise the 194 + keys with which they sign JWTs for authentication. 195 + - **`jwks_uri`**: An alternative to an inline `jwks` field in a client 196 + metadata document; the authorization server fetches the key set from 197 + this URL instead. 198 + - **JWS (JSON Web Signature)**: The signed-token format used for DPoP 199 + proofs and `private_key_jwt` client assertions. A JWS is a 200 + base64url-encoded JWT with a detachable or attached signature. 201 + - **Labeler**: An atproto service that applies content labels to posts or 202 + accounts. The `test labeler` subcommand is the existing conformance 203 + command that this design follows as its primary architectural precedent. 204 + - **Loopback client**: An atproto OAuth client identified by 205 + `http://localhost[...]` or `http://127.0.0.1[...]`. The spec treats 206 + these as development clients with implicit (no-document) metadata, 207 + making them exempt from metadata-document and JWKS validation. 208 + - **PAR (Pushed Authorization Request)**: RFC 9126. Instead of sending 209 + authorization parameters in the browser redirect URL, the client POSTs 210 + them directly to the AS and receives a short-lived `request_uri` opaque 211 + reference to use in the redirect. The atproto OAuth profile mandates 212 + PAR. 213 + - **PDS (Personal Data Server)**: The server that hosts a user's atproto 214 + repository. In the atproto OAuth model, the PDS is also the 215 + authorization server for that user's account. 216 + - **PKCE (Proof Key for Code Exchange)**: RFC 7636. A mechanism that binds 217 + an authorization code to the client that requested it by requiring the 218 + client to send a `code_challenge` upfront and a `code_verifier` at token 219 + exchange. The atproto OAuth profile mandates the S256 challenge method. 220 + - **PRM (Protected Resource Metadata)**: RFC 9728. A JSON document served 221 + at `/.well-known/oauth-protected-resource` that tells clients which 222 + authorization server is responsible for protecting a given resource. 223 + - **`private_key_jwt`**: A client authentication method where the client 224 + signs a JWT assertion with its private key and presents it to the token 225 + endpoint instead of a shared secret. Required by the atproto OAuth 226 + profile for confidential clients. 227 + - **`RelyingParty`**: The name used in this codebase for a spec-compliant 228 + atproto OAuth client implementation. It is a "relying party" in the 229 + identity-federation sense — a service that relies on an external 230 + authorization server to authenticate users. Here it serves both as the 231 + test driver for the fake AS and as future production code for 232 + `test oauth server`. 233 + - **RFC 7636**: The PKCE specification. 234 + - **RFC 8414**: The OAuth 2.0 Authorization Server Metadata specification. 235 + - **RFC 9126**: The Pushed Authorization Request (PAR) specification. 236 + - **RFC 9449**: The DPoP specification. 237 + - **RFC 9728**: The OAuth 2.0 Protected Resource Metadata specification. 238 + - **`Stage` newtype**: A `pub struct Stage(pub &'static str)` wrapper used 239 + in the shared report contract to identify which pipeline stage produced 240 + a `CheckResult`. Promoted from a closed enum so that both the labeler 241 + and oauth_client commands can use it without coupling. 242 + 243 + ## Architecture 244 + 245 + ### CLI surface 246 + 247 + `atproto-devtool` grows one new top-level test family member with two sibling 248 + modes: 249 + 250 + ``` 251 + atproto-devtool test oauth client <target> # static mode (default) 252 + atproto-devtool test oauth client interactive <target> [--public-base-url URL] [--port PORT] 253 + ``` 254 + 255 + Clap routing nests two new levels under the existing `test` subcommand: 256 + `Command::Test(TestCmd) → TestCmd::Oauth(OauthCmd) → OauthCmd::Client(ClientCmd)`, 257 + where `ClientCmd` itself is a `Subcommand` enum with `Static` (the default 258 + when no further subcommand is provided) and `Interactive` variants. 259 + 260 + `<target>` accepted forms: 261 + 262 + - `https://...` — confidential or public web client; treated as `client_id` URL 263 + pointing at a fetchable JSON metadata document. 264 + - `http://localhost[:port][/path]` or `http://127.0.0.1[:port][/path]` — 265 + atproto loopback client; metadata is implicit per spec, no document is fetched. 266 + 267 + Anything else is rejected before the pipeline starts. 268 + 269 + ### Module layout 270 + 271 + Mirrors the existing `commands/test/labeler/` layout (no `mod.rs`, sibling 272 + `foo.rs + foo/` pattern): 273 + 274 + ``` 275 + src/commands/test.rs # add `Oauth(OauthCmd)` variant 276 + src/commands/test/oauth.rs # OauthCmd enum, dispatches to Client 277 + src/commands/test/oauth/client.rs # ClientCmd, top-level run() dispatch 278 + src/commands/test/oauth/client/ 279 + pipeline.rs # OauthClientTarget, OauthClientOptions, run_pipeline 280 + discovery.rs # Stage 1 281 + metadata.rs # Stage 2 282 + jwks.rs # Stage 3 283 + interactive.rs # Stage 4 (only when subcommand is `interactive`) 284 + fake_as.rs / fake_as/ 285 + endpoints.rs # PAR / authorize / token / metadata routes 286 + identity.rs # did:web bootstrap, .well-known/* documents 287 + src/common/oauth.rs # new common namespace 288 + src/common/oauth/jws.rs # JWS / JWK helpers (jsonwebtoken w/ rust_crypto feature) 289 + src/common/oauth/relying_party.rs # dual-purpose probe RP (used by interactive tests + future `test oauth server`) 290 + src/common/report.rs # promoted from labeler 291 + ``` 292 + 293 + The labeler tree's existing `src/commands/test/labeler/report.rs` is removed 294 + and its callers updated to import from `crate::common::report`. Existing 295 + labeler insta snapshots are unaffected — they pin rendered text, not module 296 + paths. 297 + 298 + ### Static-mode pipeline 299 + 300 + Three stages, mirroring the labeler's serial-with-Facts-gating pattern. Each 301 + stage consumes a `*Facts` struct from its prerequisite, emits its own 302 + `*Facts` (when it succeeds enough to unblock downstream), and produces a 303 + `Vec<CheckResult>`. 304 + 305 + ``` 306 + discovery -> DiscoveryFacts { client_id, kind, raw_metadata } 307 + metadata -> MetadataFacts { kind, redirect_uris, requires_jwks, dpop_bound, scope, ... } 308 + jwks -> JwksFacts { keys, source } (only if MetadataFacts.requires_jwks) 309 + ``` 310 + 311 + `DiscoveryFacts.kind` is one of `ClientIdKind::{HttpsUrl, Loopback}`. 312 + `MetadataFacts.kind` is the resolved `ClientKind::{WebConfidential, WebPublic, Native, Loopback}` 313 + once the document (or implicit shape) is parsed. 314 + 315 + For loopback targets, document-fetching and document-validating checks emit 316 + `Skipped` with reason `"metadata is implicit for loopback clients"` rather 317 + than being silently dropped. Diagnostic-code prefixes: 318 + `oauth_client::discovery::*`, `oauth_client::metadata::*`, 319 + `oauth_client::jwks::*`. 320 + 321 + ### Interactive-mode pipeline 322 + 323 + Interactive mode runs the static pipeline first (always), then runs the 324 + `interactive` stage which: 325 + 326 + 1. Spawns the in-process fake AS (`fake_as::ServerHandle`, `axum`-based) on 327 + `127.0.0.1:<port>` — random ephemeral port if `--port` not given. 328 + 2. If `--public-base-url <url>` is set, the fake AS uses that URL when 329 + serving every metadata document; the synthetic identity is bound to that 330 + hostname. Otherwise it advertises `http://127.0.0.1:<port>` as its own 331 + base, and the synthetic identity is bound to localhost. The user is 332 + responsible for any tunneling needed to make `--public-base-url` 333 + reachable from their client (cloudflared, Tailscale Funnel, ngrok, etc.). 334 + 3. Prints the synthetic handle / DID for the user to enter into their client. 335 + 4. For each day-1 flow variant (scope variations, DPoP edges) — runs the 336 + variant as its own sub-stage, populating its own `RequestLog` against the 337 + shared `fake_as::ServerHandle`, with `Check::blocked_by` graph entries 338 + pointing at both static-mode prerequisites (`metadata::scope_present`, 339 + `jwks::keys_have_alg`, etc.) and interactive prerequisites within the 340 + stage. 341 + 342 + ### Fake AS endpoint surface 343 + 344 + The fake AS exposes these routes (all under whichever base URL is in 345 + effect): 346 + 347 + | Route | Purpose | 348 + |---|---| 349 + | `GET /<did-web-path>/did.json` | DID document for the synthetic identity, with a `service` entry pointing at the same base. | 350 + | `GET /.well-known/oauth-protected-resource` | RFC 9728 PRM document; advertises the AS metadata URL. | 351 + | `GET /.well-known/oauth-authorization-server` | RFC 8414 AS metadata. | 352 + | `POST /oauth/par` | RFC 9126 PAR endpoint. Validates PKCE, DPoP, client auth (private_key_jwt for confidential clients). Records the request into the active flow's `RequestLog`. | 353 + | `GET /oauth/authorize` | Per-flow scripted "user response" — approve, deny, partial-grant — without human input. The conformance signal is what the client sent, not what the user did. | 354 + | `POST /oauth/token` | Token issuance with DPoP nonce rotation, refresh-token rotation, replay rejection. Records every inbound request. | 355 + 356 + Conformance checks in the `interactive` stage are assertions over the 357 + recorded `RequestLog` — i.e., did the client correctly send a PAR request 358 + with PKCE S256 before redirecting; did it include a DPoP proof on the token 359 + request; did it retry with the issued nonce on a `use_dpop_nonce` 400; did 360 + it generate a fresh `jti` for each request — not assertions over what the 361 + fake AS returned. 362 + 363 + ### Cross-mode contracts (promoted from labeler) 364 + 365 + `src/common/report.rs` holds: 366 + 367 + ```rust 368 + pub enum CheckStatus { Pass, SpecViolation, NetworkError, Advisory, Skipped } 369 + pub struct CheckResult { 370 + pub id: &'static str, 371 + pub stage: Stage, 372 + pub status: CheckStatus, 373 + pub summary: Cow<'static, str>, 374 + pub diagnostic: Option<Box<dyn miette::Diagnostic + Send + Sync>>, 375 + pub skipped_reason: Option<Cow<'static, str>>, 376 + } 377 + pub struct Stage(pub &'static str); // newtype, was a labeler-specific enum 378 + pub struct Report { header, results, summary, exit_code } 379 + pub struct ReportHeader { ... } 380 + pub struct RenderConfig { ... } 381 + pub struct SummaryCounts { ... } 382 + 383 + impl Check { 384 + pub fn blocked_by(&self, other: Check) -> CheckResult { ... } // moved here too 385 + } 386 + ``` 387 + 388 + Exit-code precedence: `1` if any `SpecViolation`, else `2` if any 389 + `NetworkError`, else `0`. Identical to labeler. 390 + 391 + ### Injection seams 392 + 393 + `OauthClientOptions<'a>` carries (using existing `common::identity` traits 394 + where reusable): 395 + 396 + ```rust 397 + pub struct OauthClientOptions<'a> { 398 + pub http: &'a dyn HttpClient, // reused from common::identity 399 + pub dns: &'a dyn DnsResolver, // reused from common::identity 400 + pub jwks: &'a dyn JwksFetcher, // new, in jwks.rs 401 + pub clock: &'a dyn Clock, // new, for deterministic timestamps under test 402 + pub interactive: Option<InteractiveOptions<'a>>, // present iff the `interactive` subcommand was invoked 403 + pub verbose: bool, 404 + } 405 + 406 + pub struct InteractiveOptions<'a> { 407 + pub bind_port: Option<u16>, 408 + pub public_base_url: Option<Url>, 409 + // No FakeAsListener trait — interactive tests use the production fake_as 410 + // server and drive it with a RelyingParty. Test-determinism for jti/token 411 + // strings/timestamps is achieved through Clock and a per-test RNG seed 412 + // configured on the RelyingParty. 413 + } 414 + ``` 415 + 416 + ### Testing strategy 417 + 418 + Per-stage integration test binaries, mirroring the labeler: 419 + 420 + ``` 421 + tests/oauth_client_discovery.rs 422 + tests/oauth_client_metadata.rs 423 + tests/oauth_client_jwks.rs 424 + tests/oauth_client_interactive.rs # spins up real fake_as, drives it with RelyingParty 425 + tests/oauth_client_endtoend.rs # full pipeline, snapshot-pinned 426 + tests/oauth_client_cli.rs # smoke: --help, exit codes, --verbose 427 + ``` 428 + 429 + Static-stage tests use existing fakes (`FakeHttpClient`, plus a new 430 + `FakeJwksFetcher`) seeded with fixture documents. Interactive tests use the 431 + production `fake_as::ServerHandle` and `common::oauth::relying_party::RelyingParty` 432 + with a deterministic clock and seeded RNG, so generated `jti`s, token 433 + strings, and timestamps are stable across runs and snapshot-comparable. 434 + 435 + Fixtures live under `tests/fixtures/oauth_client/<stage>/<case>/`. Snapshots 436 + under `tests/snapshots/` follow the existing `<binary>__<test_name>.snap` 437 + pattern. Per CLAUDE.md, snapshots are part of the public CLI contract and 438 + require `cargo insta review` for any check-ID, diagnostic-code, or rendered 439 + text changes. 440 + 441 + ## Existing Patterns 442 + 443 + This design closely follows patterns established by `test labeler`: 444 + 445 + - **Pipeline-with-Facts-gating.** Each stage consumes a `*Facts` struct 446 + from its prerequisite and emits its own `*Facts` when it succeeds enough 447 + to let downstream proceed. `pipeline::run_pipeline` is a single 448 + orchestrator that every entry point routes through, present in 449 + `src/commands/test/labeler/pipeline.rs`. 450 + - **`Check::blocked_by` skip semantics.** When a prerequisite fails, 451 + downstream checks emit `Skipped` rows with a reason string referencing 452 + the failing prerequisite. The user explicitly asked for this dependency 453 + graph to extend across the static/interactive boundary. 454 + - **Five-way `CheckStatus` and miette diagnostics.** Pinned in 455 + `src/commands/test/labeler/report.rs`. Promoted to `src/common/report.rs` 456 + so both this command and labeler share the rendering and exit-code 457 + contract verbatim. 458 + - **Per-stage injection traits.** Network and IO boundaries live behind 459 + `dyn`-injectable traits passed through an `Options` struct. `HttpClient` 460 + and `DnsResolver` from `src/common/identity.rs` are reused unchanged; 461 + `JwksFetcher` and `Clock` are added as new stage-local seams. 462 + - **Snapshot-pinned check IDs and diagnostic codes.** Each stage has a 463 + stable diagnostic-code prefix 464 + (`<command>::<stage>::<check_id>`) appearing verbatim in `tests/snapshots/`. 465 + Renaming one is a breaking change to the CLI contract. 466 + - **Per-stage integration test binaries with fixture trees.** One binary 467 + per stage under `tests/`, fixtures under `tests/fixtures/<command>/<stage>/<case>/`, 468 + insta snapshots under `tests/snapshots/`. Empty case directories take 469 + `.gitkeep`. 470 + - **No `mod.rs`.** `foo.rs + foo/` sibling-file convention applies to 471 + every new module. 472 + - **Single shared reqwest client.** `client.rs::run()` constructs one 473 + `reqwest::Client` (rustls + 10s timeout + user-agent) and threads it 474 + through every stage that needs HTTP, never letting stages construct 475 + their own. 476 + - **Tracing instrumentation.** Each stage adds `tracing::instrument` 477 + spans, with `--verbose` toggling DEBUG level. The 478 + `verbose_flag_accepted` smoke pattern from `tests/labeler_cli.rs` is 479 + duplicated in `tests/oauth_client_cli.rs`. 480 + 481 + The two genuinely new patterns introduced (with explicit justification): 482 + 483 + - **`Stage` becomes a `&'static str` newtype**, not a closed enum. Forced 484 + by having two consumers (labeler + oauth_client). A closed enum would 485 + couple them; a sealed trait would be heavier than the value justifies. 486 + - **`common::oauth::relying_party::RelyingParty` is a real OAuth client, 487 + used both as a test driver for the `fake_as` server and as production 488 + code in the future `test oauth server` command.** No analogue exists 489 + yet; introducing it here amortizes the cost across both commands. 490 + 491 + ## Implementation Phases 492 + 493 + <!-- START_PHASE_1 --> 494 + ### Phase 1: Promote `report.rs` to `common`, add CLI scaffolding 495 + 496 + **Goal:** Move the shared report contract out of the labeler tree and stub 497 + the new `test oauth client` / `test oauth client interactive` subcommand 498 + shape, so subsequent phases have a stable foundation to build on. 499 + 500 + **Components:** 501 + 502 + - Move `src/commands/test/labeler/report.rs` → `src/common/report.rs`. 503 + Change `Stage` from a labeler-specific enum to a `pub struct Stage(pub &'static str)` 504 + newtype. Move `Check::blocked_by` helper into the same module. Update all 505 + labeler imports to read from `crate::common::report`. 506 + - Add `Oauth(OauthCmd)` variant to `TestCmd` in `src/commands/test.rs`. 507 + Create `src/commands/test/oauth.rs` (`OauthCmd` enum with `Client(ClientCmd)` 508 + variant). Create `src/commands/test/oauth/client.rs` (`ClientCmd` enum 509 + with `Static` and `Interactive` variants and a placeholder `run()` that 510 + prints a "not implemented" message and exits with code 0). 511 + - Update `src/lib.rs` re-exports so integration tests can reach the new 512 + module tree. 513 + 514 + **Dependencies:** None (first phase). 515 + 516 + **Done when:** 517 + 518 + - `cargo build` succeeds. 519 + - `cargo test` passes — all existing labeler tests, including snapshots 520 + under `tests/snapshots/labeler_*`, are unaffected (they pin rendered 521 + text, not module paths). 522 + - `cargo run -- test oauth client --help` shows the `static` and 523 + `interactive` subcommand structure. 524 + - `cargo run -- test oauth client https://example.com` runs the 525 + placeholder and exits 0. 526 + <!-- END_PHASE_1 --> 527 + 528 + <!-- START_PHASE_2 --> 529 + ### Phase 2: Common JWS/JWK helpers 530 + 531 + **Goal:** Add the shared JOSE primitives used by every later phase that 532 + touches OAuth crypto (Phase 5 JWKS validation, Phase 6 fake AS, Phase 7 533 + RelyingParty). 534 + 535 + **Components:** 536 + 537 + - Add `jsonwebtoken` (with `rust_crypto` feature, no default features) to 538 + `Cargo.toml`. This reuses the existing `k256` / `p256` / `sha2` 539 + primitives rather than pulling in `ring`. 540 + - Create `src/common/oauth.rs` (module root) and 541 + `src/common/oauth/jws.rs` containing: 542 + - `ParsedJwk` newtype wrapping a parsed JWK with `kid`, `alg`, `use`, 543 + and the underlying public key. 544 + - `parse_jwk(value: &serde_json::Value) -> Result<ParsedJwk, JwsError>`. 545 + - `parse_jwks(bytes: &[u8]) -> Result<Vec<ParsedJwk>, JwsError>`. 546 + - `verify_jws(token: &str, key: &ParsedJwk, opts: ...) -> Result<JwsClaims, JwsError>` 547 + wrapping `jsonwebtoken::decode`. 548 + - `sign_jws(claims: &JwsClaims, key: &SigningKey, opts: ...) -> Result<String, JwsError>` 549 + wrapping `jsonwebtoken::encode`. 550 + - `JwsError` deriving `thiserror::Error` and `miette::Diagnostic` with 551 + stable `code = "oauth_client::jws::*"` strings (so JWKS-stage failures 552 + can surface them). 553 + - Tests in `src/common/oauth/jws.rs` covering: ES256 sign/verify roundtrip, 554 + ES256K sign/verify roundtrip, JWK parsing happy-path, JWKS parsing 555 + with multiple keys, rejection of unknown algorithms, rejection of 556 + duplicate `kid` values within a JWKS. 557 + 558 + **Dependencies:** Phase 1. 559 + 560 + **Covers ACs:** None directly (infrastructure that other phases use). 561 + 562 + **Done when:** 563 + 564 + - `cargo test --lib common::oauth::jws` passes. 565 + - `cargo clippy -- -D warnings` clean. 566 + <!-- END_PHASE_2 --> 567 + 568 + <!-- START_PHASE_3 --> 569 + ### Phase 3: Static stage 1 — discovery 570 + 571 + **Goal:** Resolve a `<target>` argument into `DiscoveryFacts`, fetching the 572 + client metadata document for HTTPS targets and synthesizing implicit 573 + metadata for loopback targets. 574 + 575 + **Components:** 576 + 577 + - `src/commands/test/oauth/client/pipeline.rs`: 578 + `OauthClientTarget`, `parse_target`, `OauthClientOptions`, 579 + `OauthClientReport`, top-level `run_pipeline` that for now only invokes 580 + the discovery stage. 581 + - `src/commands/test/oauth/client/discovery.rs`: 582 + - `DiscoveryFacts { client_id: Url, kind: ClientIdKind, raw_metadata: Option<RawMetadata> }`. 583 + - `ClientIdKind::{HttpsUrl, Loopback}`. 584 + - `RawMetadata::{Document(Bytes), Implicit { client_id: Url }}`. 585 + - `Check` enum with stable IDs (`oauth_client::discovery::client_id_well_formed`, 586 + `oauth_client::discovery::metadata_document_fetchable`, 587 + `oauth_client::discovery::metadata_is_json`). 588 + - `run(target, &OauthClientOptions) -> DiscoveryStageOutput`. 589 + - Wire `client.rs::run()` to construct a real `HttpClient` (shared 590 + `reqwest::Client` with rustls + 10s timeout + user-agent), invoke 591 + `run_pipeline`, render the report, and return the appropriate exit 592 + code. 593 + - `tests/oauth_client_discovery.rs` exercising fixtures under 594 + `tests/fixtures/oauth_client/discovery/`: 595 + - `https_confidential_happy/` 596 + - `https_404/` 597 + - `https_not_json/` 598 + - `loopback_root/` 599 + - `loopback_with_path/` 600 + - `target_invalid_scheme/` (plain http non-loopback host) 601 + - New test fakes in `tests/common/mod.rs` if needed (existing 602 + `FakeHttpClient` should suffice). 603 + 604 + **Dependencies:** Phase 1, Phase 2. 605 + 606 + **Covers ACs:** `test-oauth-client.AC1.*` (discovery / target parsing). 607 + 608 + **Done when:** 609 + 610 + - `cargo test --test oauth_client_discovery` passes. 611 + - New insta snapshots reviewed via `cargo insta review`. 612 + - Manual: `cargo run -- test oauth client <good URL>` reports discovery 613 + results. 614 + <!-- END_PHASE_3 --> 615 + 616 + <!-- START_PHASE_4 --> 617 + ### Phase 4: Static stage 2 — metadata validation 618 + 619 + **Goal:** Parse the discovered metadata document and validate every 620 + spec-derived static property of an atproto OAuth client metadata document. 621 + 622 + **Components:** 623 + 624 + - `src/commands/test/oauth/client/metadata.rs`: 625 + - `MetadataFacts { kind: ClientKind, redirect_uris: Vec<Url>, requires_jwks: bool, dpop_bound: bool, scope: ScopeSet, ... }`. 626 + - `ClientKind::{WebConfidential, WebPublic, Native, Loopback}`. 627 + - `ScopeSet` newtype wrapping the parsed atproto permission scope grammar 628 + (per `https://atproto.com/specs/permission`). 629 + - `Check` enum with stable IDs covering: required fields present 630 + (`application_type`, `response_types`, `grant_types`, 631 + `dpop_bound_access_tokens`, `redirect_uris`, `scope`), 632 + `redirect_uris` shape per `application_type`, 633 + `token_endpoint_auth_method` matches client kind, scope grammar parses, 634 + URL fields use HTTPS where required. 635 + - `run(facts: &DiscoveryFacts, ...) -> MetadataStageOutput`. For 636 + `RawMetadata::Implicit` (loopback), validation checks emit `Skipped` 637 + with reason `"metadata is implicit for loopback clients"`. 638 + - Wire stage into `run_pipeline`. Diagnostic-code prefix 639 + `oauth_client::metadata::*`. 640 + - `tests/oauth_client_metadata.rs` and fixtures 641 + `tests/fixtures/oauth_client/metadata/`: 642 + - `confidential_happy/` 643 + - `public_happy/` 644 + - `native_happy/` 645 + - `confidential_missing_jwks/` (spec violation) 646 + - `public_with_token_endpoint_auth/` (spec violation) 647 + - `dpop_bound_false/` (spec violation) 648 + - `native_redirect_scheme_mismatch/` (spec violation) 649 + - `scope_grammar_invalid/` (spec violation) 650 + 651 + **Dependencies:** Phase 3. 652 + 653 + **Covers ACs:** `test-oauth-client.AC2.*` (metadata document validation). 654 + 655 + **Done when:** 656 + 657 + - `cargo test --test oauth_client_metadata` passes. 658 + - `cargo insta review` snapshots accepted. 659 + <!-- END_PHASE_4 --> 660 + 661 + <!-- START_PHASE_5 --> 662 + ### Phase 5: Static stage 3 — JWKS validation 663 + 664 + **Goal:** Fetch and validate the client's JWKS for confidential clients (and 665 + emit `Skipped` rows for client kinds where JWKS is not required). 666 + 667 + **Components:** 668 + 669 + - `src/commands/test/oauth/client/jwks.rs`: 670 + - `JwksFetcher` trait + `RealJwksFetcher` wrapping the shared 671 + `reqwest::Client`. 672 + - `JwksFacts { keys: Vec<ParsedJwk>, source: JwksSource }`. 673 + - `JwksSource::{Inline, Uri(Url)}`. 674 + - `Check` enum: `jwks_present`, `jwks_uri_fetchable` (when applicable), 675 + `jwks_is_json`, `keys_have_unique_kids`, `keys_have_alg`, 676 + `keys_use_signing_use`, `algs_are_modern_ec`. 677 + - `run(facts: &MetadataFacts, options: &OauthClientOptions) -> JwksStageOutput`. 678 + - Add `JwksFetcher` to `OauthClientOptions`, default to `RealJwksFetcher` 679 + in `client.rs::run()`. 680 + - Wire stage into `run_pipeline`. Diagnostic-code prefix 681 + `oauth_client::jwks::*`. 682 + - `tests/common/mod.rs`: `FakeJwksFetcher` with the same HashMap-keyed 683 + pattern as `FakeRawHttpTee`. 684 + - `tests/oauth_client_jwks.rs` and fixtures 685 + `tests/fixtures/oauth_client/jwks/`: 686 + - `inline_es256_happy/` 687 + - `uri_es256_happy/` 688 + - `uri_unreachable/` (network error path) 689 + - `duplicate_kids/` 690 + - `missing_alg/` 691 + - `wrong_use/` 692 + - `weak_alg_rs1/` 693 + - `not_required_skipped/` (public client, all checks Skipped) 694 + 695 + **Dependencies:** Phase 2 (JWS helpers), Phase 4. 696 + 697 + **Covers ACs:** `test-oauth-client.AC3.*` (JWKS validation). 698 + 699 + **Done when:** 700 + 701 + - `cargo test --test oauth_client_jwks` passes. 702 + - Snapshots accepted. 703 + - Static mode end-to-end run against a real public atproto OAuth client 704 + (e.g. one of the published reference clients) produces sensible output. 705 + <!-- END_PHASE_5 --> 706 + 707 + <!-- START_PHASE_6 --> 708 + ### Phase 6: Fake authorization server (axum) + identity bootstrap 709 + 710 + **Goal:** Stand up the in-process fake AS as a real, network-bound HTTP 711 + server, including the synthetic identity chain and the `--public-base-url` 712 + flag. No client conformance checks yet — just the server with the routes 713 + returning spec-shaped responses, and the request log it records. 714 + 715 + **Components:** 716 + 717 + - Add `axum` and `tower` to `Cargo.toml` (axum 0.7+ for tokio 1.x). No 718 + TLS support — the fake AS speaks plaintext HTTP, deferring HTTPS to the 719 + user's tunnel. 720 + - `src/commands/test/oauth/client/fake_as.rs` (or directory) with: 721 + - `ServerHandle { local_url: Url, public_url: Url, requests: Arc<RequestLog> }`. 722 + - `RequestLog`: append-only log of `(timestamp, route, headers, body)` 723 + tuples, behind a `Mutex` or `tokio::sync::Mutex`. Cloneable handle for 724 + reads from the interactive stage. 725 + - `bind(options: FakeAsOptions, clock: Arc<dyn Clock>) -> ServerHandle`. 726 + - Endpoint handlers in `endpoints.rs`: 727 + - `GET /<did-web-path>/did.json` — synthetic DID document. 728 + - `GET /.well-known/oauth-protected-resource` — RFC 9728 PRM. 729 + - `GET /.well-known/oauth-authorization-server` — RFC 8414 AS metadata 730 + with PAR / authorize / token endpoint URLs derived from the active 731 + base. 732 + - `POST /oauth/par` — accepts and records, returns a generated 733 + `request_uri` (deterministic under test via the injected clock and 734 + RNG). 735 + - `GET /oauth/authorize` — placeholder that records the request and 736 + redirects with a generated `code` (no flow scripting yet — that's 737 + Phase 7). 738 + - `POST /oauth/token` — placeholder that records and returns a token 739 + response (no DPoP nonce handling yet — that's Phase 7). 740 + - `identity.rs`: `synthetic_identity(base: &Url) -> SyntheticIdentity` 741 + constructing the `did:web:<host>` and the matching `.well-known` / 742 + `did.json` documents. 743 + - `src/common/oauth/relying_party.rs` (skeleton): 744 + - `RelyingParty` struct with `Clock` + RNG seed injection. 745 + - Stub methods for `discover_as`, `do_par`, `do_authorize`, `do_token`, 746 + `do_refresh` returning `unimplemented!()` placeholders, with the 747 + full method signatures spec'd out so Phase 7 fills them in. 748 + - Tests in `tests/oauth_client_interactive.rs` (initial form): 749 + - Spawn a `ServerHandle` on an ephemeral port. 750 + - Issue raw HTTP requests against each route and assert the responses 751 + have the expected shapes (spec-conformant JSON, correct headers). 752 + - Verify `RequestLog` records inbound requests faithfully. 753 + - Verify `--public-base-url` rewrites every URL in served documents to 754 + use the public host. 755 + 756 + **Dependencies:** Phase 5. 757 + 758 + **Covers ACs:** `test-oauth-client.AC4.*` (fake AS scaffolding / 759 + reachability surface) — partial; the conformance assertions land in 760 + Phase 7. 761 + 762 + **Done when:** 763 + 764 + - `cargo test --test oauth_client_interactive` passes the scaffolding 765 + tests. 766 + - Manual: `cargo run -- test oauth client interactive https://example.com` 767 + starts the server, prints the synthetic identity, and exits cleanly on 768 + Ctrl-C. 769 + - Manual with `--public-base-url`: served documents reference the public 770 + URL throughout. 771 + <!-- END_PHASE_6 --> 772 + 773 + <!-- START_PHASE_7 --> 774 + ### Phase 7: RelyingParty implementation + interactive stage gating 775 + 776 + **Goal:** Complete `RelyingParty` as a fully spec-compliant atproto OAuth 777 + client (PAR + PKCE S256 + DPoP nonce handling + private_key_jwt + token 778 + refresh + DPoP-bound resource access). Wire interactive stage to use 779 + RelyingParty in tests against the production fake AS, and add the 780 + `Check::blocked_by` graph linking interactive checks to static 781 + prerequisites. 782 + 783 + **Components:** 784 + 785 + - Complete `src/common/oauth/relying_party.rs`: 786 + - `RelyingParty::new(client_kind, signing_key, clock, rng_seed) -> RelyingParty`. 787 + - `discover_as(handle: &str, http: &dyn HttpClient) -> Result<AsDescriptor, RpError>`. 788 + - `do_par(req: ParRequest) -> Result<ParResponse, RpError>` with PKCE 789 + S256, DPoP proof generation, private_key_jwt assertion (for 790 + confidential), automatic nonce-retry. 791 + - `do_authorize(...)` — drives the GET to `/oauth/authorize` and 792 + handles the redirect. 793 + - `do_token(code, code_verifier) -> Result<TokenResponse, RpError>` 794 + with DPoP, nonce handling, refresh-token capture. 795 + - `do_refresh(refresh_token) -> Result<TokenResponse, RpError>` with 796 + rotation handling. 797 + - `FlowScript` enum / config struct: parameterizes scope set, 798 + DPoP-nonce timing, refresh strategy, "user denial" / "partial grant" 799 + behavior. 800 + - Upgrade `fake_as` endpoints to perform real conformance work: 801 + - PAR: validate PKCE method (must be S256, not plain), validate DPoP 802 + proof shape and signature, validate `private_key_jwt` for 803 + confidential clients, record DPoP `jti` for replay detection. 804 + - Authorize: per-flow scripted response (approve, deny, partial-grant). 805 + - Token: validate DPoP proof, validate PKCE verifier, issue 806 + DPoP-bound tokens, rotate refresh tokens single-use, reject reused 807 + refresh tokens, issue / rotate / require DPoP nonces. 808 + - `src/commands/test/oauth/client/interactive.rs`: 809 + - `InteractiveFacts { server: ServerHandle, identity: SyntheticIdentity }`. 810 + - Stage runner that prints the synthetic identity and waits for the 811 + user to drive their client; in test mode, drives `RelyingParty` 812 + against the same server in a child task. Conformance checks 813 + introspect the `RequestLog` and emit `CheckResult`s. 814 + - `Check::blocked_by` entries pointing at static-stage checks 815 + (`metadata::scope_present`, `metadata::dpop_bound_true`, 816 + `jwks::keys_have_alg` for confidential, etc.). 817 + - `tests/oauth_client_interactive.rs` extended with end-to-end scenarios 818 + driving `RelyingParty` against the production `fake_as` server, 819 + asserting that the resulting checks emit expected pass/fail patterns. 820 + - `tests/common/mod.rs`: helpers to construct deterministic 821 + `RelyingParty` instances (fixed clock, seeded RNG, fixed signing key). 822 + 823 + **Dependencies:** Phase 6. 824 + 825 + **Covers ACs:** `test-oauth-client.AC4.*` (interactive scaffolding — 826 + full), `test-oauth-client.AC5.*` (cross-mode dependency gating). 827 + 828 + **Done when:** 829 + 830 + - `cargo test --test oauth_client_interactive` passes. 831 + - Snapshots reviewed. 832 + - Manual: pointing a real reference client (e.g. a small TypeScript 833 + `@atproto/oauth-client-node` example) at the running fake AS completes 834 + a full PAR → authorize → token → refresh round-trip. 835 + <!-- END_PHASE_7 --> 836 + 837 + <!-- START_PHASE_8 --> 838 + ### Phase 8: Day-1 flow variants — scope variations + DPoP edges 839 + 840 + **Goal:** Land the two day-1 flow variant suites the user picked during 841 + clarification, each as its own interactive sub-stage with a dedicated 842 + diagnostic-code prefix and snapshot tests. 843 + 844 + **Components:** 845 + 846 + - `src/commands/test/oauth/client/interactive/scope_variations.rs`: 847 + - Per-flow `FlowScript`s: full-grant approve, partial-grant approve, 848 + user-denied, downscoped refresh. 849 + - Conformance checks introspecting the `RequestLog` to verify the 850 + client correctly handles each scripted authorize-response. 851 + - Diagnostic-code prefix 852 + `oauth_client::interactive::scope_variations::*`. 853 + - `src/commands/test/oauth/client/interactive/dpop_edges.rs`: 854 + - Per-flow `FlowScript`s: nonce rotation mid-flow, 855 + `use_dpop_nonce`-on-PAR retry, replay rejection (server rejects 856 + duplicate `jti`), refresh-token rotation race. 857 + - Conformance checks asserting client reaction (correctly retried with 858 + new nonce; re-signed with fresh `jti`; surfaced refresh-failure 859 + appropriately). 860 + - Diagnostic-code prefix `oauth_client::interactive::dpop_edges::*`. 861 + - Wire both into `interactive.rs` as parallel sub-stages, each gated by 862 + its own `Check::blocked_by` entries against static prereqs. 863 + - `tests/oauth_client_interactive.rs`: per-variant end-to-end scenarios 864 + driving `RelyingParty` with each `FlowScript` and asserting expected 865 + CheckResults. 866 + - `tests/oauth_client_endtoend.rs`: a compact suite that exercises one 867 + representative case per stage all in one snapshot, providing the 868 + CLI-output regression baseline. 869 + - `tests/oauth_client_cli.rs`: smoke tests for `--help`, `--verbose`, 870 + exit codes (0 / 1 / 2), and `interactive` subcommand argument 871 + validation. 872 + 873 + **Dependencies:** Phase 7. 874 + 875 + **Covers ACs:** `test-oauth-client.AC6.*` (scope variation flows), 876 + `test-oauth-client.AC7.*` (DPoP edge flows), `test-oauth-client.AC8.*` 877 + (cross-cutting CLI / report contract). 878 + 879 + **Done when:** 880 + 881 + - All `cargo test` binaries pass. 882 + - `cargo insta review` snapshots accepted. 883 + - `cargo clippy -- -D warnings` clean. 884 + - `cargo fmt --check` clean. 885 + - Manual: end-to-end smoke against a real reference client confirms each 886 + flow variant lands sensible PASS / SpecViolation / Skipped rows. 887 + <!-- END_PHASE_8 --> 888 + 889 + ## Additional Considerations 890 + 891 + **Future extensibility — `test oauth server`.** The `RelyingParty` in 892 + `src/common/oauth/relying_party.rs` is intentionally factored as a 893 + production-grade probe client, not a test fixture. The future 894 + `test oauth server` command will reuse it verbatim to drive flows against 895 + a real PDS authorization server (target: handle / DID); the only addition 896 + will be a server-side conformance check inventory that mirrors what the 897 + fake AS validates today on the inbound side. Promoting `report.rs` to 898 + `common` and using the `Stage` newtype already accommodates this third 899 + consumer without further refactoring. 900 + 901 + **HTTPS / production-deployed clients.** The fake AS speaks only plaintext 902 + HTTP. Day-1 supported workflows are: (a) client running on the same host 903 + as devtool, talking to `http://127.0.0.1:<port>`; (b) client deployed 904 + elsewhere, with the developer running a tunnel (cloudflared / Tailscale 905 + Funnel / ngrok) that fronts our HTTP server with HTTPS, and pointing us 906 + at the public URL via `--public-base-url`. We do not generate or manage 907 + TLS certificates and do not auto-spawn tunneling binaries. Documenting 908 + this as the supported workflow (probably in a short `docs/` file 909 + alongside the labeler one if the project grows one) is part of the 910 + implementation, not a separate phase. 911 + 912 + **Determinism under test.** DPoP `jti` values, generated token strings, 913 + and timestamps in `iat` / `exp` claims would otherwise leak into insta 914 + snapshots. The `Clock` trait in `OauthClientOptions` and a per-test RNG 915 + seed configured on `RelyingParty` and `fake_as::ServerHandle` make these 916 + fully deterministic in test runs while remaining cryptographically random 917 + in production. Any helper functions in those modules that need 918 + non-deterministic input must accept the clock / RNG via injection — never 919 + calling `SystemTime::now()` or thread-local RNG directly. 920 + 921 + **Loopback client interactive testing.** Loopback clients (`client_id = 922 + http://localhost`) are in scope for static checks but the spec implies a 923 + narrower interactive surface (no `private_key_jwt`, no JWKS). The 924 + interactive stage will run them against the fake AS but with a smaller 925 + check inventory; checks that don't apply emit `Skipped` with reason 926 + `"not applicable to loopback clients"`. This matches the labeler crypto 927 + stage's pattern of emitting `Skipped` for `did:web` targets where PLC 928 + history doesn't exist.