Skip to main content

didbot_serve/oauth/
client_auth.rs

1//! Confidential clients: `private_key_jwt` client authentication.
2//!
3//! A confidential client names `private_key_jwt` as its
4//! `token_endpoint_auth_method` and publishes its public keys in its client
5//! metadata, as `jwks` or behind `jwks_uri`. Every request it makes to
6//! `POST /oauth/par` and `POST /oauth/token` carries a `client_assertion`: a
7//! JWT it signs with one of those keys (RFC 7523 §2.2). A public client
8//! (`none`) sends none, and a request from one that carries one is refused.
9//!
10//! A confidential client's login is bound to the key its assertion at PAR was
11//! signed with ([`ClientAuthKey`]), as the atproto profile's "Confidential
12//! Client Authentication" requires. The code, and every refresh after it, is
13//! refused unless the request is signed with that same key, found in the key
14//! set the client publishes now; see `crate::oauth::token`.
15//!
16//! An assertion is single-use. Its `jti` is held for as long as the assertion
17//! could still be accepted, in a window bounded the way the DPoP proof window
18//! is ([`crate::replay`]).
19
20use std::sync::Mutex;
21
22use didbot_pds::oauth::ClientAuthKey;
23use jsonwebtoken::jwk::{
24    AlgorithmParameters, EllipticCurve, Jwk, KeyAlgorithm, PublicKeyUse, ThumbprintHash,
25};
26use jsonwebtoken::{Algorithm, DecodingKey, Validation};
27use serde::{Deserialize, Serialize};
28use sha2::{Digest, Sha256};
29use time::{Duration, OffsetDateTime};
30
31use crate::oauth::client_metadata::ClientMetadataDocument;
32use crate::oauth::error::OAuthErrorCode;
33use crate::replay::{Spent, SpentKeys, MAX_JTI_BYTES, REPLAY_CAPACITY};
34
35/// The `client_assertion_type` a confidential client sends (RFC 7523 §2.2).
36pub const ASSERTION_TYPE: &str = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
37
38/// The `token_endpoint_auth_method` of a public client.
39pub const NONE: &str = "none";
40
41/// The `token_endpoint_auth_method` of a confidential client.
42pub const PRIVATE_KEY_JWT: &str = "private_key_jwt";
43
44/// The algorithm a confidential client signs its assertions with. The
45/// atproto profile requires every client and server to support ES256, and
46/// this server verifies nothing else.
47pub const SIGNING_ALG: &str = "ES256";
48
49/// [`SIGNING_ALG`], as `jsonwebtoken` names it.
50const ALGORITHM: Algorithm = Algorithm::ES256;
51
52/// How old an assertion may be, by its `iat`, before it has expired.
53/// [`ASSERTION_CLOCK_SKEW`] is allowed on top.
54///
55/// The atproto profile: "Authorization Servers should not reject client
56/// assertion JWTs generated less than a minute ago."
57pub const ASSERTION_MAX_AGE: Duration = Duration::minutes(1);
58
59/// How far a client's clock may sit from this server's, either way, when an
60/// assertion's `iat`, `exp` and `nbf` are read.
61pub const ASSERTION_CLOCK_SKEW: Duration = Duration::minutes(1);
62
63/// The longest an assertion is accepted for after its `iat`, clock skew
64/// included, and so how long its `jti` is held.
65fn window() -> Duration {
66    ASSERTION_MAX_AGE + ASSERTION_CLOCK_SKEW
67}
68
69/// How a client authenticates: the client authentication half of its
70/// metadata document, flattened into [`ClientMetadataDocument`].
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct ClientAuthentication {
73    /// [`NONE`] or [`PRIVATE_KEY_JWT`]; see
74    /// `crate::oauth::client_metadata::validate`.
75    pub token_endpoint_auth_method: String,
76    /// The algorithm a confidential client signs its assertions with.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub token_endpoint_auth_signing_alg: Option<String>,
79    /// A confidential client's public keys, in the document itself.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub jwks: Option<Jwks>,
82    /// Where a confidential client publishes its public keys instead.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub jwks_uri: Option<String>,
85    /// What `jwks_uri` served when the document was fetched. Set by
86    /// `crate::oauth::client_metadata::resolve_client` and cached with the
87    /// document. Never read from the document, so not part of its content
88    /// key.
89    #[serde(skip)]
90    pub(crate) jwks_uri_keys: Option<Jwks>,
91}
92
93impl ClientAuthentication {
94    /// A public client's: [`NONE`], and no keys.
95    pub fn public() -> Self {
96        Self {
97            token_endpoint_auth_method: NONE.to_owned(),
98            token_endpoint_auth_signing_alg: None,
99            jwks: None,
100            jwks_uri: None,
101            jwks_uri_keys: None,
102        }
103    }
104
105    /// Whether this is a confidential client's.
106    pub fn is_confidential(&self) -> bool {
107        self.token_endpoint_auth_method == PRIVATE_KEY_JWT
108    }
109
110    /// The keys this client's assertions are checked against.
111    pub fn keys(&self) -> Option<&Jwks> {
112        self.jwks.as_ref().or(self.jwks_uri_keys.as_ref())
113    }
114}
115
116/// A confidential client's key set, as this server reads it: the keys it can
117/// check an assertion against.
118///
119/// RFC 7517 §5 has a reader ignore a key it cannot use, and this does. A key
120/// is kept only when it is an EC P-256 public key with a `kid`, and it names
121/// no other algorithm and no other use. A key that carries its private half
122/// is dropped too: anyone who read the document could sign with it.
123#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
124pub struct Jwks {
125    keys: Vec<Jwk>,
126}
127
128impl<'de> Deserialize<'de> for Jwks {
129    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
130        #[derive(Deserialize)]
131        struct Published {
132            keys: Vec<serde_json::Value>,
133        }
134        let published = Published::deserialize(deserializer)?;
135        Ok(Self {
136            keys: published.keys.into_iter().filter_map(usable).collect(),
137        })
138    }
139}
140
141/// `published`, if it is a key an assertion can be checked against.
142fn usable(published: serde_json::Value) -> Option<Jwk> {
143    if published.get("d").is_some() {
144        return None;
145    }
146    let key: Jwk = serde_json::from_value(published).ok()?;
147    let named = key
148        .common
149        .key_id
150        .as_deref()
151        .is_some_and(|kid| !kid.is_empty());
152    let p256 = matches!(
153        &key.algorithm,
154        AlgorithmParameters::EllipticCurve(ec) if ec.curve == EllipticCurve::P256
155    );
156    let signs = key
157        .common
158        .public_key_use
159        .as_ref()
160        .is_none_or(|used| *used == PublicKeyUse::Signature);
161    let es256 = key
162        .common
163        .key_algorithm
164        .is_none_or(|alg| alg == KeyAlgorithm::ES256);
165    (named && p256 && signs && es256).then_some(key)
166}
167
168impl Jwks {
169    /// Whether no key in the set is one an assertion can be checked against.
170    pub fn is_empty(&self) -> bool {
171        self.keys.is_empty()
172    }
173
174    /// The first key named `kid`.
175    fn find(&self, kid: &str) -> Option<&Jwk> {
176        self.keys
177            .iter()
178            .find(|key| key.common.key_id.as_deref() == Some(kid))
179    }
180}
181
182/// The client authentication a request carries: RFC 7521 §4.2's two
183/// parameters, as they arrived.
184#[derive(Debug, Clone, Default, PartialEq, Eq)]
185pub struct ClientCredentials {
186    /// `client_assertion_type`.
187    pub assertion_type: Option<String>,
188    /// `client_assertion`.
189    pub assertion: Option<String>,
190}
191
192impl ClientCredentials {
193    /// Whether the request carries neither parameter.
194    pub fn is_empty(&self) -> bool {
195        self.assertion_type.is_none() && self.assertion.is_none()
196    }
197
198    /// The assertion, when the request carries a whole one of the one type
199    /// this server reads.
200    fn assertion(&self) -> Result<Option<&str>, ClientAuthError> {
201        match (self.assertion_type.as_deref(), self.assertion.as_deref()) {
202            (None, None) => Ok(None),
203            (Some(ASSERTION_TYPE), Some(assertion)) => Ok(Some(assertion)),
204            (Some(_), Some(_)) => Err(ClientAuthError::UnsupportedAssertionType),
205            _ => Err(ClientAuthError::Incomplete),
206        }
207    }
208}
209
210/// A confidential client that authenticated a request, and the key it signed
211/// with.
212#[derive(Debug, Clone, PartialEq, Eq)]
213pub struct AuthenticatedClient {
214    /// The `client_id` its assertion names as `iss` and `sub`.
215    pub client_id: String,
216    /// The key it signed with.
217    pub key: ClientAuthKey,
218}
219
220/// Why a client did not authenticate.
221#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
222pub enum ClientAuthError {
223    /// One of the two assertion parameters arrived without the other.
224    #[error("client_assertion and client_assertion_type go together")]
225    Incomplete,
226    /// `client_assertion_type` named something other than
227    /// [`ASSERTION_TYPE`].
228    #[error("client_assertion_type must be {ASSERTION_TYPE}")]
229    UnsupportedAssertionType,
230    /// A confidential client sent no assertion.
231    #[error("this client authenticates with private_key_jwt, and the request carries no client_assertion")]
232    AssertionRequired,
233    /// A public client sent one.
234    #[error("this client's metadata names no client authentication, and the request carries a client_assertion")]
235    AssertionRefused,
236    /// No key the client publishes has the `kid` the assertion names.
237    #[error("no key this client publishes has the kid {0:?}")]
238    UnknownKey(String),
239    /// The assertion is older than [`ASSERTION_MAX_AGE`] allows, or past its
240    /// `exp`.
241    #[error("the client_assertion has expired")]
242    Expired,
243    /// The assertion's `jti` was spent by an earlier request.
244    #[error("the client_assertion has already been used")]
245    Replayed,
246    /// The replay window is holding as many `jti`s as it will, so this
247    /// server cannot promise this one is new. Refused rather than admitted,
248    /// as [`REPLAY_CAPACITY`] explains.
249    #[error("this server is holding as many client assertions as it will; retry shortly")]
250    WindowFull,
251    /// The assertion was stamped before this server started, and it is still
252    /// inside the first window since: the replay window started empty and
253    /// cannot say whether it was spent.
254    #[error("the client_assertion was issued before this server started; sign a fresh one")]
255    PredatesStart,
256    /// Anything else the assertion gets wrong. The text says what.
257    #[error("the client_assertion is not valid: {0}")]
258    Invalid(String),
259}
260
261impl ClientAuthError {
262    /// The RFC 6749 §5.2 error this refusal answers with: `invalid_request`
263    /// for a malformed request, and `invalid_client` for every failed
264    /// authentication, as RFC 7523 §3.2 requires.
265    pub fn code(&self) -> OAuthErrorCode {
266        match self {
267            ClientAuthError::Incomplete => OAuthErrorCode::InvalidRequest,
268            _ => OAuthErrorCode::InvalidClient,
269        }
270    }
271}
272
273/// An [`ClientAuthError::Invalid`] saying `what`.
274fn invalid(what: impl Into<String>) -> ClientAuthError {
275    ClientAuthError::Invalid(what.into())
276}
277
278/// What a failed signature or claim check says, in this server's words.
279fn refusal(error: jsonwebtoken::errors::Error) -> ClientAuthError {
280    use jsonwebtoken::errors::ErrorKind;
281    match error.kind() {
282        ErrorKind::ExpiredSignature => ClientAuthError::Expired,
283        ErrorKind::InvalidSignature => {
284            invalid("its signature does not verify against the key its kid names")
285        }
286        ErrorKind::InvalidAlgorithm => invalid(format!("it must be signed with {SIGNING_ALG}")),
287        ErrorKind::InvalidAudience => invalid("its aud is not this server's issuer"),
288        ErrorKind::InvalidIssuer => invalid("its iss is not the client_id"),
289        ErrorKind::InvalidSubject => invalid("its sub is not the client_id"),
290        ErrorKind::ImmatureSignature => invalid("its nbf has not arrived"),
291        ErrorKind::MissingRequiredClaim(claim) => invalid(format!("it carries no {claim}")),
292        _ => invalid(error.to_string()),
293    }
294}
295
296/// The claims this module reads itself. `jsonwebtoken` checks `iss`, `sub`,
297/// `aud`, `exp` and `nbf`.
298#[derive(Deserialize)]
299struct AssertionClaims {
300    iat: Option<i64>,
301    jti: Option<String>,
302}
303
304/// Where a spent `jti` is held: a digest of the client and the `jti`
305/// together, so two clients' `jti`s never collide and a long `client_id`
306/// costs the window no more than a short one.
307fn replay_key(client_id: &str, jti: &str) -> String {
308    hex::encode(Sha256::digest(format!("{client_id}\n{jti}")))
309}
310
311/// Checks client assertions, and holds the `jti` of each one it accepts until
312/// the assertion could no longer be accepted anyway.
313pub struct ClientAssertionVerifier {
314    /// When this verifier began accepting assertions, to the whole second.
315    /// For the first [`window`] after it, an assertion stamped earlier is
316    /// refused ([`ClientAuthError::PredatesStart`]).
317    started: OffsetDateTime,
318    /// The `jti`s spent inside the window, bounded at [`REPLAY_CAPACITY`].
319    spent: Mutex<SpentKeys>,
320}
321
322impl Default for ClientAssertionVerifier {
323    fn default() -> Self {
324        Self::new()
325    }
326}
327
328impl ClientAssertionVerifier {
329    /// A verifier that begins accepting assertions now.
330    pub fn new() -> Self {
331        Self::started_at(OffsetDateTime::now_utc())
332    }
333
334    /// [`ClientAssertionVerifier::new`], started at `started` rather than
335    /// now, so a test can stamp an assertion on either side of it.
336    fn started_at(started: OffsetDateTime) -> Self {
337        let started = OffsetDateTime::from_unix_timestamp(started.unix_timestamp())
338            .unwrap_or(OffsetDateTime::UNIX_EPOCH);
339        Self {
340            started,
341            spent: Mutex::new(SpentKeys::new(REPLAY_CAPACITY, started)),
342        }
343    }
344
345    /// How the client `document` describes authenticated this request: the
346    /// key a login it begins is bound to, or `None` for a public client.
347    ///
348    /// `issuer` is this server's own, which an assertion's `aud` must name.
349    /// An assertion that checks out is spent here, so the same one presented
350    /// again is [`ClientAuthError::Replayed`].
351    pub fn authenticate(
352        &self,
353        document: &ClientMetadataDocument,
354        credentials: &ClientCredentials,
355        issuer: &str,
356    ) -> Result<Option<ClientAuthKey>, ClientAuthError> {
357        self.authenticate_at(document, credentials, issuer, OffsetDateTime::now_utc())
358    }
359
360    fn authenticate_at(
361        &self,
362        document: &ClientMetadataDocument,
363        credentials: &ClientCredentials,
364        issuer: &str,
365        now: OffsetDateTime,
366    ) -> Result<Option<ClientAuthKey>, ClientAuthError> {
367        let assertion = credentials.assertion()?;
368        if !document.authentication.is_confidential() {
369            return match assertion {
370                Some(_) => Err(ClientAuthError::AssertionRefused),
371                None => Ok(None),
372            };
373        }
374        let assertion = assertion.ok_or(ClientAuthError::AssertionRequired)?;
375        self.verify(document, assertion, issuer, now).map(Some)
376    }
377
378    /// Checks `assertion` against `document`'s keys: the signature first, so
379    /// no claim is read before it verifies, then the claims RFC 7523 §3
380    /// requires, then the `jti`.
381    fn verify(
382        &self,
383        document: &ClientMetadataDocument,
384        assertion: &str,
385        issuer: &str,
386        now: OffsetDateTime,
387    ) -> Result<ClientAuthKey, ClientAuthError> {
388        let client_id = document.client_id.as_str();
389        if issuer.is_empty() {
390            return Err(invalid("this server has no issuer yet for its aud to name"));
391        }
392        let header = jsonwebtoken::decode_header(assertion)
393            .map_err(|_| invalid(format!("its header does not decode as {SIGNING_ALG}")))?;
394        let kid = header
395            .kid
396            .ok_or_else(|| invalid("its header names no kid"))?;
397        let jwk = document
398            .authentication
399            .keys()
400            .and_then(|keys| keys.find(&kid))
401            .ok_or_else(|| ClientAuthError::UnknownKey(kid.clone()))?;
402        let key = DecodingKey::from_jwk(jwk)
403            .map_err(|_| invalid("the key its kid names does not decode"))?;
404
405        let mut validation = Validation::new(ALGORITHM);
406        validation.leeway = ASSERTION_CLOCK_SKEW.whole_seconds().unsigned_abs();
407        validation.validate_nbf = true;
408        validation.set_required_spec_claims(&["iss", "sub", "aud"]);
409        validation.set_issuer(&[client_id]);
410        validation.sub = Some(client_id.to_owned());
411        validation.set_audience(&[issuer]);
412        let claims = jsonwebtoken::decode::<AssertionClaims>(assertion, &key, &validation)
413            .map_err(refusal)?
414            .claims;
415
416        let iat = claims
417            .iat
418            .and_then(|iat| OffsetDateTime::from_unix_timestamp(iat).ok())
419            .ok_or_else(|| invalid("it carries no iat"))?;
420        if iat > now + ASSERTION_CLOCK_SKEW {
421            return Err(invalid("its iat is in the future"));
422        }
423        if now - iat > window() {
424            return Err(ClientAuthError::Expired);
425        }
426        // The window below is in memory, so it cannot speak for an assertion
427        // stamped before this verifier existed. After the first window every
428        // such assertion is refused as expired above.
429        if now < self.started + window() && iat < self.started {
430            return Err(ClientAuthError::PredatesStart);
431        }
432        let jti = claims
433            .jti
434            .filter(|jti| !jti.is_empty())
435            .ok_or_else(|| invalid("it carries no jti"))?;
436        if jti.len() > MAX_JTI_BYTES {
437            return Err(invalid(format!(
438                "its jti is longer than the {MAX_JTI_BYTES} bytes this server keeps"
439            )));
440        }
441        let jkt = jwk
442            .thumbprint(ThumbprintHash::SHA256)
443            .map_err(|_| invalid("the key its kid names has no thumbprint"))?;
444
445        let mut spent = self.spent.lock().unwrap_or_else(|p| p.into_inner());
446        spent.sweep_if_due(now, window());
447        spent
448            .spend(replay_key(client_id, &jti), iat + window(), now)
449            .map_err(|refused| match refused {
450                Spent::Replayed => ClientAuthError::Replayed,
451                Spent::WindowFull => ClientAuthError::WindowFull,
452            })?;
453        Ok(ClientAuthKey {
454            kid,
455            alg: SIGNING_ALG.to_owned(),
456            jkt,
457        })
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use jacquard_oauth::jose::jwt::{RegisteredClaims, RegisteredClaimsAud};
464    use jacquard_oauth::keyset::Keyset;
465    use serde_json::json;
466
467    use super::*;
468
469    const CLIENT_ID: &str = "https://app.client.example/oauth-client-metadata.json";
470    const ISSUER: &str = "https://pds.example";
471
472    /// A confidential client's document, publishing `keyset`'s public half.
473    fn document(keyset: &Keyset) -> ClientMetadataDocument {
474        serde_json::from_value(json!({
475            "client_id": CLIENT_ID,
476            "redirect_uris": ["https://app.client.example/callback"],
477            "grant_types": ["authorization_code", "refresh_token"],
478            "response_types": ["code"],
479            "token_endpoint_auth_method": PRIVATE_KEY_JWT,
480            "token_endpoint_auth_signing_alg": SIGNING_ALG,
481            "jwks": keyset.public_jwks(),
482            "dpop_bound_access_tokens": true,
483        }))
484        .expect("a confidential client's document reads")
485    }
486
487    /// An assertion `keyset` signs for [`CLIENT_ID`], stamped at `iat`.
488    fn assertion(keyset: &Keyset, iat: OffsetDateTime, jti: &str) -> ClientCredentials {
489        let es256 = jacquard_oauth::keyset::parse_signing_alg(SIGNING_ALG).expect("ES256");
490        let iat = iat.unix_timestamp();
491        let signed = keyset
492            .create_jwt(
493                &[es256],
494                RegisteredClaims {
495                    iss: Some(CLIENT_ID),
496                    sub: Some(CLIENT_ID),
497                    aud: Some(RegisteredClaimsAud::Single(ISSUER)),
498                    exp: Some(iat + 60),
499                    iat: Some(iat),
500                    jti: Some(jti.into()),
501                    ..Default::default()
502                }
503                .into(),
504            )
505            .expect("jacquard signs the assertion");
506        ClientCredentials {
507            assertion_type: Some(ASSERTION_TYPE.to_owned()),
508            assertion: Some(signed.to_string()),
509        }
510    }
511
512    /// The replay window starts empty, so for the first window after a
513    /// restart an assertion stamped before it is refused with its own
514    /// answer. One stamped after the start is accepted, and past the window
515    /// the age check alone decides.
516    #[test]
517    fn an_assertion_stamped_before_a_restart_is_refused_for_one_window() {
518        let keyset = Keyset::generate_es256("client-key-1").expect("a key");
519        let document = document(&keyset);
520        let started = OffsetDateTime::now_utc() - Duration::seconds(30);
521        let verifier = ClientAssertionVerifier::started_at(started);
522
523        let captured = assertion(&keyset, started - Duration::seconds(10), "captured");
524        assert_eq!(
525            verifier.authenticate(&document, &captured, ISSUER),
526            Err(ClientAuthError::PredatesStart)
527        );
528
529        let fresh = assertion(&keyset, OffsetDateTime::now_utc(), "fresh");
530        let bound = verifier
531            .authenticate(&document, &fresh, ISSUER)
532            .expect("an assertion stamped after the start is accepted")
533            .expect("a confidential client authenticates with a key");
534        assert_eq!(bound.kid, "client-key-1");
535        assert_eq!(bound.alg, SIGNING_ALG);
536    }
537
538    /// A key set keeps only the keys an assertion can be checked against.
539    /// A published private key is dropped, so nobody who read the document
540    /// can sign as the client with it.
541    #[test]
542    fn a_key_set_keeps_only_public_p256_signing_keys_with_a_kid() {
543        let public =
544            serde_json::to_value(Keyset::generate_es256("kept").expect("a key").public_jwks())
545                .expect("a key set serializes");
546        let kept = public["keys"][0].clone();
547        let with = |field: &str, value: serde_json::Value| {
548            let mut key = kept.clone();
549            key[field] = value;
550            key
551        };
552        let private = serde_json::to_value(Keyset::generate_es256("private").expect("a key"))
553            .expect("a private key set serializes")[0]
554            .clone();
555        let mut unnamed = kept.clone();
556        unnamed
557            .as_object_mut()
558            .expect("a key is an object")
559            .remove("kid");
560
561        // Each refused key but the RSA one is `kept` itself, bent one way,
562        // so the one key left over is the unbent original.
563        let set: Jwks = serde_json::from_value(json!({
564            "keys": [
565                private,
566                unnamed,
567                with("use", json!("enc")),
568                with("alg", json!("ES384")),
569                with("crv", json!("P-384")),
570                { "kty": "RSA", "kid": "rsa", "n": "sXch", "e": "AQAB" },
571                kept.clone(),
572            ]
573        }))
574        .expect("a key set with keys this server cannot use still reads");
575        assert_eq!(set.keys.len(), 1, "{set:?}");
576        let survivor = set.find("kept").expect("the unbent key is kept");
577        assert_eq!(survivor.common.public_key_use, None);
578        assert_eq!(survivor.common.key_algorithm, None);
579        assert!(set.find("private").is_none());
580    }
581}