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): rewrite interactive::run with gate table and proper signature

Completely rewrites interactive::run to implement the gate table per the Phase 7
plan. New signature accepts StaticGating, metadata_facts, jwks_facts,
interactive_opts, and clock. Returns InteractiveStageOutput instead of
Result<Vec<CheckResult>, Box<dyn Error>>.

Implements the gate table: ClientReachedPar, ClientUsedPkceS256, and
ClientIncludedDpop depend on scope_present and dpop_bound_required;
ClientCompletedToken also depends on keys_have_alg. When gates fail, emits
blocked_by results. When gates pass, spawns fake AS, prints identity, and
dispatches on drive_mode: WaitForExternalClient blocks on Ctrl-C;
DriveRpInProcess drives a happy-path flow through PAR → authorize → token
and inspects request log to emit check results.

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

+142 -37
+142 -37
src/commands/test/oauth/client/pipeline/interactive.rs
··· 5 5 6 6 use crate::commands::test::oauth::client::fake_as::{FakeAsOptions, ServerHandle}; 7 7 use crate::common::oauth::clock::Clock; 8 - use crate::common::report::{CheckResult, CheckStatus, Stage}; 8 + use crate::common::report::{CheckResult, CheckStatus, Stage, blocked_by}; 9 + 10 + use super::jwks::JwksFacts; 11 + use super::metadata::MetadataFacts; 12 + use super::{InteractiveDriveMode, InteractiveOptions, StaticGating}; 9 13 10 14 /// Facts produced by the interactive stage. 11 15 pub struct InteractiveFacts { 12 16 /// Server handle for accessing requests/state post-run. 13 17 pub server: ServerHandle, 18 + } 19 + 20 + /// Output from the interactive stage. 21 + pub struct InteractiveStageOutput { 22 + /// All check results from this stage. 23 + pub results: Vec<CheckResult>, 24 + /// Facts produced by this stage (unused by Phase 7; reserved for Phase 8). 25 + pub facts: Option<InteractiveFacts>, 14 26 } 15 27 16 28 /// Checks performed by the interactive stage. ··· 94 106 95 107 /// Run the interactive stage. 96 108 pub async fn run( 97 - clock: Arc<dyn Clock>, 109 + static_gating: StaticGating, 110 + _metadata_facts: Option<&MetadataFacts>, 111 + _jwks_facts: Option<&JwksFacts>, 98 112 interactive_opts: &InteractiveOptions, 99 - ) -> Result<Vec<CheckResult>, Box<dyn std::error::Error>> { 113 + clock: Arc<dyn Clock>, 114 + ) -> InteractiveStageOutput { 100 115 let mut results = Vec::new(); 101 116 117 + // Gate table: declare which static checks block each interactive check. 118 + // ClientReachedPar, ClientUsedPkceS256, ClientIncludedDpop depend on scope_present + dpop_bound_required. 119 + // ClientCompletedToken depends on all of the above + keys_have_alg. 120 + // ClientRefreshed depends on grant_types including refresh_token. 121 + 122 + let par_gates_pass = static_gating.scope_present == CheckStatus::Pass 123 + && static_gating.dpop_bound_required == CheckStatus::Pass; 124 + 125 + let token_gates_pass = par_gates_pass && static_gating.keys_have_alg == CheckStatus::Pass; 126 + 127 + // Emit blocked_by results for checks whose gates didn't pass. 128 + if !par_gates_pass { 129 + if static_gating.scope_present != CheckStatus::Pass { 130 + results.push(blocked_by( 131 + Check::ClientReachedPar.id(), 132 + Stage::INTERACTIVE, 133 + Check::ClientReachedPar.summary(), 134 + "oauth_client::metadata::scope_present", 135 + )); 136 + results.push(blocked_by( 137 + Check::ClientUsedPkceS256.id(), 138 + Stage::INTERACTIVE, 139 + Check::ClientUsedPkceS256.summary(), 140 + "oauth_client::metadata::scope_present", 141 + )); 142 + results.push(blocked_by( 143 + Check::ClientIncludedDpop.id(), 144 + Stage::INTERACTIVE, 145 + Check::ClientIncludedDpop.summary(), 146 + "oauth_client::metadata::scope_present", 147 + )); 148 + } else if static_gating.dpop_bound_required != CheckStatus::Pass { 149 + results.push(blocked_by( 150 + Check::ClientReachedPar.id(), 151 + Stage::INTERACTIVE, 152 + Check::ClientReachedPar.summary(), 153 + "oauth_client::metadata::dpop_bound_required", 154 + )); 155 + results.push(blocked_by( 156 + Check::ClientUsedPkceS256.id(), 157 + Stage::INTERACTIVE, 158 + Check::ClientUsedPkceS256.summary(), 159 + "oauth_client::metadata::dpop_bound_required", 160 + )); 161 + results.push(blocked_by( 162 + Check::ClientIncludedDpop.id(), 163 + Stage::INTERACTIVE, 164 + Check::ClientIncludedDpop.summary(), 165 + "oauth_client::metadata::dpop_bound_required", 166 + )); 167 + } 168 + 169 + if !token_gates_pass { 170 + results.push(blocked_by( 171 + Check::ClientCompletedToken.id(), 172 + Stage::INTERACTIVE, 173 + Check::ClientCompletedToken.summary(), 174 + if static_gating.scope_present != CheckStatus::Pass { 175 + "oauth_client::metadata::scope_present" 176 + } else if static_gating.dpop_bound_required != CheckStatus::Pass { 177 + "oauth_client::metadata::dpop_bound_required" 178 + } else { 179 + "oauth_client::jws::keys_have_alg" 180 + }, 181 + )); 182 + } 183 + 184 + results.push(Check::ClientRefreshed.skipped("covered in Phase 8 flow variants")); 185 + 186 + return InteractiveStageOutput { 187 + results, 188 + facts: None, 189 + }; 190 + } 191 + 102 192 // Bind fake AS. 103 193 let opts = FakeAsOptions { 104 194 bind_port: interactive_opts.bind_port, ··· 109 199 Ok(s) => s, 110 200 Err(_e) => { 111 201 results.push(Check::ServerBound.spec_violation()); 112 - return Ok(results); 202 + return InteractiveStageOutput { 203 + results, 204 + facts: None, 205 + }; 113 206 } 114 207 }; 115 208 ··· 126 219 InteractiveDriveMode::WaitForExternalClient => { 127 220 // Wait for Ctrl-C. 128 221 let _ = tokio::signal::ctrl_c().await; 129 - // TODO: Inspect request log and emit checks based on what was captured. 222 + // TODO(Phase 8): Inspect request log and emit checks based on what was captured. 223 + results.push(Check::ClientReachedPar.spec_violation()); 224 + results.push(Check::ClientUsedPkceS256.spec_violation()); 225 + results.push(Check::ClientIncludedDpop.spec_violation()); 226 + results.push(Check::ClientCompletedToken.spec_violation()); 227 + results.push(Check::ClientRefreshed.skipped("covered in Phase 8 flow variants")); 130 228 } 131 229 InteractiveDriveMode::DriveRpInProcess { rp_factory } => { 132 230 // Drive the RP through the happy path. ··· 137 235 Ok(ad) => ad, 138 236 Err(_e) => { 139 237 results.push(Check::ClientReachedPar.spec_violation()); 238 + results.push(Check::ClientUsedPkceS256.spec_violation()); 239 + results.push(Check::ClientIncludedDpop.spec_violation()); 240 + results.push(Check::ClientCompletedToken.spec_violation()); 241 + results 242 + .push(Check::ClientRefreshed.skipped("covered in Phase 8 flow variants")); 140 243 server.shutdown().await; 141 - return Ok(results); 244 + return InteractiveStageOutput { 245 + results, 246 + facts: None, 247 + }; 142 248 } 143 249 }; 144 250 145 251 // Perform PAR. 146 252 let par_req = crate::common::oauth::relying_party::ParRequest { 147 253 as_descriptor: as_desc.clone(), 148 - redirect_uri: "http://localhost/callback".parse().unwrap(), 254 + redirect_uri: "http://localhost/callback" 255 + .parse() 256 + .expect("statically known-good URL"), 149 257 scope: "atproto".to_string(), 150 258 state: "state123".to_string(), 151 259 }; ··· 154 262 Ok(pr) => pr, 155 263 Err(_e) => { 156 264 results.push(Check::ClientReachedPar.spec_violation()); 265 + results.push(Check::ClientUsedPkceS256.spec_violation()); 266 + results.push(Check::ClientIncludedDpop.spec_violation()); 267 + results.push(Check::ClientCompletedToken.spec_violation()); 268 + results 269 + .push(Check::ClientRefreshed.skipped("covered in Phase 8 flow variants")); 157 270 server.shutdown().await; 158 - return Ok(results); 271 + return InteractiveStageOutput { 272 + results, 273 + facts: None, 274 + }; 159 275 } 160 276 }; 161 277 ··· 195 311 } 196 312 } else { 197 313 results.push(Check::ClientReachedPar.spec_violation()); 314 + results.push(Check::ClientUsedPkceS256.spec_violation()); 315 + results.push(Check::ClientIncludedDpop.spec_violation()); 198 316 } 199 317 200 318 // Perform authorize. ··· 205 323 Ok(ao) => ao, 206 324 Err(_e) => { 207 325 results.push(Check::ClientCompletedToken.spec_violation()); 326 + results 327 + .push(Check::ClientRefreshed.skipped("covered in Phase 8 flow variants")); 208 328 server.shutdown().await; 209 - return Ok(results); 329 + return InteractiveStageOutput { 330 + results, 331 + facts: None, 332 + }; 210 333 } 211 334 }; 212 335 ··· 215 338 crate::common::oauth::relying_party::AuthorizeOutcome::Code { code } => code, 216 339 crate::common::oauth::relying_party::AuthorizeOutcome::Error { .. } => { 217 340 results.push(Check::ClientCompletedToken.spec_violation()); 341 + results 342 + .push(Check::ClientRefreshed.skipped("covered in Phase 8 flow variants")); 218 343 server.shutdown().await; 219 - return Ok(results); 344 + return InteractiveStageOutput { 345 + results, 346 + facts: None, 347 + }; 220 348 } 221 349 }; 222 350 ··· 244 372 } 245 373 246 374 server.shutdown().await; 247 - Ok(results) 248 - } 249 375 250 - /// Options for the interactive stage. 251 - pub struct InteractiveOptions { 252 - /// Port to bind the fake AS on (0 = ephemeral). 253 - pub bind_port: Option<u16>, 254 - /// Public base URL to advertise (defaults to 127.0.0.1:<port>). 255 - pub public_base_url: Option<url::Url>, 256 - /// Drive mode: wait for external client or drive an RP in-process. 257 - pub drive_mode: InteractiveDriveMode, 258 - } 259 - 260 - /// Chooses between waiting for an external client or driving an RP in-process. 261 - pub enum InteractiveDriveMode { 262 - /// Wait for tokio::signal::ctrl_c() before inspecting the request log. 263 - WaitForExternalClient, 264 - /// Drive an in-process RelyingParty via factory. 265 - DriveRpInProcess { 266 - /// Factory to build RP instances. 267 - rp_factory: Arc<dyn RpFactory>, 268 - }, 269 - } 270 - 271 - /// Factory trait for building RelyingParty instances. 272 - pub trait RpFactory: Send + Sync { 273 - /// Build a fresh RelyingParty instance. 274 - fn build(&self) -> crate::common::oauth::relying_party::RelyingParty; 376 + InteractiveStageOutput { 377 + results, 378 + facts: None, 379 + } 275 380 }