cedarling 2.3.0

The Cedarling: a high-performance local authorization service powered by the Rust Cedar Engine.
Documentation
// This software is available under the Apache-2.0 license.
// See https://www.apache.org/licenses/LICENSE-2.0.txt for full text.
//
// Copyright (c) 2024, Gluu, Inc.

//! This module is responsible for Cedarling's Dynamic Client Registration (DCR) at startup.

use std::collections::HashMap;

use super::{init_http_client, lock_config::Url};
use crate::{HttpClientConfig, app_types::PdpID, http::InitializeHttpClientError};
use serde::Deserialize;
use serde_json::json;
use thiserror::Error;

pub(super) const DCR_SCOPE: &str = "cedarling";
pub(super) const ACCESS_TKN_SCOPE: &str = "https://jans.io/oauth/lock/log.write https://jans.io/oauth/lock/health.write https://jans.io/oauth/lock/telemetry.write";

pub(super) async fn register_client(
    pdp_id: PdpID,
    oidc_endpoint: &Url,
    ssa_jwt: Option<&String>,
    accept_invalid_certs: bool,
    http_conf: HttpClientConfig,
) -> Result<ClientCredentials, ClientRegistrationError> {
    let client = init_http_client(None, accept_invalid_certs, http_conf)?;

    // Get openid config
    let oidc: OpenidConfig = client
        .get_json(oidc_endpoint.0.as_str())
        .await
        .map_err(ClientRegistrationError::GetOpenidConfig)?;

    // Register client with SSA JWT if provided
    let mut dcr_body = json!({
        "token_endpoint_auth_method": "client_secret_basic",
        "grant_types": ["client_credentials"],
        "client_name": format!("cedarling-{}", pdp_id),
        "scope": DCR_SCOPE,
        "access_token_as_jwt": true,
    });

    // Add SSA JWT to the DCR request if provided
    if let Some(ssa_jwt) = ssa_jwt {
        dcr_body["software_statement"] = json!(ssa_jwt);
    }

    let ClientIdAndSecret {
        client_id,
        client_secret,
    } = client
        .post_json(|client| {
            client
                .post(&oidc.registration_endpoint)
                .header("Content-Type", "application/json")
                .body(dcr_body.to_string())
        })
        .await
        .map_err(ClientRegistrationError::RegisterLockClient)?;

    // Get access token
    let form_data = serde_json::from_value::<HashMap<String, String>>(json!({
        "grant_type": "client_credentials",
        "scope": ACCESS_TKN_SCOPE,
    }))
    // this should never fail since this is a hard-coded valid JSON
    .expect("serialize form data");

    let AccessToken { access_token } = client
        .post_json(|client| {
            client
                .post(&oidc.token_endpoint)
                .basic_auth(&client_id, Some(&client_secret))
                .header("Content-Type", "application/x-www-form-urlencoded")
                .form(&form_data)
        })
        .await
        .map_err(ClientRegistrationError::GetAccessToken)?;

    Ok(ClientCredentials {
        client_id,
        client_secret,
        access_token,
    })
}

#[derive(Debug, PartialEq, Clone)]
pub(super) struct ClientCredentials {
    pub client_id: String,
    pub client_secret: String,
    pub access_token: String,
}

#[derive(Debug, Error)]
pub enum ClientRegistrationError {
    #[error("failed to get openid config: {0}")]
    GetOpenidConfig(#[source] crate::http_utils::HttpRequestError),
    #[error("failed to register lock client: {0}")]
    RegisterLockClient(#[source] crate::http_utils::HttpRequestError),
    #[error("failed to get access token: {0}")]
    GetAccessToken(#[source] crate::http_utils::HttpRequestError),
    #[error("failed to initialize HTTP client: {0}")]
    InitializeHttpClient(#[from] InitializeHttpClientError),
}

#[derive(Debug, Deserialize)]
struct OpenidConfig {
    registration_endpoint: String,
    token_endpoint: String,
}

#[derive(Debug, Deserialize)]
struct ClientIdAndSecret {
    client_id: String,
    client_secret: String,
}

#[derive(Debug, Deserialize)]
struct AccessToken {
    #[serde(rename = "access_token")]
    access_token: String,
}

#[cfg(test)]
mod tests {
    use super::*;
    use mockito::{Mock, Server, ServerGuard};
    use serde_json::json;

    #[tokio::test]
    async fn test_register_client_with_ssa() {
        let pdp_id = PdpID::new();
        let ssa_jwt = "eyJraWQiOiJzc2FfOTgwYTQ0ZDQtZWE3OS00YTM1LThlNjMtNzlhNzg4NTNmYzUwX3NpZ19yczI1NiIsInR5cCI6IkpXVCIsImFsZyI6IlJTMjU2In0.eyJzb2Z0d2FyZV9pZCI6IkNlZGFybGluZ1Rlc3QiLCJncmFudF90eXBlcyI6WyJhdXRob3JpemF0aW9uX2NvZGUiLCJyZWZyZXNoX3Rva2VuIl0sIm9yZ19pZCI6InRlc3QiLCJpc3MiOiJodHRwczovL2RlbW9leGFtcGxlLmphbnMuaW8iLCJzb2Z0d2FyZV9yb2xlcyI6WyJjZWRhcmxpbmciXSwiZXhwIjozMzE5NzE3ODEyLCJpYXQiOjE3NDI5MTc4MTMsImp0aSI6IjM5NTA0NTRlLTM5MWMtNDlhOS05YzYxLTY4MGMyNWE4MDk0ZCJ9.INA5qvpheWvJe6DJaeLkOYt1YH3W9gJQ3yy5Cr5G9_QbzazV23FMJDH2Rbysauk4YNC0oIsTL4MBQ_dRn3YaPLapOhizIlxZQF_uHBpYnopsk6KxgiRQTotg1Kw7Kwsi1RHtfHXpplSS15Dc-9QrOIGbNu44zEt1F5FYV5feW2c0u5HIRISoMNPutOYfMH18bZaBM28N8BssuqLv5X_Bc8EuSkmNTERP5L4khv6Mi3uVItkgK9xTbMKCpUstH_LchT1BKD_pTTMAQx6g6TOf3gnwKYQcmQhjJWFUbXnKCjghExV4PrYc6P8YaXdFnPBYoovd8FxS5qrX8trkh6pxeQ";

        let mut mock_server = Server::new_async().await;

        let oidc_endpoint = mock_oidc_endpoint(&mut mock_server);
        let dcr_endpoint = mock_dcr_endpoint(&mut mock_server, pdp_id, ssa_jwt);
        let token_endpoint = mock_token_endpoint(&mut mock_server);

        let oidc_url: url::Url = format!("{}/.well-known/openid-configuration", mock_server.url())
            .parse()
            .expect("valid URL");

        let result = register_client(
            pdp_id,
            &super::super::lock_config::Url(oidc_url),
            Some(&ssa_jwt.to_string()),
            false,
            crate::http::HttpClientConfig::default(),
        )
        .await;

        assert!(result.is_ok());
        oidc_endpoint.assert();
        dcr_endpoint.assert();
        token_endpoint.assert();
    }

    #[tokio::test]
    async fn test_register_client_without_ssa() {
        let pdp_id = PdpID::new();

        let mut mock_server = Server::new_async().await;

        let oidc_endpoint = mock_oidc_endpoint(&mut mock_server);
        let dcr_endpoint = mock_dcr_endpoint_without_ssa(&mut mock_server, pdp_id);
        let token_endpoint = mock_token_endpoint(&mut mock_server);

        let oidc_url: url::Url = format!("{}/.well-known/openid-configuration", mock_server.url())
            .parse()
            .expect("valid URL");

        let result = register_client(
            pdp_id,
            &super::super::lock_config::Url(oidc_url),
            None,
            false,
            crate::http::HttpClientConfig::default(),
        )
        .await;

        assert!(result.is_ok());
        oidc_endpoint.assert();
        dcr_endpoint.assert();
        token_endpoint.assert();
    }

    /// Mocks the `.well-known/openid-configuration` endpoint
    fn mock_oidc_endpoint(server: &mut ServerGuard) -> Mock {
        let oidc_path = "/.well-known/openid-configuration";

        let registration_endpoint = format!("{}/jans-auth/restv1/register", server.url());
        let token_endpoint = format!("{}/jans-auth/restv1/token", server.url());
        server
            .mock("GET", oidc_path)
            .with_body(
                json!({
                    "registration_endpoint": registration_endpoint,
                    "token_endpoint": token_endpoint,
                })
                .to_string(),
            )
            .expect(1)
            .create()
    }

    /// Mocks the dynamic client registration endpoint with SSA
    fn mock_dcr_endpoint(server: &mut ServerGuard, pdp_id: PdpID, ssa_jwt: &str) -> Mock {
        let dcr_path = "/jans-auth/restv1/register";

        server
            .mock("POST", dcr_path)
            .match_body(mockito::Matcher::PartialJson(json!({
                "token_endpoint_auth_method": "client_secret_basic",
                "grant_types": ["client_credentials"],
                "client_name": format!("cedarling-{}", pdp_id),
                "scope": DCR_SCOPE,
                "access_token_as_jwt": true,
                "software_statement": ssa_jwt,
            })))
            .with_body(
                json!({
                    "client_id": "some_client_id",
                    "client_secret": "some_client_secret",
                })
                .to_string(),
            )
            .expect(1)
            .create()
    }

    /// Mocks the dynamic client registration endpoint without SSA
    fn mock_dcr_endpoint_without_ssa(server: &mut ServerGuard, pdp_id: PdpID) -> Mock {
        let dcr_path = "/jans-auth/restv1/register";

        server
            .mock("POST", dcr_path)
            .match_body(mockito::Matcher::PartialJson(json!({
                "token_endpoint_auth_method": "client_secret_basic",
                "grant_types": ["client_credentials"],
                "client_name": format!("cedarling-{}", pdp_id),
                "scope": DCR_SCOPE,
                "access_token_as_jwt": true,
            })))
            .with_body(
                json!({
                    "client_id": "some_client_id",
                    "client_secret": "some_client_secret",
                })
                .to_string(),
            )
            .expect(1)
            .create()
    }

    /// Mocks the `/token` endpoint
    fn mock_token_endpoint(server: &mut ServerGuard) -> Mock {
        let token_path = "/jans-auth/restv1/token";

        server
            .mock("POST", token_path)
            .with_body(
                json!({
                    "access_token": "some.access.token",
                })
                .to_string(),
            )
            .expect(1)
            .create()
    }
}