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