arche 4.11.0

An opinionated backend foundation for Axum applications, providing batteries-included integrations for cloud services, databases, authentication, middleware, and logging.
Documentation
use serde::Deserialize;

use crate::error::AppError;

use super::{default_http_client, dependency_label};

#[derive(Debug, Clone)]
pub struct ProviderMetadata {
    pub key: String,
    pub issuers: Vec<String>,
    pub auth_endpoint: String,
    pub token_endpoint: String,
    pub jwks_endpoint: String,
    pub extra_auth_params: Vec<(String, String)>,
}

impl ProviderMetadata {
    pub async fn discover(
        key: impl Into<String>,
        issuer: &str,
        http: &reqwest::Client,
    ) -> Result<Self, AppError> {
        let key = key.into();
        let doc = discover_inner(issuer, http, &dependency_label(&key)).await?;
        Ok(Self {
            key,
            issuers: vec![doc.issuer],
            auth_endpoint: doc.authorization_endpoint,
            token_endpoint: doc.token_endpoint,
            jwks_endpoint: doc.jwks_uri,
            extra_auth_params: Vec::new(),
        })
    }

    pub async fn discover_default(key: impl Into<String>, issuer: &str) -> Result<Self, AppError> {
        let http = default_http_client("OIDC discovery")?;
        Self::discover(key, issuer, &http).await
    }
}

#[derive(Deserialize)]
struct DiscoveryDocument {
    issuer: String,
    authorization_endpoint: String,
    token_endpoint: String,
    jwks_uri: String,
}

async fn discover_inner(
    issuer: &str,
    http: &reqwest::Client,
    dependency: &str,
) -> Result<DiscoveryDocument, AppError> {
    let url = format!(
        "{}/.well-known/openid-configuration",
        issuer.trim_end_matches('/')
    );

    let resp =
        http.get(&url).send().await.map_err(|e| {
            AppError::dependency_failed(dependency, format!("discovery fetch: {e}"))
        })?;

    let status = resp.status();
    if status.is_server_error() {
        return Err(AppError::dependency_failed(
            dependency,
            format!("discovery endpoint returned {status}"),
        ));
    }
    if !status.is_success() {
        return Err(AppError::dependency_failed_permanent(
            dependency,
            format!("discovery endpoint returned {status}"),
        ));
    }

    let body = resp
        .bytes()
        .await
        .map_err(|e| AppError::dependency_failed(dependency, format!("discovery body: {e}")))?;
    let doc: DiscoveryDocument = serde_json::from_slice(&body).map_err(|e| {
        AppError::dependency_failed_permanent(dependency, format!("discovery parse: {e}"))
    })?;

    if doc.issuer.trim_end_matches('/') != issuer.trim_end_matches('/') {
        return Err(AppError::dependency_failed_permanent(
            dependency,
            format!(
                "issuer mismatch: requested {issuer}, document says {}",
                doc.issuer
            ),
        ));
    }

    Ok(doc)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::error::AppError;
    use serde_json::json;
    use wiremock::matchers::{method, path};
    use wiremock::{Mock, MockServer, ResponseTemplate};

    fn discovery_doc(issuer: &str) -> serde_json::Value {
        json!({
            "issuer": issuer,
            "authorization_endpoint": format!("{issuer}/authorize"),
            "token_endpoint": format!("{issuer}/token"),
            "jwks_uri": format!("{issuer}/jwks"),
        })
    }

    async fn mount_discovery(server: &MockServer, body: serde_json::Value, status: u16) {
        Mock::given(method("GET"))
            .and(path("/.well-known/openid-configuration"))
            .respond_with(ResponseTemplate::new(status).set_body_json(body))
            .mount(server)
            .await;
    }

    #[tokio::test]
    async fn discover_happy_path() {
        let server = MockServer::start().await;
        let issuer = server.uri();
        mount_discovery(&server, discovery_doc(&issuer), 200).await;

        let p = ProviderMetadata::discover("acme", &issuer, &reqwest::Client::new())
            .await
            .unwrap();
        assert_eq!(p.key, "acme");
        assert_eq!(p.issuers, vec![issuer.clone()]);
        assert_eq!(p.auth_endpoint, format!("{issuer}/authorize"));
        assert_eq!(p.token_endpoint, format!("{issuer}/token"));
        assert_eq!(p.jwks_endpoint, format!("{issuer}/jwks"));
        assert!(p.extra_auth_params.is_empty());
    }

    #[tokio::test]
    async fn discover_trailing_slash_issuer_matches() {
        let server = MockServer::start().await;
        let issuer = server.uri();
        mount_discovery(&server, discovery_doc(&issuer), 200).await;

        let p = ProviderMetadata::discover("acme", &format!("{issuer}/"), &reqwest::Client::new())
            .await
            .unwrap();
        assert_eq!(p.issuers, vec![issuer]);
    }

    #[tokio::test]
    async fn discover_rejects_issuer_mismatch() {
        let server = MockServer::start().await;
        mount_discovery(&server, discovery_doc("https://evil.example"), 200).await;

        let err = ProviderMetadata::discover("acme", &server.uri(), &reqwest::Client::new())
            .await
            .unwrap_err();
        assert!(matches!(
            err,
            AppError::DependencyFailed {
                retryable: false,
                ..
            }
        ));
    }

    #[tokio::test]
    async fn discover_404_is_permanent() {
        let server = MockServer::start().await;
        mount_discovery(&server, json!({}), 404).await;

        let err = ProviderMetadata::discover("acme", &server.uri(), &reqwest::Client::new())
            .await
            .unwrap_err();
        assert!(matches!(
            err,
            AppError::DependencyFailed {
                retryable: false,
                ..
            }
        ));
    }

    #[tokio::test]
    async fn discover_503_is_retryable() {
        let server = MockServer::start().await;
        mount_discovery(&server, json!({}), 503).await;

        let err = ProviderMetadata::discover("acme", &server.uri(), &reqwest::Client::new())
            .await
            .unwrap_err();
        assert!(matches!(
            err,
            AppError::DependencyFailed {
                retryable: true,
                ..
            }
        ));
    }

    #[tokio::test]
    async fn discover_malformed_doc_is_permanent() {
        let server = MockServer::start().await;
        mount_discovery(&server, json!({"issuer": "x"}), 200).await;

        let err = ProviderMetadata::discover("acme", &server.uri(), &reqwest::Client::new())
            .await
            .unwrap_err();
        assert!(matches!(
            err,
            AppError::DependencyFailed {
                retryable: false,
                ..
            }
        ));
    }
}