Skip to main content

authplane_sdk/
dpop_provider.rs

1//! Outbound DPoP provider with per-origin nonce store and helper to build
2//! the `DPoP` request header.
3//!
4//! The provider is intentionally synchronous around proof generation (it does
5//! not perform network I/O); nonce retry on 401 `use_dpop_nonce` is implemented
6//! at the call site by:
7//!
8//! 1. building a proof with the current nonce,
9//! 2. sending the request,
10//! 3. on `use_dpop_nonce`, calling [`DpopProvider::note_nonce`] with the
11//!    server-supplied `DPoP-Nonce` header value, and
12//! 4. retrying once with the freshly-stored nonce.
13
14use 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
29/// Trait used by [`DpopProvider`] to remember per-origin DPoP nonces.
30pub trait DpopNonceStore: Send + Sync {
31    /// Return the nonce currently stored for `key`, or empty.
32    fn get(&self, key: &str) -> String;
33    /// Insert (or replace) the nonce for `key`.
34    fn put(&self, key: &str, nonce: &str);
35}
36
37/// Bounded LRU-style in-memory nonce store. Single-process default.
38#[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    /// Default cap.
52    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            // Move-to-end semantics for LRU.
77            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/// Outbound DPoP provider.
100#[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    /// Default proof TTL.
122    pub const DEFAULT_PROOF_TTL_SECONDS: u64 = 300;
123
124    /// Build a provider by importing a PEM-encoded private key.
125    ///
126    /// Extracts the public JWK parameters automatically. Supports ES256
127    /// (P-256 EC) and RS256 (RSA) algorithms.
128    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    /// Build a provider with an in-memory nonce store.
134    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    /// Build a provider with all knobs exposed.
150    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    /// RFC 7638 SHA-256 thumbprint of the configured public JWK.
173    ///
174    /// This is the value that appears as `cnf.jkt` in DPoP-bound tokens.
175    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    /// Algorithm of the configured signing key.
180    pub fn algorithm(&self) -> Algorithm {
181        self.algorithm
182    }
183
184    /// Configured proof TTL in seconds.
185    pub fn proof_ttl_seconds(&self) -> u64 {
186        self.proof_ttl_seconds
187    }
188
189    /// Public JWK shipped in the proof header (clone).
190    pub fn public_jwk(&self) -> Value {
191        self.public_jwk.clone()
192    }
193
194    /// Build a DPoP proof JWT for the given HTTP method/URL/access token.
195    ///
196    /// The current per-origin nonce (if any) is automatically added to the
197    /// proof claims. Callers can override the nonce or leave the provider
198    /// to fetch one from its store.
199    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    /// Convenience: build proof + return `{ "DPoP": <proof> }` headers map.
218    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    /// Record a server-issued `DPoP-Nonce` for the URL's origin so the next
229    /// outbound request reuses it.
230    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    /// Look up the current nonce for the URL's origin (empty string if none).
237    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
257/// Extract the public JWK (as `serde_json::Value`) from a PEM-encoded
258/// private key. Supports ES256 (P-256) and RS256 (RSA).
259fn 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            // Also try SEC1 format (-----BEGIN EC PRIVATE KEY-----)
277            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        // "a" should have been evicted (oldest).
334        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}