learn and share notes on atproto (wip) 🦉 malfestio.stormlightlabs.org/
readability solid axum atproto srs
5
fork

Configure Feed

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

feat: enable app password sessions

* PDS resolution

* use optimistic validation

+162 -21
+70 -4
crates/server/src/api/auth.rs
··· 18 18 handle: String, 19 19 } 20 20 21 - /// TODO: Find user's PDS URL 21 + /// Login with app password. 22 + /// 23 + /// Resolves the user's PDS from their handle/DID, authenticates with that PDS, 24 + /// and stores the session for future requests. 22 25 pub async fn login(State(state): State<SharedState>, Json(payload): Json<LoginRequest>) -> impl IntoResponse { 26 + use crate::oauth::resolver::{is_valid_did, is_valid_handle}; 27 + use crate::repository::oauth::StoreAppPasswordSessionRequest; 28 + 23 29 let client = reqwest::Client::new(); 24 - let pds_url = &state.config.pds_url; 30 + 31 + let pds_url = if is_valid_did(&payload.identifier) { 32 + match state.identity_resolver.resolve_did(&payload.identifier).await { 33 + Ok(identity) => identity.pds_url, 34 + Err(e) => { 35 + tracing::error!("Failed to resolve DID {}: {}", payload.identifier, e); 36 + return ( 37 + StatusCode::BAD_REQUEST, 38 + Json(json!({ "error": format!("Failed to resolve DID: {}", e) })), 39 + ) 40 + .into_response(); 41 + } 42 + } 43 + } else if is_valid_handle(&payload.identifier) { 44 + let did = match state.identity_resolver.resolve_handle(&payload.identifier).await { 45 + Ok(did) => did, 46 + Err(e) => { 47 + tracing::error!("Failed to resolve handle {}: {}", payload.identifier, e); 48 + return ( 49 + StatusCode::BAD_REQUEST, 50 + Json(json!({ "error": format!("Failed to resolve handle: {}", e) })), 51 + ) 52 + .into_response(); 53 + } 54 + }; 55 + 56 + match state.identity_resolver.resolve_did(&did).await { 57 + Ok(identity) => identity.pds_url, 58 + Err(e) => { 59 + tracing::error!("Failed to resolve DID {} for handle {}: {}", did, payload.identifier, e); 60 + return ( 61 + StatusCode::BAD_REQUEST, 62 + Json(json!({ "error": format!("Failed to resolve DID: {}", e) })), 63 + ) 64 + .into_response(); 65 + } 66 + } 67 + } else { 68 + tracing::warn!("Invalid identifier format: {}, using default PDS", payload.identifier); 69 + state.config.pds_url.clone() 70 + }; 71 + 72 + tracing::info!("Authenticating {} with PDS: {}", payload.identifier, pds_url); 25 73 26 74 let resp = client 27 75 .post(format!("{}/xrpc/com.atproto.server.createSession", pds_url)) ··· 41 89 let did = body["did"].as_str().unwrap_or("").to_string(); 42 90 let handle = body["handle"].as_str().unwrap_or("").to_string(); 43 91 92 + // Store the session with the resolved PDS URL 93 + let store_result = state 94 + .oauth_repo 95 + .store_app_password_session(StoreAppPasswordSessionRequest { 96 + did: &did, 97 + pds_url: &pds_url, 98 + access_token: &access_jwt, 99 + refresh_token: Some(&refresh_jwt), 100 + expires_at: None, 101 + }) 102 + .await; 103 + 104 + if let Err(e) = store_result { 105 + tracing::error!("Failed to store app password session: {}", e); 106 + } 107 + 44 108 ( 45 109 StatusCode::OK, 46 110 Json(json!({ ··· 50 114 "handle": handle 51 115 })), 52 116 ) 117 + .into_response() 53 118 } else { 54 119 let error_body: serde_json::Value = response.json().await.unwrap_or_default(); 55 - (StatusCode::UNAUTHORIZED, Json(error_body)) 120 + (StatusCode::UNAUTHORIZED, Json(error_body)).into_response() 56 121 } 57 122 } 58 123 Err(e) => ( 59 124 StatusCode::INTERNAL_SERVER_ERROR, 60 125 Json(json!({ "error": e.to_string() })), 61 - ), 126 + ) 127 + .into_response(), 62 128 } 63 129 } 64 130
+6
crates/server/src/api/oauth.rs
··· 53 53 Ok(()) 54 54 } 55 55 56 + async fn store_app_password_session( 57 + &self, _req: crate::repository::oauth::StoreAppPasswordSessionRequest<'_>, 58 + ) -> Result<(), crate::repository::oauth::OAuthRepoError> { 59 + Ok(()) 60 + } 61 + 56 62 async fn get_tokens( 57 63 &self, did: &str, 58 64 ) -> Result<crate::repository::oauth::StoredToken, crate::repository::oauth::OAuthRepoError> {
+1
crates/server/src/api/search.rs
··· 116 116 config, 117 117 auth_cache, 118 118 dpop_nonces: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())), 119 + identity_resolver: crate::oauth::resolver::IdentityResolver::new(), 119 120 }) 120 121 } 121 122
+1
crates/server/src/api/social.rs
··· 209 209 config, 210 210 auth_cache, 211 211 dpop_nonces: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())), 212 + identity_resolver: crate::oauth::resolver::IdentityResolver::new(), 212 213 }) 213 214 } 214 215
+1
crates/server/src/api/users.rs
··· 55 55 config: crate::state::AppConfig { pds_url: "https://bsky.social".to_string() }, 56 56 auth_cache: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())), 57 57 dpop_nonces: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())), 58 + identity_resolver: crate::oauth::resolver::IdentityResolver::new(), 58 59 }) 59 60 } 60 61
+2 -11
crates/server/src/pds/client.rs
··· 143 143 record, 144 144 swap_record: None, 145 145 swap_commit: None, 146 - validate: Some(true), 146 + validate: Some(false), 147 147 }; 148 148 149 149 let mut request_builder = self.http_client.post(&url); 150 150 151 - // Conditionally add DPoP or Bearer authentication 152 151 if let Some(ref dpop_keypair) = self.dpop_keypair { 153 - // OAuth with DPoP 154 152 let dpop_proof = dpop_keypair.generate_proof("POST", &url, Some(&self.access_token)); 155 153 request_builder = request_builder 156 154 .header("Authorization", format!("DPoP {}", self.access_token)) 157 155 .header("DPoP", dpop_proof); 158 156 } else { 159 - // App password with Bearer 160 157 request_builder = request_builder.header("Authorization", format!("Bearer {}", self.access_token)); 161 158 } 162 159 ··· 183 180 184 181 let mut request_builder = self.http_client.post(&url); 185 182 186 - // Conditionally add DPoP or Bearer authentication 187 183 if let Some(ref dpop_keypair) = self.dpop_keypair { 188 - // OAuth with DPoP 189 184 let dpop_proof = dpop_keypair.generate_proof("POST", &url, Some(&self.access_token)); 190 185 request_builder = request_builder 191 186 .header("Authorization", format!("DPoP {}", self.access_token)) 192 187 .header("DPoP", dpop_proof); 193 188 } else { 194 - // App password with Bearer 195 189 request_builder = request_builder.header("Authorization", format!("Bearer {}", self.access_token)); 196 190 } 197 191 ··· 216 210 217 211 let mut request_builder = self.http_client.post(&url); 218 212 219 - // Conditionally add DPoP or Bearer authentication 220 213 if let Some(ref dpop_keypair) = self.dpop_keypair { 221 - // OAuth with DPoP 222 214 let dpop_proof = dpop_keypair.generate_proof("POST", &url, Some(&self.access_token)); 223 215 request_builder = request_builder 224 216 .header("Authorization", format!("DPoP {}", self.access_token)) 225 217 .header("DPoP", dpop_proof); 226 218 } else { 227 - // App password with Bearer 228 219 request_builder = request_builder.header("Authorization", format!("Bearer {}", self.access_token)); 229 220 } 230 221 ··· 314 305 315 306 let json = serde_json::to_string(&request).unwrap(); 316 307 assert!(json.contains("\"repo\":\"did:plc:abc123\"")); 317 - assert!(!json.contains("swapRecord")); // Should be omitted when None 308 + assert!(!json.contains("swapRecord")); 318 309 } 319 310 320 311 #[test]
+70 -6
crates/server/src/repository/oauth.rs
··· 10 10 use serde::{Deserialize, Serialize}; 11 11 12 12 /// Stored OAuth token record. 13 + /// 14 + /// Supports both OAuth sessions (with DPoP) and app password sessions (without DPoP). 13 15 #[derive(Clone, Serialize, Deserialize)] 14 16 pub struct StoredToken { 15 17 pub did: String, ··· 18 20 pub refresh_token: Option<String>, 19 21 pub token_type: String, 20 22 pub expires_at: Option<DateTime<Utc>>, 21 - pub dpop_private_key: Vec<u8>, 23 + pub dpop_private_key: Option<Vec<u8>>, 22 24 pub created_at: DateTime<Utc>, 23 25 pub updated_at: DateTime<Utc>, 24 26 } 25 27 26 28 impl StoredToken { 27 29 /// Reconstruct the DPoP keypair from stored bytes. 30 + /// 31 + /// Returns None for app password sessions (no DPoP) or if the key is invalid. 28 32 pub fn dpop_keypair(&self) -> Option<DpopKeypair> { 29 - if self.dpop_private_key.len() != 32 { 33 + let key_bytes_vec = self.dpop_private_key.as_ref()?; 34 + if key_bytes_vec.len() != 32 { 30 35 return None; 31 36 } 32 37 let mut key_bytes = [0u8; 32]; 33 - key_bytes.copy_from_slice(&self.dpop_private_key); 38 + key_bytes.copy_from_slice(key_bytes_vec); 34 39 let signing_key = SigningKey::from_bytes(&key_bytes); 35 40 Some(DpopKeypair::from_signing_key(signing_key)) 36 41 } ··· 56 61 57 62 impl std::error::Error for OAuthRepoError {} 58 63 59 - /// Request to store OAuth tokens. 64 + /// Request to store OAuth tokens with DPoP. 60 65 pub struct StoreTokensRequest<'a> { 61 66 pub did: &'a str, 62 67 pub pds_url: &'a str, ··· 67 72 pub dpop_keypair: &'a DpopKeypair, 68 73 } 69 74 75 + /// Request to store app password session (without DPoP). 76 + pub struct StoreAppPasswordSessionRequest<'a> { 77 + pub did: &'a str, 78 + pub pds_url: &'a str, 79 + pub access_token: &'a str, 80 + pub refresh_token: Option<&'a str>, 81 + pub expires_at: Option<DateTime<Utc>>, 82 + } 83 + 70 84 /// Repository trait for OAuth token operations. 71 85 #[async_trait] 72 86 pub trait OAuthRepository: Send + Sync { 73 - /// Store OAuth tokens for a user. 87 + /// Store OAuth tokens for a user (with DPoP). 74 88 async fn store_tokens(&self, req: StoreTokensRequest<'_>) -> Result<(), OAuthRepoError>; 89 + 90 + /// Store app password session for a user (without DPoP). 91 + async fn store_app_password_session(&self, req: StoreAppPasswordSessionRequest<'_>) -> Result<(), OAuthRepoError>; 75 92 76 93 /// Get stored tokens for a user. 77 94 async fn get_tokens(&self, did: &str) -> Result<StoredToken, OAuthRepoError>; ··· 130 147 Ok(()) 131 148 } 132 149 150 + async fn store_app_password_session(&self, req: StoreAppPasswordSessionRequest<'_>) -> Result<(), OAuthRepoError> { 151 + let client = self 152 + .pool 153 + .get() 154 + .await 155 + .map_err(|e| OAuthRepoError::DatabaseError(e.to_string()))?; 156 + 157 + client 158 + .execute( 159 + "INSERT INTO oauth_tokens (did, pds_url, access_token, refresh_token, token_type, expires_at, dpop_private_key) 160 + VALUES ($1, $2, $3, $4, 'Bearer', $5, NULL) 161 + ON CONFLICT (did) DO UPDATE SET 162 + pds_url = EXCLUDED.pds_url, 163 + access_token = EXCLUDED.access_token, 164 + refresh_token = EXCLUDED.refresh_token, 165 + expires_at = EXCLUDED.expires_at, 166 + dpop_private_key = NULL, 167 + updated_at = NOW()", 168 + &[&req.did, &req.pds_url, &req.access_token, &req.refresh_token, &req.expires_at], 169 + ) 170 + .await 171 + .map_err(|e| OAuthRepoError::DatabaseError(e.to_string()))?; 172 + 173 + Ok(()) 174 + } 175 + 133 176 async fn get_tokens(&self, did: &str) -> Result<StoredToken, OAuthRepoError> { 134 177 let client = self 135 178 .pool ··· 290 333 refresh_token: req.refresh_token.map(String::from), 291 334 token_type: req.token_type.to_string(), 292 335 expires_at: req.expires_at, 293 - dpop_private_key: req.dpop_keypair.private_key_bytes(), 336 + dpop_private_key: Some(req.dpop_keypair.private_key_bytes()), 337 + created_at: Utc::now(), 338 + updated_at: Utc::now(), 339 + }; 340 + 341 + self.tokens.lock().unwrap().push(token); 342 + Ok(()) 343 + } 344 + 345 + async fn store_app_password_session(&self, req: StoreAppPasswordSessionRequest<'_>) -> Result<(), OAuthRepoError> { 346 + if *self.should_fail.lock().unwrap() { 347 + return Err(OAuthRepoError::DatabaseError("Mock failure".to_string())); 348 + } 349 + 350 + let token = StoredToken { 351 + did: req.did.to_string(), 352 + pds_url: req.pds_url.to_string(), 353 + access_token: req.access_token.to_string(), 354 + refresh_token: req.refresh_token.map(String::from), 355 + token_type: "Bearer".to_string(), 356 + expires_at: req.expires_at, 357 + dpop_private_key: None, 294 358 created_at: Utc::now(), 295 359 updated_at: Utc::now(), 296 360 };
+5
crates/server/src/state.rs
··· 1 1 use crate::db::DbPool; 2 2 use crate::middleware::auth::UserContext; 3 + use crate::oauth::resolver::IdentityResolver; 3 4 use crate::repository; 4 5 use crate::repository::card::CardRepository; 5 6 use crate::repository::deck::DeckRepository; ··· 93 94 pub auth_cache: AuthCache, 94 95 /// Cache of valid DPoP nonces. Nonces are single-use and expire after TTL. 95 96 pub dpop_nonces: DpopNonceCache, 97 + /// Identity resolver for AT Protocol handle/DID resolution. 98 + pub identity_resolver: IdentityResolver, 96 99 } 97 100 98 101 impl AppState { 99 102 pub fn new(pool: DbPool, repos: Repositories, config: AppConfig) -> SharedState { 100 103 let auth_cache = Arc::new(RwLock::new(HashMap::new())); 101 104 let dpop_nonces = Arc::new(RwLock::new(HashMap::new())); 105 + let identity_resolver = IdentityResolver::new(); 102 106 Arc::new(Self { 103 107 pool, 104 108 oauth_repo: repos.oauth, ··· 112 116 config, 113 117 auth_cache, 114 118 dpop_nonces, 119 + identity_resolver, 115 120 }) 116 121 } 117 122
+6
migrations/014_2026_01_02_nullable_dpop_key.sql
··· 1 + -- Make dpop_private_key nullable to support app password sessions 2 + -- App password sessions don't use DPoP, only OAuth sessions do 3 + 4 + ALTER TABLE oauth_tokens ALTER COLUMN dpop_private_key DROP NOT NULL; 5 + 6 + COMMENT ON COLUMN oauth_tokens.dpop_private_key IS 'DPoP private key for OAuth sessions. NULL for app password sessions.';