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}