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): introduce fake_as module with RequestLog and ServerHandle skeleton

Created fake_as module root with ServerHandle, FakeAsOptions, and BindError
types. Added request_log.rs with append-only RequestLog supporting push and
snapshot operations. Added identity.rs with SyntheticIdentity supporting
did:web-bound base URLs with both localhost and public variants. Added
endpoints.rs skeleton with router building and request logging infrastructure.
All module-level unit tests pass.

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

+479
+104
src/commands/test/oauth/client/fake_as.rs
··· 1 + //! In-process fake atproto OAuth authorization server for interactive mode. 2 + 3 + pub mod endpoints; 4 + pub mod identity; 5 + pub mod request_log; 6 + 7 + use std::net::SocketAddr; 8 + use std::sync::Arc; 9 + 10 + use tokio::net::TcpListener; 11 + use tokio::task::JoinHandle; 12 + use url::Url; 13 + 14 + use crate::common::oauth::clock::Clock; 15 + use request_log::RequestLog; 16 + use identity::SyntheticIdentity; 17 + 18 + pub struct FakeAsOptions { 19 + pub bind_port: Option<u16>, 20 + pub public_base_url: Option<Url>, 21 + } 22 + 23 + pub struct ServerHandle { 24 + pub local_addr: SocketAddr, 25 + pub active_base: Url, 26 + pub identity: SyntheticIdentity, 27 + pub requests: Arc<RequestLog>, 28 + pub shutdown: tokio::sync::oneshot::Sender<()>, 29 + pub join: JoinHandle<()>, 30 + } 31 + 32 + impl ServerHandle { 33 + pub async fn bind( 34 + opts: FakeAsOptions, 35 + clock: Arc<dyn Clock>, 36 + ) -> Result<Self, BindError> { 37 + let bind_port = opts.bind_port.unwrap_or(0); 38 + let addr = format!("127.0.0.1:{bind_port}"); 39 + let listener = TcpListener::bind(&addr) 40 + .await 41 + .map_err(|e| BindError::BindFailed { 42 + addr: addr.clone(), 43 + err: e, 44 + })?; 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 + }); 55 + let identity = SyntheticIdentity::for_base(&active_base); 56 + let requests = Arc::new(RequestLog::new()); 57 + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); 58 + 59 + let state = Arc::new(endpoints::AppState { 60 + clock, 61 + active_base: active_base.clone(), 62 + identity: identity.clone(), 63 + requests: requests.clone(), 64 + }); 65 + let router = endpoints::build_router(state); 66 + 67 + let serve = 68 + axum::serve(listener, router).with_graceful_shutdown(async move { let _ = shutdown_rx.await; }); 69 + let join = tokio::spawn(async move { 70 + let _ = serve.await; 71 + }); 72 + 73 + Ok(ServerHandle { 74 + local_addr, 75 + active_base, 76 + identity, 77 + requests, 78 + shutdown: shutdown_tx, 79 + join, 80 + }) 81 + } 82 + 83 + pub async fn shutdown(self) { 84 + let _ = self.shutdown.send(()); 85 + let _ = self.join.await; 86 + } 87 + } 88 + 89 + #[derive(Debug, thiserror::Error, miette::Diagnostic)] 90 + pub enum BindError { 91 + #[error("could not bind fake AS to `{addr}`: {err}")] 92 + #[diagnostic(code("oauth_client::interactive::bind_failed"))] 93 + BindFailed { 94 + addr: String, 95 + #[source] 96 + err: std::io::Error, 97 + }, 98 + #[error("could not determine local address for fake AS listener: {err}")] 99 + #[diagnostic(code("oauth_client::interactive::local_addr_failed"))] 100 + LocalAddrFailed { 101 + #[source] 102 + err: std::io::Error, 103 + }, 104 + }
+137
src/commands/test/oauth/client/fake_as/endpoints.rs
··· 1 + use std::sync::Arc; 2 + 3 + use axum::{ 4 + body::Bytes, 5 + extract::State, 6 + http::{HeaderMap, Method, StatusCode, Uri}, 7 + response::{IntoResponse, Json, Response}, 8 + routing::{get, post}, 9 + Router, 10 + }; 11 + use serde_json::json; 12 + use url::Url; 13 + 14 + use crate::common::oauth::clock::Clock; 15 + use super::identity::SyntheticIdentity; 16 + use super::request_log::{LoggedRequest, RequestLog}; 17 + 18 + pub struct AppState { 19 + pub clock: Arc<dyn Clock>, 20 + pub active_base: Url, 21 + pub identity: SyntheticIdentity, 22 + pub requests: Arc<RequestLog>, 23 + } 24 + 25 + pub fn build_router(state: Arc<AppState>) -> Router { 26 + Router::new() 27 + .route("/.well-known/did.json", get(did_json)) 28 + .route("/.well-known/oauth-protected-resource", get(prm)) 29 + .route("/.well-known/oauth-authorization-server", get(as_metadata)) 30 + .route("/oauth/par", post(par)) 31 + .route("/oauth/authorize", get(authorize)) 32 + .route("/oauth/token", post(token)) 33 + .with_state(state) 34 + } 35 + 36 + async fn did_json( 37 + State(s): State<Arc<AppState>>, 38 + method: Method, 39 + uri: Uri, 40 + headers: HeaderMap, 41 + body: Bytes, 42 + ) -> Response { 43 + log_request(&s, &method, &uri, &headers, &body); 44 + Json(s.identity.did_document.clone()).into_response() 45 + } 46 + 47 + async fn prm( 48 + State(s): State<Arc<AppState>>, 49 + method: Method, 50 + uri: Uri, 51 + headers: HeaderMap, 52 + body: Bytes, 53 + ) -> Response { 54 + log_request(&s, &method, &uri, &headers, &body); 55 + Json(s.identity.prm_document.clone()).into_response() 56 + } 57 + 58 + async fn as_metadata( 59 + State(s): State<Arc<AppState>>, 60 + method: Method, 61 + uri: Uri, 62 + headers: HeaderMap, 63 + body: Bytes, 64 + ) -> Response { 65 + log_request(&s, &method, &uri, &headers, &body); 66 + Json(s.identity.as_metadata.clone()).into_response() 67 + } 68 + 69 + async fn par( 70 + State(s): State<Arc<AppState>>, 71 + method: Method, 72 + uri: Uri, 73 + headers: HeaderMap, 74 + body: Bytes, 75 + ) -> Response { 76 + log_request(&s, &method, &uri, &headers, &body); 77 + // Phase 6 placeholder: return a deterministic request_uri. 78 + let now = s.clock.now_unix_seconds(); 79 + let request_uri = format!("urn:ietf:params:oauth:request_uri:fake-{now}"); 80 + ( 81 + StatusCode::CREATED, 82 + Json(json!({ "request_uri": request_uri, "expires_in": 60 })), 83 + ) 84 + .into_response() 85 + } 86 + 87 + async fn authorize( 88 + State(s): State<Arc<AppState>>, 89 + method: Method, 90 + uri: Uri, 91 + headers: HeaderMap, 92 + body: Bytes, 93 + ) -> Response { 94 + log_request(&s, &method, &uri, &headers, &body); 95 + // Phase 6 placeholder: redirect to the client's redirect_uri with a fake code. 96 + // In Phase 7, this becomes per-flow scripted (approve/deny/partial). 97 + // For Phase 6, return 200 OK with a simple message — tests assert by inspecting 98 + // the RequestLog, not the response shape. 99 + (StatusCode::OK, "authorize placeholder").into_response() 100 + } 101 + 102 + async fn token( 103 + State(s): State<Arc<AppState>>, 104 + method: Method, 105 + uri: Uri, 106 + headers: HeaderMap, 107 + body: Bytes, 108 + ) -> Response { 109 + log_request(&s, &method, &uri, &headers, &body); 110 + // Phase 6 placeholder token response. 111 + let now = s.clock.now_unix_seconds(); 112 + let access_token = format!("fake-access-{now}"); 113 + let refresh_token = format!("fake-refresh-{now}"); 114 + let resp = json!({ 115 + "access_token": access_token, 116 + "token_type": "DPoP", 117 + "expires_in": 3600, 118 + "refresh_token": refresh_token, 119 + "scope": "atproto", 120 + }); 121 + (StatusCode::OK, Json(resp)).into_response() 122 + } 123 + 124 + fn log_request(s: &AppState, method: &Method, uri: &Uri, headers: &HeaderMap, body: &Bytes) { 125 + let entry = LoggedRequest { 126 + timestamp_unix: s.clock.now_unix_seconds(), 127 + method: method.as_str().to_owned(), 128 + path: uri.path().to_owned(), 129 + query: uri.query().map(str::to_owned), 130 + headers: headers 131 + .iter() 132 + .map(|(k, v)| (k.as_str().to_owned(), v.as_bytes().to_vec())) 133 + .collect(), 134 + body: body.to_vec(), 135 + }; 136 + s.requests.push(entry); 137 + }
+142
src/commands/test/oauth/client/fake_as/identity.rs
··· 1 + use serde_json::json; 2 + use url::Url; 3 + 4 + /// A synthetic atproto identity chain — handle, DID, DID document, 5 + /// PRM and AS metadata — all bound to a base URL. `did:web` resolves 6 + /// to that base. 7 + #[derive(Debug, Clone)] 8 + pub struct SyntheticIdentity { 9 + pub handle: String, 10 + pub did: String, 11 + pub did_document: serde_json::Value, 12 + pub prm_document: serde_json::Value, 13 + pub as_metadata: serde_json::Value, 14 + } 15 + 16 + impl SyntheticIdentity { 17 + pub fn for_base(base: &Url) -> Self { 18 + let host = base.host_str().expect("base URL must have a host"); 19 + let port = base.port(); 20 + 21 + // did:web uses percent-encoded ":<port>" when a port is present. 22 + let did = if let Some(port) = port { 23 + format!("did:web:{host}%3A{port}") 24 + } else { 25 + format!("did:web:{host}") 26 + }; 27 + let handle = format!("fake-client-under-test.{host}"); 28 + 29 + let base_with_slash = ensure_trailing_slash(base); 30 + let base_no_slash = base.as_str().trim_end_matches('/'); 31 + let at_url = format!("at://{handle}"); 32 + let par_endpoint = format!("{base_with_slash}oauth/par"); 33 + let authorize_endpoint = format!("{base_with_slash}oauth/authorize"); 34 + let token_endpoint = format!("{base_with_slash}oauth/token"); 35 + 36 + let did_document = json!({ 37 + "@context": ["https://www.w3.org/ns/did/v1"], 38 + "id": did, 39 + "alsoKnownAs": [at_url], 40 + "service": [ 41 + { 42 + "id": "#atproto_pds", 43 + "type": "AtprotoPersonalDataServer", 44 + "serviceEndpoint": base_no_slash, 45 + } 46 + ], 47 + }); 48 + let prm_document = json!({ 49 + "resource": base_no_slash, 50 + "authorization_servers": [base_no_slash], 51 + }); 52 + let as_metadata = json!({ 53 + "issuer": base_no_slash, 54 + "authorization_endpoint": authorize_endpoint, 55 + "token_endpoint": token_endpoint, 56 + "pushed_authorization_request_endpoint": par_endpoint, 57 + "scopes_supported": ["atproto"], 58 + "response_types_supported": ["code"], 59 + "response_modes_supported": ["query"], 60 + "grant_types_supported": ["authorization_code", "refresh_token"], 61 + "token_endpoint_auth_methods_supported": ["none", "private_key_jwt"], 62 + "token_endpoint_auth_signing_alg_values_supported": ["ES256"], 63 + "code_challenge_methods_supported": ["S256"], 64 + "dpop_signing_alg_values_supported": ["ES256"], 65 + "require_pushed_authorization_requests": true, 66 + }); 67 + 68 + Self { 69 + handle, 70 + did, 71 + did_document, 72 + prm_document, 73 + as_metadata, 74 + } 75 + } 76 + } 77 + 78 + fn ensure_trailing_slash(base: &Url) -> String { 79 + let s = base.as_str(); 80 + if s.ends_with('/') { 81 + s.to_owned() 82 + } else { 83 + format!("{s}/") 84 + } 85 + } 86 + 87 + #[cfg(test)] 88 + mod tests { 89 + use super::*; 90 + 91 + #[test] 92 + fn synthetic_identity_with_port() { 93 + let base: Url = "http://127.0.0.1:12345".parse().unwrap(); 94 + let identity = SyntheticIdentity::for_base(&base); 95 + 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")); 98 + 99 + let par_endpoint = identity.as_metadata["pushed_authorization_request_endpoint"] 100 + .as_str() 101 + .unwrap(); 102 + assert_eq!(par_endpoint, "http://127.0.0.1:12345/oauth/par"); 103 + 104 + let resource = identity.prm_document["resource"].as_str().unwrap(); 105 + assert_eq!(resource, "http://127.0.0.1:12345"); 106 + } 107 + 108 + #[test] 109 + fn synthetic_identity_without_port() { 110 + let base: Url = "https://funnel.example.com".parse().unwrap(); 111 + let identity = SyntheticIdentity::for_base(&base); 112 + 113 + assert_eq!(identity.did, "did:web:funnel.example.com"); 114 + 115 + let issuer = identity.as_metadata["issuer"].as_str().unwrap(); 116 + assert_eq!(issuer, "https://funnel.example.com"); 117 + 118 + let par_endpoint = identity.as_metadata["pushed_authorization_request_endpoint"] 119 + .as_str() 120 + .unwrap(); 121 + assert_eq!(par_endpoint, "https://funnel.example.com/oauth/par"); 122 + } 123 + 124 + #[test] 125 + fn as_metadata_has_required_fields() { 126 + let base: Url = "http://127.0.0.1:8080".parse().unwrap(); 127 + let identity = SyntheticIdentity::for_base(&base); 128 + 129 + assert_eq!( 130 + identity.as_metadata["require_pushed_authorization_requests"], 131 + true 132 + ); 133 + assert_eq!( 134 + identity.as_metadata["dpop_signing_alg_values_supported"], 135 + json!(["ES256"]) 136 + ); 137 + assert_eq!( 138 + identity.as_metadata["code_challenge_methods_supported"], 139 + json!(["S256"]) 140 + ); 141 + } 142 + }
+96
src/commands/test/oauth/client/fake_as/request_log.rs
··· 1 + use std::sync::Mutex; 2 + 3 + /// Append-only log of inbound requests. Cloneable `Arc` handle; locks 4 + /// on push and snapshot. 5 + pub struct RequestLog { 6 + entries: Mutex<Vec<LoggedRequest>>, 7 + } 8 + 9 + #[derive(Debug, Clone)] 10 + pub struct LoggedRequest { 11 + pub timestamp_unix: u64, 12 + pub method: String, 13 + pub path: String, 14 + pub query: Option<String>, 15 + pub headers: Vec<(String, Vec<u8>)>, 16 + pub body: Vec<u8>, 17 + } 18 + 19 + impl RequestLog { 20 + pub fn new() -> Self { 21 + Self { 22 + entries: Mutex::new(Vec::new()), 23 + } 24 + } 25 + 26 + pub fn push(&self, req: LoggedRequest) { 27 + let mut guard = self.entries.lock().expect("request log poisoned"); 28 + guard.push(req); 29 + } 30 + 31 + pub fn snapshot(&self) -> Vec<LoggedRequest> { 32 + self.entries.lock().expect("request log poisoned").clone() 33 + } 34 + } 35 + 36 + #[cfg(test)] 37 + mod tests { 38 + use super::*; 39 + 40 + #[test] 41 + fn request_log_push_and_snapshot() { 42 + let log = RequestLog::new(); 43 + 44 + let req1 = LoggedRequest { 45 + timestamp_unix: 1000, 46 + method: "GET".to_string(), 47 + path: "/test".to_string(), 48 + query: None, 49 + headers: vec![], 50 + body: vec![], 51 + }; 52 + 53 + log.push(req1.clone()); 54 + 55 + let snapshot = log.snapshot(); 56 + assert_eq!(snapshot.len(), 1); 57 + assert_eq!(snapshot[0].method, "GET"); 58 + assert_eq!(snapshot[0].path, "/test"); 59 + assert_eq!(snapshot[0].timestamp_unix, 1000); 60 + } 61 + 62 + #[test] 63 + fn request_log_multiple_entries() { 64 + let log = RequestLog::new(); 65 + 66 + let req1 = LoggedRequest { 67 + timestamp_unix: 1000, 68 + method: "GET".to_string(), 69 + path: "/path1".to_string(), 70 + query: None, 71 + headers: vec![], 72 + body: vec![], 73 + }; 74 + 75 + let req2 = LoggedRequest { 76 + timestamp_unix: 2000, 77 + method: "POST".to_string(), 78 + path: "/path2".to_string(), 79 + query: Some("key=value".to_string()), 80 + headers: vec![( 81 + "content-type".to_string(), 82 + b"application/json".to_vec(), 83 + )], 84 + body: b"{\"test\": true}".to_vec(), 85 + }; 86 + 87 + log.push(req1); 88 + log.push(req2); 89 + 90 + let snapshot = log.snapshot(); 91 + assert_eq!(snapshot.len(), 2); 92 + assert_eq!(snapshot[0].method, "GET"); 93 + assert_eq!(snapshot[1].method, "POST"); 94 + assert_eq!(snapshot[1].query, Some("key=value".to_string())); 95 + } 96 + }