Skip to main content

authkestra_engine/
client_assertion.rs

1//! Client-side minting of `private_key_jwt` client assertions (RFC 7523
2//! §2.2, OIDC Core §9).
3//!
4//! This is the mirror image of `authkestra_op::client_assertion`, which
5//! *verifies* an inbound assertion at the OP — that module cannot live here
6//! (it needs `ClientRegistration`/replay-store types this crate has no
7//! business knowing about), but this crate is the one place both halves can
8//! share without a dependency cycle: `authkestra-op` already depends on
9//! `authkestra-engine`, never the other way around. So the two constants
10//! below are defined exactly once, here, and `authkestra-op` re-exports them
11//! rather than keeping its own copies — the assertion-type URN and the
12//! maximum assertion lifetime literally cannot drift between the client side
13//! ([`crate::flow::ClientCredentialsFlow::new_private_key_jwt`]) and this
14//! workspace's own OP.
15//!
16//! See [`mint_client_assertion`] for the minting logic itself.
17
18use crate::auth::error::AuthError;
19use chrono::Utc;
20use jsonwebtoken::{Algorithm, EncodingKey, Header};
21use serde::Serialize;
22
23/// The `client_assertion_type` value RFC 7523 §2.2 requires when presenting
24/// a `private_key_jwt` assertion at a token endpoint.
25pub const CLIENT_ASSERTION_TYPE_JWT_BEARER: &str =
26    "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
27
28/// Upper bound on how far in the future a minted assertion's `exp` may sit.
29///
30/// RFC 7523 §3 requires `exp` to be present and unexpired but sets no
31/// ceiling, and an assertion is a bearer credential for its whole lifetime —
32/// a decade-long one would be a permanent password in JWT clothing. This
33/// crate's own OP (`authkestra_op::client_assertion`) enforces the identical
34/// bound on the verifying side, so minting anything longer-lived here would
35/// only produce assertions that OP (or any other conformant verifier
36/// applying the same discipline) rejects for a reason invisible from the
37/// client side.
38pub const MAX_CLIENT_ASSERTION_LIFETIME_SECS: i64 = 300;
39
40/// The claims RFC 7523 §3 requires a `private_key_jwt` assertion to carry.
41///
42/// `iss` and `sub` are both the client's own `client_id` — RFC 7523 §3
43/// points 1-2 require the assertion to be self-issued for the client it
44/// authenticates, and `authkestra_op::client_assertion::verify_client_assertion`
45/// rejects anything else.
46#[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/// Mints a fresh `private_key_jwt` client assertion authenticating
57/// `client_id` to `audience` (the token endpoint URL, per RFC 7523 §3).
58///
59/// A fresh `jti` (UUIDv4) is generated on every call: reusing one across
60/// calls would hand a replay-tracking verifier — such as
61/// `authkestra_op::client_assertion::ClientAssertionStore` — a second
62/// presentation of an id it already spent, which is indistinguishable from
63/// an actual replay and would be rejected.
64///
65/// `lifetime_secs` is clamped to `1..=MAX_CLIENT_ASSERTION_LIFETIME_SECS`
66/// rather than trusted verbatim: a caller-supplied value above that ceiling
67/// would only mint an assertion this workspace's own OP (or any verifier
68/// enforcing the same bound) refuses, for a reason invisible from here: it's
69/// cheaper to clamp than to hand back an assertion doomed to fail
70/// verification for a reason invisible at the call site.
71#[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    /// Throwaway Ed25519 private key (PKCS#8 PEM), test-only. Same key used
112    /// in `crate::token::tests`, generated with
113    /// `openssl genpkey -algorithm ed25519`.
114    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}