Skip to main content

didbot_serve/oauth/
dpop_seam.rs

1//! The narrow interface every caller needs from DPoP verification (RFC 9449).
2//!
3//! [`crate::oauth::dpop`] does the proof checking, nonce issuance, replay
4//! cache and thumbprints. Its verifier implements [`DpopVerifier`], and
5//! `OAuthState::default` wires it in. The rest of this crate holds the
6//! verifier through this trait.
7//!
8//! [`RefusingDpopVerifier`] refuses every proof. Tests use it to check that
9//! token exchange refuses rather than mints an unbound token.
10
11use std::fmt;
12
13/// The output of a successful DPoP proof check: the JWK thumbprint (RFC 7638)
14/// the proof's key hashes to, which is what a minted access token is bound
15/// to and what a later resource request's own proof must match.
16#[derive(Debug, Clone, PartialEq, Eq, Hash)]
17pub struct DpopThumbprint(pub String);
18
19impl fmt::Display for DpopThumbprint {
20    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
21        f.write_str(&self.0)
22    }
23}
24
25/// Why a presented DPoP proof was not accepted.
26#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
27pub enum DpopError {
28    /// Returned by [`RefusingDpopVerifier`] for every proof.
29    #[error("DPoP verification is not wired into this deployment yet")]
30    NotImplemented,
31    /// The proof carried no nonce the verifier accepts. The client retries
32    /// with `nonce`, handed to it in `DPoP-Nonce` (RFC 9449 §8 and §9).
33    #[error("{message}")]
34    UseNonce {
35        /// The nonce to retry with.
36        nonce: String,
37        /// Why the proof's own nonce was refused.
38        message: String,
39    },
40    /// The verifier refused the proof. The message says why.
41    #[error("{0}")]
42    Refused(String),
43}
44
45/// A proof that checked out, and what [`DpopVerifier::accept`] needs to
46/// record it against replay.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct VerifiedProof {
49    /// The key this proof proves possession of, as a thumbprint.
50    pub thumbprint: DpopThumbprint,
51    /// The proof's own `jti`, which is what a replay window is over.
52    pub jti: String,
53    /// The proof's `iat`, which is when its replay window starts.
54    pub iat: time::OffsetDateTime,
55}
56
57impl VerifiedProof {
58    /// A proof standing only for the key it proved, for a caller that does
59    /// not run a replay window of its own.
60    pub fn for_key(thumbprint: impl Into<String>) -> Self {
61        Self {
62            thumbprint: DpopThumbprint(thumbprint.into()),
63            jti: String::new(),
64            iat: time::OffsetDateTime::now_utc(),
65        }
66    }
67}
68
69/// What the OAuth routes and the resource routes need to bind a request to
70/// a client's key.
71///
72/// `http_method` and `http_uri` are the request the proof was presented on —
73/// RFC 9449 binds a proof to the exact request it rides with. Nonces, replay
74/// detection and thumbprint computation are entirely
75/// [`crate::oauth::dpop`]'s concern; this trait only asks for the answer.
76pub trait DpopVerifier: Send + Sync {
77    /// Verifies `proof` (the `DPoP` request header) against the request it
78    /// was presented on, returning the key it proves possession of.
79    ///
80    /// `access_token` is the token this proof arrived alongside, when there
81    /// is one: RFC 9449 §7.1 requires the proof's `ath` claim to hash it, so
82    /// that a proof minted for one token cannot be presented with another.
83    /// `None` at `/oauth/token`, where no access token exists yet.
84    fn verify(
85        &self,
86        proof: &str,
87        http_method: &str,
88        http_uri: &str,
89        access_token: Option<&str>,
90    ) -> Result<VerifiedProof, DpopError>;
91
92    /// Records a verified proof so that the same proof presented again is
93    /// refused.
94    ///
95    /// Called once the credential the proof arrived with has checked out,
96    /// and not before: every route that takes a proof can be reached with a
97    /// freshly generated key and no credential, so a caller that recorded
98    /// at [`DpopVerifier::verify`] would be spending its replay window on
99    /// strangers. Default: nothing to record.
100    fn accept(&self, _proof: &VerifiedProof) -> Result<(), DpopError> {
101        Ok(())
102    }
103
104    /// The nonce a response to a DPoP request hands the client in
105    /// `DPoP-Nonce`, or `None` for a verifier that checks none. Default:
106    /// none.
107    fn nonce(&self) -> Option<String> {
108        None
109    }
110}
111
112/// A verifier that refuses every proof, unconditionally.
113#[derive(Debug, Clone, Copy, Default)]
114pub struct RefusingDpopVerifier;
115
116impl DpopVerifier for RefusingDpopVerifier {
117    fn verify(
118        &self,
119        _proof: &str,
120        _http_method: &str,
121        _http_uri: &str,
122        _access_token: Option<&str>,
123    ) -> Result<VerifiedProof, DpopError> {
124        Err(DpopError::NotImplemented)
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn the_default_verifier_refuses_everything() {
134        let verifier = RefusingDpopVerifier;
135        assert_eq!(
136            verifier.verify("anything", "POST", "https://example/oauth/token", None),
137            Err(DpopError::NotImplemented)
138        );
139    }
140}