1use 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
25const JWT_BEARER: &str = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
27
28const ASSERTION_LIFETIME_SECS: i64 = 60;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum AuthMethod {
35 None,
37 PrivateKeyJwt,
39}
40
41impl AuthMethod {
42 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
63impl 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
76pub 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
98pub 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 #[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 #[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 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 #[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 #[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 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 #[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 #[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 #[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 #[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}