huskarl-core 0.6.3

Base library for huskarl (OAuth2 client) ecosystem.
Documentation
use std::{sync::Arc, time::Duration};

use bon::Builder;
use http::Uri;

use crate::{
    client_auth::{AuthenticationParams, ClientAuthentication},
    crypto::signer::{JwsSigner, JwsSignerSelector},
    jwt::{JwsSerializationError, Jwt},
};

/// JWT Authentication (RFC 7521 / 7523 / `OpenID` Connect Core 1.0 §9)
///
/// With this method, the client authenticates using a JWT which has been
/// cryptographically signed.
///
/// The caller provides the client ID and signing implementation.
///
/// The implementation creates a JWT with these claims:
///  - iss (client ID)
///  - sub (client ID)
///  - aud (defaults to the token endpoint)
///  - exp (expiry time)
///  - iat (current time)
///  - jti (unique ID for replay protection)
///
/// ## Asymmetric private key
///
/// When the underlying key is an asymmetric private key, the code implements
/// RFC 7523 (private key JWT).
///
/// Benefits:
///  - no shared secrets
///  - stateless verification
///  - non-repudiation (proof that the client sent it)
///
/// ## HMAC shared key
///
/// When the underlying key is a symmetric HMAC key, the code implements
/// `OpenID` Connect Core 1.0 §9 (`client_secret_jwt`).
///
/// Benefits:
///  - simpler setup when a shared secret is acceptable
#[derive(Debug, Clone, Builder)]
pub struct JwtBearer<Sgn: JwsSignerSelector> {
    /// The signer of the JWT.
    signer: Sgn,
    /// Sets the subject, if different to the issuer.
    #[builder(into)]
    subject: Option<String>,
    /// Sets the audience value for the bearer token.
    audience: Audience,
    /// The lifetime of the JWT (as set in the `exp` claim).
    #[builder(default = Duration::from_mins(1))]
    expires_after: Duration,
}

/// Sets the value used for the audience of the JWT.
///
/// This should be set to a value that is known to work for the particular
/// authorization server. Historically, the token endpoint was recommended
/// as the value for the audience. However, the issuer value may be safer,
/// especially when using authorization server metadata. The issuer value
/// is also required for FAPI 2.0.
///
/// Recommendation: if a particular value is known (and different to the
/// issuer or token endpoint), set it. If using authorization server
/// metadata, generally prefer the issuer value unless it fails to work
/// with your authorization server.
///
/// See <https://www.rfc-editor.org/rfc/rfc7523>,
/// <https://openid.net/specs/fapi-security-profile-2_0-final.html> and
/// <https://datatracker.ietf.org/doc/draft-ietf-oauth-rfc7523bis/>
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Audience {
    /// If the issuer value is available, it will be used as the audience.
    ///
    /// This value usually comes from authorization server metadata, and
    /// reduces issues with mix-up attacks.
    PreferIssuer,
    /// Use the token endpoint, which is always available.
    PreferTokenEndpoint,
    /// Use a custom audience value.
    Custom(Arc<str>),
}

impl<Sgn: JwsSignerSelector> ClientAuthentication for JwtBearer<Sgn> {
    type Error = JwsSerializationError<<Sgn::Signer as JwsSigner>::Error>;

    async fn authentication_params<'a>(
        &'a self,
        client_id: &'a str,
        issuer: Option<&'a str>,
        token_endpoint: &'a Uri,
        _allowed_methods: Option<&'a [String]>,
    ) -> Result<super::AuthenticationParams<'a>, Self::Error> {
        let audience = match &self.audience {
            Audience::PreferIssuer => {
                issuer.map_or_else(|| token_endpoint.to_string(), ToString::to_string)
            }
            Audience::PreferTokenEndpoint => token_endpoint.to_string(),
            Audience::Custom(custom) => custom.to_string(),
        };

        let jwt = Jwt::builder()
            .audience(audience)
            .issuer(client_id)
            .subject(self.subject.as_deref().unwrap_or(client_id))
            .issued_now_expires_after(self.expires_after)
            .claims(())
            .build();

        Ok(AuthenticationParams::builder()
            .form_params(bon::map! {
                "client_id": client_id,
                "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
                "client_assertion": jwt.to_jws_compact(&self.signer.select_signer()).await?
            })
            .build())
    }
}

#[cfg(test)]
mod tests {
    use std::convert::Infallible;

    use base64::Engine as _;

    use super::*;
    use crate::{
        client_auth::{ClientAuthentication, FormValue},
        crypto::signer::JwsSigner,
    };

    #[derive(Debug, Clone)]
    struct MockJwsSigner {
        alg: &'static str,
    }

    impl JwsSigner for MockJwsSigner {
        type Error = Infallible;
        fn jws_algorithm(&self) -> std::borrow::Cow<'_, str> {
            self.alg.into()
        }
        fn key_id(&self) -> Option<std::borrow::Cow<'_, str>> {
            None
        }
        async fn sign(&self, _input: &[u8]) -> Result<Vec<u8>, Infallible> {
            Ok(vec![0xAB])
        }
    }

    impl JwsSignerSelector for MockJwsSigner {
        type Signer = Self;
        fn select_signer(&self) -> Self {
            self.clone()
        }
    }

    fn extract_form_str(form: &[(&'static str, FormValue<'_>)], key: &str) -> String {
        form.iter().find(|(k, _)| *k == key).map_or_else(
            || unreachable!("key {key} not found in form params"),
            |(_, v)| match v {
                FormValue::NonSensitive(c) => c.to_string(),
                FormValue::Sensitive(c) => c.expose_secret().to_string(),
            },
        )
    }

    #[tokio::test]
    async fn audience_prefer_issuer_with_issuer() {
        let bearer = JwtBearer::builder()
            .signer(MockJwsSigner { alg: "ES256" })
            .audience(Audience::PreferIssuer)
            .build();
        let uri: Uri = "https://token.example.com/token".parse().unwrap();
        let params = bearer
            .authentication_params("cid", Some("https://issuer.example.com"), &uri, None)
            .await
            .unwrap();
        let form = params.form_params.unwrap();
        let assertion = extract_form_str(&form, "client_assertion");
        let parts: Vec<&str> = assertion.split('.').collect();
        assert_eq!(parts.len(), 3);

        let claims: serde_json::Value = serde_json::from_slice(
            &base64::prelude::BASE64_URL_SAFE_NO_PAD
                .decode(parts[1])
                .unwrap(),
        )
        .unwrap();
        assert_eq!(claims["aud"], "https://issuer.example.com");
    }

    #[tokio::test]
    async fn audience_prefer_issuer_no_issuer() {
        let bearer = JwtBearer::builder()
            .signer(MockJwsSigner { alg: "ES256" })
            .audience(Audience::PreferIssuer)
            .build();
        let uri: Uri = "https://token.example.com/token".parse().unwrap();
        let params = bearer
            .authentication_params("cid", None, &uri, None)
            .await
            .unwrap();
        let form = params.form_params.unwrap();
        let assertion = extract_form_str(&form, "client_assertion");
        let parts: Vec<&str> = assertion.split('.').collect();
        let claims: serde_json::Value = serde_json::from_slice(
            &base64::prelude::BASE64_URL_SAFE_NO_PAD
                .decode(parts[1])
                .unwrap(),
        )
        .unwrap();
        assert_eq!(claims["aud"], "https://token.example.com/token");
    }

    #[tokio::test]
    async fn audience_prefer_token_endpoint() {
        let bearer = JwtBearer::builder()
            .signer(MockJwsSigner { alg: "ES256" })
            .audience(Audience::PreferTokenEndpoint)
            .build();
        let uri: Uri = "https://token.example.com/token".parse().unwrap();
        let params = bearer
            .authentication_params("cid", Some("https://issuer.example.com"), &uri, None)
            .await
            .unwrap();
        let form = params.form_params.unwrap();
        let assertion = extract_form_str(&form, "client_assertion");
        let parts: Vec<&str> = assertion.split('.').collect();
        let claims: serde_json::Value = serde_json::from_slice(
            &base64::prelude::BASE64_URL_SAFE_NO_PAD
                .decode(parts[1])
                .unwrap(),
        )
        .unwrap();
        assert_eq!(claims["aud"], "https://token.example.com/token");
    }

    #[tokio::test]
    async fn audience_custom() {
        let bearer = JwtBearer::builder()
            .signer(MockJwsSigner { alg: "ES256" })
            .audience(Audience::Custom("https://custom.example.com".into()))
            .build();
        let uri: Uri = "https://token.example.com/token".parse().unwrap();
        let params = bearer
            .authentication_params("cid", Some("https://issuer.example.com"), &uri, None)
            .await
            .unwrap();
        let form = params.form_params.unwrap();
        let assertion = extract_form_str(&form, "client_assertion");
        let parts: Vec<&str> = assertion.split('.').collect();
        let claims: serde_json::Value = serde_json::from_slice(
            &base64::prelude::BASE64_URL_SAFE_NO_PAD
                .decode(parts[1])
                .unwrap(),
        )
        .unwrap();
        assert_eq!(claims["aud"], "https://custom.example.com");
    }

    #[tokio::test]
    async fn form_params_structure() {
        let bearer = JwtBearer::builder()
            .signer(MockJwsSigner { alg: "ES256" })
            .audience(Audience::PreferTokenEndpoint)
            .build();
        let uri: Uri = "https://token.example.com/token".parse().unwrap();
        let params = bearer
            .authentication_params("my-client", None, &uri, None)
            .await
            .unwrap();
        let form = params.form_params.unwrap();
        assert_eq!(extract_form_str(&form, "client_id"), "my-client");
        assert_eq!(
            extract_form_str(&form, "client_assertion_type"),
            "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
        );
        let assertion = extract_form_str(&form, "client_assertion");
        assert_eq!(assertion.split('.').count(), 3);
    }

    #[tokio::test]
    async fn subject_override() {
        let bearer = JwtBearer::builder()
            .signer(MockJwsSigner { alg: "ES256" })
            .audience(Audience::PreferTokenEndpoint)
            .subject("custom-subject")
            .build();
        let uri: Uri = "https://token.example.com/token".parse().unwrap();
        let params = bearer
            .authentication_params("my-client", None, &uri, None)
            .await
            .unwrap();
        let form = params.form_params.unwrap();
        let assertion = extract_form_str(&form, "client_assertion");
        let parts: Vec<&str> = assertion.split('.').collect();
        let claims: serde_json::Value = serde_json::from_slice(
            &base64::prelude::BASE64_URL_SAFE_NO_PAD
                .decode(parts[1])
                .unwrap(),
        )
        .unwrap();
        assert_eq!(claims["iss"], "my-client");
        assert_eq!(claims["sub"], "custom-subject");
    }
}