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