Skip to main content

alibi_plugins/oauth/
client_assertion.rs

1//! Published RFC 7523 client assertions for application-owned token endpoints.
2use super::{OAuthClientAssertion, OAuthClientAssertionContext, OAuthClientAssertionGetter};
3use base64::{
4    Engine,
5    alphabet::URL_SAFE,
6    engine::{
7        DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig, general_purpose::URL_SAFE_NO_PAD,
8    },
9};
10use rsa::{
11    pkcs8::DecodePrivateKey,
12    signature::{RandomizedSigner, SignatureEncoding, Signer},
13    traits::PublicKeyParts,
14};
15use serde_json::{Value, json};
16use std::sync::Arc;
17
18const ALGORITHMS: &[&str] = &[
19    "RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512", "EdDSA",
20];
21
22/// Key material stays private; Debug deliberately never renders it.
23/// JWK takes precedence over PKCS#8 PEM. Algorithm defaults to JWK `alg`, then
24/// RS256; explicit algorithm and JWK `alg` must agree. `kid` overrides JWK `kid`.
25#[derive(Clone, Default)]
26pub struct OAuthPrivateKeyJwtOptions {
27    pub private_key_jwk: Option<Value>,
28    pub private_key_pem: Option<String>,
29    pub algorithm: Option<String>,
30    pub kid: Option<String>,
31    /// Seconds, default 120. Negative lifetimes produce expired assertions.
32    pub expires_in: Option<f64>,
33}
34impl std::fmt::Debug for OAuthPrivateKeyJwtOptions {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        f.debug_struct("OAuthPrivateKeyJwtOptions")
37            .finish_non_exhaustive()
38    }
39}
40impl OAuthPrivateKeyJwtOptions {
41    /// Validate configuration eagerly; key import/signing occurs for each grant,
42    /// matching the published getter's construction versus request boundaries.
43    /// # Errors
44    /// Rejects missing key material, unsupported algorithms, and algorithm conflicts.
45    pub fn into_assertion(self) -> Result<OAuthClientAssertion, String> {
46        _ = self.resolved_algorithm()?;
47        Ok(OAuthClientAssertion(Arc::new(self)))
48    }
49    fn resolved_algorithm(&self) -> Result<&str, String> {
50        if self.private_key_jwk.as_ref().is_none_or(Value::is_null)
51            && self.private_key_pem.as_deref().is_none_or(str::is_empty)
52        {
53            return Err("private_key_jwt requires either privateKeyJwk or privateKeyPem".into());
54        }
55        let embedded = self
56            .private_key_jwk
57            .as_ref()
58            .and_then(|k| k.get("alg"))
59            .and_then(Value::as_str);
60        for algorithm in [
61            self.algorithm.as_deref().filter(|s| !s.is_empty()),
62            embedded,
63        ]
64        .into_iter()
65        .flatten()
66        {
67            if !ALGORITHMS.contains(&algorithm) {
68                return Err("Unsupported private_key_jwt signing algorithm".into());
69            }
70        }
71        if let (Some(explicit), Some(embedded)) = (
72            self.algorithm.as_deref().filter(|s| !s.is_empty()),
73            embedded,
74        ) && explicit != embedded
75        {
76            return Err("JWK alg does not match configured algorithm".into());
77        }
78        Ok(self.algorithm.as_deref().or(embedded).unwrap_or("RS256"))
79    }
80    fn sign(&self, algorithm: &str, message: &[u8]) -> Result<Vec<u8>, String> {
81        let jwk = self.private_key_jwk.as_ref().filter(|v| !v.is_null());
82        let pem = self.private_key_pem.as_deref().unwrap_or_default();
83        if let Some(jwk) = jwk {
84            if jwk.get("ext").is_some_and(|value| !value.is_boolean()) {
85                return Err("JWK ext must be a boolean".into());
86            }
87            if let Some(ops) = jwk.get("key_ops")
88                && !ops.as_array().is_some_and(|ops| {
89                    ops.iter().any(|op| op.as_str() == Some("sign"))
90                        && ops.iter().enumerate().all(|(index, op)| {
91                            op.is_string() && ops.iter().take(index).all(|previous| previous != op)
92                        })
93                })
94            {
95                return Err("JWK does not permit signing".into());
96            }
97            let (kty, curve) = match algorithm {
98                "EdDSA" => ("OKP", Some("Ed25519")),
99                "ES256" => ("EC", Some("P-256")),
100                "ES384" => ("EC", Some("P-384")),
101                "ES512" => ("EC", Some("P-521")),
102                _ => ("RSA", None),
103            };
104            if jwk.get("kty").and_then(Value::as_str) != Some(kty)
105                || curve.is_some_and(|c| jwk.get("crv").and_then(Value::as_str) != Some(c))
106            {
107                return Err("JWK does not match signing algorithm".into());
108            }
109        }
110        if algorithm.starts_with("RS") || algorithm.starts_with("PS") {
111            let key = if let Some(jwk) = jwk {
112                // WebCrypto imports the complete two-prime private JWK.
113                for name in ["dp", "dq", "qi"] {
114                    _ = field(jwk, name)?;
115                }
116                rsa::RsaPrivateKey::from_components(
117                    rsa::BigUint::from_bytes_be(&field(jwk, "n")?),
118                    rsa::BigUint::from_bytes_be(&field(jwk, "e")?),
119                    rsa::BigUint::from_bytes_be(&field(jwk, "d")?),
120                    vec![
121                        rsa::BigUint::from_bytes_be(&field(jwk, "p")?),
122                        rsa::BigUint::from_bytes_be(&field(jwk, "q")?),
123                    ],
124                )
125                .map_err(key_error)?
126            } else {
127                rsa::RsaPrivateKey::from_pkcs8_pem(pem).map_err(key_error)?
128            };
129            if key.n().bits() < 2048 {
130                return Err("RSA modulus length must be at least 2048 bits".into());
131            }
132            macro_rules! rsa_sign {
133                ($digest:ty) => {
134                    if algorithm.starts_with("RS") {
135                        rsa::pkcs1v15::SigningKey::<$digest>::new(key)
136                            .sign(message)
137                            .to_vec()
138                    } else {
139                        rsa::pss::SigningKey::<$digest>::new(key)
140                            .sign_with_rng(&mut rsa::rand_core::OsRng, message)
141                            .to_vec()
142                    }
143                };
144            }
145            return Ok(match algorithm {
146                "RS256" | "PS256" => rsa_sign!(rsa::sha2::Sha256),
147                "RS384" | "PS384" => rsa_sign!(rsa::sha2::Sha384),
148                _ => rsa_sign!(rsa::sha2::Sha512),
149            });
150        }
151        macro_rules! ec_sign {
152            ($curve:ident) => {{
153                use $curve::ecdsa::signature::RandomizedSigner as _;
154                use $curve::elliptic_curve::pkcs8::DecodePrivateKey as _;
155                use $curve::elliptic_curve::sec1::ToSec1Point as _;
156
157                let key = if let Some(jwk) = jwk {
158                    $curve::SecretKey::from_slice(&field(jwk, "d")?).map_err(key_error)?
159                } else {
160                    $curve::SecretKey::from_pkcs8_pem(pem).map_err(key_error)?
161                };
162                if let Some(jwk) = jwk {
163                    let public = key.public_key().to_sec1_point(false);
164                    if public.x().map(|x| x.as_slice()) != Some(field(jwk, "x")?.as_slice())
165                        || public.y().map(|y| y.as_slice()) != Some(field(jwk, "y")?.as_slice())
166                    {
167                        return Err("EC private/public key mismatch".into());
168                    }
169                }
170                let signer = $curve::ecdsa::SigningKey::from_slice(key.to_bytes().as_slice())
171                    .map_err(key_error)?;
172                let signature: $curve::ecdsa::Signature = signer
173                    .try_sign_with_rng(&mut rand::rng(), message)
174                    .map_err(key_error)?;
175                signature.to_bytes().to_vec()
176            }};
177        }
178        Ok(match algorithm {
179            "ES256" => ec_sign!(p256),
180            "ES384" => ec_sign!(p384),
181            "ES512" => ec_sign!(p521),
182            "EdDSA" => {
183                use ed25519_dalek::Signer;
184                let key = if let Some(jwk) = jwk {
185                    let seed: [u8; 32] = field(jwk, "d")?
186                        .try_into()
187                        .map_err(|_| "Invalid Ed25519 seed")?;
188                    ed25519_dalek::SigningKey::from_bytes(&seed)
189                } else {
190                    use ed25519_dalek::pkcs8::DecodePrivateKey as _;
191                    ed25519_dalek::SigningKey::from_pkcs8_pem(pem).map_err(key_error)?
192                };
193                key.sign(message).to_bytes().to_vec()
194            }
195            _ => return Err("Unsupported private_key_jwt signing algorithm".into()),
196        })
197    }
198}
199#[async_trait::async_trait]
200impl OAuthClientAssertionGetter for OAuthPrivateKeyJwtOptions {
201    async fn get_client_assertion(
202        &self,
203        context: OAuthClientAssertionContext,
204    ) -> Result<String, String> {
205        let algorithm = self.resolved_algorithm()?;
206        let mut header = json!({"alg":algorithm,"typ":"JWT"});
207        let kid = self.kid.as_deref().map(str::to_owned).or_else(|| {
208            self.private_key_jwk
209                .as_ref()
210                .and_then(|k| k.get("kid"))
211                .and_then(Value::as_str)
212                .map(str::to_owned)
213        });
214        if let Some(kid) = kid.filter(|k| !k.is_empty())
215            && let Some(object) = header.as_object_mut()
216        {
217            _ = object.insert("kid".into(), Value::String(kid));
218        }
219        let now = chrono::Utc::now().timestamp();
220        #[expect(clippy::cast_precision_loss, reason = "JavaScript number arithmetic")]
221        let exp = now as f64 + self.expires_in.unwrap_or(120.0);
222        if !exp.is_finite() {
223            return Err("Invalid client assertion expiration time".into());
224        }
225        let claims = json!({"iss":context.client_id,"sub":context.client_id,"aud":context.token_endpoint,"iat":now,"exp":exp,"jti":uuid::Uuid::new_v4().to_string()});
226        let message = format!(
227            "{}.{}",
228            URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).map_err(key_error)?),
229            URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).map_err(key_error)?)
230        );
231        Ok(format!(
232            "{message}.{}",
233            URL_SAFE_NO_PAD.encode(self.sign(algorithm, message.as_bytes())?)
234        ))
235    }
236}
237fn field(key: &Value, name: &str) -> Result<Vec<u8>, String> {
238    GeneralPurpose::new(
239        &URL_SAFE,
240        GeneralPurposeConfig::new()
241            .with_decode_padding_mode(DecodePaddingMode::Indifferent)
242            .with_decode_allow_trailing_bits(true),
243    )
244    .decode(
245        key.get(name)
246            .and_then(Value::as_str)
247            .ok_or("Missing JWK field")?,
248    )
249    .map_err(key_error)
250}
251fn key_error(_: impl std::fmt::Display) -> String {
252    "Client assertion key operation failed".into()
253}