Our Personal Data Server from scratch!
0

Configure Feed

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

1use axum::body::Bytes; 2use axum::{Json, extract::State, http::HeaderMap}; 3use chrono::{Duration, Utc}; 4use serde::{Deserialize, Serialize}; 5use tranquil_pds::oauth::{ 6 AuthorizationRequestParameters, ClientAuth, ClientMetadataCache, CodeChallengeMethod, 7 OAuthError, Prompt, RequestData, RequestId, ResponseMode, ResponseType, 8 scopes::{ParsedScope, parse_scope}, 9}; 10use tranquil_pds::rate_limit::{OAuthParLimit, OAuthRateLimited}; 11use tranquil_pds::state::AppState; 12use tranquil_types::{ClientId, JwkThumbprint}; 13 14const PAR_EXPIRY_SECONDS: i64 = 600; 15 16#[derive(Debug, Deserialize)] 17pub struct ParRequest { 18 pub response_type: String, 19 pub client_id: ClientId, 20 pub redirect_uri: String, 21 #[serde(default)] 22 pub scope: Option<String>, 23 #[serde(default)] 24 pub state: Option<String>, 25 #[serde(default)] 26 pub code_challenge: Option<String>, 27 #[serde(default)] 28 pub code_challenge_method: Option<String>, 29 #[serde(default)] 30 pub response_mode: Option<String>, 31 #[serde(default)] 32 pub login_hint: Option<String>, 33 #[serde(default)] 34 pub dpop_jkt: Option<JwkThumbprint>, 35 #[serde(default)] 36 pub client_secret: Option<String>, 37 #[serde(default)] 38 pub client_assertion: Option<String>, 39 #[serde(default)] 40 pub client_assertion_type: Option<String>, 41 #[serde(default)] 42 pub prompt: Option<String>, 43} 44 45#[derive(Debug, Serialize)] 46pub struct ParResponse { 47 pub request_uri: String, 48 pub expires_in: u64, 49} 50 51pub async fn pushed_authorization_request( 52 State(state): State<AppState>, 53 _rate_limit: OAuthRateLimited<OAuthParLimit>, 54 headers: HeaderMap, 55 body: Bytes, 56) -> Result<(axum::http::StatusCode, Json<ParResponse>), OAuthError> { 57 let content_type = headers 58 .get("content-type") 59 .and_then(|v| v.to_str().ok()) 60 .unwrap_or(""); 61 let request: ParRequest = if content_type.starts_with("application/json") { 62 serde_json::from_slice(&body) 63 .map_err(|e| OAuthError::InvalidRequest(format!("Invalid JSON: {}", e)))? 64 } else if content_type.starts_with("application/x-www-form-urlencoded") { 65 let parsed: ParRequest = serde_urlencoded::from_bytes(&body) 66 .map_err(|e| OAuthError::InvalidRequest(format!("Invalid form data: {}", e)))?; 67 tracing::info!(login_hint = ?parsed.login_hint, "PAR request received (form)"); 68 parsed 69 } else { 70 return Err(OAuthError::InvalidRequest( 71 "Content-Type must be application/json or application/x-www-form-urlencoded" 72 .to_string(), 73 )); 74 }; 75 let response_type = parse_response_type(&request.response_type)?; 76 let code_challenge = request 77 .code_challenge 78 .as_ref() 79 .filter(|s| !s.is_empty()) 80 .ok_or_else(|| OAuthError::InvalidRequest("code_challenge is required".to_string()))?; 81 let code_challenge_method = 82 parse_code_challenge_method(request.code_challenge_method.as_deref())?; 83 let client_cache = ClientMetadataCache::new(3600); 84 let client_metadata = client_cache.get(&request.client_id).await?; 85 client_cache.validate_redirect_uri(&client_metadata, &request.redirect_uri)?; 86 let client_auth = determine_client_auth(&request)?; 87 let validated_scope = validate_scope(&request.scope, &client_metadata)?; 88 let request_id = RequestId::generate(); 89 let expires_at = Utc::now() + Duration::seconds(PAR_EXPIRY_SECONDS); 90 let response_mode = parse_response_mode(request.response_mode.as_deref())?; 91 let prompt = parse_prompt(request.prompt.as_deref())?; 92 let parameters = AuthorizationRequestParameters { 93 response_type, 94 client_id: request.client_id.clone(), 95 redirect_uri: request.redirect_uri, 96 scope: validated_scope, 97 state: request.state, 98 code_challenge: code_challenge.clone(), 99 code_challenge_method, 100 response_mode, 101 login_hint: request.login_hint, 102 dpop_jkt: request.dpop_jkt, 103 prompt, 104 extra: None, 105 }; 106 let request_data = RequestData { 107 client_id: request.client_id, 108 client_auth: Some(client_auth), 109 parameters, 110 expires_at, 111 did: None, 112 device_id: None, 113 code: None, 114 controller_did: None, 115 }; 116 state 117 .repos 118 .oauth 119 .create_authorization_request(&request_id, &request_data) 120 .await 121 .map_err(tranquil_pds::oauth::db_err_to_oauth)?; 122 tokio::spawn({ 123 let oauth_repo = state.repos.oauth.clone(); 124 async move { 125 if let Err(e) = oauth_repo.delete_expired_authorization_requests().await { 126 tracing::warn!("Failed to cleanup expired authorization requests: {:?}", e); 127 } 128 } 129 }); 130 Ok(( 131 axum::http::StatusCode::CREATED, 132 Json(ParResponse { 133 request_uri: request_id.into_inner(), 134 expires_in: u64::try_from(PAR_EXPIRY_SECONDS).unwrap_or(600), 135 }), 136 )) 137} 138 139fn determine_client_auth(request: &ParRequest) -> Result<ClientAuth, OAuthError> { 140 let assertion = request 141 .client_assertion 142 .as_deref() 143 .filter(|s| !s.is_empty()); 144 let assertion_type = request 145 .client_assertion_type 146 .as_deref() 147 .filter(|s| !s.is_empty()); 148 let secret = request.client_secret.as_deref().filter(|s| !s.is_empty()); 149 150 if let (Some(assertion), Some(assertion_type)) = (assertion, assertion_type) { 151 if assertion_type != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" { 152 return Err(OAuthError::InvalidRequest( 153 "Unsupported client_assertion_type".to_string(), 154 )); 155 } 156 return Ok(ClientAuth::PrivateKeyJwt { 157 client_assertion: assertion.to_string(), 158 }); 159 } 160 if let Some(secret) = secret { 161 return Ok(ClientAuth::SecretPost { 162 client_secret: secret.to_string(), 163 }); 164 } 165 Ok(ClientAuth::None) 166} 167 168fn validate_scope( 169 requested_scope: &Option<String>, 170 client_metadata: &tranquil_pds::oauth::ClientMetadata, 171) -> Result<Option<String>, OAuthError> { 172 let scope_str = match requested_scope { 173 Some(s) if !s.is_empty() => s, 174 _ => return Ok(Some("atproto".to_string())), 175 }; 176 let requested_scopes: Vec<&str> = scope_str.split_whitespace().collect(); 177 if requested_scopes.is_empty() { 178 return Ok(Some("atproto".to_string())); 179 } 180 if let Some(unknown) = requested_scopes 181 .iter() 182 .find(|s| matches!(parse_scope(s), ParsedScope::Unknown(_))) 183 { 184 return Err(OAuthError::InvalidScope(format!( 185 "Unsupported scope: {}", 186 unknown 187 ))); 188 } 189 190 let has_transition = requested_scopes.iter().any(|s| { 191 matches!( 192 parse_scope(s), 193 ParsedScope::TransitionGeneric 194 | ParsedScope::TransitionChat 195 | ParsedScope::TransitionEmail 196 ) 197 }); 198 let has_granular = requested_scopes.iter().any(|s| { 199 matches!( 200 parse_scope(s), 201 ParsedScope::Repo(_) 202 | ParsedScope::Blob(_) 203 | ParsedScope::Rpc(_) 204 | ParsedScope::Account(_) 205 | ParsedScope::Identity(_) 206 | ParsedScope::Include(_) 207 ) 208 }); 209 210 if has_transition && has_granular { 211 return Err(OAuthError::InvalidScope( 212 "Cannot mix transition scopes with granular scopes. Use either transition:* scopes OR granular scopes (repo:*, blob:*, rpc:*, account:*, include:*), not both.".to_string() 213 )); 214 } 215 216 if let Some(client_scope) = &client_metadata.scope { 217 let client_scopes: Vec<&str> = client_scope.split_whitespace().collect(); 218 if let Some(unregistered) = requested_scopes 219 .iter() 220 .find(|scope| !client_scopes.iter().any(|cs| scope_matches(cs, scope))) 221 { 222 return Err(OAuthError::InvalidScope(format!( 223 "Scope '{}' not registered for this client", 224 unregistered 225 ))); 226 } 227 } 228 Ok(Some(requested_scopes.join(" "))) 229} 230 231fn scope_matches(client_scope: &str, requested_scope: &str) -> bool { 232 if client_scope == requested_scope { 233 return true; 234 } 235 236 fn get_resource_type(scope: &str) -> &str { 237 let base = scope.split('?').next().unwrap_or(scope); 238 base.split(':').next().unwrap_or(base) 239 } 240 241 let client_type = get_resource_type(client_scope); 242 let requested_type = get_resource_type(requested_scope); 243 244 if client_type == requested_type { 245 let client_base = client_scope.split('?').next().unwrap_or(client_scope); 246 if client_base.contains('*') { 247 return true; 248 } 249 } 250 251 false 252} 253 254fn parse_response_type(value: &str) -> Result<ResponseType, OAuthError> { 255 match value { 256 "code" => Ok(ResponseType::Code), 257 other => Err(OAuthError::InvalidRequest(format!( 258 "response_type must be 'code', got '{}'", 259 other 260 ))), 261 } 262} 263 264fn parse_code_challenge_method(value: Option<&str>) -> Result<CodeChallengeMethod, OAuthError> { 265 match value { 266 Some("S256") | None => Ok(CodeChallengeMethod::S256), 267 Some("plain") => Err(OAuthError::InvalidRequest( 268 "code_challenge_method 'plain' is not allowed, use 'S256'".to_string(), 269 )), 270 Some(other) => Err(OAuthError::InvalidRequest(format!( 271 "Unsupported code_challenge_method: {}", 272 other 273 ))), 274 } 275} 276 277fn parse_response_mode(value: Option<&str>) -> Result<Option<ResponseMode>, OAuthError> { 278 match value { 279 None | Some("query") => Ok(None), 280 Some("fragment") => Ok(Some(ResponseMode::Fragment)), 281 Some("form_post") => Ok(Some(ResponseMode::FormPost)), 282 Some(other) => Err(OAuthError::InvalidRequest(format!( 283 "Unsupported response_mode: {}", 284 other 285 ))), 286 } 287} 288 289fn parse_prompt(value: Option<&str>) -> Result<Option<Prompt>, OAuthError> { 290 match value { 291 None | Some("") => Ok(None), 292 Some("none") => Ok(Some(Prompt::None)), 293 Some("login") => Ok(Some(Prompt::Login)), 294 Some("consent") => Ok(Some(Prompt::Consent)), 295 Some("select_account") => Ok(Some(Prompt::SelectAccount)), 296 Some("create") => Ok(Some(Prompt::Create)), 297 Some(other) => Err(OAuthError::InvalidRequest(format!( 298 "Unsupported prompt value: {}", 299 other 300 ))), 301 } 302}