Skip to main content

feather_reader/oauth/
client_auth.rs

1//! How this client authenticates to the authorization server.
2//!
3//! Two shapes, and which one applies is not a detail:
4//!
5//! * **`none`** — the localhost development client. Credentials are *just* the
6//!   `client_id`; there is no assertion, because a public client has no key
7//!   registered to sign one with.
8//! * **`private_key_jwt`** — production. A short-lived ES256 assertion signed
9//!   with the confidential client's key.
10//!
11//! Sending an assertion unconditionally would make every dev login fail at PAR
12//! (a public client presenting credentials it never registered), leaving the
13//! flow only exercisable against production. Sending none in production is a
14//! silent downgrade to an unauthenticated client. So the method is negotiated,
15//! stored in the state row, and re-checked at token time.
16
17use anyhow::{bail, Result};
18use base64::engine::general_purpose::URL_SAFE_NO_PAD;
19use base64::Engine;
20use serde_json::json;
21
22use super::jwt;
23use super::keys::SigningKey;
24
25/// RFC 7523's assertion type for a JWT client credential.
26const JWT_BEARER: &str = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
27
28/// How long a client assertion is valid. Matches the reference's `now + 60`;
29/// an assertion is used immediately, so a short life bounds replay.
30const ASSERTION_LIFETIME_SECS: i64 = 60;
31
32/// The client-authentication method in use for a flow.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum AuthMethod {
35    /// Public client (localhost development). No assertion.
36    None,
37    /// Confidential client. ES256 assertion signed with the client's key.
38    PrivateKeyJwt,
39}
40
41impl AuthMethod {
42    /// Which method this client shape uses.
43    ///
44    /// Must agree with the `token_endpoint_auth_method` published by
45    /// [`super::metadata`], or the authorization server is told one thing and
46    /// sent another.
47    pub fn negotiate(dev: bool) -> Self {
48        if dev {
49            AuthMethod::None
50        } else {
51            AuthMethod::PrivateKeyJwt
52        }
53    }
54
55    pub fn as_str(self) -> &'static str {
56        match self {
57            AuthMethod::None => "none",
58            AuthMethod::PrivateKeyJwt => "private_key_jwt",
59        }
60    }
61}
62
63/// Parse the wire name, for reading back the method stored in a state row.
64impl std::str::FromStr for AuthMethod {
65    type Err = anyhow::Error;
66
67    fn from_str(value: &str) -> Result<Self> {
68        match value {
69            "none" => Ok(AuthMethod::None),
70            "private_key_jwt" => Ok(AuthMethod::PrivateKeyJwt),
71            other => bail!("unsupported client-auth method {other:?}"),
72        }
73    }
74}
75
76/// The client-credential form parameters for a request.
77///
78/// A public client sends only its `client_id` — attaching an assertion it never
79/// registered is what makes every dev login fail at PAR. A confidential client
80/// that somehow has no assertion is an error rather than a silently
81/// unauthenticated request.
82pub fn credential_params(
83    method: AuthMethod,
84    client_id: &str,
85    assertion: Option<&str>,
86) -> Result<Vec<(&'static str, String)>> {
87    let mut params = vec![("client_id", client_id.to_string())];
88    if method == AuthMethod::PrivateKeyJwt {
89        let Some(assertion) = assertion else {
90            bail!("private_key_jwt requires a client assertion, but none was supplied");
91        };
92        params.push(("client_assertion_type", JWT_BEARER.to_string()));
93        params.push(("client_assertion", assertion.to_string()));
94    }
95    Ok(params)
96}
97
98/// Mint a `private_key_jwt` client assertion.
99///
100/// `aud` is the **issuer**, not the token endpoint. RFC 7523 permits either
101/// reading; atproto settles it — *"the `aud` claim of the client assertion JWT
102/// must be the Authorization Server's `issuer`"* — which also means one audience
103/// covers PAR, token, refresh and revocation alike.
104///
105/// A fresh `jti` per call, because that claim exists to stop replay.
106///
107/// **But NOT per retry.** The nonce-challenge retry in
108/// [`super::request::send_with_dpop`] re-sends the identical form body and
109/// re-mints only the DPoP proof, so the assertion — and its `jti` — is replayed
110/// verbatim. This matches the reference client, which does the same, but an
111/// authorization server that enforces single-use `jti` on client assertions
112/// would answer the retry with `invalid_client` and turn a recoverable nonce
113/// challenge into a failed login.
114///
115/// This comment previously claimed the opposite. It was wrong, and the retry
116/// path is structurally unable to reach the assertion builder.
117///
118/// The header carries `kid` and nothing else beyond `alg` (which
119/// [`super::jwt::sign`] owns). The reference sends no `typ` here, and keeping it
120/// absent leaves `typ` meaningful where it IS load-bearing — `dpop+jwt` on a
121/// DPoP proof. `nbf` is not required and is not sent.
122pub fn client_assertion(
123    key: &SigningKey,
124    client_id: &str,
125    issuer: &str,
126    now: i64,
127) -> Result<String> {
128    let mut jti = [0u8; 16];
129    getrandom::fill(&mut jti).expect("OS CSPRNG unavailable; refusing to mint a client assertion");
130
131    jwt::sign(
132        key,
133        &json!({ "kid": key.kid() }),
134        &json!({
135            "iss": client_id,
136            "sub": client_id,
137            "aud": issuer,
138            "jti": URL_SAFE_NO_PAD.encode(jti),
139            "iat": now,
140            "exp": now + ASSERTION_LIFETIME_SECS,
141        }),
142    )
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use serde_json::Value;
149
150    const CLIENT_ID: &str = "https://feather-reader.com/oauth/client-metadata.json";
151    const ISSUER: &str = "https://auth.example.com";
152    const KID: &str = "featherreader-oauth-1";
153    const NOW: i64 = 1_700_000_000;
154
155    fn part(jws: &str, index: usize) -> Value {
156        let raw = URL_SAFE_NO_PAD
157            .decode(jws.split('.').nth(index).unwrap())
158            .unwrap();
159        serde_json::from_slice(&raw).unwrap()
160    }
161
162    // ── negotiation ──────────────────────────────────────────────────────────
163
164    /// Must agree with what `metadata.rs` publishes as
165    /// `token_endpoint_auth_method`, or the AS is told one thing and sent
166    /// another.
167    #[test]
168    fn the_method_follows_the_client_shape() {
169        assert_eq!(AuthMethod::negotiate(true), AuthMethod::None);
170        assert_eq!(AuthMethod::negotiate(false), AuthMethod::PrivateKeyJwt);
171        assert_eq!(AuthMethod::None.as_str(), "none");
172        assert_eq!(AuthMethod::PrivateKeyJwt.as_str(), "private_key_jwt");
173    }
174
175    #[test]
176    fn the_method_round_trips_through_its_wire_name() {
177        for method in [AuthMethod::None, AuthMethod::PrivateKeyJwt] {
178            assert_eq!(method.as_str().parse::<AuthMethod>().unwrap(), method);
179        }
180        assert!("client_secret_basic".parse::<AuthMethod>().is_err());
181        assert!("".parse::<AuthMethod>().is_err());
182    }
183
184    // ── credential parameters ────────────────────────────────────────────────
185
186    /// A public client sends ONLY its id. Attaching an assertion it cannot
187    /// register is what makes a dev login fail at PAR.
188    #[test]
189    fn a_public_client_sends_only_its_client_id() {
190        let params = credential_params(AuthMethod::None, CLIENT_ID, None).unwrap();
191        assert_eq!(params, vec![("client_id", CLIENT_ID.to_string())]);
192
193        // Even if an assertion is offered, `none` must not send one.
194        let params = credential_params(AuthMethod::None, CLIENT_ID, Some("jws")).unwrap();
195        assert_eq!(params.len(), 1);
196        assert!(!params
197            .iter()
198            .any(|(k, _)| k.starts_with("client_assertion")));
199    }
200
201    #[test]
202    fn a_confidential_client_sends_the_assertion_and_its_type() {
203        let params =
204            credential_params(AuthMethod::PrivateKeyJwt, CLIENT_ID, Some("the-jws")).unwrap();
205        assert_eq!(
206            params,
207            vec![
208                ("client_id", CLIENT_ID.to_string()),
209                (
210                    "client_assertion_type",
211                    "urn:ietf:params:oauth:client-assertion-type:jwt-bearer".to_string()
212                ),
213                ("client_assertion", "the-jws".to_string()),
214            ]
215        );
216    }
217
218    /// Failing loudly beats silently sending an unauthenticated request that
219    /// the AS would reject with a less legible error.
220    #[test]
221    fn a_confidential_client_without_an_assertion_is_an_error() {
222        assert!(credential_params(AuthMethod::PrivateKeyJwt, CLIENT_ID, None).is_err());
223    }
224
225    // ── the assertion ────────────────────────────────────────────────────────
226
227    #[test]
228    fn the_assertion_carries_the_required_claims() {
229        let key = SigningKey::generate(KID);
230        let jws = client_assertion(&key, CLIENT_ID, ISSUER, NOW).unwrap();
231        let claims = part(&jws, 1);
232
233        // RFC 7523 §3: iss, sub, aud, exp are REQUIRED. atproto additionally
234        // requires iat and jti.
235        assert_eq!(claims["iss"], CLIENT_ID);
236        assert_eq!(claims["sub"], CLIENT_ID);
237        assert_eq!(claims["iat"], NOW);
238        assert_eq!(claims["exp"], NOW + 60);
239        assert!(claims["jti"].as_str().is_some_and(|j| j.len() >= 22));
240    }
241
242    /// **`aud` is the ISSUER, not the token endpoint.** RFC 7523 permits either
243    /// reading; atproto settles it — "the `aud` claim of the client assertion
244    /// JWT must be the Authorization Server's `issuer`". The same audience is
245    /// therefore used for PAR, token, refresh and revocation.
246    #[test]
247    fn the_audience_is_the_issuer() {
248        let key = SigningKey::generate(KID);
249        let claims = part(&client_assertion(&key, CLIENT_ID, ISSUER, NOW).unwrap(), 1);
250        assert_eq!(claims["aud"], ISSUER);
251    }
252
253    /// `nbf` is not required and the reference does not send it; a header `typ`
254    /// is likewise absent there. Keeping the header minimal also keeps `typ`
255    /// meaningful where it IS load-bearing — `dpop+jwt` on a DPoP proof.
256    #[test]
257    fn the_header_carries_kid_and_alg_and_nothing_else() {
258        let key = SigningKey::generate(KID);
259        let jws = client_assertion(&key, CLIENT_ID, ISSUER, NOW).unwrap();
260        let header = part(&jws, 0);
261
262        assert_eq!(header["alg"], "ES256");
263        assert_eq!(header["kid"], KID);
264        assert!(header.get("typ").is_none(), "header should carry no typ");
265        assert_eq!(header.as_object().unwrap().len(), 2);
266        assert!(part(&jws, 1).get("nbf").is_none());
267    }
268
269    /// `jti` exists to stop replay, so it must be fresh per assertion — every
270    /// request mints a new one, including the retry after a nonce challenge.
271    #[test]
272    fn every_assertion_gets_a_fresh_jti() {
273        let key = SigningKey::generate(KID);
274        let mut seen = std::collections::HashSet::new();
275        for _ in 0..32 {
276            let claims = part(&client_assertion(&key, CLIENT_ID, ISSUER, NOW).unwrap(), 1);
277            let jti = claims["jti"].as_str().unwrap().to_string();
278            assert!(seen.insert(jti), "jti repeated across assertions");
279        }
280    }
281
282    #[test]
283    fn the_assertion_verifies_under_the_clients_key() {
284        let key = SigningKey::generate(KID);
285        let jws = client_assertion(&key, CLIENT_ID, ISSUER, NOW).unwrap();
286        assert!(jwt::verify(&key, &jws).is_ok());
287        assert!(jwt::verify(&SigningKey::generate(KID), &jws).is_err());
288    }
289
290    /// The `kid` must name a key in the published JWKS, or the AS cannot select
291    /// one to verify with.
292    #[test]
293    fn the_header_kid_matches_the_signing_key() {
294        let key = SigningKey::generate("some-other-kid");
295        let jws = client_assertion(&key, CLIENT_ID, ISSUER, NOW).unwrap();
296        assert_eq!(part(&jws, 0)["kid"], "some-other-kid");
297        assert_eq!(part(&jws, 0)["kid"], key.kid());
298    }
299}