···11//! `atproto-devtool test labeler <target>` command.
2233+// pattern: Imperative Shell
44+35pub mod pipeline;
46pub mod target;
57
+8
src/commands/test/labeler/pipeline.rs
···11//! Target parsing and pipeline driver skeleton for labeler conformance checks.
2233+// pattern: Mixed (unavoidable)
44+//
55+// Stage orchestration: drives every stage's `run` (which itself does I/O
66+// through trait seams) and threads facts forward. Splitting orchestration
77+// from per-stage gating logic would not improve testability — the
88+// pipeline is already exercised end-to-end by `tests/labeler_*` with
99+// fakes wired in.
1010+311use std::time::Duration;
412513use url::Url;
···99//! Shuts down on drop: the RAII handle aborts the background task and
1010//! closes the listener.
11111212+// pattern: Imperative Shell
1313+//
1414+// Pure-listener loop: binds a TCP socket and serves a fixed JSON
1515+// payload. No business logic; the DID document is built upstream.
1616+1217use std::net::SocketAddr;
1318use std::sync::Arc;
1419
···2323//! (pointing at the reporter's own DID) to exercise the simplest working
2424//! shape. This makes the test deterministic for round-trip debugging.
25252626+// pattern: Functional Core
2727+2628use serde_json::{Value, json};
27292830use crate::common::identity::Did;
···22//! server, and a reference curve. Exposes a single method for signing
33//! atproto service-auth JWTs with that identity.
4455+// pattern: Mixed (unavoidable)
66+//
77+// Owns a signing key (pure crypto), a `DidDocServer` (imperative TCP),
88+// and a JWT minting helper (pure). The mix mirrors the lifecycle: the
99+// signer must keep the server alive while it's signing tokens.
1010+511use std::net::SocketAddr;
612use std::time::Duration;
713
···1414//! within a single `test labeler` invocation so operators can trace a group
1515//! of test reports back to one run.
16161717+// pattern: Functional Core
1818+//
1919+// `build` and `format_rfc3339_utc` take time as a parameter; the
2020+// `SystemTime` import is for the type only.
2121+1722use std::time::{SystemTime, UNIX_EPOCH};
18231924/// Prefix used so operators can grep their moderation queue for
+2
src/commands/test/labeler/pipeline/crypto.rs
···55//! (deterministic canonical encoding per RFC 8949) and supports key rotation via
66//! the did:plc audit log.
7788+// pattern: Mixed (unavoidable: stage orchestration interleaves trait-object I/O with check-result construction)
99+810use std::borrow::Cow;
911use std::collections::BTreeMap;
1012
+2
src/commands/test/labeler/pipeline/http.rs
···33//! Performs `com.atproto.label.queryLabels` requests against the labeler endpoint,
44//! verifies schema conformance, and exercises pagination.
5566+// pattern: Mixed (unavoidable: stage orchestration interleaves trait-object I/O with check-result construction)
77+68use std::borrow::Cow;
79use std::sync::Arc;
810
+2
src/commands/test/labeler/pipeline/identity.rs
···33//! Performs DID document resolution and labeler record validation,
44//! emitting a series of named checks for each identity-layer requirement.
5566+// pattern: Mixed (unavoidable: stage orchestration interleaves trait-object I/O with check-result construction)
77+68use std::borrow::Cow;
79use std::sync::Arc;
810
···44//! using a two-connection strategy: backfill with cursor=0, and live-tail if backfill
55//! did not complete within the budget.
6677+// pattern: Mixed (unavoidable: stage orchestration interleaves trait-object I/O with check-result construction)
88+79use std::sync::Arc;
810use std::time::Duration;
911
···66//! spins up an in-process fake authorization server and observes the
77//! client driving end-to-end OAuth flows.
8899+// pattern: Imperative Shell
1010+911pub mod fake_as;
1012pub mod pipeline;
1113pub mod target;
+7
src/commands/test/oauth/client/fake_as.rs
···11//! In-process fake atproto OAuth authorization server for interactive mode.
2233+// pattern: Imperative Shell
44+//
55+// Owns the axum server lifecycle (bind, spawn, shutdown) and exposes
66+// a `ServerHandle` through which tests address the AS by URL. Routing
77+// and per-flow logic live in `endpoints` and `identity` (Mixed and
88+// Functional Core respectively).
99+310pub mod endpoints;
411pub mod identity;
512pub mod request_log;
···11+// pattern: Mixed (unavoidable)
22+//
33+// Axum handlers (Imperative Shell — accept the request, log it, return
44+// the response) wrap the per-`FlowScript` decision logic (pure: which
55+// status, which body, which header). The mix is deliberate: each handler
66+// is short enough that splitting would obscure the request/response shape.
77+18use std::collections::{HashMap, HashSet, VecDeque};
29use std::sync::{Arc, Mutex};
310
···1818//! behaviour on all other endpoints, a cooperating RP can reach the
1919//! final verification step before the broken-AS injection kicks in.
20202121+// pattern: Mixed (unavoidable: stage orchestration interleaves trait-object I/O with check-result construction)
2222+2123use crate::commands::test::oauth::client::fake_as::{ServerHandle, endpoints::FlowScript};
2224use crate::common::oauth::relying_party::{AuthorizeOutcome, ParRequest, RelyingParty, RpError};
2325use crate::common::report::CheckResult;
···22//!
33//! Verifies AC6.1–AC6.6: scope grant behaviors, user denial, refresh scoping, and mandatory fields.
4455+// pattern: Mixed (unavoidable: stage orchestration interleaves trait-object I/O with check-result construction)
66+57use std::borrow::Cow;
68use std::fmt;
79use std::sync::Arc;
+2
src/commands/test/oauth/client/pipeline/jwks.rs
···33//! Fetches and validates the client's JWKS (JSON Web Key Set) for confidential
44//! clients using either an inline document or an external URI.
5566+// pattern: Mixed (unavoidable: stage orchestration interleaves trait-object I/O with check-result construction)
77+68use async_trait::async_trait;
79use miette::{Diagnostic, LabeledSpan, NamedSource};
810use reqwest::Client as ReqwestClient;
···11//! Shared miette configuration and `NamedSource` helpers.
2233+// pattern: Mixed (unavoidable)
44+//
55+// `install_miette_handler` mutates global process state via
66+// `miette::set_hook` (a side effect, called once from `cli::run`); the
77+// other helpers (`named_source_from_bytes`, `pretty_json_for_display`,
88+// `span_at_line_column`, `span_for_quoted_literal`) are pure and could
99+// live in a sibling file, but the module is small enough that splitting
1010+// would obscure the shared diagnostic concern.
1111+312use std::sync::Arc;
413514use miette::{GraphicalTheme, MietteHandlerOpts, NamedSource, SourceSpan};
+117
src/common/identity.rs
···33//! This module provides a narrow interface over HTTP and DNS resolution,
44//! allowing callers to swap real network I/O with recorded fixtures in tests.
5566+// pattern: Mixed (unavoidable)
77+//
88+// Pure: `parse_multikey`, `encode_multikey`, the `AnyVerifyingKey` /
99+// `AnySigningKey` / `AnySignature` newtypes and their crypto methods,
1010+// `is_local_labeler_hostname`, all `IdentityError` construction.
1111+// Imperative shell: `RealHttpClient`, `RealDnsResolver`, `resolve_handle`,
1212+// `resolve_did`, `plc_history_for_fragment`. Splitting would force every
1313+// downstream stage to import from two modules and would not improve
1414+// testability — the I/O surface is already behind narrow trait seams
1515+// (`HttpClient`, `DnsResolver`).
1616+617use async_trait::async_trait;
718use k256::ecdsa::signature::hazmat::PrehashVerifier;
819use serde::{Deserialize, Serialize};
···17371748 );
17381749 }
17391750 _ => panic!("Expected P256 keys"),
17511751+ }
17521752+ }
17531753+17541754+ // Property-based tests pinning the roundtrip and length invariants
17551755+ // documented in `src/common/CLAUDE.md`:
17561756+ // - `encode_multikey(parse_multikey(s).verifying_key) == s` for
17571757+ // every well-formed atproto multikey,
17581758+ // - `AnySignature::to_jws_bytes()` is always exactly 64 bytes for
17591759+ // both curves.
17601760+ // Keys are generated deterministically from the proptest-supplied
17611761+ // 32-byte seed via `ChaCha20Rng`, so each shrunk failure is
17621762+ // reproducible from the seed alone.
17631763+ mod pbt {
17641764+ use super::*;
17651765+ use proptest::prelude::*;
17661766+ use rand_chacha::ChaCha20Rng;
17671767+ use rand_core::SeedableRng;
17681768+17691769+ proptest! {
17701770+ #![proptest_config(ProptestConfig::with_cases(64))]
17711771+17721772+ #[test]
17731773+ fn multikey_roundtrip_k256(seed in any::<[u8; 32]>()) {
17741774+ let mut rng = ChaCha20Rng::from_seed(seed);
17751775+ let signing = k256::ecdsa::SigningKey::random(&mut rng);
17761776+ let original = AnyVerifyingKey::K256(*signing.verifying_key());
17771777+17781778+ let encoded = encode_multikey(&original);
17791779+ prop_assert!(encoded.starts_with('z'));
17801780+17811781+ let parsed = parse_multikey(&encoded)
17821782+ .expect("a freshly encoded multikey must parse");
17831783+ let re_encoded = encode_multikey(&parsed.verifying_key);
17841784+ prop_assert_eq!(
17851785+ encoded,
17861786+ re_encoded,
17871787+ "encode_multikey must round-trip through parse_multikey"
17881788+ );
17891789+ }
17901790+17911791+ #[test]
17921792+ fn multikey_roundtrip_p256(seed in any::<[u8; 32]>()) {
17931793+ let mut rng = ChaCha20Rng::from_seed(seed);
17941794+ let signing = p256::ecdsa::SigningKey::random(&mut rng);
17951795+ let original = AnyVerifyingKey::P256(*signing.verifying_key());
17961796+17971797+ let encoded = encode_multikey(&original);
17981798+ prop_assert!(encoded.starts_with('z'));
17991799+18001800+ let parsed = parse_multikey(&encoded)
18011801+ .expect("a freshly encoded multikey must parse");
18021802+ let re_encoded = encode_multikey(&parsed.verifying_key);
18031803+ prop_assert_eq!(
18041804+ encoded,
18051805+ re_encoded,
18061806+ "encode_multikey must round-trip through parse_multikey"
18071807+ );
18081808+ }
18091809+18101810+ #[test]
18111811+ fn signature_jws_bytes_is_always_64_k256(
18121812+ seed in any::<[u8; 32]>(),
18131813+ msg_seed in any::<[u8; 32]>(),
18141814+ ) {
18151815+ let mut rng = ChaCha20Rng::from_seed(seed);
18161816+ let signing = AnySigningKey::K256(k256::ecdsa::SigningKey::random(&mut rng));
18171817+ let signature = signing.sign_prehash(&msg_seed);
18181818+ prop_assert_eq!(signature.to_jws_bytes().len(), 64);
18191819+ }
18201820+18211821+ #[test]
18221822+ fn signature_jws_bytes_is_always_64_p256(
18231823+ seed in any::<[u8; 32]>(),
18241824+ msg_seed in any::<[u8; 32]>(),
18251825+ ) {
18261826+ let mut rng = ChaCha20Rng::from_seed(seed);
18271827+ let signing = AnySigningKey::P256(p256::ecdsa::SigningKey::random(&mut rng));
18281828+ let signature = signing.sign_prehash(&msg_seed);
18291829+ prop_assert_eq!(signature.to_jws_bytes().len(), 64);
18301830+ }
18311831+18321832+ #[test]
18331833+ fn sign_verify_prehash_roundtrip_k256(
18341834+ seed in any::<[u8; 32]>(),
18351835+ msg in any::<[u8; 32]>(),
18361836+ ) {
18371837+ let mut rng = ChaCha20Rng::from_seed(seed);
18381838+ let signing = AnySigningKey::K256(k256::ecdsa::SigningKey::random(&mut rng));
18391839+ let signature = signing.sign_prehash(&msg);
18401840+ let verifying = signing.verifying_key();
18411841+ verifying.verify_prehash(&msg, &signature)
18421842+ .expect("verify_prehash must accept a freshly produced signature");
18431843+ }
18441844+18451845+ #[test]
18461846+ fn sign_verify_prehash_roundtrip_p256(
18471847+ seed in any::<[u8; 32]>(),
18481848+ msg in any::<[u8; 32]>(),
18491849+ ) {
18501850+ let mut rng = ChaCha20Rng::from_seed(seed);
18511851+ let signing = AnySigningKey::P256(p256::ecdsa::SigningKey::random(&mut rng));
18521852+ let signature = signing.sign_prehash(&msg);
18531853+ let verifying = signing.verifying_key();
18541854+ verifying.verify_prehash(&msg, &signature)
18551855+ .expect("verify_prehash must accept a freshly produced signature");
18561856+ }
17401857 }
17411858 }
17421859}
+91
src/common/jwt.rs
···77//! Only ES256 and ES256K are supported (RFC 7518 §3.4); raw r||s signature
88//! encoding, unpadded base64url segments, UTF-8 JSON payloads.
991010+// pattern: Functional Core
1111+1012use base64::Engine;
1113use base64::engine::general_purpose::URL_SAFE_NO_PAD;
1214use serde::{Deserialize, Serialize};
···458460459461 let result = verify_compact(&tampered, &vkey);
460462 assert!(matches!(result, Err(JwtError::InvalidSignatureScalar)));
463463+ }
464464+465465+ // Property-based roundtrip tests pinning the invariant that
466466+ // `verify_compact(encode_compact(claims, key), key.verifying_key())`
467467+ // recovers the same claim payload, for every well-formed claim set
468468+ // and every signing key generated from a 32-byte seed.
469469+ mod pbt {
470470+ use super::*;
471471+ use proptest::prelude::*;
472472+ use rand_chacha::ChaCha20Rng;
473473+ use rand_core::SeedableRng;
474474+475475+ // 16 hex chars matches the format atproto labelers expect for
476476+ // `jti` and is what `RelyingParty::new_jti` produces.
477477+ const JTI_REGEX: &str = "[0-9a-f]{16}";
478478+479479+ proptest! {
480480+ #![proptest_config(ProptestConfig::with_cases(32))]
481481+482482+ #[test]
483483+ fn encode_verify_compact_roundtrip_k256(
484484+ seed in any::<[u8; 32]>(),
485485+ jti in JTI_REGEX,
486486+ iat in 1_500_000_000i64..2_500_000_000i64,
487487+ exp_offset in 1i64..86_400i64,
488488+ ) {
489489+ let mut rng = ChaCha20Rng::from_seed(seed);
490490+ let signing = AnySigningKey::K256(k256::ecdsa::SigningKey::random(&mut rng));
491491+ let vkey = signing.verifying_key();
492492+ let header = JwtHeader::for_signing_key(&signing);
493493+ let claims = JwtClaims {
494494+ iss: "did:web:test".to_string(),
495495+ aud: "did:plc:test".to_string(),
496496+ exp: iat + exp_offset,
497497+ iat,
498498+ lxm: "com.atproto.moderation.createReport".to_string(),
499499+ jti,
500500+ };
501501+502502+ let token = encode_compact(&header, &claims, &signing)
503503+ .expect("encode_compact must succeed");
504504+ let (decoded_header, decoded_claims) = verify_compact(&token, &vkey)
505505+ .expect("verify_compact must accept a freshly produced token");
506506+507507+ prop_assert_eq!(decoded_header.alg, "ES256K");
508508+ prop_assert_eq!(decoded_header.typ, header.typ);
509509+ prop_assert_eq!(decoded_claims.iss, claims.iss);
510510+ prop_assert_eq!(decoded_claims.aud, claims.aud);
511511+ prop_assert_eq!(decoded_claims.exp, claims.exp);
512512+ prop_assert_eq!(decoded_claims.iat, claims.iat);
513513+ prop_assert_eq!(decoded_claims.lxm, claims.lxm);
514514+ prop_assert_eq!(decoded_claims.jti, claims.jti);
515515+ }
516516+517517+ #[test]
518518+ fn encode_verify_compact_roundtrip_p256(
519519+ seed in any::<[u8; 32]>(),
520520+ jti in JTI_REGEX,
521521+ iat in 1_500_000_000i64..2_500_000_000i64,
522522+ exp_offset in 1i64..86_400i64,
523523+ ) {
524524+ let mut rng = ChaCha20Rng::from_seed(seed);
525525+ let signing = AnySigningKey::P256(p256::ecdsa::SigningKey::random(&mut rng));
526526+ let vkey = signing.verifying_key();
527527+ let header = JwtHeader::for_signing_key(&signing);
528528+ let claims = JwtClaims {
529529+ iss: "did:web:example.com".to_string(),
530530+ aud: "did:plc:test".to_string(),
531531+ exp: iat + exp_offset,
532532+ iat,
533533+ lxm: "com.atproto.moderation.createReport".to_string(),
534534+ jti,
535535+ };
536536+537537+ let token = encode_compact(&header, &claims, &signing)
538538+ .expect("encode_compact must succeed");
539539+ let (decoded_header, decoded_claims) = verify_compact(&token, &vkey)
540540+ .expect("verify_compact must accept a freshly produced token");
541541+542542+ prop_assert_eq!(decoded_header.alg, "ES256");
543543+ prop_assert_eq!(decoded_header.typ, header.typ);
544544+ prop_assert_eq!(decoded_claims.iss, claims.iss);
545545+ prop_assert_eq!(decoded_claims.aud, claims.aud);
546546+ prop_assert_eq!(decoded_claims.exp, claims.exp);
547547+ prop_assert_eq!(decoded_claims.iat, claims.iat);
548548+ prop_assert_eq!(decoded_claims.lxm, claims.lxm);
549549+ prop_assert_eq!(decoded_claims.jti, claims.jti);
550550+ }
551551+ }
461552 }
462553}
+6
src/common/oauth/clock.rs
···11+// pattern: Imperative Shell
22+//
33+// `RealClock::now_unix_seconds` reads `SystemTime::now()`, the canonical
44+// non-deterministic side effect. The trait itself is the seam tests use
55+// to inject `FakeClock`.
66+17use std::time::{Duration, SystemTime, UNIX_EPOCH};
2839/// A clock source. `RealClock` uses `SystemTime::now()`; tests inject
+73
src/common/oauth/jws.rs
···44//! test-friendly JWS signing and JWKS parsing. Diagnostic codes in the
55//! `oauth_client::jws::*` namespace enable stable error reporting downstream.
6677+// pattern: Functional Core
88+79use std::sync::Arc;
810911use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey};
···748750 if let Err(err) = result {
749751 let code_str = err.code().map(|c| c.to_string());
750752 assert_eq!(code_str, Some("oauth_client::jws::not_json".to_string()));
753753+ }
754754+ }
755755+756756+ // Property-based test for the ES256 sign/verify roundtrip:
757757+ // `verify_jws(sign_es256_jws(claims, key), parsed_jwk(key)) == claims`
758758+ // for every signing key generated from a 32-byte seed and arbitrary
759759+ // string claim payload.
760760+ mod pbt {
761761+ use super::*;
762762+ use p256::ecdsa::SigningKey;
763763+ use p256::pkcs8::EncodePrivateKey;
764764+ use proptest::prelude::*;
765765+ use rand_chacha::ChaCha20Rng;
766766+ use rand_core::SeedableRng;
767767+768768+ /// Construct the JWK + encoding key pair backing a P-256 signing
769769+ /// key, in the same shape `oauth/jws::parse_jwk` accepts.
770770+ fn build_es256_material(seed: [u8; 32]) -> (ParsedJwk, EncodingKey) {
771771+ let mut rng = ChaCha20Rng::from_seed(seed);
772772+ let signing_key = SigningKey::random(&mut rng);
773773+774774+ let public_key = signing_key.verifying_key();
775775+ let sec1 = public_key.to_sec1_bytes();
776776+ let x_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&sec1[1..33]);
777777+ let y_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&sec1[33..65]);
778778+779779+ let jwk_json = serde_json::json!({
780780+ "kty": "EC",
781781+ "crv": "P-256",
782782+ "x": x_b64,
783783+ "y": y_b64,
784784+ "kid": "k1",
785785+ "alg": "ES256",
786786+ "use": "sig",
787787+ });
788788+ let source = Arc::<[u8]>::from(b"test".as_ref());
789789+ let parsed = parse_jwk(&jwk_json, "test", source).expect("freshly built JWK parses");
790790+791791+ let der = signing_key
792792+ .to_pkcs8_der()
793793+ .expect("p256 keys must export to PKCS8");
794794+ let encoding_key = EncodingKey::from_ec_der(der.as_bytes());
795795+796796+ (parsed, encoding_key)
797797+ }
798798+799799+ proptest! {
800800+ // Each iteration builds a key + does a sign/verify pair; cap
801801+ // at 16 to keep the suite fast.
802802+ #![proptest_config(ProptestConfig::with_cases(16))]
803803+804804+ #[test]
805805+ fn es256_sign_verify_roundtrip(
806806+ seed in any::<[u8; 32]>(),
807807+ foo in any::<String>(),
808808+ count in 0u32..1000u32,
809809+ ) {
810810+ let (jwk, encoding_key) = build_es256_material(seed);
811811+812812+ let claims = serde_json::json!({"foo": foo, "count": count});
813813+ let mut header = jsonwebtoken::Header::new(Algorithm::ES256);
814814+ header.kid = Some("k1".to_string());
815815+816816+ let token = sign_es256_jws(&header, &claims, &encoding_key)
817817+ .expect("sign_es256_jws must succeed");
818818+819819+ let decoded: jsonwebtoken::TokenData<serde_json::Value> =
820820+ verify_jws(&token, &jwk, JwsAlg::Es256)
821821+ .expect("verify_jws must accept a freshly produced token");
822822+ prop_assert_eq!(decoded.claims, claims);
823823+ }
751824 }
752825 }
753826}
+218-62
src/common/oauth/relying_party.rs
···44//! (Pushed Authorization Request), PKCE S256, DPoP proof, and private_key_jwt
55//! flows. The RelyingParty is constructed with a deterministic seeded RNG to
66//! enable reproducible testing.
77+//!
88+//! # HTTP-client seam exception
99+//!
1010+//! Unlike every other network-touching module in the crate (which routes
1111+//! through the `HttpClient` trait from `common::identity` so tests can
1212+//! intercept traffic with `FakeHttpClient`), the RelyingParty uses
1313+//! `reqwest::Client` directly. This is deliberate:
1414+//!
1515+//! 1. The RP only ever talks to an authorization server over genuine HTTP.
1616+//! The interactive tests stand up a real axum fake-AS on `127.0.0.1`,
1717+//! so determinism comes from a fixed `Clock` + seeded `ChaCha20Rng`,
1818+//! not from intercepting bytes on the wire.
1919+//! 2. The `do_authorize` flow needs a redirect-following policy distinct
2020+//! from the rest of the RP's calls, which is awkward to express
2121+//! through a two-method `HttpClient` trait without leaking
2222+//! reqwest-specific concepts back through the seam.
2323+//!
2424+//! The RP owns two `reqwest::Client` instances built once at construction
2525+//! time: `http` (default redirect policy) for PAR / token / refresh /
2626+//! discover_as, and `http_no_redirect` for `do_authorize_inner`, which
2727+//! manually inspects the first 3xx response.
2828+2929+// pattern: Mixed (unavoidable)
3030+//
3131+// Pure: `parse_as_descriptor`, `verify_redirect_iss`, `extract_auth_response`,
3232+// `new_pkce`, `new_jti`, `sign_dpop`, `sign_private_key_jwt`. Imperative
3333+// shell: every `do_*` flow method (PAR / authorize / token / refresh)
3434+// drives reqwest. The mix is deliberate — see the module-level docs above
3535+// for why the RP holds its own reqwest clients rather than going through
3636+// the project's `HttpClient` seam.
737838use std::collections::HashMap;
939use std::sync::{Arc, Mutex};
···147177 signing_jwk_public: Value,
148178 clock: Arc<dyn Clock>,
149179 rng: Mutex<ChaCha20Rng>,
180180+ /// Default-redirect-policy client used for PAR, token, refresh,
181181+ /// and `discover_as`.
150182 http: ReqwestClient,
183183+ /// Redirect-disabled client used by `do_authorize_inner`, which
184184+ /// inspects the first 3xx response from the authorization endpoint
185185+ /// and resolves the `redirect_uri` itself.
186186+ http_no_redirect: ReqwestClient,
151187 /// DPoP nonces keyed by endpoint URL, for use_dpop_nonce retry.
152188 dpop_nonces: Mutex<HashMap<Url, String>>,
153189}
···246282 "y": y,
247283 });
248284249249- // Build HTTP client with rustls, user-agent, and timeout.
285285+ // Build HTTP clients with rustls, user-agent, and timeout. The
286286+ // no-redirect variant is used by `do_authorize_inner` to inspect
287287+ // the first 3xx response from the authorization endpoint
288288+ // directly. Both clients share the same TLS pool only within
289289+ // their own builder; reqwest does not let us share a connection
290290+ // pool across two redirect policies, so we accept two pools per
291291+ // RP.
250292 let http = ReqwestClient::builder()
251293 .use_rustls_tls()
252294 .user_agent(APP_USER_AGENT)
253295 .timeout(Duration::from_secs(30))
254296 .build()
255297 .unwrap_or_else(|_| ReqwestClient::new());
298298+ let http_no_redirect = ReqwestClient::builder()
299299+ .use_rustls_tls()
300300+ .user_agent(APP_USER_AGENT)
301301+ .timeout(Duration::from_secs(30))
302302+ .redirect(reqwest::redirect::Policy::none())
303303+ .build()
304304+ .unwrap_or_else(|_| ReqwestClient::new());
256305257306 Self {
258307 client_id,
···262311 clock,
263312 rng: Mutex::new(rng),
264313 http,
314314+ http_no_redirect,
265315 dpop_nonces: Mutex::new(HashMap::new()),
266316 }
267317 }
···278328 });
279329 }
280330281281- let metadata: serde_json::Value = response.json().await?;
282282-283283- let issuer = metadata
284284- .get("issuer")
285285- .and_then(|v| v.as_str())
286286- .and_then(|s| Url::parse(s).ok())
287287- .ok_or_else(|| RpError::MetadataMalformed {
288288- reason: "missing or invalid issuer".to_string(),
289289- })?;
290290-291291- let pushed_authorization_request_endpoint = metadata
292292- .get("pushed_authorization_request_endpoint")
293293- .and_then(|v| v.as_str())
294294- .and_then(|s| Url::parse(s).ok())
295295- .ok_or_else(|| RpError::MetadataMalformed {
296296- reason: "missing or invalid pushed_authorization_request_endpoint".to_string(),
297297- })?;
298298-299299- let authorization_endpoint = metadata
300300- .get("authorization_endpoint")
301301- .and_then(|v| v.as_str())
302302- .and_then(|s| Url::parse(s).ok())
303303- .ok_or_else(|| RpError::MetadataMalformed {
304304- reason: "missing or invalid authorization_endpoint".to_string(),
305305- })?;
306306-307307- let token_endpoint = metadata
308308- .get("token_endpoint")
309309- .and_then(|v| v.as_str())
310310- .and_then(|s| Url::parse(s).ok())
311311- .ok_or_else(|| RpError::MetadataMalformed {
312312- reason: "missing or invalid token_endpoint".to_string(),
313313- })?;
314314-315315- let require_pushed_authorization_requests = metadata
316316- .get("require_pushed_authorization_requests")
317317- .and_then(|v| v.as_bool())
318318- .unwrap_or(false);
319319-320320- if !require_pushed_authorization_requests {
321321- return Err(RpError::MetadataMalformed {
322322- reason: "require_pushed_authorization_requests is not true".to_string(),
323323- });
324324- }
325325-326326- Ok(AsDescriptor {
327327- issuer,
328328- pushed_authorization_request_endpoint,
329329- authorization_endpoint,
330330- token_endpoint,
331331- })
331331+ let metadata: Value = response.json().await?;
332332+ parse_as_descriptor(&metadata)
332333 }
333334334335 /// Perform Pushed Authorization Request (PAR).
···478479 .append_pair("request_uri", request_uri)
479480 .append_pair("client_id", self.client_id.as_str());
480481481481- // Build a client with redirect policy disabled to manually follow redirects.
482482- let client = ReqwestClient::builder()
483483- .use_rustls_tls()
484484- .user_agent(APP_USER_AGENT)
485485- .timeout(Duration::from_secs(30))
486486- .redirect(reqwest::redirect::Policy::none())
487487- .build()
488488- .unwrap_or_else(|_| ReqwestClient::new());
489489-490490- let response = client.get(url).send().await?;
482482+ // Reuse the redirect-disabled client built once in
483483+ // `RelyingParty::new` so successive `do_authorize` calls share
484484+ // its connection pool.
485485+ let response = self.http_no_redirect.get(url).send().await?;
491486492487 // Follow redirects manually: stop on the first redirect whose
493488 // Location header matches the redirect_uri scheme/origin.
···965960 }
966961}
967962963963+/// Pure: parse an authorization-server metadata document into an
964964+/// `AsDescriptor`. Returns `RpError::MetadataMalformed` if any required
965965+/// field (`issuer`, `pushed_authorization_request_endpoint`,
966966+/// `authorization_endpoint`, `token_endpoint`) is missing or unparseable,
967967+/// or if `require_pushed_authorization_requests` is not advertised as
968968+/// `true` (atproto requires PAR).
969969+fn parse_as_descriptor(metadata: &Value) -> Result<AsDescriptor, RpError> {
970970+ let issuer = metadata
971971+ .get("issuer")
972972+ .and_then(|v| v.as_str())
973973+ .and_then(|s| Url::parse(s).ok())
974974+ .ok_or_else(|| RpError::MetadataMalformed {
975975+ reason: "missing or invalid issuer".to_string(),
976976+ })?;
977977+978978+ let pushed_authorization_request_endpoint = metadata
979979+ .get("pushed_authorization_request_endpoint")
980980+ .and_then(|v| v.as_str())
981981+ .and_then(|s| Url::parse(s).ok())
982982+ .ok_or_else(|| RpError::MetadataMalformed {
983983+ reason: "missing or invalid pushed_authorization_request_endpoint".to_string(),
984984+ })?;
985985+986986+ let authorization_endpoint = metadata
987987+ .get("authorization_endpoint")
988988+ .and_then(|v| v.as_str())
989989+ .and_then(|s| Url::parse(s).ok())
990990+ .ok_or_else(|| RpError::MetadataMalformed {
991991+ reason: "missing or invalid authorization_endpoint".to_string(),
992992+ })?;
993993+994994+ let token_endpoint = metadata
995995+ .get("token_endpoint")
996996+ .and_then(|v| v.as_str())
997997+ .and_then(|s| Url::parse(s).ok())
998998+ .ok_or_else(|| RpError::MetadataMalformed {
999999+ reason: "missing or invalid token_endpoint".to_string(),
10001000+ })?;
10011001+10021002+ let require_pushed_authorization_requests = metadata
10031003+ .get("require_pushed_authorization_requests")
10041004+ .and_then(|v| v.as_bool())
10051005+ .unwrap_or(false);
10061006+10071007+ if !require_pushed_authorization_requests {
10081008+ return Err(RpError::MetadataMalformed {
10091009+ reason: "require_pushed_authorization_requests is not true".to_string(),
10101010+ });
10111011+ }
10121012+10131013+ Ok(AsDescriptor {
10141014+ issuer,
10151015+ pushed_authorization_request_endpoint,
10161016+ authorization_endpoint,
10171017+ token_endpoint,
10181018+ })
10191019+}
10201020+9681021/// Verify that the `iss` query parameter on an authorization
9691022/// redirect matches the expected AS issuer. The comparison strips a
9701023/// single trailing slash from both sides because atproto's
···11091162 assert_eq!(payload.get("htm").and_then(|v| v.as_str()), Some("POST"));
11101163 assert!(payload.get("jti").is_some(), "JTI should be present");
11111164 assert!(payload.get("iat").is_some(), "iat should be present");
11651165+ }
11661166+11671167+ fn good_metadata() -> serde_json::Value {
11681168+ json!({
11691169+ "issuer": "https://auth.example.com",
11701170+ "pushed_authorization_request_endpoint": "https://auth.example.com/oauth/par",
11711171+ "authorization_endpoint": "https://auth.example.com/oauth/authorize",
11721172+ "token_endpoint": "https://auth.example.com/oauth/token",
11731173+ "require_pushed_authorization_requests": true,
11741174+ })
11751175+ }
11761176+11771177+ #[test]
11781178+ fn parse_as_descriptor_accepts_well_formed_metadata() {
11791179+ let metadata = good_metadata();
11801180+ let descriptor = parse_as_descriptor(&metadata).expect("well-formed metadata parses");
11811181+ assert_eq!(descriptor.issuer.as_str(), "https://auth.example.com/");
11821182+ assert_eq!(
11831183+ descriptor.token_endpoint.as_str(),
11841184+ "https://auth.example.com/oauth/token"
11851185+ );
11861186+ }
11871187+11881188+ #[test]
11891189+ fn parse_as_descriptor_requires_par_advertisement() {
11901190+ // atproto requires `require_pushed_authorization_requests = true`;
11911191+ // any other value (including missing) must error.
11921192+ let mut metadata = good_metadata();
11931193+ metadata
11941194+ .as_object_mut()
11951195+ .unwrap()
11961196+ .remove("require_pushed_authorization_requests");
11971197+ let err = parse_as_descriptor(&metadata).expect_err("missing PAR flag must error");
11981198+ assert!(matches!(err, RpError::MetadataMalformed { .. }));
11991199+12001200+ let metadata = json!({
12011201+ "issuer": "https://auth.example.com",
12021202+ "pushed_authorization_request_endpoint": "https://auth.example.com/oauth/par",
12031203+ "authorization_endpoint": "https://auth.example.com/oauth/authorize",
12041204+ "token_endpoint": "https://auth.example.com/oauth/token",
12051205+ "require_pushed_authorization_requests": false,
12061206+ });
12071207+ let err = parse_as_descriptor(&metadata).expect_err("PAR=false must error");
12081208+ assert!(matches!(err, RpError::MetadataMalformed { .. }));
12091209+ }
12101210+12111211+ #[test]
12121212+ fn parse_as_descriptor_rejects_each_missing_required_field() {
12131213+ for field in [
12141214+ "issuer",
12151215+ "pushed_authorization_request_endpoint",
12161216+ "authorization_endpoint",
12171217+ "token_endpoint",
12181218+ ] {
12191219+ let mut metadata = good_metadata();
12201220+ metadata.as_object_mut().unwrap().remove(field);
12211221+ let err = parse_as_descriptor(&metadata)
12221222+ .unwrap_err_with(|_| format!("missing {field} must error"));
12231223+ match err {
12241224+ RpError::MetadataMalformed { reason } => {
12251225+ assert!(
12261226+ reason.contains(field),
12271227+ "error message should mention {field}, got {reason:?}"
12281228+ );
12291229+ }
12301230+ other => panic!("expected MetadataMalformed, got {other:?}"),
12311231+ }
12321232+ }
12331233+ }
12341234+12351235+ #[test]
12361236+ fn parse_as_descriptor_rejects_non_url_endpoint() {
12371237+ let mut metadata = good_metadata();
12381238+ metadata.as_object_mut().unwrap().insert(
12391239+ "token_endpoint".to_string(),
12401240+ serde_json::Value::String("not a url".to_string()),
12411241+ );
12421242+ let err = parse_as_descriptor(&metadata).expect_err("non-URL endpoint must error");
12431243+ match err {
12441244+ RpError::MetadataMalformed { reason } => {
12451245+ assert!(
12461246+ reason.contains("token_endpoint"),
12471247+ "error message should mention token_endpoint, got {reason:?}"
12481248+ );
12491249+ }
12501250+ other => panic!("expected MetadataMalformed, got {other:?}"),
12511251+ }
12521252+ }
12531253+12541254+ /// Variant of `Result::expect_err` whose panic message is computed
12551255+ /// from the surprising `Ok` value, so loop iterations can attribute
12561256+ /// the failure back to the field under test.
12571257+ trait UnwrapErrWith<T, E> {
12581258+ fn unwrap_err_with<F: FnOnce(&T) -> String>(self, msg: F) -> E;
12591259+ }
12601260+12611261+ impl<T: std::fmt::Debug, E> UnwrapErrWith<T, E> for Result<T, E> {
12621262+ fn unwrap_err_with<F: FnOnce(&T) -> String>(self, msg: F) -> E {
12631263+ match self {
12641264+ Ok(v) => panic!("{}: got Ok({v:?})", msg(&v)),
12651265+ Err(e) => e,
12661266+ }
12671267+ }
11121268 }
11131269}
+2
src/common/report.rs
···11//! Report aggregation and rendering for the labeler conformance suite.
2233+// pattern: Functional Core
44+35use std::borrow::Cow;
46use std::fmt;
57use std::io;