1use std::collections::VecDeque;
15use std::sync::{Arc, Mutex};
16
17use base64::Engine;
18use base64::engine::general_purpose::URL_SAFE_NO_PAD;
19use jsonwebtoken::Algorithm;
20use serde_json::{Value, json};
21use url::Url;
22
23use crate::AuthplaneError;
24use crate::dpop::{
25 DpopProofOptions, SUPPORTED_DPOP_ALGORITHMS, create_dpop_proof, ensure_supported_dpop_alg,
26};
27use crate::errors::validation_error;
28
29pub trait DpopNonceStore: Send + Sync {
31 fn get(&self, key: &str) -> String;
33 fn put(&self, key: &str, nonce: &str);
35}
36
37#[derive(Debug)]
39pub struct InMemoryDpopNonceStore {
40 max_entries: usize,
41 inner: Mutex<NonceInner>,
42}
43
44#[derive(Debug, Default)]
45struct NonceInner {
46 keys: VecDeque<String>,
47 values: std::collections::HashMap<String, String>,
48}
49
50impl InMemoryDpopNonceStore {
51 pub const DEFAULT_MAX_ENTRIES: usize = 128;
53
54 pub fn new() -> Self {
55 Self::with_max_entries(Self::DEFAULT_MAX_ENTRIES)
56 }
57
58 pub fn with_max_entries(max_entries: usize) -> Self {
59 Self {
60 max_entries: max_entries.max(1),
61 inner: Mutex::new(NonceInner::default()),
62 }
63 }
64}
65
66impl Default for InMemoryDpopNonceStore {
67 fn default() -> Self {
68 Self::new()
69 }
70}
71
72impl DpopNonceStore for InMemoryDpopNonceStore {
73 fn get(&self, key: &str) -> String {
74 let mut inner = self.inner.lock().expect("poisoned");
75 if let Some(value) = inner.values.get(key).cloned() {
76 inner.keys.retain(|k| k != key);
78 inner.keys.push_back(key.to_string());
79 return value;
80 }
81 String::new()
82 }
83
84 fn put(&self, key: &str, nonce: &str) {
85 let mut inner = self.inner.lock().expect("poisoned");
86 if inner.values.contains_key(key) {
87 inner.keys.retain(|k| k != key);
88 }
89 inner.keys.push_back(key.to_string());
90 inner.values.insert(key.to_string(), nonce.to_string());
91 while inner.keys.len() > self.max_entries {
92 if let Some(oldest) = inner.keys.pop_front() {
93 inner.values.remove(&oldest);
94 }
95 }
96 }
97}
98
99#[derive(Clone)]
101pub struct DpopProvider {
102 private_key_pem: String,
103 public_jwk: Value,
104 algorithm: Algorithm,
105 key_id: Option<String>,
106 proof_ttl_seconds: u64,
107 nonce_store: Arc<dyn DpopNonceStore>,
108}
109
110impl std::fmt::Debug for DpopProvider {
111 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112 f.debug_struct("DpopProvider")
113 .field("algorithm", &self.algorithm)
114 .field("key_id", &self.key_id)
115 .field("proof_ttl_seconds", &self.proof_ttl_seconds)
116 .finish()
117 }
118}
119
120impl DpopProvider {
121 pub const DEFAULT_PROOF_TTL_SECONDS: u64 = 300;
123
124 pub fn from_pem(pem_str: &str, algorithm: Algorithm) -> Result<Self, AuthplaneError> {
129 let public_jwk = extract_public_jwk(pem_str, algorithm)?;
130 Self::new(pem_str, public_jwk, algorithm)
131 }
132
133 pub fn new(
135 private_key_pem: impl Into<String>,
136 public_jwk: Value,
137 algorithm: Algorithm,
138 ) -> Result<Self, AuthplaneError> {
139 Self::with_options(
140 private_key_pem,
141 public_jwk,
142 algorithm,
143 None,
144 Self::DEFAULT_PROOF_TTL_SECONDS,
145 Arc::new(InMemoryDpopNonceStore::new()),
146 )
147 }
148
149 pub fn with_options(
151 private_key_pem: impl Into<String>,
152 public_jwk: Value,
153 algorithm: Algorithm,
154 key_id: Option<String>,
155 proof_ttl_seconds: u64,
156 nonce_store: Arc<dyn DpopNonceStore>,
157 ) -> Result<Self, AuthplaneError> {
158 if proof_ttl_seconds == 0 {
159 return Err(validation_error("DPoP proof_ttl_seconds must be positive"));
160 }
161 ensure_supported_dpop_alg(algorithm)?;
162 Ok(Self {
163 private_key_pem: private_key_pem.into(),
164 public_jwk,
165 algorithm,
166 key_id,
167 proof_ttl_seconds,
168 nonce_store,
169 })
170 }
171
172 pub fn thumbprint(&self) -> Result<String, AuthplaneError> {
176 crate::dpop::jwk_thumbprint_sha256(&self.public_jwk).map_err(|msg| validation_error(&msg))
177 }
178
179 pub fn algorithm(&self) -> Algorithm {
181 self.algorithm
182 }
183
184 pub fn proof_ttl_seconds(&self) -> u64 {
186 self.proof_ttl_seconds
187 }
188
189 pub fn public_jwk(&self) -> Value {
191 self.public_jwk.clone()
192 }
193
194 pub fn build_proof(
200 &self,
201 method: &str,
202 target_url: &str,
203 access_token: Option<&str>,
204 ) -> Result<String, AuthplaneError> {
205 let nonce = self.current_nonce(target_url)?;
206 let options = DpopProofOptions {
207 private_key_pem: self.private_key_pem.clone(),
208 public_jwk: self.public_jwk.clone(),
209 algorithm: self.algorithm,
210 key_id: self.key_id.clone(),
211 nonce: if nonce.is_empty() { None } else { Some(nonce) },
212 proof_ttl_seconds: Some(self.proof_ttl_seconds),
213 };
214 create_dpop_proof(method, target_url, access_token, &options)
215 }
216
217 pub fn build_headers(
219 &self,
220 method: &str,
221 target_url: &str,
222 access_token: Option<&str>,
223 ) -> Result<Vec<(String, String)>, AuthplaneError> {
224 let proof = self.build_proof(method, target_url, access_token)?;
225 Ok(vec![("DPoP".to_string(), proof)])
226 }
227
228 pub fn note_nonce(&self, target_url: &str, nonce: &str) -> Result<(), AuthplaneError> {
231 let key = nonce_key(target_url)?;
232 self.nonce_store.put(&key, nonce);
233 Ok(())
234 }
235
236 pub fn current_nonce(&self, target_url: &str) -> Result<String, AuthplaneError> {
238 let key = nonce_key(target_url)?;
239 Ok(self.nonce_store.get(&key))
240 }
241}
242
243fn nonce_key(target_url: &str) -> Result<String, AuthplaneError> {
244 let parsed = Url::parse(target_url)
245 .map_err(|error| validation_error(&format!("DPoP URL must be absolute: {error}")))?;
246 let scheme = parsed.scheme().to_ascii_lowercase();
247 let host = parsed
248 .host_str()
249 .ok_or_else(|| validation_error("DPoP URL is missing host"))?
250 .to_ascii_lowercase();
251 let port = parsed
252 .port()
253 .unwrap_or_else(|| if scheme == "https" { 443 } else { 80 });
254 Ok(format!("{scheme}://{host}:{port}"))
255}
256
257fn extract_public_jwk(pem_str: &str, algorithm: Algorithm) -> Result<Value, AuthplaneError> {
260 match algorithm {
261 Algorithm::ES256 => extract_ec_p256_jwk(pem_str),
262 Algorithm::RS256 => extract_rsa_jwk(pem_str),
263 _ => Err(validation_error(&format!(
264 "from_pem: unsupported algorithm {algorithm:?}; must be one of {:?}",
265 SUPPORTED_DPOP_ALGORITHMS
266 ))),
267 }
268}
269
270fn extract_ec_p256_jwk(pem_str: &str) -> Result<Value, AuthplaneError> {
271 use p256::elliptic_curve::sec1::ToEncodedPoint;
272 use p256::pkcs8::DecodePrivateKey;
273
274 let secret_key = p256::SecretKey::from_pkcs8_pem(pem_str)
275 .or_else(|_| {
276 p256::SecretKey::from_sec1_pem(pem_str)
278 })
279 .map_err(|e| validation_error(&format!("from_pem: failed to parse EC P-256 key: {e}")))?;
280 let public_key = secret_key.public_key();
281 let point = public_key.to_encoded_point(false);
282 let x = point
283 .x()
284 .ok_or_else(|| validation_error("from_pem: EC key missing x coordinate"))?;
285 let y = point
286 .y()
287 .ok_or_else(|| validation_error("from_pem: EC key missing y coordinate"))?;
288
289 Ok(json!({
290 "kty": "EC",
291 "crv": "P-256",
292 "x": URL_SAFE_NO_PAD.encode(x),
293 "y": URL_SAFE_NO_PAD.encode(y),
294 }))
295}
296
297fn extract_rsa_jwk(pem_str: &str) -> Result<Value, AuthplaneError> {
298 use rsa::pkcs8::DecodePrivateKey;
299 use rsa::traits::PublicKeyParts;
300
301 let private_key = rsa::RsaPrivateKey::from_pkcs8_pem(pem_str)
302 .map_err(|e| validation_error(&format!("from_pem: failed to parse RSA key: {e}")))?;
303 let public_key = private_key.to_public_key();
304
305 let n = public_key.n().to_bytes_be();
306 let e = public_key.e().to_bytes_be();
307
308 Ok(json!({
309 "kty": "RSA",
310 "n": URL_SAFE_NO_PAD.encode(&n),
311 "e": URL_SAFE_NO_PAD.encode(&e),
312 }))
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318
319 #[test]
320 fn nonce_store_round_trip() {
321 let store = InMemoryDpopNonceStore::new();
322 store.put("https://auth.example.com:443", "abc");
323 assert_eq!(store.get("https://auth.example.com:443"), "abc");
324 assert_eq!(store.get("https://other.example.com:443"), "");
325 }
326
327 #[test]
328 fn nonce_store_is_lru_bounded() {
329 let store = InMemoryDpopNonceStore::with_max_entries(2);
330 store.put("a", "1");
331 store.put("b", "2");
332 store.put("c", "3");
333 assert_eq!(store.get("a"), "");
335 assert_eq!(store.get("b"), "2");
336 assert_eq!(store.get("c"), "3");
337 }
338
339 #[test]
340 fn nonce_key_normalizes_origin() {
341 assert_eq!(
342 nonce_key("https://AUTH.example.com/oauth/token").unwrap(),
343 "https://auth.example.com:443"
344 );
345 assert_eq!(
346 nonce_key("http://localhost:9000/path").unwrap(),
347 "http://localhost:9000"
348 );
349 }
350
351 #[test]
352 fn rejects_invalid_url() {
353 assert!(nonce_key("not-a-url").is_err());
354 }
355
356 #[test]
357 fn rejects_zero_ttl() {
358 let result = DpopProvider::with_options(
359 "pem",
360 serde_json::json!({"kty":"RSA"}),
361 Algorithm::RS256,
362 None,
363 0,
364 Arc::new(InMemoryDpopNonceStore::new()),
365 );
366 assert!(result.is_err());
367 }
368
369 #[test]
370 fn rejects_disallowed_algorithm() {
371 let result = DpopProvider::with_options(
372 "pem",
373 serde_json::json!({"kty":"oct"}),
374 Algorithm::HS256,
375 None,
376 300,
377 Arc::new(InMemoryDpopNonceStore::new()),
378 );
379 assert!(result.is_err());
380 }
381
382 #[test]
383 fn note_nonce_persists_per_origin() {
384 let store = Arc::new(InMemoryDpopNonceStore::new());
385 let provider = DpopProvider::with_options(
386 include_str!("../tests/fixtures/test-private.pem").to_string(),
387 serde_json::json!({"kty":"RSA","alg":"RS256","use":"sig","n":"x","e":"AQAB"}),
388 Algorithm::RS256,
389 Some("kid".to_string()),
390 300,
391 store.clone(),
392 )
393 .expect("provider");
394 provider
395 .note_nonce("https://auth.example.com/token", "n-1")
396 .unwrap();
397 assert_eq!(
398 provider
399 .current_nonce("https://auth.example.com/oauth/token")
400 .unwrap(),
401 "n-1"
402 );
403 assert_eq!(
404 provider.current_nonce("https://other.example.com").unwrap(),
405 ""
406 );
407 }
408}