CLI app for developers prototyping atproto functionality
1
fork

Configure Feed

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

feat(oauth-client): interactive scope variation and dpop-edges sub-stages (scaffolding)

Convert interactive.rs to directory module structure with mod.rs root.
Add interactive/scope_variations.rs with AC6 check inventory (6 checks):
- FullGrantApprove, PartialGrantApprove, UserDenialPropagated,
DownscopedRefresh, PkceRequired, DpopRequired.
Add interactive/dpop_edges.rs with AC7 check inventory (6 checks, scaffolded):
- NonceRotation, RefreshRotation, ReplayRejection, JtiReuseViolation,
NonceIgnoredViolation, RefreshTokenReuseViolation.
Expose ServerHandle.app_state() for flow_script mutation in tests.
Add Debug impl for AppState to satisfy ServerHandle derivation.
Scope variations flows verify full PAR→authorize→token sequences and
log inspection for PKCE/DPoP requirements.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

+614 -1
+8 -1
src/commands/test/oauth/client/fake_as.rs
··· 28 28 pub requests: Arc<RequestLog>, 29 29 pub shutdown: tokio::sync::oneshot::Sender<()>, 30 30 pub join: JoinHandle<()>, 31 + app_state: Arc<endpoints::AppState>, 31 32 } 32 33 33 34 impl ServerHandle { ··· 68 69 codes: std::sync::Mutex::new(std::collections::HashMap::new()), 69 70 public_jwk_by_thumbprint: std::sync::Mutex::new(std::collections::HashMap::new()), 70 71 }); 71 - let router = endpoints::build_router(state); 72 + let router = endpoints::build_router(state.clone()); 72 73 73 74 let serve = axum::serve(listener, router).with_graceful_shutdown(async move { 74 75 let _ = shutdown_rx.await; ··· 84 85 requests, 85 86 shutdown: shutdown_tx, 86 87 join, 88 + app_state: state, 87 89 }) 88 90 } 89 91 90 92 pub async fn shutdown(self) { 91 93 let _ = self.shutdown.send(()); 92 94 let _ = self.join.await; 95 + } 96 + 97 + /// Get a reference to the application state (for flow scripting). 98 + pub fn app_state(&self) -> Arc<endpoints::AppState> { 99 + self.app_state.clone() 93 100 } 94 101 } 95 102
+9
src/commands/test/oauth/client/fake_as/endpoints.rs
··· 101 101 pub public_jwk_by_thumbprint: Mutex<HashMap<String, Value>>, 102 102 } 103 103 104 + impl std::fmt::Debug for AppState { 105 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 106 + f.debug_struct("AppState") 107 + .field("active_base", &self.active_base) 108 + .field("identity", &self.identity) 109 + .finish_non_exhaustive() 110 + } 111 + } 112 + 104 113 /// Binding information for an issued token (e.g., grant scope, DPoP public key). 105 114 #[derive(Debug, Clone)] 106 115 pub struct TokenBinding {
+3
src/commands/test/oauth/client/pipeline/interactive.rs src/commands/test/oauth/client/pipeline/interactive/mod.rs
··· 1 1 //! OAuth client interactive stage — fake AS server, RP-driven flow, conformance checks. 2 2 3 + pub mod dpop_edges; 4 + pub mod scope_variations; 5 + 3 6 use std::borrow::Cow; 4 7 use std::fmt; 5 8 use std::sync::Arc;
+144
src/commands/test/oauth/client/pipeline/interactive/dpop_edges.rs
··· 1 + //! OAuth client interactive stage — DPoP edge case flows. 2 + //! 3 + //! Verifies AC7.1–AC7.6: nonce rotation, refresh rotation, replay rejection, and compliance violations. 4 + 5 + use std::borrow::Cow; 6 + use std::fmt; 7 + use std::sync::Arc; 8 + 9 + use miette::Diagnostic; 10 + use thiserror::Error; 11 + 12 + use crate::commands::test::oauth::client::fake_as::ServerHandle; 13 + use crate::common::oauth::clock::Clock; 14 + use crate::common::oauth::relying_party::RpFactory; 15 + use crate::common::report::{CheckResult, CheckStatus, Stage}; 16 + 17 + /// Diagnostic for DPoP edges spec violations. 18 + #[derive(Debug, Error)] 19 + #[error("{message}")] 20 + struct DpopEdgesViolationDiagnostic { 21 + /// The violation message. 22 + message: String, 23 + /// The stable diagnostic code for this check. 24 + code: &'static str, 25 + } 26 + 27 + impl Diagnostic for DpopEdgesViolationDiagnostic { 28 + fn code<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> { 29 + Some(Box::new(self.code)) 30 + } 31 + } 32 + 33 + /// Checks performed by the DPoP edges sub-stage. 34 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 35 + pub enum Check { 36 + /// DPoP nonce rotation on use_dpop_nonce response. 37 + NonceRotation, 38 + /// Refresh token rotation flow. 39 + RefreshRotation, 40 + /// Replay rejection for duplicate JTI. 41 + ReplayRejection, 42 + /// Violation: JTI reused across requests. 43 + JtiReuseViolation, 44 + /// Violation: Nonce received but not adopted. 45 + NonceIgnoredViolation, 46 + /// Violation: Refresh token reused after rotation. 47 + RefreshTokenReuseViolation, 48 + } 49 + 50 + impl Check { 51 + /// Get the stable check ID for this check. 52 + pub fn id(self) -> &'static str { 53 + match self { 54 + Check::NonceRotation => "oauth_client::interactive::dpop_edges::nonce_rotation", 55 + Check::RefreshRotation => "oauth_client::interactive::dpop_edges::refresh_rotation", 56 + Check::ReplayRejection => "oauth_client::interactive::dpop_edges::replay_rejection", 57 + Check::JtiReuseViolation => "oauth_client::interactive::dpop_edges::jti_reused", 58 + Check::NonceIgnoredViolation => "oauth_client::interactive::dpop_edges::nonce_ignored", 59 + Check::RefreshTokenReuseViolation => { 60 + "oauth_client::interactive::dpop_edges::refresh_token_reused" 61 + } 62 + } 63 + } 64 + 65 + /// Get the summary text for this check. 66 + pub fn summary(self) -> &'static str { 67 + match self { 68 + Check::NonceRotation => "DPoP nonce rotation on use_dpop_nonce response", 69 + Check::RefreshRotation => "Refresh token rotation flow", 70 + Check::ReplayRejection => "Replay rejection for duplicate JTI", 71 + Check::JtiReuseViolation => "Violation: JTI reused across requests", 72 + Check::NonceIgnoredViolation => "Violation: Nonce received but not adopted", 73 + Check::RefreshTokenReuseViolation => "Violation: Refresh token reused after rotation", 74 + } 75 + } 76 + 77 + /// Emit a Pass result. 78 + pub fn pass(self) -> CheckResult { 79 + CheckResult { 80 + id: self.id(), 81 + stage: Stage::INTERACTIVE, 82 + status: CheckStatus::Pass, 83 + summary: Cow::Borrowed(self.summary()), 84 + diagnostic: None, 85 + skipped_reason: None, 86 + } 87 + } 88 + 89 + /// Emit a SpecViolation result with diagnostic code. 90 + pub fn spec_violation(self) -> CheckResult { 91 + let diagnostic = DpopEdgesViolationDiagnostic { 92 + message: format!("DPoP edges check failed: {}", self.summary()), 93 + code: self.id(), 94 + }; 95 + CheckResult { 96 + id: self.id(), 97 + stage: Stage::INTERACTIVE, 98 + status: CheckStatus::SpecViolation, 99 + summary: Cow::Borrowed(self.summary()), 100 + diagnostic: Some(Box::new(diagnostic)), 101 + skipped_reason: None, 102 + } 103 + } 104 + 105 + /// Emit a Skipped result with reason. 106 + pub fn skipped(self, reason: &'static str) -> CheckResult { 107 + CheckResult { 108 + id: self.id(), 109 + stage: Stage::INTERACTIVE, 110 + status: CheckStatus::Skipped, 111 + summary: Cow::Borrowed(self.summary()), 112 + diagnostic: None, 113 + skipped_reason: Some(Cow::Borrowed(reason)), 114 + } 115 + } 116 + } 117 + 118 + /// All DPoP edges checks. 119 + pub const CHECK_ALL: &[Check] = &[ 120 + Check::NonceRotation, 121 + Check::RefreshRotation, 122 + Check::ReplayRejection, 123 + Check::JtiReuseViolation, 124 + Check::NonceIgnoredViolation, 125 + Check::RefreshTokenReuseViolation, 126 + ]; 127 + 128 + /// Run the DPoP edges sub-stage. 129 + pub async fn run( 130 + _server: &ServerHandle, 131 + _rp_factory: &dyn RpFactory, 132 + _clock: Arc<dyn Clock>, 133 + ) -> Vec<CheckResult> { 134 + // TODO(Phase 8 Task 2): Implement DPoP edge case flows. 135 + // For now, return skipped checks to allow compilation. 136 + vec![ 137 + Check::NonceRotation.skipped("Phase 8 Task 2 pending"), 138 + Check::RefreshRotation.skipped("Phase 8 Task 2 pending"), 139 + Check::ReplayRejection.skipped("Phase 8 Task 2 pending"), 140 + Check::JtiReuseViolation.skipped("Phase 8 Task 2 pending"), 141 + Check::NonceIgnoredViolation.skipped("Phase 8 Task 2 pending"), 142 + Check::RefreshTokenReuseViolation.skipped("Phase 8 Task 2 pending"), 143 + ] 144 + }
+450
src/commands/test/oauth/client/pipeline/interactive/scope_variations.rs
··· 1 + //! OAuth client interactive stage — scope variation flows. 2 + //! 3 + //! Verifies AC6.1–AC6.6: scope grant behaviors, user denial, refresh scoping, and mandatory fields. 4 + 5 + use std::borrow::Cow; 6 + use std::fmt; 7 + use std::sync::Arc; 8 + 9 + use miette::Diagnostic; 10 + use thiserror::Error; 11 + use url::Url; 12 + 13 + use crate::commands::test::oauth::client::fake_as::{ServerHandle, endpoints::FlowScript}; 14 + use crate::common::oauth::clock::Clock; 15 + use crate::common::oauth::relying_party::{ClientKind, RpFactory}; 16 + use crate::common::report::{CheckResult, CheckStatus, Stage}; 17 + 18 + /// Diagnostic for scope variations spec violations. 19 + #[derive(Debug, Error)] 20 + #[error("{message}")] 21 + struct ScopeVariationsViolationDiagnostic { 22 + /// The violation message. 23 + message: String, 24 + /// The stable diagnostic code for this check. 25 + code: &'static str, 26 + } 27 + 28 + impl Diagnostic for ScopeVariationsViolationDiagnostic { 29 + fn code<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> { 30 + Some(Box::new(self.code)) 31 + } 32 + } 33 + 34 + /// Checks performed by the scope variations sub-stage. 35 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 36 + pub enum Check { 37 + /// Full grant approval flow. 38 + FullGrantApprove, 39 + /// Partial grant approval flow (narrower scope returned). 40 + PartialGrantApprove, 41 + /// User denial propagated correctly. 42 + UserDenialPropagated, 43 + /// Downscoped refresh flow. 44 + DownscopedRefresh, 45 + /// PAR requests include code_challenge (PKCE). 46 + PkceRequired, 47 + /// PAR requests include DPoP header. 48 + DpopRequired, 49 + } 50 + 51 + impl Check { 52 + /// Get the stable check ID for this check. 53 + pub fn id(self) -> &'static str { 54 + match self { 55 + Check::FullGrantApprove => { 56 + "oauth_client::interactive::scope_variations::full_grant_approve" 57 + } 58 + Check::PartialGrantApprove => { 59 + "oauth_client::interactive::scope_variations::partial_grant_approve" 60 + } 61 + Check::UserDenialPropagated => { 62 + "oauth_client::interactive::scope_variations::user_denial_propagated" 63 + } 64 + Check::DownscopedRefresh => { 65 + "oauth_client::interactive::scope_variations::downscoped_refresh" 66 + } 67 + Check::PkceRequired => "oauth_client::interactive::scope_variations::pkce_required", 68 + Check::DpopRequired => "oauth_client::interactive::scope_variations::dpop_required", 69 + } 70 + } 71 + 72 + /// Get the summary text for this check. 73 + pub fn summary(self) -> &'static str { 74 + match self { 75 + Check::FullGrantApprove => "Full grant approval flow", 76 + Check::PartialGrantApprove => "Partial grant approval flow", 77 + Check::UserDenialPropagated => "User denial propagated correctly", 78 + Check::DownscopedRefresh => "Downscoped refresh flow", 79 + Check::PkceRequired => "PAR requests include code_challenge (PKCE)", 80 + Check::DpopRequired => "PAR requests include DPoP header", 81 + } 82 + } 83 + 84 + /// Emit a Pass result. 85 + pub fn pass(self) -> CheckResult { 86 + CheckResult { 87 + id: self.id(), 88 + stage: Stage::INTERACTIVE, 89 + status: CheckStatus::Pass, 90 + summary: Cow::Borrowed(self.summary()), 91 + diagnostic: None, 92 + skipped_reason: None, 93 + } 94 + } 95 + 96 + /// Emit a SpecViolation result with diagnostic code. 97 + pub fn spec_violation(self) -> CheckResult { 98 + let diagnostic = ScopeVariationsViolationDiagnostic { 99 + message: format!("Scope variations check failed: {}", self.summary()), 100 + code: self.id(), 101 + }; 102 + CheckResult { 103 + id: self.id(), 104 + stage: Stage::INTERACTIVE, 105 + status: CheckStatus::SpecViolation, 106 + summary: Cow::Borrowed(self.summary()), 107 + diagnostic: Some(Box::new(diagnostic)), 108 + skipped_reason: None, 109 + } 110 + } 111 + 112 + /// Emit a Skipped result with reason. 113 + pub fn skipped(self, reason: &'static str) -> CheckResult { 114 + CheckResult { 115 + id: self.id(), 116 + stage: Stage::INTERACTIVE, 117 + status: CheckStatus::Skipped, 118 + summary: Cow::Borrowed(self.summary()), 119 + diagnostic: None, 120 + skipped_reason: Some(Cow::Borrowed(reason)), 121 + } 122 + } 123 + } 124 + 125 + /// All scope variation checks. 126 + pub const CHECK_ALL: &[Check] = &[ 127 + Check::FullGrantApprove, 128 + Check::PartialGrantApprove, 129 + Check::UserDenialPropagated, 130 + Check::DownscopedRefresh, 131 + Check::PkceRequired, 132 + Check::DpopRequired, 133 + ]; 134 + 135 + /// Run the scope variations sub-stage. 136 + pub async fn run( 137 + server: &ServerHandle, 138 + rp_factory: &dyn RpFactory, 139 + _clock: Arc<dyn Clock>, 140 + ) -> Vec<CheckResult> { 141 + let mut results = Vec::new(); 142 + let client_id: Url = "http://localhost:3000" 143 + .parse() 144 + .expect("statically known-good URL"); 145 + 146 + // AC6.1: FullGrantApprove — set flow_script=Approve, drive happy path, verify request log. 147 + { 148 + let rp = rp_factory.build(client_id.clone(), ClientKind::Public); 149 + *server.app_state().flow_script.lock().unwrap() = FlowScript::Approve { 150 + granted_scope: "atproto".to_string(), 151 + }; 152 + 153 + match run_full_grant_flow(&rp, server, &client_id).await { 154 + Ok(_) => { 155 + // Verify request log structure. 156 + let log = server.requests.snapshot(); 157 + let par_count = log 158 + .iter() 159 + .filter(|r| r.method == "POST" && r.path == "/oauth/par") 160 + .count(); 161 + let authorize_count = log 162 + .iter() 163 + .filter(|r| r.method == "GET" && r.path.starts_with("/oauth/authorize")) 164 + .count(); 165 + let token_count = log 166 + .iter() 167 + .filter(|r| r.method == "POST" && r.path == "/oauth/token") 168 + .count(); 169 + 170 + if par_count >= 1 && authorize_count >= 1 && token_count >= 1 { 171 + results.push(Check::FullGrantApprove.pass()); 172 + } else { 173 + results.push(Check::FullGrantApprove.spec_violation()); 174 + } 175 + } 176 + Err(_) => { 177 + results.push(Check::FullGrantApprove.spec_violation()); 178 + } 179 + } 180 + } 181 + 182 + // AC6.2: PartialGrantApprove — set flow_script=PartialGrant, drive flow, verify narrower scope. 183 + { 184 + let rp = rp_factory.build(client_id.clone(), ClientKind::Public); 185 + *server.app_state().flow_script.lock().unwrap() = FlowScript::PartialGrant { 186 + granted_scope: "atproto".to_string(), 187 + }; 188 + 189 + match run_partial_grant_flow(&rp, server, &client_id).await { 190 + Ok(_) => { 191 + // The test verifies that the AS returns narrower scope. 192 + // The RP doesn't retry with original scope, so pass is automatic. 193 + results.push(Check::PartialGrantApprove.pass()); 194 + } 195 + Err(_) => { 196 + results.push(Check::PartialGrantApprove.spec_violation()); 197 + } 198 + } 199 + } 200 + 201 + // AC6.3: UserDenialPropagated — set flow_script=Deny, verify error not retried. 202 + { 203 + let rp = rp_factory.build(client_id.clone(), ClientKind::Public); 204 + *server.app_state().flow_script.lock().unwrap() = FlowScript::Deny; 205 + 206 + match run_user_denial_flow(&rp, server, &client_id).await { 207 + Ok(_) => { 208 + // Verify request log: 1 PAR, 1 authorize, 0 token. 209 + let log = server.requests.snapshot(); 210 + let par_count = log 211 + .iter() 212 + .filter(|r| r.method == "POST" && r.path == "/oauth/par") 213 + .count(); 214 + let authorize_count = log 215 + .iter() 216 + .filter(|r| r.method == "GET" && r.path.starts_with("/oauth/authorize")) 217 + .count(); 218 + let token_count = log 219 + .iter() 220 + .filter(|r| r.method == "POST" && r.path == "/oauth/token") 221 + .count(); 222 + 223 + if par_count >= 1 && authorize_count >= 1 && token_count == 0 { 224 + results.push(Check::UserDenialPropagated.pass()); 225 + } else { 226 + results.push(Check::UserDenialPropagated.spec_violation()); 227 + } 228 + } 229 + Err(_) => { 230 + results.push(Check::UserDenialPropagated.spec_violation()); 231 + } 232 + } 233 + } 234 + 235 + // AC6.4: DownscopedRefresh — full grant, then refresh with narrower scope. 236 + { 237 + let rp = rp_factory.build(client_id.clone(), ClientKind::Public); 238 + *server.app_state().flow_script.lock().unwrap() = FlowScript::Approve { 239 + granted_scope: "atproto".to_string(), 240 + }; 241 + 242 + match run_downscoped_refresh_flow(&rp, server, &client_id).await { 243 + Ok(_) => { 244 + results.push(Check::DownscopedRefresh.pass()); 245 + } 246 + Err(_) => { 247 + results.push(Check::DownscopedRefresh.spec_violation()); 248 + } 249 + } 250 + } 251 + 252 + // AC6.5 & AC6.6: Log inspection for PKCE and DPoP requirements. 253 + { 254 + let log = server.requests.snapshot(); 255 + 256 + // Check AC6.5: All PAR requests have code_challenge. 257 + let all_par_have_pkce = log 258 + .iter() 259 + .filter(|r| r.method == "POST" && r.path == "/oauth/par") 260 + .all(|req| String::from_utf8_lossy(&req.body).contains("code_challenge=")); 261 + 262 + if all_par_have_pkce { 263 + results.push(Check::PkceRequired.pass()); 264 + } else { 265 + results.push(Check::PkceRequired.spec_violation()); 266 + } 267 + 268 + // Check AC6.6: All PAR requests have DPoP header. 269 + let all_par_have_dpop = log 270 + .iter() 271 + .filter(|r| r.method == "POST" && r.path == "/oauth/par") 272 + .all(|req| { 273 + req.headers 274 + .iter() 275 + .any(|(k, _)| k.eq_ignore_ascii_case("DPoP")) 276 + }); 277 + 278 + if all_par_have_dpop { 279 + results.push(Check::DpopRequired.pass()); 280 + } else { 281 + results.push(Check::DpopRequired.spec_violation()); 282 + } 283 + } 284 + 285 + results 286 + } 287 + 288 + /// Run the full grant approval flow. 289 + async fn run_full_grant_flow( 290 + rp: &crate::common::oauth::relying_party::RelyingParty, 291 + server: &ServerHandle, 292 + _client_id: &Url, 293 + ) -> Result<(), Box<dyn std::error::Error>> { 294 + let base = &server.active_base; 295 + let as_desc = rp.discover_as(base).await?; 296 + 297 + let par_req = crate::common::oauth::relying_party::ParRequest { 298 + as_descriptor: as_desc.clone(), 299 + redirect_uri: "http://localhost/callback".parse()?, 300 + scope: "atproto".to_string(), 301 + state: "state123".to_string(), 302 + }; 303 + 304 + let par_resp = rp.do_par(&par_req).await?; 305 + let auth_outcome = rp 306 + .do_authorize(&as_desc, &par_resp.request_uri, &par_req.redirect_uri) 307 + .await?; 308 + 309 + let code = match auth_outcome { 310 + crate::common::oauth::relying_party::AuthorizeOutcome::Code { code } => code, 311 + _ => return Err("Expected authorization code".into()), 312 + }; 313 + 314 + let _token_resp = rp 315 + .do_token( 316 + &as_desc, 317 + &par_req.redirect_uri, 318 + &code, 319 + &par_resp.code_verifier, 320 + ) 321 + .await?; 322 + Ok(()) 323 + } 324 + 325 + /// Run the partial grant approval flow. 326 + async fn run_partial_grant_flow( 327 + rp: &crate::common::oauth::relying_party::RelyingParty, 328 + server: &ServerHandle, 329 + _client_id: &Url, 330 + ) -> Result<(), Box<dyn std::error::Error>> { 331 + let base = &server.active_base; 332 + let as_desc = rp.discover_as(base).await?; 333 + 334 + let par_req = crate::common::oauth::relying_party::ParRequest { 335 + as_descriptor: as_desc.clone(), 336 + redirect_uri: "http://localhost/callback".parse()?, 337 + scope: "atproto repo:app.bsky.feed.post?action=create".to_string(), 338 + state: "state123".to_string(), 339 + }; 340 + 341 + let par_resp = rp.do_par(&par_req).await?; 342 + let auth_outcome = rp 343 + .do_authorize(&as_desc, &par_resp.request_uri, &par_req.redirect_uri) 344 + .await?; 345 + 346 + let code = match auth_outcome { 347 + crate::common::oauth::relying_party::AuthorizeOutcome::Code { code } => code, 348 + _ => return Err("Expected authorization code".into()), 349 + }; 350 + 351 + let token_resp = rp 352 + .do_token( 353 + &as_desc, 354 + &par_req.redirect_uri, 355 + &code, 356 + &par_resp.code_verifier, 357 + ) 358 + .await?; 359 + // Verify that token response scope is narrower than requested. 360 + let granted_scope = token_resp.scope.as_deref().unwrap_or(""); 361 + if granted_scope != par_req.scope { 362 + Ok(()) 363 + } else { 364 + Err("Granted scope should be narrower than requested".into()) 365 + } 366 + } 367 + 368 + /// Run the user denial flow. 369 + async fn run_user_denial_flow( 370 + rp: &crate::common::oauth::relying_party::RelyingParty, 371 + server: &ServerHandle, 372 + _client_id: &Url, 373 + ) -> Result<(), Box<dyn std::error::Error>> { 374 + let base = &server.active_base; 375 + let as_desc = rp.discover_as(base).await?; 376 + 377 + let par_req = crate::common::oauth::relying_party::ParRequest { 378 + as_descriptor: as_desc.clone(), 379 + redirect_uri: "http://localhost/callback".parse()?, 380 + scope: "atproto".to_string(), 381 + state: "state123".to_string(), 382 + }; 383 + 384 + let par_resp = rp.do_par(&par_req).await?; 385 + let auth_outcome = rp 386 + .do_authorize(&as_desc, &par_resp.request_uri, &par_req.redirect_uri) 387 + .await?; 388 + 389 + match auth_outcome { 390 + crate::common::oauth::relying_party::AuthorizeOutcome::Error { error, .. } => { 391 + if error == "access_denied" { 392 + Ok(()) 393 + } else { 394 + Err(format!("Expected access_denied, got {error}").into()) 395 + } 396 + } 397 + _ => Err("Expected authorization error".into()), 398 + } 399 + } 400 + 401 + /// Run the downscoped refresh flow. 402 + async fn run_downscoped_refresh_flow( 403 + rp: &crate::common::oauth::relying_party::RelyingParty, 404 + server: &ServerHandle, 405 + _client_id: &Url, 406 + ) -> Result<(), Box<dyn std::error::Error>> { 407 + let base = &server.active_base; 408 + let as_desc = rp.discover_as(base).await?; 409 + 410 + let par_req = crate::common::oauth::relying_party::ParRequest { 411 + as_descriptor: as_desc.clone(), 412 + redirect_uri: "http://localhost/callback".parse()?, 413 + scope: "atproto".to_string(), 414 + state: "state123".to_string(), 415 + }; 416 + 417 + let par_resp = rp.do_par(&par_req).await?; 418 + let auth_outcome = rp 419 + .do_authorize(&as_desc, &par_resp.request_uri, &par_req.redirect_uri) 420 + .await?; 421 + 422 + let code = match auth_outcome { 423 + crate::common::oauth::relying_party::AuthorizeOutcome::Code { code } => code, 424 + _ => return Err("Expected authorization code".into()), 425 + }; 426 + 427 + let token_resp = rp 428 + .do_token( 429 + &as_desc, 430 + &par_req.redirect_uri, 431 + &code, 432 + &par_resp.code_verifier, 433 + ) 434 + .await?; 435 + let refresh_token = token_resp 436 + .refresh_token 437 + .ok_or("No refresh token in response")?; 438 + 439 + // Perform refresh with narrower scope. 440 + let refreshed = rp 441 + .do_refresh(&as_desc, &refresh_token, Some("atproto")) 442 + .await?; 443 + // Verify the new token response scope matches narrower scope. 444 + let granted_scope = refreshed.scope.as_deref().unwrap_or(""); 445 + if granted_scope == "atproto" { 446 + Ok(()) 447 + } else { 448 + Err(format!("Expected scope 'atproto', got '{granted_scope}'").into()) 449 + } 450 + }