A better Rust ATProto crate
at oauth 7.4 kB view raw
1use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 2use chrono::Utc; 3use http::{Request, Response, header::InvalidHeaderValue}; 4use jacquard_common::{CowStr, IntoStatic, cowstr::ToCowStr, http_client::HttpClient}; 5use jose_jwa::{Algorithm, Signing}; 6use jose_jwk::{Jwk, Key, crypto}; 7use p256::ecdsa::SigningKey; 8use rand::{RngCore, SeedableRng}; 9use sha2::Digest; 10 11use crate::{ 12 jose::{ 13 create_signed_jwt, 14 jws::RegisteredHeader, 15 jwt::{Claims, PublicClaims, RegisteredClaims}, 16 }, 17 session::DpopDataSource, 18}; 19 20pub const JWT_HEADER_TYP_DPOP: &str = "dpop+jwt"; 21 22#[derive(serde::Deserialize)] 23struct ErrorResponse { 24 error: String, 25} 26 27#[derive(thiserror::Error, Debug, miette::Diagnostic)] 28pub enum Error { 29 #[error(transparent)] 30 InvalidHeaderValue(#[from] InvalidHeaderValue), 31 #[error("crypto error: {0:?}")] 32 JwkCrypto(crypto::Error), 33 #[error("key does not match any alg supported by the server")] 34 UnsupportedKey, 35 #[error(transparent)] 36 SerdeJson(#[from] serde_json::Error), 37 #[error("Inner: {0}")] 38 Inner(#[source] Box<dyn std::error::Error + Send + Sync>), 39} 40 41type Result<T> = core::result::Result<T, Error>; 42 43#[async_trait::async_trait] 44pub trait DpopClient: HttpClient { 45 async fn dpop_server(&self, request: Request<Vec<u8>>) -> Result<Response<Vec<u8>>>; 46 async fn dpop_client(&self, request: Request<Vec<u8>>) -> Result<Response<Vec<u8>>>; 47 async fn wrap_request(&self, request: Request<Vec<u8>>) -> Result<Response<Vec<u8>>>; 48} 49 50pub trait DpopExt: HttpClient { 51 fn dpop_server_call<'r, D>(&'r self, data_source: &'r mut D) -> DpopCall<'r, Self, D> 52 where 53 Self: Sized, 54 D: DpopDataSource, 55 { 56 DpopCall::server(self, data_source) 57 } 58 59 fn dpop_call<'r, N>(&'r self, data_source: &'r mut N) -> DpopCall<'r, Self, N> 60 where 61 Self: Sized, 62 N: DpopDataSource, 63 { 64 DpopCall::client(self, data_source) 65 } 66} 67 68pub struct DpopCall<'r, C: HttpClient, D: DpopDataSource> { 69 pub client: &'r C, 70 pub is_to_auth_server: bool, 71 pub data_source: &'r mut D, 72} 73 74impl<'r, C: HttpClient, N: DpopDataSource> DpopCall<'r, C, N> { 75 pub fn server(client: &'r C, data_source: &'r mut N) -> Self { 76 Self { 77 client, 78 is_to_auth_server: true, 79 data_source, 80 } 81 } 82 83 pub fn client(client: &'r C, data_source: &'r mut N) -> Self { 84 Self { 85 client, 86 is_to_auth_server: false, 87 data_source, 88 } 89 } 90 91 pub async fn send(self, request: Request<Vec<u8>>) -> Result<Response<Vec<u8>>> { 92 wrap_request_with_dpop( 93 self.client, 94 self.data_source, 95 self.is_to_auth_server, 96 request, 97 ) 98 .await 99 } 100} 101 102pub async fn wrap_request_with_dpop<T, N>( 103 client: &T, 104 data_source: &mut N, 105 is_to_auth_server: bool, 106 mut request: Request<Vec<u8>>, 107) -> Result<Response<Vec<u8>>> 108where 109 T: HttpClient, 110 N: DpopDataSource, 111{ 112 let uri = request.uri().clone(); 113 let method = request.method().to_cowstr().into_static(); 114 let uri = uri.to_cowstr(); 115 // https://datatracker.ietf.org/doc/html/rfc9449#section-4.2 116 let ath = request 117 .headers() 118 .get("Authorization") 119 .filter(|v| v.to_str().is_ok_and(|s| s.starts_with("DPoP "))) 120 .map(|auth| { 121 URL_SAFE_NO_PAD 122 .encode(sha2::Sha256::digest(&auth.as_bytes()[5..])) 123 .into() 124 }); 125 126 let init_nonce = if is_to_auth_server { 127 data_source.authserver_nonce() 128 } else { 129 data_source.host_nonce() 130 }; 131 let init_proof = build_dpop_proof( 132 data_source.key(), 133 method.clone(), 134 uri.clone(), 135 init_nonce.clone(), 136 ath.clone(), 137 )?; 138 request.headers_mut().insert("DPoP", init_proof.parse()?); 139 let response = client 140 .send_http(request.clone()) 141 .await 142 .map_err(|e| Error::Inner(e.into()))?; 143 144 let next_nonce = response 145 .headers() 146 .get("DPoP-Nonce") 147 .and_then(|v| v.to_str().ok()) 148 .map(|c| c.to_cowstr()); 149 match &next_nonce { 150 Some(s) if next_nonce != init_nonce => { 151 // Store the fresh nonce for future requests 152 if is_to_auth_server { 153 data_source.set_authserver_nonce(s.clone()); 154 } else { 155 data_source.set_host_nonce(s.clone()); 156 } 157 } 158 _ => { 159 // No nonce was returned or it is the same as the one we sent. No need to 160 // update the nonce store, or retry the request. 161 return Ok(response); 162 } 163 } 164 165 if !is_use_dpop_nonce_error(is_to_auth_server, &response) { 166 return Ok(response); 167 } 168 let next_proof = build_dpop_proof(data_source.key(), method, uri, next_nonce, ath)?; 169 request.headers_mut().insert("DPoP", next_proof.parse()?); 170 let response = client 171 .send_http(request) 172 .await 173 .map_err(|e| Error::Inner(e.into()))?; 174 Ok(response) 175} 176 177#[inline] 178fn is_use_dpop_nonce_error(is_to_auth_server: bool, response: &Response<Vec<u8>>) -> bool { 179 // https://datatracker.ietf.org/doc/html/rfc9449#name-authorization-server-provid 180 if is_to_auth_server { 181 if response.status() == 400 { 182 if let Ok(res) = serde_json::from_slice::<ErrorResponse>(response.body()) { 183 return res.error == "use_dpop_nonce"; 184 }; 185 } 186 } 187 // https://datatracker.ietf.org/doc/html/rfc6750#section-3 188 // https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no 189 else if response.status() == 401 { 190 if let Some(www_auth) = response 191 .headers() 192 .get("WWW-Authenticate") 193 .and_then(|v| v.to_str().ok()) 194 { 195 return www_auth.starts_with("DPoP") && www_auth.contains(r#"error="use_dpop_nonce""#); 196 } 197 } 198 false 199} 200 201#[inline] 202pub(crate) fn generate_jti() -> CowStr<'static> { 203 let mut rng = rand::rngs::SmallRng::from_entropy(); 204 let mut bytes = [0u8; 12]; 205 rng.fill_bytes(&mut bytes); 206 URL_SAFE_NO_PAD.encode(bytes).into() 207} 208 209/// Build a compact JWS (ES256) for DPoP with embedded public JWK. 210#[inline] 211pub fn build_dpop_proof<'s>( 212 key: &Key, 213 method: CowStr<'s>, 214 url: CowStr<'s>, 215 nonce: Option<CowStr<'s>>, 216 ath: Option<CowStr<'s>>, 217) -> Result<CowStr<'s>> { 218 let secret = match crypto::Key::try_from(key).map_err(Error::JwkCrypto)? { 219 crypto::Key::P256(crypto::Kind::Secret(sk)) => sk, 220 _ => return Err(Error::UnsupportedKey), 221 }; 222 let mut header = RegisteredHeader::from(Algorithm::Signing(Signing::Es256)); 223 header.typ = Some(JWT_HEADER_TYP_DPOP.into()); 224 header.jwk = Some(Jwk { 225 key: Key::from(&crypto::Key::from(secret.public_key())), 226 prm: Default::default(), 227 }); 228 229 let claims = Claims { 230 registered: RegisteredClaims { 231 jti: Some(generate_jti()), 232 iat: Some(Utc::now().timestamp()), 233 ..Default::default() 234 }, 235 public: PublicClaims { 236 htm: Some(method), 237 htu: Some(url), 238 ath: ath, 239 nonce: nonce, 240 }, 241 }; 242 Ok(create_signed_jwt( 243 SigningKey::from(secret.clone()), 244 header.into(), 245 claims, 246 )?) 247}