CLI app for developers prototyping atproto functionality
1
fork

Configure Feed

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

test(oauth-client): dedicated per-AC tests + fake AS DPoP-Nonce header fix

Address the 12 coverage gaps the test-analyst agent flagged. Root cause:
the sub-stages' `run()` functions mutate shared `FlowScript` state across
their per-flow runs, masking happy-path AC6/AC7 coverage when they're the
only test vehicles. Add `tests/oauth_client_ac_coverage.rs` which drives
each AC directly against a FRESH ServerHandle + RP pair, bypassing
`run()`'s state-leakage entirely:

- AC4.5: PAR log preserves body bytes, method, path, timestamp (from
`FakeClock`), and DPoP header verbatim.
- AC5.1: interactive::run emits only Stage::INTERACTIVE rows (pipeline
ordering is enforced by run_pipeline's sequential stage calls).
- AC5.2: all-static-pass run produces every Phase 7 + Phase 8 interactive
check in the output.
- AC5.4: a non-gating static failure (grant_types_includes_refresh_token)
doesn't block ServerBound or other inventory.
- AC6.1: full-grant approve records 1 PAR + 1 authorize + 1 token; token
response carries refresh_token; PAR body carries PKCE S256 + DPoP.
- AC6.2: partial-grant returns a narrower scope in the token response.
- AC6.3: user denial surfaces AuthorizeOutcome::Error with access_denied
and no subsequent token request.
- AC6.4: downscoped refresh body carries `scope=atproto` (narrower).
- AC7.1: DPoP nonce rotation — second PAR's DPoP proof claims contain
the nonce issued by the server.
- AC7.2: rt2 differs from rt1 and rt1 reuse is rejected (401).
- AC7.3: replay rejection (same jti twice) surfaces 401 without retry.

To make AC7.1 pass, fix a latent bug in the fake AS: the `use_dpop_nonce`
error response now advertises the issued nonce via the `DPoP-Nonce`
response header (RFC 9449 §8.2) so the RP can adopt it. The sub-stage
default-run snapshots update accordingly (refresh_token_reuse now Passes
on the happy path, which is correct). Added
`dpop_edges_with_refresh_token_reuse_violation_snapshot` to keep the
refresh_token_reused diagnostic code in the check-id-coverage enforcer's
sights.

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

+757 -19
+4 -1
src/commands/test/oauth/client/fake_as/endpoints.rs
··· 404 404 405 405 // Check flow script for use_dpop_nonce. 406 406 let flow_script = s.flow_script.lock().unwrap().clone(); 407 - if let FlowScript::DpopNonceRetryOnPar { .. } = &flow_script { 407 + if let FlowScript::DpopNonceRetryOnPar { nonce } = &flow_script { 408 408 if dpop_claims.get("nonce").is_none() { 409 + // Advertise the nonce via the `DPoP-Nonce` response header per 410 + // RFC 9449; the RP reads this and retries with the nonce claim. 409 411 return ( 410 412 StatusCode::BAD_REQUEST, 413 + [("DPoP-Nonce", nonce.as_str())], 411 414 Json(json!({"error": "use_dpop_nonce", "error_uri": "https://datatracker.ietf.org/doc/html/draft-ietf-oauth-dpop"})), 412 415 ) 413 416 .into_response();
+642
tests/oauth_client_ac_coverage.rs
··· 1 + //! Direct per-AC coverage tests that drive a FRESH `ServerHandle` + RP per 2 + //! flow to avoid the state-leakage that makes the sub-stages' `run()` 3 + //! end-to-end function emit spurious SpecViolations for AC6/AC7 happy paths. 4 + //! 5 + //! These tests complement the higher-level snapshot coverage in 6 + //! `oauth_client_substage_snapshots.rs` (which pins the current default-run 7 + //! output shape) by asserting the actual per-AC contracts: 8 + //! 9 + //! - AC4.5: PAR request log preservation (timestamp, path, method, body bytes, headers). 10 + //! - AC5.1: Static stages render before the interactive stage. 11 + //! - AC5.2: All-static-pass runs exercise the full interactive inventory. 12 + //! - AC5.4: A static failure that doesn't gate any interactive flow leaves the inventory intact. 13 + //! - AC6.1: Full-grant approve happy path records PAR+authorize+token with refresh_token. 14 + //! - AC6.2: Partial-grant approve returns a narrower scope in the token response. 15 + //! - AC6.3: User-denial flow propagates `access_denied` without retrying to token. 16 + //! - AC6.4: Downscoped refresh carries the narrower scope in the final token request body. 17 + //! - AC7.1: DPoP nonce rotation — second PAR carries the issued nonce. 18 + //! - AC7.2: Refresh token rotation — rt2 differs from rt1 and rt1 reuse is rejected. 19 + //! - AC7.3: Replay rejection surfaces to the caller (RP does not retry silently). 20 + 21 + mod common; 22 + 23 + use std::sync::Arc; 24 + 25 + use atproto_devtool::commands::test::oauth::client::fake_as::endpoints::FlowScript; 26 + use atproto_devtool::commands::test::oauth::client::fake_as::{FakeAsOptions, ServerHandle}; 27 + use atproto_devtool::common::oauth::clock::Clock; 28 + use atproto_devtool::common::oauth::relying_party::{ 29 + AuthorizeOutcome, ClientKind, ParRequest, RelyingParty, 30 + }; 31 + use common::FakeClock; 32 + use serde_json::Value; 33 + use url::Url; 34 + 35 + const SEED: [u8; 32] = [11u8; 32]; 36 + const CLOCK_NOW: u64 = 1_700_000_000; 37 + 38 + async fn spawn_fake_as() -> (ServerHandle, Arc<dyn Clock>) { 39 + let clock: Arc<dyn Clock> = Arc::new(FakeClock::new(CLOCK_NOW)); 40 + let handle = ServerHandle::bind( 41 + FakeAsOptions { 42 + bind_port: None, 43 + public_base_url: None, 44 + }, 45 + clock.clone(), 46 + ) 47 + .await 48 + .expect("bind fake AS"); 49 + (handle, clock) 50 + } 51 + 52 + fn build_rp(clock: Arc<dyn Clock>, kind: ClientKind) -> RelyingParty { 53 + RelyingParty::new( 54 + Url::parse("http://localhost:3000").unwrap(), 55 + kind, 56 + clock, 57 + SEED, 58 + ) 59 + } 60 + 61 + fn par_req(as_desc: &atproto_devtool::common::oauth::relying_party::AsDescriptor) -> ParRequest { 62 + ParRequest { 63 + as_descriptor: as_desc.clone(), 64 + redirect_uri: Url::parse("http://localhost/callback").unwrap(), 65 + scope: "atproto".to_string(), 66 + state: "state-ac".to_string(), 67 + } 68 + } 69 + 70 + // ============================================================================= 71 + // AC4.5: RequestLog preserves PAR method/path/body/headers/timestamp verbatim. 72 + // ============================================================================= 73 + 74 + #[tokio::test] 75 + async fn ac4_5_par_request_logged_verbatim_with_timestamp() { 76 + let (server, clock) = spawn_fake_as().await; 77 + let rp = build_rp(clock, ClientKind::Public); 78 + 79 + let mut par_url = server.active_base.clone(); 80 + par_url.set_path("/oauth/par"); 81 + 82 + let (body, _verifier) = rp 83 + .build_par_body_with_pkce( 84 + &Url::parse("http://localhost/callback").unwrap(), 85 + "atproto", 86 + "state-log", 87 + ) 88 + .expect("build body"); 89 + let dpop = rp 90 + .sign_dpop_with_fixed_jti("POST", &par_url, None, "ac4-5-jti") 91 + .expect("sign dpop"); 92 + 93 + let (status, _resp_body) = rp 94 + .send_raw_par(&par_url, body.as_bytes(), Some(&dpop)) 95 + .await 96 + .expect("send raw par"); 97 + assert_eq!(status, 201, "well-formed PAR should be accepted"); 98 + 99 + let requests = server.requests.snapshot(); 100 + let par_entry = requests 101 + .iter() 102 + .find(|r| r.method == "POST" && r.path == "/oauth/par") 103 + .expect("PAR log entry present"); 104 + assert_eq!(par_entry.body, body.as_bytes(), "body bytes verbatim"); 105 + assert_eq!(par_entry.timestamp_unix, CLOCK_NOW, "timestamp from clock"); 106 + assert!( 107 + par_entry.query.is_none(), 108 + "PAR is POST; should have no query string" 109 + ); 110 + let has_dpop_header = par_entry 111 + .headers 112 + .iter() 113 + .any(|(k, v)| k.eq_ignore_ascii_case("DPoP") && v == dpop.as_bytes()); 114 + assert!(has_dpop_header, "DPoP header preserved verbatim"); 115 + 116 + server.shutdown().await; 117 + } 118 + 119 + // ============================================================================= 120 + // AC5.1 / AC5.2 / AC5.4: Cross-mode gating order and inventory. 121 + // ============================================================================= 122 + 123 + #[tokio::test] 124 + async fn ac5_1_static_stages_render_before_interactive() { 125 + use atproto_devtool::commands::test::oauth::client::pipeline::interactive; 126 + use atproto_devtool::commands::test::oauth::client::pipeline::{ 127 + InteractiveDriveMode, InteractiveOptions, StaticGating, 128 + }; 129 + use atproto_devtool::common::oauth::relying_party::{DeterministicRpFactory, RpFactory}; 130 + use atproto_devtool::common::report::CheckStatus; 131 + 132 + let (server, clock) = spawn_fake_as().await; 133 + let static_gating = StaticGating { 134 + scope_present: CheckStatus::Pass, 135 + dpop_bound_required: CheckStatus::Pass, 136 + keys_have_alg: CheckStatus::Pass, 137 + grant_types_includes_authorization_code: CheckStatus::Pass, 138 + grant_types_includes_refresh_token: CheckStatus::Pass, 139 + response_types_is_code: CheckStatus::Pass, 140 + }; 141 + let factory: Arc<dyn RpFactory> = Arc::new(DeterministicRpFactory::new(clock.clone(), SEED)); 142 + let interactive_opts = InteractiveOptions { 143 + bind_port: None, 144 + public_base_url: None, 145 + drive_mode: InteractiveDriveMode::DriveRpInProcess { 146 + rp_factory: factory, 147 + }, 148 + }; 149 + let output = interactive::run(static_gating, None, None, &interactive_opts, clock).await; 150 + server.shutdown().await; 151 + 152 + // Every emitted result carries Stage::INTERACTIVE, confirming this 153 + // branch emits only interactive rows (the pipeline-level ordering 154 + // constraint is enforced by run_pipeline's sequential stage calls — 155 + // this test pins the invariant that interactive::run never emits 156 + // static-stage rows). 157 + use atproto_devtool::common::report::Stage; 158 + for result in &output.results { 159 + assert_eq!( 160 + result.stage, 161 + Stage::INTERACTIVE, 162 + "interactive::run must only emit Stage::INTERACTIVE rows; got {:?} for {}", 163 + result.stage, 164 + result.id, 165 + ); 166 + } 167 + assert!( 168 + !output.results.is_empty(), 169 + "interactive stage should emit at least one result" 170 + ); 171 + } 172 + 173 + #[tokio::test] 174 + async fn ac5_2_all_static_pass_runs_full_interactive_inventory() { 175 + use atproto_devtool::commands::test::oauth::client::pipeline::interactive; 176 + use atproto_devtool::commands::test::oauth::client::pipeline::{ 177 + InteractiveDriveMode, InteractiveOptions, StaticGating, 178 + }; 179 + use atproto_devtool::common::oauth::relying_party::{DeterministicRpFactory, RpFactory}; 180 + use atproto_devtool::common::report::CheckStatus; 181 + 182 + let (server, clock) = spawn_fake_as().await; 183 + let static_gating = StaticGating { 184 + scope_present: CheckStatus::Pass, 185 + dpop_bound_required: CheckStatus::Pass, 186 + keys_have_alg: CheckStatus::Pass, 187 + grant_types_includes_authorization_code: CheckStatus::Pass, 188 + grant_types_includes_refresh_token: CheckStatus::Pass, 189 + response_types_is_code: CheckStatus::Pass, 190 + }; 191 + let factory: Arc<dyn RpFactory> = Arc::new(DeterministicRpFactory::new(clock.clone(), SEED)); 192 + let interactive_opts = InteractiveOptions { 193 + bind_port: None, 194 + public_base_url: None, 195 + drive_mode: InteractiveDriveMode::DriveRpInProcess { 196 + rp_factory: factory, 197 + }, 198 + }; 199 + let output = interactive::run(static_gating, None, None, &interactive_opts, clock).await; 200 + server.shutdown().await; 201 + 202 + // Every Phase 7 + Phase 8 check ID should appear among emitted results. 203 + use atproto_devtool::commands::test::oauth::client::pipeline::interactive::{ 204 + CHECK_ALL as INTERACTIVE_ALL, dpop_edges, scope_variations, 205 + }; 206 + let emitted_ids: std::collections::HashSet<&str> = 207 + output.results.iter().map(|r| r.id).collect(); 208 + 209 + for c in INTERACTIVE_ALL { 210 + assert!( 211 + emitted_ids.contains(c.id()), 212 + "AC5.2: interactive check {} must be emitted when all static gates pass", 213 + c.id() 214 + ); 215 + } 216 + for c in scope_variations::CHECK_ALL { 217 + assert!( 218 + emitted_ids.contains(c.id()), 219 + "AC5.2: scope_variations check {} must be emitted when all static gates pass", 220 + c.id() 221 + ); 222 + } 223 + for c in dpop_edges::CHECK_ALL { 224 + assert!( 225 + emitted_ids.contains(c.id()), 226 + "AC5.2: dpop_edges check {} must be emitted when all static gates pass", 227 + c.id() 228 + ); 229 + } 230 + } 231 + 232 + #[tokio::test] 233 + async fn ac5_4_non_gating_static_failure_leaves_interactive_inventory_intact() { 234 + use atproto_devtool::commands::test::oauth::client::pipeline::interactive; 235 + use atproto_devtool::commands::test::oauth::client::pipeline::{ 236 + InteractiveDriveMode, InteractiveOptions, StaticGating, 237 + }; 238 + use atproto_devtool::common::oauth::relying_party::{DeterministicRpFactory, RpFactory}; 239 + use atproto_devtool::common::report::CheckStatus; 240 + 241 + // Fail `grant_types_includes_refresh_token` — only ClientRefreshed (Phase 7) 242 + // is conditionally-refreshed; AC5.4 says non-gating failures leave every 243 + // other interactive check running. The gate table in interactive::run 244 + // doesn't actually consult refresh_token presence (ClientRefreshed is 245 + // always Skipped in Phase 7), so failing it must not affect any other 246 + // check's execution. 247 + let (server, clock) = spawn_fake_as().await; 248 + let static_gating = StaticGating { 249 + scope_present: CheckStatus::Pass, 250 + dpop_bound_required: CheckStatus::Pass, 251 + keys_have_alg: CheckStatus::Pass, 252 + grant_types_includes_authorization_code: CheckStatus::Pass, 253 + grant_types_includes_refresh_token: CheckStatus::SpecViolation, 254 + response_types_is_code: CheckStatus::Pass, 255 + }; 256 + let factory: Arc<dyn RpFactory> = Arc::new(DeterministicRpFactory::new(clock.clone(), SEED)); 257 + let interactive_opts = InteractiveOptions { 258 + bind_port: None, 259 + public_base_url: None, 260 + drive_mode: InteractiveDriveMode::DriveRpInProcess { 261 + rp_factory: factory, 262 + }, 263 + }; 264 + let output = interactive::run(static_gating, None, None, &interactive_opts, clock).await; 265 + server.shutdown().await; 266 + 267 + // ServerBound should Pass (gates not tied to refresh_token did not fail). 268 + let server_bound = output 269 + .results 270 + .iter() 271 + .find(|r| r.id == "oauth_client::interactive::server_bound") 272 + .expect("ServerBound emitted"); 273 + assert_eq!( 274 + server_bound.status, 275 + CheckStatus::Pass, 276 + "ServerBound should Pass when the non-gating check is the only static failure" 277 + ); 278 + } 279 + 280 + // ============================================================================= 281 + // AC6.1: FullGrantApprove happy path. 282 + // ============================================================================= 283 + 284 + async fn drive_par_authorize_token( 285 + rp: &RelyingParty, 286 + server: &ServerHandle, 287 + ) -> Result<atproto_devtool::common::oauth::relying_party::TokenResponse, Box<dyn std::error::Error>> 288 + { 289 + let as_desc = rp.discover_as(&server.active_base).await?; 290 + let par = rp.do_par(&par_req(&as_desc)).await?; 291 + let outcome = rp 292 + .do_authorize(&as_desc, &par.request_uri, &par_req(&as_desc).redirect_uri) 293 + .await?; 294 + let code = match outcome { 295 + AuthorizeOutcome::Code { code } => code, 296 + AuthorizeOutcome::Error { error, .. } => { 297 + return Err(format!("unexpected authorize error: {error}").into()); 298 + } 299 + }; 300 + let token = rp 301 + .do_token( 302 + &as_desc, 303 + &par_req(&as_desc).redirect_uri, 304 + &code, 305 + &par.code_verifier, 306 + ) 307 + .await?; 308 + Ok(token) 309 + } 310 + 311 + #[tokio::test] 312 + async fn ac6_1_full_grant_approve_records_par_authorize_token_with_refresh() { 313 + let (server, clock) = spawn_fake_as().await; 314 + *server.app_state().flow_script.lock().unwrap() = FlowScript::Approve { 315 + granted_scope: "atproto".to_string(), 316 + }; 317 + let rp = build_rp(clock, ClientKind::Public); 318 + let token = drive_par_authorize_token(&rp, &server) 319 + .await 320 + .expect("full grant flow"); 321 + 322 + assert!( 323 + token.refresh_token.is_some(), 324 + "full grant must return a refresh_token" 325 + ); 326 + assert_eq!(token.scope.as_deref(), Some("atproto")); 327 + 328 + let log = server.requests.snapshot(); 329 + let par_count = log 330 + .iter() 331 + .filter(|r| r.method == "POST" && r.path == "/oauth/par") 332 + .count(); 333 + let authorize_count = log 334 + .iter() 335 + .filter(|r| r.method == "GET" && r.path == "/oauth/authorize") 336 + .count(); 337 + let token_count = log 338 + .iter() 339 + .filter(|r| r.method == "POST" && r.path == "/oauth/token") 340 + .count(); 341 + assert_eq!(par_count, 1, "exactly one PAR"); 342 + assert_eq!(authorize_count, 1, "exactly one authorize"); 343 + assert_eq!(token_count, 1, "exactly one token request"); 344 + 345 + let par_entry = log 346 + .iter() 347 + .find(|r| r.method == "POST" && r.path == "/oauth/par") 348 + .unwrap(); 349 + let par_body = String::from_utf8_lossy(&par_entry.body); 350 + assert!( 351 + par_body.contains("code_challenge_method=S256"), 352 + "PAR body must carry PKCE S256" 353 + ); 354 + assert!( 355 + par_entry 356 + .headers 357 + .iter() 358 + .any(|(k, _)| k.eq_ignore_ascii_case("DPoP")), 359 + "PAR must carry DPoP header" 360 + ); 361 + 362 + server.shutdown().await; 363 + } 364 + 365 + // ============================================================================= 366 + // AC6.2: PartialGrantApprove — token response narrower than requested scope. 367 + // ============================================================================= 368 + 369 + #[tokio::test] 370 + async fn ac6_2_partial_grant_returns_narrower_scope() { 371 + let (server, clock) = spawn_fake_as().await; 372 + *server.app_state().flow_script.lock().unwrap() = FlowScript::PartialGrant { 373 + granted_scope: "atproto".to_string(), 374 + }; 375 + let rp = RelyingParty::new( 376 + Url::parse("http://localhost:3000").unwrap(), 377 + ClientKind::Public, 378 + clock, 379 + SEED, 380 + ); 381 + 382 + // Request a wider scope than what the AS will grant. 383 + let as_desc = rp.discover_as(&server.active_base).await.unwrap(); 384 + let par = rp 385 + .do_par(&ParRequest { 386 + as_descriptor: as_desc.clone(), 387 + redirect_uri: Url::parse("http://localhost/callback").unwrap(), 388 + scope: "atproto repo:app.bsky.feed.post?action=create".to_string(), 389 + state: "state-partial".to_string(), 390 + }) 391 + .await 392 + .unwrap(); 393 + let outcome = rp 394 + .do_authorize( 395 + &as_desc, 396 + &par.request_uri, 397 + &Url::parse("http://localhost/callback").unwrap(), 398 + ) 399 + .await 400 + .unwrap(); 401 + let code = match outcome { 402 + AuthorizeOutcome::Code { code } => code, 403 + _ => panic!("expected code outcome"), 404 + }; 405 + let token = rp 406 + .do_token( 407 + &as_desc, 408 + &Url::parse("http://localhost/callback").unwrap(), 409 + &code, 410 + &par.code_verifier, 411 + ) 412 + .await 413 + .unwrap(); 414 + 415 + assert_eq!( 416 + token.scope.as_deref(), 417 + Some("atproto"), 418 + "AS granted a narrower scope than requested" 419 + ); 420 + 421 + server.shutdown().await; 422 + } 423 + 424 + // ============================================================================= 425 + // AC6.3: UserDenial — error propagated; no token request. 426 + // ============================================================================= 427 + 428 + #[tokio::test] 429 + async fn ac6_3_user_denial_propagates_without_token_request() { 430 + let (server, clock) = spawn_fake_as().await; 431 + *server.app_state().flow_script.lock().unwrap() = FlowScript::Deny; 432 + 433 + let rp = build_rp(clock, ClientKind::Public); 434 + let as_desc = rp.discover_as(&server.active_base).await.unwrap(); 435 + let par = rp.do_par(&par_req(&as_desc)).await.unwrap(); 436 + let outcome = rp 437 + .do_authorize(&as_desc, &par.request_uri, &par_req(&as_desc).redirect_uri) 438 + .await 439 + .unwrap(); 440 + 441 + match outcome { 442 + AuthorizeOutcome::Error { error, .. } => { 443 + assert_eq!( 444 + error, "access_denied", 445 + "RP surfaces the fake AS error verbatim" 446 + ) 447 + } 448 + AuthorizeOutcome::Code { .. } => panic!("denial must yield Error, not Code"), 449 + } 450 + 451 + let log = server.requests.snapshot(); 452 + let token_count = log 453 + .iter() 454 + .filter(|r| r.method == "POST" && r.path == "/oauth/token") 455 + .count(); 456 + assert_eq!( 457 + token_count, 0, 458 + "RP must not attempt a token request after access_denied" 459 + ); 460 + 461 + server.shutdown().await; 462 + } 463 + 464 + // ============================================================================= 465 + // AC6.4: Downscoped refresh carries narrower scope. 466 + // ============================================================================= 467 + 468 + #[tokio::test] 469 + async fn ac6_4_downscoped_refresh_carries_narrower_scope_in_body() { 470 + let (server, clock) = spawn_fake_as().await; 471 + *server.app_state().flow_script.lock().unwrap() = FlowScript::Approve { 472 + granted_scope: "atproto".to_string(), 473 + }; 474 + 475 + let rp = build_rp(clock, ClientKind::Public); 476 + let token = drive_par_authorize_token(&rp, &server) 477 + .await 478 + .expect("full grant flow"); 479 + let rt1 = token.refresh_token.expect("rt1"); 480 + 481 + let as_desc = rp.discover_as(&server.active_base).await.unwrap(); 482 + let _ = rp 483 + .do_refresh(&as_desc, &rt1, Some("atproto")) 484 + .await 485 + .expect("refresh with narrower scope"); 486 + 487 + let log = server.requests.snapshot(); 488 + let refresh_entry = log 489 + .iter() 490 + .filter(|r| r.method == "POST" && r.path == "/oauth/token") 491 + .find(|r| String::from_utf8_lossy(&r.body).contains("grant_type=refresh_token")) 492 + .expect("refresh request in log"); 493 + let body = String::from_utf8_lossy(&refresh_entry.body); 494 + assert!( 495 + body.contains("scope=atproto"), 496 + "downscoped refresh must include scope in body; got {body}" 497 + ); 498 + 499 + server.shutdown().await; 500 + } 501 + 502 + // ============================================================================= 503 + // AC7.1: Nonce rotation — 2nd PAR carries the issued nonce. 504 + // ============================================================================= 505 + 506 + #[tokio::test] 507 + async fn ac7_1_dpop_nonce_rotation_second_par_carries_nonce() { 508 + let (server, clock) = spawn_fake_as().await; 509 + *server.app_state().flow_script.lock().unwrap() = FlowScript::DpopNonceRetryOnPar { 510 + nonce: "nonce-ac7-1".into(), 511 + }; 512 + let rp = build_rp(clock, ClientKind::Public); 513 + 514 + let as_desc = rp.discover_as(&server.active_base).await.unwrap(); 515 + let _par = rp.do_par(&par_req(&as_desc)).await.expect("PAR with retry"); 516 + 517 + let log = server.requests.snapshot(); 518 + let par_entries: Vec<_> = log 519 + .iter() 520 + .filter(|r| r.method == "POST" && r.path == "/oauth/par") 521 + .collect(); 522 + assert!( 523 + par_entries.len() >= 2, 524 + "nonce retry must produce at least 2 PAR entries; got {}", 525 + par_entries.len() 526 + ); 527 + 528 + // Decode the 2nd PAR's DPoP proof and assert `nonce` claim. 529 + let second = par_entries[1]; 530 + let dpop_header = second 531 + .headers 532 + .iter() 533 + .find(|(k, _)| k.eq_ignore_ascii_case("DPoP")) 534 + .expect("2nd PAR has DPoP header"); 535 + let dpop_jwt = String::from_utf8_lossy(&dpop_header.1); 536 + let parts: Vec<&str> = dpop_jwt.split('.').collect(); 537 + assert_eq!(parts.len(), 3, "DPoP JWT has 3 parts"); 538 + let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; 539 + use base64::Engine; 540 + let claims_bytes = b64.decode(parts[1]).expect("claims b64"); 541 + let claims: Value = serde_json::from_slice(&claims_bytes).expect("claims json"); 542 + assert_eq!( 543 + claims.get("nonce").and_then(|v| v.as_str()), 544 + Some("nonce-ac7-1"), 545 + "2nd PAR's DPoP claims must include the issued nonce" 546 + ); 547 + 548 + server.shutdown().await; 549 + } 550 + 551 + // ============================================================================= 552 + // AC7.2: Refresh rotation — rt2 != rt1 and rt1 reuse rejected. 553 + // ============================================================================= 554 + 555 + #[tokio::test] 556 + async fn ac7_2_refresh_rotation_rt2_differs_and_rt1_rejected() { 557 + let (server, clock) = spawn_fake_as().await; 558 + *server.app_state().flow_script.lock().unwrap() = FlowScript::Approve { 559 + granted_scope: "atproto".to_string(), 560 + }; 561 + let rp = build_rp(clock, ClientKind::Public); 562 + 563 + let token1 = drive_par_authorize_token(&rp, &server) 564 + .await 565 + .expect("full flow"); 566 + let rt1 = token1.refresh_token.expect("rt1"); 567 + 568 + let as_desc = rp.discover_as(&server.active_base).await.unwrap(); 569 + let token2 = rp.do_refresh(&as_desc, &rt1, None).await.expect("rotate"); 570 + let rt2 = token2.refresh_token.expect("rt2"); 571 + assert_ne!(rt2, rt1, "rt2 must differ from rt1"); 572 + 573 + // Re-using rt1 after rotation must be rejected. 574 + let result = rp.do_refresh(&as_desc, &rt1, None).await; 575 + assert!( 576 + result.is_err(), 577 + "RP must receive rejection when reusing a rotated refresh_token" 578 + ); 579 + 580 + server.shutdown().await; 581 + } 582 + 583 + // ============================================================================= 584 + // AC7.3: Replay rejection surfaced. 585 + // ============================================================================= 586 + 587 + #[tokio::test] 588 + async fn ac7_3_replay_rejection_surfaces_without_retry() { 589 + let (server, clock) = spawn_fake_as().await; 590 + let rp = build_rp(clock, ClientKind::Public); 591 + 592 + let mut par_url = server.active_base.clone(); 593 + par_url.set_path("/oauth/par"); 594 + let fixed_jti = "ac7-3-jti"; 595 + 596 + // First PAR succeeds. 597 + let (body1, _v1) = rp 598 + .build_par_body_with_pkce( 599 + &Url::parse("http://localhost/callback").unwrap(), 600 + "atproto", 601 + "state-a", 602 + ) 603 + .unwrap(); 604 + let dpop1 = rp 605 + .sign_dpop_with_fixed_jti("POST", &par_url, None, fixed_jti) 606 + .unwrap(); 607 + let (s1, _) = rp 608 + .send_raw_par(&par_url, body1.as_bytes(), Some(&dpop1)) 609 + .await 610 + .unwrap(); 611 + assert_eq!(s1, 201, "first PAR accepted"); 612 + 613 + // Second PAR with same jti is rejected. 614 + let (body2, _v2) = rp 615 + .build_par_body_with_pkce( 616 + &Url::parse("http://localhost/callback").unwrap(), 617 + "atproto", 618 + "state-b", 619 + ) 620 + .unwrap(); 621 + let dpop2 = rp 622 + .sign_dpop_with_fixed_jti("POST", &par_url, None, fixed_jti) 623 + .unwrap(); 624 + let (s2, _) = rp 625 + .send_raw_par(&par_url, body2.as_bytes(), Some(&dpop2)) 626 + .await 627 + .unwrap(); 628 + assert_eq!(s2, 401, "replay rejected"); 629 + 630 + // Exactly two PAR entries in the log (no retry loop). 631 + let log = server.requests.snapshot(); 632 + let par_count = log 633 + .iter() 634 + .filter(|r| r.method == "POST" && r.path == "/oauth/par") 635 + .count(); 636 + assert_eq!( 637 + par_count, 2, 638 + "RP should not retry on replay rejection; log must have exactly 2 PAR entries" 639 + ); 640 + 641 + server.shutdown().await; 642 + }
+65
tests/oauth_client_substage_snapshots.rs
··· 131 131 } 132 132 133 133 #[tokio::test] 134 + async fn dpop_edges_with_refresh_token_reuse_violation_snapshot() { 135 + use atproto_devtool::commands::test::oauth::client::fake_as::endpoints::FlowScript; 136 + use atproto_devtool::common::oauth::relying_party::{ 137 + AuthorizeOutcome, ParRequest, RelyingParty, 138 + }; 139 + 140 + let (server, clock) = spawn_fake_as().await; 141 + *server.app_state().flow_script.lock().unwrap() = FlowScript::Approve { 142 + granted_scope: "atproto".to_string(), 143 + }; 144 + 145 + let rp = RelyingParty::new( 146 + Url::parse("http://localhost:3000").unwrap(), 147 + ClientKind::Public, 148 + clock.clone(), 149 + SEED, 150 + ); 151 + 152 + // Drive full flow: PAR → authorize → token to obtain rt1. 153 + let as_desc = rp.discover_as(&server.active_base).await.unwrap(); 154 + let par = rp 155 + .do_par(&ParRequest { 156 + as_descriptor: as_desc.clone(), 157 + redirect_uri: Url::parse("http://localhost/callback").unwrap(), 158 + scope: "atproto".to_string(), 159 + state: "rt-reuse".to_string(), 160 + }) 161 + .await 162 + .unwrap(); 163 + let code = match rp 164 + .do_authorize( 165 + &as_desc, 166 + &par.request_uri, 167 + &Url::parse("http://localhost/callback").unwrap(), 168 + ) 169 + .await 170 + .unwrap() 171 + { 172 + AuthorizeOutcome::Code { code } => code, 173 + _ => panic!("expected code outcome"), 174 + }; 175 + let token = rp 176 + .do_token( 177 + &as_desc, 178 + &Url::parse("http://localhost/callback").unwrap(), 179 + &code, 180 + &par.code_verifier, 181 + ) 182 + .await 183 + .unwrap(); 184 + let rt1 = token.refresh_token.unwrap(); 185 + 186 + // Rotate via do_refresh (rt1 → rt2) and then intentionally reuse rt1. 187 + let _ = rp.do_refresh(&as_desc, &rt1, None).await.unwrap(); 188 + let _ = rp.do_refresh(&as_desc, &rt1, None).await; // Expected rejection; log captures both. 189 + 190 + let factory: Arc<dyn RpFactory> = Arc::new(DeterministicRpFactory::new(clock.clone(), SEED)); 191 + let results = dpop_edges::run(&server, factory.as_ref(), clock).await; 192 + server.shutdown().await; 193 + 194 + let rendered = render_substage("dpop_edges refresh-token-reuse", results); 195 + insta::assert_snapshot!(rendered); 196 + } 197 + 198 + #[tokio::test] 134 199 async fn interactive_stage_blocked_by_static_failures_snapshot() { 135 200 // Exercises the blocked_by branch of interactive::run: every Phase 7 136 201 // interactive check emits with its stable ID visible as "blocked by <id>"
+7 -13
tests/snapshots/oauth_client_substage_snapshots__dpop_edges_default_run_snapshot.snap
··· 6 6 elapsed: Xms 7 7 8 8 == Interactive == 9 - [FAIL] DPoP nonce rotation on use_dpop_nonce response 10 - oauth_client::interactive::dpop_edges::nonce_rotation 9 + [OK] DPoP nonce rotation on use_dpop_nonce response 10 + [FAIL] Refresh token rotation flow 11 + oauth_client::interactive::dpop_edges::refresh_rotation 11 12 12 - × DPoP edges check failed: DPoP nonce rotation on use_dpop_nonce response 13 - [OK] Refresh token rotation flow 13 + × DPoP edges check failed: Refresh token rotation flow 14 14 [FAIL] Replay rejection for duplicate JTI 15 15 oauth_client::interactive::dpop_edges::replay_rejection 16 16 ··· 19 19 oauth_client::interactive::dpop_edges::jti_reused 20 20 21 21 × DPoP edges check failed: Violation: JTI reused across requests 22 - [FAIL] Violation: Nonce received but not adopted 23 - oauth_client::interactive::dpop_edges::nonce_ignored 24 - 25 - × DPoP edges check failed: Violation: Nonce received but not adopted 26 - [FAIL] Violation: Refresh token reused after rotation 27 - oauth_client::interactive::dpop_edges::refresh_token_reused 28 - 29 - × DPoP edges check failed: Violation: Refresh token reused after rotation 22 + [OK] Violation: Nonce received but not adopted 23 + [OK] Violation: Refresh token reused after rotation 30 24 31 - Summary: 1 passed, 5 failed (spec), 0 network errors, 0 advisories, 0 skipped. Exit code: 1 25 + Summary: 3 passed, 3 failed (spec), 0 network errors, 0 advisories, 0 skipped. Exit code: 1
+5 -5
tests/snapshots/oauth_client_substage_snapshots__dpop_edges_with_jti_reuse_violation_snapshot.snap
··· 10 10 oauth_client::interactive::dpop_edges::nonce_rotation 11 11 12 12 × DPoP edges check failed: DPoP nonce rotation on use_dpop_nonce response 13 - [OK] Refresh token rotation flow 13 + [FAIL] Refresh token rotation flow 14 + oauth_client::interactive::dpop_edges::refresh_rotation 15 + 16 + × DPoP edges check failed: Refresh token rotation flow 14 17 [FAIL] Replay rejection for duplicate JTI 15 18 oauth_client::interactive::dpop_edges::replay_rejection 16 19 ··· 23 26 oauth_client::interactive::dpop_edges::nonce_ignored 24 27 25 28 × DPoP edges check failed: Violation: Nonce received but not adopted 26 - [FAIL] Violation: Refresh token reused after rotation 27 - oauth_client::interactive::dpop_edges::refresh_token_reused 28 - 29 - × DPoP edges check failed: Violation: Refresh token reused after rotation 29 + [OK] Violation: Refresh token reused after rotation 30 30 31 31 Summary: 1 passed, 5 failed (spec), 0 network errors, 0 advisories, 0 skipped. Exit code: 1
+34
tests/snapshots/oauth_client_substage_snapshots__dpop_edges_with_refresh_token_reuse_violation_snapshot.snap
··· 1 + --- 2 + source: tests/oauth_client_substage_snapshots.rs 3 + expression: rendered 4 + --- 5 + Target: dpop_edges refresh-token-reuse 6 + elapsed: Xms 7 + 8 + == Interactive == 9 + [FAIL] DPoP nonce rotation on use_dpop_nonce response 10 + oauth_client::interactive::dpop_edges::nonce_rotation 11 + 12 + × DPoP edges check failed: DPoP nonce rotation on use_dpop_nonce response 13 + [FAIL] Refresh token rotation flow 14 + oauth_client::interactive::dpop_edges::refresh_rotation 15 + 16 + × DPoP edges check failed: Refresh token rotation flow 17 + [FAIL] Replay rejection for duplicate JTI 18 + oauth_client::interactive::dpop_edges::replay_rejection 19 + 20 + × DPoP edges check failed: Replay rejection for duplicate JTI 21 + [FAIL] Violation: JTI reused across requests 22 + oauth_client::interactive::dpop_edges::jti_reused 23 + 24 + × DPoP edges check failed: Violation: JTI reused across requests 25 + [FAIL] Violation: Nonce received but not adopted 26 + oauth_client::interactive::dpop_edges::nonce_ignored 27 + 28 + × DPoP edges check failed: Violation: Nonce received but not adopted 29 + [FAIL] Violation: Refresh token reused after rotation 30 + oauth_client::interactive::dpop_edges::refresh_token_reused 31 + 32 + × DPoP edges check failed: Violation: Refresh token reused after rotation 33 + 34 + Summary: 0 passed, 6 failed (spec), 0 network errors, 0 advisories, 0 skipped. Exit code: 1