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}