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): add fake AS endpoint handlers with request logging

Added endpoints.rs with axum router supporting 6 routes: did.json, PRM,
AS metadata, PAR, authorize, and token. Each endpoint logs inbound requests
with timestamp, method, path, query, headers, and body. PAR returns
deterministic request_uri based on clock timestamp. Authorize and token
endpoints return placeholder responses for Phase 6.

Added #[derive(Debug)] to ServerHandle and RequestLog to support integration
testing. Updated fake_as module to export endpoints, identity, and request_log
submodules. Added pub mod fake_as to client.rs for integration tests.

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

+276 -24
+1
src/commands/test/oauth/client.rs
··· 6 6 //! spins up an in-process fake authorization server and observes the 7 7 //! client driving end-to-end OAuth flows. 8 8 9 + pub mod fake_as; 9 10 pub mod pipeline; 10 11 11 12 use std::process::ExitCode;
+15 -17
src/commands/test/oauth/client/fake_as.rs
··· 12 12 use url::Url; 13 13 14 14 use crate::common::oauth::clock::Clock; 15 - use request_log::RequestLog; 16 15 use identity::SyntheticIdentity; 16 + use request_log::RequestLog; 17 17 18 18 pub struct FakeAsOptions { 19 19 pub bind_port: Option<u16>, 20 20 pub public_base_url: Option<Url>, 21 21 } 22 22 23 + #[derive(Debug)] 23 24 pub struct ServerHandle { 24 25 pub local_addr: SocketAddr, 25 26 pub active_base: Url, ··· 30 31 } 31 32 32 33 impl ServerHandle { 33 - pub async fn bind( 34 - opts: FakeAsOptions, 35 - clock: Arc<dyn Clock>, 36 - ) -> Result<Self, BindError> { 34 + pub async fn bind(opts: FakeAsOptions, clock: Arc<dyn Clock>) -> Result<Self, BindError> { 37 35 let bind_port = opts.bind_port.unwrap_or(0); 38 36 let addr = format!("127.0.0.1:{bind_port}"); 39 37 let listener = TcpListener::bind(&addr) ··· 42 40 addr: addr.clone(), 43 41 err: e, 44 42 })?; 45 - let local_addr = listener.local_addr().map_err(|e| BindError::LocalAddrFailed { err: e })?; 46 - let active_base = opts 47 - .public_base_url 48 - .clone() 49 - .unwrap_or_else(|| { 50 - let port = local_addr.port(); 51 - format!("http://127.0.0.1:{port}") 52 - .parse() 53 - .expect("valid url") 54 - }); 43 + let local_addr = listener 44 + .local_addr() 45 + .map_err(|e| BindError::LocalAddrFailed { err: e })?; 46 + let active_base = opts.public_base_url.clone().unwrap_or_else(|| { 47 + let port = local_addr.port(); 48 + format!("http://127.0.0.1:{port}") 49 + .parse() 50 + .expect("valid url") 51 + }); 55 52 let identity = SyntheticIdentity::for_base(&active_base); 56 53 let requests = Arc::new(RequestLog::new()); 57 54 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); ··· 64 61 }); 65 62 let router = endpoints::build_router(state); 66 63 67 - let serve = 68 - axum::serve(listener, router).with_graceful_shutdown(async move { let _ = shutdown_rx.await; }); 64 + let serve = axum::serve(listener, router).with_graceful_shutdown(async move { 65 + let _ = shutdown_rx.await; 66 + }); 69 67 let join = tokio::spawn(async move { 70 68 let _ = serve.await; 71 69 });
+2 -2
src/commands/test/oauth/client/fake_as/endpoints.rs
··· 1 1 use std::sync::Arc; 2 2 3 3 use axum::{ 4 + Router, 4 5 body::Bytes, 5 6 extract::State, 6 7 http::{HeaderMap, Method, StatusCode, Uri}, 7 8 response::{IntoResponse, Json, Response}, 8 9 routing::{get, post}, 9 - Router, 10 10 }; 11 11 use serde_json::json; 12 12 use url::Url; 13 13 14 - use crate::common::oauth::clock::Clock; 15 14 use super::identity::SyntheticIdentity; 16 15 use super::request_log::{LoggedRequest, RequestLog}; 16 + use crate::common::oauth::clock::Clock; 17 17 18 18 pub struct AppState { 19 19 pub clock: Arc<dyn Clock>,
+6 -1
src/commands/test/oauth/client/fake_as/identity.rs
··· 94 94 let identity = SyntheticIdentity::for_base(&base); 95 95 96 96 assert_eq!(identity.did, "did:web:127.0.0.1%3A12345"); 97 - assert!(identity.did_document["id"].as_str().unwrap().contains("127.0.0.1%3A12345")); 97 + assert!( 98 + identity.did_document["id"] 99 + .as_str() 100 + .unwrap() 101 + .contains("127.0.0.1%3A12345") 102 + ); 98 103 99 104 let par_endpoint = identity.as_metadata["pushed_authorization_request_endpoint"] 100 105 .as_str()
+8 -4
src/commands/test/oauth/client/fake_as/request_log.rs
··· 2 2 3 3 /// Append-only log of inbound requests. Cloneable `Arc` handle; locks 4 4 /// on push and snapshot. 5 + #[derive(Debug)] 5 6 pub struct RequestLog { 6 7 entries: Mutex<Vec<LoggedRequest>>, 7 8 } ··· 30 31 31 32 pub fn snapshot(&self) -> Vec<LoggedRequest> { 32 33 self.entries.lock().expect("request log poisoned").clone() 34 + } 35 + } 36 + 37 + impl Default for RequestLog { 38 + fn default() -> Self { 39 + Self::new() 33 40 } 34 41 } 35 42 ··· 77 84 method: "POST".to_string(), 78 85 path: "/path2".to_string(), 79 86 query: Some("key=value".to_string()), 80 - headers: vec![( 81 - "content-type".to_string(), 82 - b"application/json".to_vec(), 83 - )], 87 + headers: vec![("content-type".to_string(), b"application/json".to_vec())], 84 88 body: b"{\"test\": true}".to_vec(), 85 89 }; 86 90
+24
tests/common/mod.rs
··· 16 16 JwksFetchError, JwksFetchResponse, JwksFetcher, 17 17 }; 18 18 use atproto_devtool::common::identity::{DnsResolver, HttpClient, IdentityError}; 19 + use atproto_devtool::common::oauth::clock::Clock; 19 20 use std::collections::{HashMap, HashSet}; 20 21 use std::sync::{Arc, Mutex}; 21 22 use std::time::Duration; ··· 516 517 } 517 518 } 518 519 } 520 + 521 + /// Fake clock for testing, stores a mutable Unix seconds timestamp. 522 + pub struct FakeClock { 523 + t: Mutex<u64>, 524 + } 525 + 526 + impl FakeClock { 527 + pub fn new(initial: u64) -> Self { 528 + Self { 529 + t: Mutex::new(initial), 530 + } 531 + } 532 + 533 + pub fn advance(&self, seconds: u64) { 534 + *self.t.lock().unwrap() += seconds; 535 + } 536 + } 537 + 538 + impl Clock for FakeClock { 539 + fn now_unix_seconds(&self) -> u64 { 540 + *self.t.lock().unwrap() 541 + } 542 + }
+220
tests/oauth_client_interactive.rs
··· 1 + mod common; 2 + 3 + use std::sync::Arc; 4 + 5 + use atproto_devtool::commands::test::oauth::client::fake_as::{FakeAsOptions, ServerHandle}; 6 + use common::FakeClock; 7 + use serde_json::json; 8 + use url::Url; 9 + 10 + async fn spawn_fake_as(opts: FakeAsOptions) -> ServerHandle { 11 + ServerHandle::bind(opts, Arc::new(FakeClock::new(1_700_000_000))) 12 + .await 13 + .expect("bind fake AS") 14 + } 15 + 16 + #[tokio::test] 17 + async fn serves_did_json_document() { 18 + let handle = spawn_fake_as(FakeAsOptions { 19 + bind_port: None, 20 + public_base_url: None, 21 + }) 22 + .await; 23 + 24 + let client = reqwest::Client::new(); 25 + let url = format!("http://{}{}", handle.local_addr, "/.well-known/did.json"); 26 + let response = client 27 + .get(&url) 28 + .send() 29 + .await 30 + .expect("GET /.well-known/did.json"); 31 + 32 + assert_eq!(response.status(), 200); 33 + let body: serde_json::Value = response.json().await.expect("parse JSON"); 34 + 35 + // Check that the DID contains the port. 36 + let port = handle.local_addr.port(); 37 + let expected_did = format!("did:web:127.0.0.1%3A{port}"); 38 + assert_eq!(body["id"], expected_did); 39 + 40 + // Check that service array has the correct serviceEndpoint. 41 + let service_endpoint = body["service"][0]["serviceEndpoint"].as_str().unwrap(); 42 + assert_eq!(service_endpoint, format!("http://127.0.0.1:{port}")); 43 + 44 + // Check request log. 45 + let requests = handle.requests.snapshot(); 46 + assert_eq!(requests.len(), 1); 47 + assert_eq!(requests[0].method, "GET"); 48 + assert_eq!(requests[0].path, "/.well-known/did.json"); 49 + 50 + handle.shutdown().await; 51 + } 52 + 53 + #[tokio::test] 54 + async fn serves_prm_document() { 55 + let handle = spawn_fake_as(FakeAsOptions { 56 + bind_port: None, 57 + public_base_url: None, 58 + }) 59 + .await; 60 + 61 + let client = reqwest::Client::new(); 62 + let url = format!( 63 + "http://{}{}", 64 + handle.local_addr, "/.well-known/oauth-protected-resource" 65 + ); 66 + let response = client 67 + .get(&url) 68 + .send() 69 + .await 70 + .expect("GET /.well-known/oauth-protected-resource"); 71 + 72 + assert_eq!(response.status(), 200); 73 + let body: serde_json::Value = response.json().await.expect("parse JSON"); 74 + 75 + // Check PRM fields exist. 76 + assert!(body.get("resource").is_some()); 77 + assert!(body.get("authorization_servers").is_some()); 78 + 79 + // Check request log. 80 + let requests = handle.requests.snapshot(); 81 + assert_eq!(requests.len(), 1); 82 + assert_eq!(requests[0].path, "/.well-known/oauth-protected-resource"); 83 + 84 + handle.shutdown().await; 85 + } 86 + 87 + #[tokio::test] 88 + async fn serves_as_metadata_document() { 89 + let handle = spawn_fake_as(FakeAsOptions { 90 + bind_port: None, 91 + public_base_url: None, 92 + }) 93 + .await; 94 + 95 + let client = reqwest::Client::new(); 96 + let url = format!( 97 + "http://{}{}", 98 + handle.local_addr, "/.well-known/oauth-authorization-server" 99 + ); 100 + let response = client 101 + .get(&url) 102 + .send() 103 + .await 104 + .expect("GET /.well-known/oauth-authorization-server"); 105 + 106 + assert_eq!(response.status(), 200); 107 + let body: serde_json::Value = response.json().await.expect("parse JSON"); 108 + 109 + // Check required fields for AC4.4. 110 + assert_eq!(body["require_pushed_authorization_requests"], true); 111 + assert_eq!(body["dpop_signing_alg_values_supported"], json!(["ES256"])); 112 + assert_eq!(body["code_challenge_methods_supported"], json!(["S256"])); 113 + 114 + // Check request log. 115 + let requests = handle.requests.snapshot(); 116 + assert_eq!(requests.len(), 1); 117 + 118 + handle.shutdown().await; 119 + } 120 + 121 + #[tokio::test] 122 + async fn par_endpoint_records_request_and_returns_request_uri() { 123 + let handle = spawn_fake_as(FakeAsOptions { 124 + bind_port: None, 125 + public_base_url: None, 126 + }) 127 + .await; 128 + 129 + let client = reqwest::Client::new(); 130 + let url = format!("http://{}/oauth/par", handle.local_addr); 131 + let form_body = 132 + "response_type=code&client_id=test&code_challenge=abc123&code_challenge_method=S256"; 133 + 134 + let response = client 135 + .post(&url) 136 + .body(form_body) 137 + .send() 138 + .await 139 + .expect("POST /oauth/par"); 140 + 141 + assert_eq!(response.status(), 201); 142 + let body: serde_json::Value = response.json().await.expect("parse JSON"); 143 + assert!(body.get("request_uri").is_some()); 144 + assert_eq!(body["expires_in"], 60); 145 + 146 + // Check request log for AC4.5. 147 + let requests = handle.requests.snapshot(); 148 + assert_eq!(requests.len(), 1); 149 + assert_eq!(requests[0].method, "POST"); 150 + assert_eq!(requests[0].path, "/oauth/par"); 151 + assert_eq!( 152 + requests[0].body, 153 + form_body.as_bytes(), 154 + "request body logged verbatim" 155 + ); 156 + assert_eq!(requests[0].timestamp_unix, 1_700_000_000); 157 + 158 + handle.shutdown().await; 159 + } 160 + 161 + #[tokio::test] 162 + async fn public_base_url_rewrites_served_urls() { 163 + let public_url: Url = "https://funnel.example.com".parse().unwrap(); 164 + let handle = spawn_fake_as(FakeAsOptions { 165 + bind_port: None, 166 + public_base_url: Some(public_url), 167 + }) 168 + .await; 169 + 170 + let client = reqwest::Client::new(); 171 + 172 + // Fetch AS metadata and check issuer. 173 + let url = format!( 174 + "http://{}{}", 175 + handle.local_addr, "/.well-known/oauth-authorization-server" 176 + ); 177 + let response = client 178 + .get(&url) 179 + .send() 180 + .await 181 + .expect("GET /.well-known/oauth-authorization-server"); 182 + 183 + let body: serde_json::Value = response.json().await.expect("parse JSON"); 184 + 185 + // Check for AC4.3: public base URL rewriting. 186 + assert_eq!(body["issuer"], "https://funnel.example.com"); 187 + assert_eq!( 188 + body["pushed_authorization_request_endpoint"], 189 + "https://funnel.example.com/oauth/par" 190 + ); 191 + 192 + handle.shutdown().await; 193 + } 194 + 195 + #[tokio::test] 196 + async fn bind_unbindable_port_returns_error() { 197 + // Bind a listener to pick an ephemeral port. 198 + let listener = 199 + std::net::TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port for test"); 200 + let bound_addr = listener.local_addr().expect("get local addr"); 201 + let port = bound_addr.port(); 202 + 203 + // Try to bind fake AS to the same port (should fail). 204 + let opts = FakeAsOptions { 205 + bind_port: Some(port), 206 + public_base_url: None, 207 + }; 208 + 209 + let result = ServerHandle::bind(opts, Arc::new(FakeClock::new(1_700_000_000))).await; 210 + 211 + assert!(result.is_err(), "bind should fail on occupied port"); 212 + match result.unwrap_err() { 213 + atproto_devtool::commands::test::oauth::client::fake_as::BindError::BindFailed { 214 + .. 215 + } => { 216 + // Expected. 217 + } 218 + _ => panic!("expected BindFailed error"), 219 + } 220 + }