authkestra_engine/
client_assertion.rs1use crate::auth::error::AuthError;
19use chrono::Utc;
20use jsonwebtoken::{Algorithm, EncodingKey, Header};
21use serde::Serialize;
22
23pub const CLIENT_ASSERTION_TYPE_JWT_BEARER: &str =
26 "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
27
28pub const MAX_CLIENT_ASSERTION_LIFETIME_SECS: i64 = 300;
39
40#[derive(Debug, Serialize)]
47struct AssertionClaims<'a> {
48 iss: &'a str,
49 sub: &'a str,
50 aud: &'a str,
51 jti: String,
52 exp: i64,
53 iat: i64,
54}
55
56#[tracing::instrument(skip(encoding_key))]
72pub fn mint_client_assertion(
73 client_id: &str,
74 audience: &str,
75 encoding_key: &EncodingKey,
76 alg: Algorithm,
77 kid: Option<&str>,
78 lifetime_secs: i64,
79) -> Result<String, AuthError> {
80 let lifetime_secs = lifetime_secs.clamp(1, MAX_CLIENT_ASSERTION_LIFETIME_SECS);
81 let now = Utc::now();
82 let exp = now + chrono::Duration::seconds(lifetime_secs);
83
84 let claims = AssertionClaims {
85 iss: client_id,
86 sub: client_id,
87 aud: audience,
88 jti: uuid::Uuid::new_v4().to_string(),
89 exp: exp.timestamp(),
90 iat: now.timestamp(),
91 };
92
93 let mut header = Header::new(alg);
94 header.kid = kid.map(str::to_string);
95
96 let assertion = jsonwebtoken::encode(&header, &claims, encoding_key).map_err(|e| {
97 tracing::error!(client_id = %client_id, error = %e, "failed to sign private_key_jwt client assertion");
98 AuthError::Token(e.to_string())
99 })?;
100
101 tracing::debug!(client_id = %client_id, audience = %audience, "minted private_key_jwt client assertion");
102 Ok(assertion)
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
109 use serde_json::Value;
110
111 const TEST_ED25519_PRIVATE_KEY_PEM: &[u8] = b"-----BEGIN PRIVATE KEY-----
115MC4CAQAwBQYDK2VwBCIEIKIPR2jojpdobYr1M/pjIRuMONpZGYQ+y5yxSqKX9T9/
116-----END PRIVATE KEY-----";
117
118 fn decode_parts(jwt: &str) -> (Value, Value) {
119 let mut parts = jwt.split('.');
120 let header_b64 = parts.next().unwrap();
121 let payload_b64 = parts.next().unwrap();
122 let header: Value =
123 serde_json::from_slice(&URL_SAFE_NO_PAD.decode(header_b64).unwrap()).unwrap();
124 let payload: Value =
125 serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload_b64).unwrap()).unwrap();
126 (header, payload)
127 }
128
129 #[test]
130 fn mints_an_assertion_with_iss_sub_client_id_and_correct_aud() {
131 let key = EncodingKey::from_ed_pem(TEST_ED25519_PRIVATE_KEY_PEM).unwrap();
132 let jwt = mint_client_assertion(
133 "svc-1",
134 "https://auth.example.com/token",
135 &key,
136 Algorithm::EdDSA,
137 None,
138 60,
139 )
140 .unwrap();
141
142 let (header, payload) = decode_parts(&jwt);
143 assert_eq!(header["alg"], "EdDSA");
144 assert_eq!(payload["iss"], "svc-1");
145 assert_eq!(payload["sub"], "svc-1");
146 assert_eq!(payload["aud"], "https://auth.example.com/token");
147 }
148
149 #[test]
150 fn mints_a_fresh_jti_every_call() {
151 let key = EncodingKey::from_ed_pem(TEST_ED25519_PRIVATE_KEY_PEM).unwrap();
152 let jwt_a = mint_client_assertion(
153 "svc-1",
154 "https://auth.example.com/token",
155 &key,
156 Algorithm::EdDSA,
157 None,
158 60,
159 )
160 .unwrap();
161 let jwt_b = mint_client_assertion(
162 "svc-1",
163 "https://auth.example.com/token",
164 &key,
165 Algorithm::EdDSA,
166 None,
167 60,
168 )
169 .unwrap();
170
171 let (_, payload_a) = decode_parts(&jwt_a);
172 let (_, payload_b) = decode_parts(&jwt_b);
173
174 let jti_a = payload_a["jti"].as_str().unwrap();
175 let jti_b = payload_b["jti"].as_str().unwrap();
176 assert!(!jti_a.is_empty());
177 assert!(!jti_b.is_empty());
178 assert_ne!(jti_a, jti_b, "each minted assertion must carry its own jti");
179 }
180
181 #[test]
182 fn clamps_a_lifetime_beyond_the_max_to_the_max() {
183 let key = EncodingKey::from_ed_pem(TEST_ED25519_PRIVATE_KEY_PEM).unwrap();
184 let jwt = mint_client_assertion(
185 "svc-1",
186 "https://auth.example.com/token",
187 &key,
188 Algorithm::EdDSA,
189 None,
190 MAX_CLIENT_ASSERTION_LIFETIME_SECS * 100,
191 )
192 .unwrap();
193
194 let (_, payload) = decode_parts(&jwt);
195 let exp = payload["exp"].as_i64().unwrap();
196 let iat = payload["iat"].as_i64().unwrap();
197 assert!(
198 exp - iat <= MAX_CLIENT_ASSERTION_LIFETIME_SECS,
199 "exp must never be minted further out than MAX_CLIENT_ASSERTION_LIFETIME_SECS, \
200 got a lifetime of {} seconds",
201 exp - iat
202 );
203 }
204
205 #[test]
206 fn stamps_the_given_kid_onto_the_header() {
207 let key = EncodingKey::from_ed_pem(TEST_ED25519_PRIVATE_KEY_PEM).unwrap();
208 let jwt = mint_client_assertion(
209 "svc-1",
210 "https://auth.example.com/token",
211 &key,
212 Algorithm::EdDSA,
213 Some("key-1"),
214 60,
215 )
216 .unwrap();
217
218 let (header, _) = decode_parts(&jwt);
219 assert_eq!(header["kid"], "key-1");
220 }
221}