this repo has no description
1use axum::{ 2 Json, 3 extract::FromRequestParts, 4 http::{StatusCode, request::Parts}, 5 response::{IntoResponse, Response}, 6}; 7use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 8use hmac::{Hmac, Mac}; 9use serde_json::json; 10use sha2::Sha256; 11use sqlx::PgPool; 12use subtle::ConstantTimeEq; 13 14use super::OAuthError; 15use super::db; 16use super::dpop::DPoPVerifier; 17use super::scopes::ScopePermissions; 18use crate::config::AuthConfig; 19use crate::state::AppState; 20 21pub struct OAuthTokenInfo { 22 pub did: String, 23 pub token_id: String, 24 pub client_id: String, 25 pub scope: Option<String>, 26 pub dpop_jkt: Option<String>, 27 pub controller_did: Option<String>, 28} 29 30pub struct VerifyResult { 31 pub did: String, 32 pub token_id: String, 33 pub client_id: String, 34 pub scope: Option<String>, 35} 36 37pub async fn verify_oauth_access_token( 38 pool: &PgPool, 39 access_token: &str, 40 dpop_proof: Option<&str>, 41 http_method: &str, 42 http_uri: &str, 43) -> Result<VerifyResult, OAuthError> { 44 let token_info = extract_oauth_token_info(access_token)?; 45 tracing::debug!( 46 token_id = %token_info.token_id, 47 has_dpop_proof = dpop_proof.is_some(), 48 "Verifying OAuth access token" 49 ); 50 let token_data = db::get_token_by_id(pool, &token_info.token_id) 51 .await? 52 .ok_or_else(|| { 53 tracing::warn!(token_id = %token_info.token_id, "Token not found in database"); 54 OAuthError::InvalidToken("Token not found or revoked".to_string()) 55 })?; 56 let now = chrono::Utc::now(); 57 if token_data.expires_at < now { 58 return Err(OAuthError::ExpiredToken( 59 "Token session has expired".to_string(), 60 )); 61 } 62 if let Some(expected_jkt) = &token_data.parameters.dpop_jkt { 63 tracing::debug!(expected_jkt = %expected_jkt, "Token requires DPoP"); 64 let proof = dpop_proof.ok_or_else(|| { 65 tracing::warn!("DPoP proof required but not provided"); 66 OAuthError::UseDpopNonce("DPoP proof required".to_string()) 67 })?; 68 let config = AuthConfig::get(); 69 let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); 70 let access_token_hash = compute_ath(access_token); 71 let result = verifier 72 .verify_proof(proof, http_method, http_uri, Some(&access_token_hash)) 73 .map_err(|e| { 74 tracing::warn!(error = ?e, http_method = %http_method, http_uri = %http_uri, "DPoP proof verification failed"); 75 e 76 })?; 77 if !db::check_and_record_dpop_jti(pool, &result.jti).await? { 78 return Err(OAuthError::InvalidDpopProof( 79 "DPoP proof has already been used".to_string(), 80 )); 81 } 82 if &result.jkt != expected_jkt { 83 return Err(OAuthError::InvalidDpopProof( 84 "DPoP key binding mismatch".to_string(), 85 )); 86 } 87 } 88 Ok(VerifyResult { 89 did: token_data.did, 90 token_id: token_info.token_id, 91 client_id: token_data.client_id, 92 scope: token_data.scope, 93 }) 94} 95 96pub fn extract_oauth_token_info(token: &str) -> Result<OAuthTokenInfo, OAuthError> { 97 let parts: Vec<&str> = token.split('.').collect(); 98 if parts.len() != 3 { 99 return Err(OAuthError::InvalidToken("Invalid token format".to_string())); 100 } 101 let header_bytes = URL_SAFE_NO_PAD 102 .decode(parts[0]) 103 .map_err(|_| OAuthError::InvalidToken("Invalid token encoding".to_string()))?; 104 let header: serde_json::Value = serde_json::from_slice(&header_bytes) 105 .map_err(|_| OAuthError::InvalidToken("Invalid token header".to_string()))?; 106 if header.get("typ").and_then(|t| t.as_str()) != Some("at+jwt") { 107 return Err(OAuthError::InvalidToken( 108 "Not an OAuth access token".to_string(), 109 )); 110 } 111 if header.get("alg").and_then(|a| a.as_str()) != Some("HS256") { 112 return Err(OAuthError::InvalidToken( 113 "Unsupported algorithm".to_string(), 114 )); 115 } 116 let config = AuthConfig::get(); 117 let secret = config.jwt_secret(); 118 let signing_input = format!("{}.{}", parts[0], parts[1]); 119 let provided_sig = URL_SAFE_NO_PAD 120 .decode(parts[2]) 121 .map_err(|_| OAuthError::InvalidToken("Invalid signature encoding".to_string()))?; 122 type HmacSha256 = Hmac<Sha256>; 123 let mut mac = HmacSha256::new_from_slice(secret.as_bytes()) 124 .map_err(|_| OAuthError::ServerError("HMAC initialization failed".to_string()))?; 125 mac.update(signing_input.as_bytes()); 126 let expected_sig = mac.finalize().into_bytes(); 127 if !bool::from(expected_sig.ct_eq(&provided_sig)) { 128 return Err(OAuthError::InvalidToken( 129 "Invalid token signature".to_string(), 130 )); 131 } 132 let payload_bytes = URL_SAFE_NO_PAD 133 .decode(parts[1]) 134 .map_err(|_| OAuthError::InvalidToken("Invalid payload encoding".to_string()))?; 135 let payload: serde_json::Value = serde_json::from_slice(&payload_bytes) 136 .map_err(|_| OAuthError::InvalidToken("Invalid token payload".to_string()))?; 137 let exp = payload 138 .get("exp") 139 .and_then(|e| e.as_i64()) 140 .ok_or_else(|| OAuthError::InvalidToken("Missing exp claim".to_string()))?; 141 let now = chrono::Utc::now().timestamp(); 142 if exp < now { 143 return Err(OAuthError::ExpiredToken("Token has expired".to_string())); 144 } 145 let token_id = payload 146 .get("jti") 147 .and_then(|j| j.as_str()) 148 .ok_or_else(|| OAuthError::InvalidToken("Missing jti claim".to_string()))? 149 .to_string(); 150 let did = payload 151 .get("sub") 152 .and_then(|s| s.as_str()) 153 .ok_or_else(|| OAuthError::InvalidToken("Missing sub claim".to_string()))? 154 .to_string(); 155 let scope = payload 156 .get("scope") 157 .and_then(|s| s.as_str()) 158 .map(|s| s.to_string()); 159 let dpop_jkt = payload 160 .get("cnf") 161 .and_then(|c| c.get("jkt")) 162 .and_then(|j| j.as_str()) 163 .map(|s| s.to_string()); 164 let client_id = payload 165 .get("client_id") 166 .and_then(|c| c.as_str()) 167 .map(|s| s.to_string()) 168 .unwrap_or_default(); 169 let controller_did = payload 170 .get("act") 171 .and_then(|a| a.get("sub")) 172 .and_then(|s| s.as_str()) 173 .map(|s| s.to_string()); 174 Ok(OAuthTokenInfo { 175 did, 176 token_id, 177 client_id, 178 scope, 179 dpop_jkt, 180 controller_did, 181 }) 182} 183 184fn compute_ath(access_token: &str) -> String { 185 use sha2::Digest; 186 let mut hasher = Sha256::new(); 187 hasher.update(access_token.as_bytes()); 188 let hash = hasher.finalize(); 189 URL_SAFE_NO_PAD.encode(hash) 190} 191 192pub fn generate_dpop_nonce() -> String { 193 let config = AuthConfig::get(); 194 let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); 195 verifier.generate_nonce() 196} 197 198pub struct OAuthUser { 199 pub did: String, 200 pub client_id: Option<String>, 201 pub scope: Option<String>, 202 pub is_oauth: bool, 203 pub permissions: ScopePermissions, 204} 205 206pub struct OAuthAuthError { 207 pub status: StatusCode, 208 pub error: String, 209 pub message: String, 210 pub dpop_nonce: Option<String>, 211 pub www_authenticate: Option<String>, 212} 213 214impl IntoResponse for OAuthAuthError { 215 fn into_response(self) -> Response { 216 let mut response = ( 217 self.status, 218 Json(json!({ 219 "error": self.error, 220 "message": self.message 221 })), 222 ) 223 .into_response(); 224 if let Some(nonce) = self.dpop_nonce { 225 response 226 .headers_mut() 227 .insert("DPoP-Nonce", nonce.parse().unwrap()); 228 } 229 if let Some(www_auth) = self.www_authenticate { 230 response 231 .headers_mut() 232 .insert("WWW-Authenticate", www_auth.parse().unwrap()); 233 } 234 response 235 } 236} 237 238impl FromRequestParts<AppState> for OAuthUser { 239 type Rejection = OAuthAuthError; 240 241 async fn from_request_parts( 242 parts: &mut Parts, 243 state: &AppState, 244 ) -> Result<Self, Self::Rejection> { 245 let auth_header = parts 246 .headers 247 .get("Authorization") 248 .and_then(|v| v.to_str().ok()) 249 .ok_or_else(|| OAuthAuthError { 250 status: StatusCode::UNAUTHORIZED, 251 error: "AuthenticationRequired".to_string(), 252 message: "Authorization header required".to_string(), 253 dpop_nonce: None, 254 www_authenticate: None, 255 })?; 256 let auth_header_trimmed = auth_header.trim(); 257 let (token, is_dpop_token) = if auth_header_trimmed.len() >= 7 258 && auth_header_trimmed[..7].eq_ignore_ascii_case("bearer ") 259 { 260 (auth_header_trimmed[7..].trim(), false) 261 } else if auth_header_trimmed.len() >= 5 262 && auth_header_trimmed[..5].eq_ignore_ascii_case("dpop ") 263 { 264 (auth_header_trimmed[5..].trim(), true) 265 } else { 266 return Err(OAuthAuthError { 267 status: StatusCode::UNAUTHORIZED, 268 error: "InvalidRequest".to_string(), 269 message: "Invalid authorization scheme".to_string(), 270 dpop_nonce: None, 271 www_authenticate: None, 272 }); 273 }; 274 let dpop_proof = parts.headers.get("DPoP").and_then(|v| v.to_str().ok()); 275 if let Ok(result) = try_legacy_auth(&state.db, token).await { 276 return Ok(OAuthUser { 277 did: result.did, 278 client_id: None, 279 scope: None, 280 is_oauth: false, 281 permissions: ScopePermissions::default(), 282 }); 283 } 284 let http_method = parts.method.as_str(); 285 let http_uri = crate::util::build_full_url(&parts.uri.to_string()); 286 match verify_oauth_access_token(&state.db, token, dpop_proof, http_method, &http_uri).await 287 { 288 Ok(result) => { 289 let permissions = ScopePermissions::from_scope_string(result.scope.as_deref()); 290 Ok(OAuthUser { 291 did: result.did, 292 client_id: Some(result.client_id), 293 scope: result.scope, 294 is_oauth: true, 295 permissions, 296 }) 297 } 298 Err(OAuthError::UseDpopNonce(nonce)) => Err(OAuthAuthError { 299 status: StatusCode::UNAUTHORIZED, 300 error: "use_dpop_nonce".to_string(), 301 message: "DPoP nonce required".to_string(), 302 dpop_nonce: Some(nonce), 303 www_authenticate: Some("DPoP error=\"use_dpop_nonce\"".to_string()), 304 }), 305 Err(OAuthError::InvalidDpopProof(msg)) => { 306 let nonce = generate_dpop_nonce(); 307 Err(OAuthAuthError { 308 status: StatusCode::UNAUTHORIZED, 309 error: "invalid_dpop_proof".to_string(), 310 message: msg, 311 dpop_nonce: Some(nonce), 312 www_authenticate: None, 313 }) 314 } 315 Err(OAuthError::ExpiredToken(msg)) => { 316 let nonce = if is_dpop_token { 317 Some(generate_dpop_nonce()) 318 } else { 319 None 320 }; 321 let scheme = if is_dpop_token { "DPoP" } else { "Bearer" }; 322 let www_auth = format!( 323 "{} error=\"invalid_token\", error_description=\"{}\"", 324 scheme, msg 325 ); 326 Err(OAuthAuthError { 327 status: StatusCode::UNAUTHORIZED, 328 error: "ExpiredToken".to_string(), 329 message: msg, 330 dpop_nonce: nonce, 331 www_authenticate: Some(www_auth), 332 }) 333 } 334 Err(OAuthError::InvalidToken(msg)) => { 335 let nonce = if is_dpop_token { 336 Some(generate_dpop_nonce()) 337 } else { 338 None 339 }; 340 let scheme = if is_dpop_token { "DPoP" } else { "Bearer" }; 341 let www_auth = format!( 342 "{} error=\"invalid_token\", error_description=\"{}\"", 343 scheme, msg 344 ); 345 Err(OAuthAuthError { 346 status: StatusCode::UNAUTHORIZED, 347 error: "InvalidToken".to_string(), 348 message: msg, 349 dpop_nonce: nonce, 350 www_authenticate: Some(www_auth), 351 }) 352 } 353 Err(e) => { 354 let nonce = if is_dpop_token { 355 Some(generate_dpop_nonce()) 356 } else { 357 None 358 }; 359 Err(OAuthAuthError { 360 status: StatusCode::UNAUTHORIZED, 361 error: "AuthenticationFailed".to_string(), 362 message: format!("{:?}", e), 363 dpop_nonce: nonce, 364 www_authenticate: None, 365 }) 366 } 367 } 368 } 369} 370 371struct LegacyAuthResult { 372 did: String, 373} 374 375async fn try_legacy_auth(pool: &PgPool, token: &str) -> Result<LegacyAuthResult, ()> { 376 match crate::auth::validate_bearer_token(pool, token).await { 377 Ok(user) if !user.is_oauth => Ok(LegacyAuthResult { did: user.did }), 378 _ => Err(()), 379 } 380}