Skip to main content

authplane_sdk/
inbound_dpop.rs

1//! Per-resource inbound DPoP validation configuration.
2//!
3//! Passing any instance — even [`InboundDPoPOptions::default`] — into
4//! [`ResourceOptions`] is the on/off switch for PRM advertising of
5//! `dpop_signing_alg_values_supported` and
6//! `dpop_bound_access_tokens_required`, **and** for the verifier accepting
7//! any inbound DPoP signal. Omitting it (`None`) causes the verifier to
8//! reject any inbound DPoP signal with [`VerifierError::DpopNotSupported`]
9//! (RFC 9449 §6).
10//!
11//! [`ResourceOptions`]: crate::resource::ResourceOptions
12//! [`VerifierError::DpopNotSupported`]: crate::verified_claims::VerifierError::DpopNotSupported
13
14use std::borrow::Cow;
15use std::sync::Arc;
16
17use jsonwebtoken::Algorithm;
18
19use crate::dpop_replay::{DpopReplayStore, InMemoryDpopReplayStore};
20
21/// Per-resource inbound DPoP validation configuration (RFC 9449 §7.1 +
22/// RFC 9728 §2).
23///
24/// Fields are private so the type-system enforces the validation that
25/// [`Self::with_allowed_proof_algorithms`] performs. Construct with
26/// [`Self::default`] (Mode 2 — DPoP optional) or [`Self::required`]
27/// (Mode 1 — DPoP mandatory) and refine with the `with_*` setters.
28#[derive(Clone, Debug)]
29pub struct InboundDPoPOptions {
30    required: bool,
31    allowed_proof_algorithms: Option<Vec<Algorithm>>,
32    max_proof_age_seconds: Option<u64>,
33    clock_skew_seconds: Option<u64>,
34    /// Always populated. [`Self::default`] allocates a fresh
35    /// [`InMemoryDpopReplayStore`] so the RFC 9449 §11.1 `jti` guarantee
36    /// holds without callers having to remember to install one. Multi-
37    /// process deployments override via [`Self::with_replay_store`].
38    /// Cloning [`InboundDPoPOptions`] clones the `Arc`, so two resources
39    /// built from the same options instance share replay state — pass
40    /// distinct `InboundDPoPOptions::default()` values when independent
41    /// per-resource deduplication is required.
42    replay_store: Arc<dyn DpopReplayStore>,
43}
44
45impl Default for InboundDPoPOptions {
46    fn default() -> Self {
47        Self {
48            required: false,
49            allowed_proof_algorithms: None,
50            max_proof_age_seconds: None,
51            clock_skew_seconds: None,
52            replay_store: Arc::new(InMemoryDpopReplayStore::new()),
53        }
54    }
55}
56
57impl InboundDPoPOptions {
58    /// Mode 1 (DPoP required) with defaults for everything else. Equivalent
59    /// to `InboundDPoPOptions::default().with_required(true)` but reads
60    /// closer to intent at call sites.
61    pub fn required() -> Self {
62        Self {
63            required: true,
64            ..Self::default()
65        }
66    }
67
68    /// Set whether DPoP binding is required. When `true`, bearer-only
69    /// tokens (no `cnf.jkt`) are rejected with `DpopBindingMismatch` and
70    /// the PRM advertises `dpop_bound_access_tokens_required: true`.
71    pub fn with_required(mut self, required: bool) -> Self {
72        self.required = required;
73        self
74    }
75
76    /// Restrict accepted DPoP proof algorithms to a non-empty subset of
77    /// [`crate::dpop::SUPPORTED_DPOP_ALGORITHMS`]. Returns an error on
78    /// empty or unsupported entries so callers cannot install
79    /// asymmetric-only-bypass values like `HS256` through this setter
80    /// (or any other path — the field is private).
81    pub fn with_allowed_proof_algorithms(
82        mut self,
83        algorithms: Vec<Algorithm>,
84    ) -> Result<Self, InboundDPoPOptionsError> {
85        if algorithms.is_empty() {
86            return Err(InboundDPoPOptionsError::EmptyAlgorithmList);
87        }
88        for alg in &algorithms {
89            if !crate::dpop::SUPPORTED_DPOP_ALGORITHMS.contains(alg) {
90                return Err(InboundDPoPOptionsError::UnsupportedAlgorithm(*alg));
91            }
92        }
93        self.allowed_proof_algorithms = Some(algorithms);
94        Ok(self)
95    }
96
97    /// Override the maximum accepted proof age (seconds from `iat`).
98    /// Unset means inherit `ResourceOptions::dpop_proof_max_age_seconds`.
99    pub fn with_max_proof_age_seconds(mut self, seconds: u64) -> Self {
100        self.max_proof_age_seconds = Some(seconds);
101        self
102    }
103
104    /// Override the clock skew (seconds) tolerated on proof time claims.
105    /// Unset means inherit `ResourceOptions::clock_skew_seconds`.
106    pub fn with_clock_skew_seconds(mut self, seconds: u64) -> Self {
107        self.clock_skew_seconds = Some(seconds);
108        self
109    }
110
111    /// Install a custom replay store. Defaults to a fresh
112    /// [`InMemoryDpopReplayStore`] per [`InboundDPoPOptions::default`]
113    /// allocation — sufficient for single-process deployments. Multi-
114    /// process and distributed deployments MUST pass a shared store
115    /// (Redis, database) so the RFC 9449 §11.1 `jti` guarantee holds
116    /// across replicas.
117    pub fn with_replay_store(mut self, store: Arc<dyn DpopReplayStore>) -> Self {
118        self.replay_store = store;
119        self
120    }
121
122    /// Whether the resource requires DPoP-bound tokens (Mode 1).
123    pub fn is_required(&self) -> bool {
124        self.required
125    }
126
127    /// Replay store the resource will use for proof-`jti` deduplication.
128    /// Always non-`None` — see [`Self::with_replay_store`] for the
129    /// default and override semantics.
130    pub fn replay_store(&self) -> &Arc<dyn DpopReplayStore> {
131        &self.replay_store
132    }
133
134    /// Effective allowed proof algorithms: borrows the configured slice
135    /// when set, or the SDK default otherwise. Borrowed in both cases —
136    /// callers iterating or `contains`-checking pay no allocation.
137    pub fn resolved_allowed_proof_algorithms(&self) -> Cow<'_, [Algorithm]> {
138        match self.allowed_proof_algorithms.as_deref() {
139            Some(algs) => Cow::Borrowed(algs),
140            None => Cow::Borrowed(crate::dpop::SUPPORTED_DPOP_ALGORITHMS),
141        }
142    }
143
144    /// Effective max proof age, falling through to `resource_default` when
145    /// the caller didn't set one explicitly. `ResourceOptions` carries the
146    /// SDK-wide default of 300s; this method only resolves the override
147    /// layer.
148    pub fn resolved_max_proof_age_seconds(&self, resource_default: u64) -> u64 {
149        self.max_proof_age_seconds.unwrap_or(resource_default)
150    }
151
152    /// Effective clock skew, falling through to `resource_default` when
153    /// the caller didn't set one explicitly. The SDK-wide default of 30s
154    /// lives on `ResourceOptions`.
155    pub fn resolved_clock_skew_seconds(&self, resource_default: u64) -> u64 {
156        self.clock_skew_seconds.unwrap_or(resource_default)
157    }
158}
159
160/// Configuration error surfaced by [`InboundDPoPOptions::with_allowed_proof_algorithms`].
161#[derive(Debug, thiserror::Error)]
162#[non_exhaustive]
163pub enum InboundDPoPOptionsError {
164    #[error(
165        "allowed_proof_algorithms must be non-empty; omit the setter to accept the default set"
166    )]
167    EmptyAlgorithmList,
168
169    #[error("DPoP proof algorithm {0:?} is not supported; supported algorithms are ES256, RS256")]
170    UnsupportedAlgorithm(Algorithm),
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[tokio::test]
178    async fn defaults_to_mode_2() {
179        let opts = InboundDPoPOptions::default();
180        assert!(!opts.is_required());
181        // Defaults inherit from the resource-level setting.
182        assert_eq!(opts.resolved_max_proof_age_seconds(600), 600);
183        assert_eq!(opts.resolved_clock_skew_seconds(60), 60);
184        // Replay store is auto-allocated, so `jti` deduplication is on by
185        // default without the
186        // caller having to remember to install a store. Smoke-check the
187        // store works by recording a `jti`.
188        let stored = opts
189            .replay_store()
190            .check_and_store("jti-default-test", 4102444800)
191            .await
192            .expect("default replay store must be usable");
193        assert!(stored, "first observation of jti must succeed");
194    }
195
196    #[tokio::test]
197    async fn each_default_allocates_an_independent_replay_store() {
198        // Two `InboundDPoPOptions::default()` calls must NOT share state —
199        // otherwise a duplicate `jti` from resource B would falsely look
200        // like a replay of resource A's earlier proof.
201        let opts_a = InboundDPoPOptions::default();
202        let opts_b = InboundDPoPOptions::default();
203        opts_a
204            .replay_store()
205            .check_and_store("jti-shared", 4102444800)
206            .await
207            .expect("store A accepts");
208        let stored_in_b = opts_b
209            .replay_store()
210            .check_and_store("jti-shared", 4102444800)
211            .await
212            .expect("store B accepts");
213        assert!(stored_in_b, "distinct defaults must not share replay state");
214    }
215
216    #[test]
217    fn required_shortcut_sets_only_the_flag() {
218        let opts = InboundDPoPOptions::required();
219        assert!(opts.is_required());
220        // Other inheritance points still inherit from the resource.
221        assert_eq!(opts.resolved_clock_skew_seconds(60), 60);
222    }
223
224    #[test]
225    fn with_allowed_proof_algorithms_rejects_empty() {
226        let res = InboundDPoPOptions::default().with_allowed_proof_algorithms(vec![]);
227        assert!(matches!(
228            res,
229            Err(InboundDPoPOptionsError::EmptyAlgorithmList)
230        ));
231    }
232
233    #[test]
234    fn with_allowed_proof_algorithms_rejects_unsupported() {
235        let res =
236            InboundDPoPOptions::default().with_allowed_proof_algorithms(vec![Algorithm::HS256]);
237        assert!(matches!(
238            res,
239            Err(InboundDPoPOptionsError::UnsupportedAlgorithm(
240                Algorithm::HS256
241            ))
242        ));
243    }
244
245    #[test]
246    fn with_allowed_proof_algorithms_accepts_es256_subset() {
247        let opts = InboundDPoPOptions::default()
248            .with_allowed_proof_algorithms(vec![Algorithm::ES256])
249            .expect("valid");
250        match opts.resolved_allowed_proof_algorithms() {
251            Cow::Borrowed(slice) => assert_eq!(slice, &[Algorithm::ES256]),
252            Cow::Owned(_) => panic!("setter slice should be borrowed"),
253        }
254    }
255
256    #[test]
257    fn resolved_allowed_proof_algorithms_borrows_default_when_unset() {
258        let opts = InboundDPoPOptions::default();
259        let resolved = opts.resolved_allowed_proof_algorithms();
260        // Hot-path-friendly: no allocation when the caller didn't override.
261        assert!(matches!(resolved, Cow::Borrowed(_)));
262        assert_eq!(&*resolved, crate::dpop::SUPPORTED_DPOP_ALGORITHMS);
263    }
264
265    #[test]
266    fn resolved_clock_skew_inherits_resource_default() {
267        let opts = InboundDPoPOptions::default();
268        assert_eq!(opts.resolved_clock_skew_seconds(60), 60);
269        let opts = opts.with_clock_skew_seconds(10);
270        assert_eq!(opts.resolved_clock_skew_seconds(60), 10);
271    }
272
273    #[test]
274    fn resolved_max_proof_age_inherits_resource_default() {
275        let opts = InboundDPoPOptions::default();
276        assert_eq!(opts.resolved_max_proof_age_seconds(600), 600);
277        let opts = opts.with_max_proof_age_seconds(120);
278        assert_eq!(opts.resolved_max_proof_age_seconds(600), 120);
279    }
280}