holger-server-lib 0.4.0

Holger server library: config, wiring, gRPC service, Rust API
use serde::{Deserialize, Serialize};

/// Auth configuration for holger-server.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthConfig {
    /// Which auth methods to accept. Empty = no auth (open access).
    pub methods: Vec<AuthMethodConfig>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum AuthMethodConfig {
    /// Validate OIDC Bearer tokens via userinfo endpoint.
    Oidc {
        /// OIDC issuer URL (e.g. http://mannequin:9999)
        issuer_url: String,
    },
    /// Validate client certificates (mTLS). Requires TLS with client auth.
    Mtls {
        /// Trusted CA PEM for client certs.
        ca_cert: String,
    },
}

impl Default for AuthConfig {
    fn default() -> Self {
        Self { methods: vec![] }
    }
}

/// Resolved identity from auth validation.
#[derive(Debug, Clone)]
pub struct AuthIdentity {
    pub subject: String,
    pub method: String,
}

/// Validate an incoming request's auth.
/// Returns Some(identity) if valid, None if no auth configured (open access).
/// Returns Err if auth is configured but credentials are invalid.
pub async fn validate_request(
    config: &AuthConfig,
    bearer_token: Option<&str>,
    client_cert_cn: Option<&str>,
) -> Result<Option<AuthIdentity>, AuthError> {
    if config.methods.is_empty() {
        return Ok(None); // No auth configured — open access
    }

    // Try each configured method
    for method in &config.methods {
        match method {
            AuthMethodConfig::Oidc { issuer_url } => {
                if let Some(token) = bearer_token {
                    match validate_oidc_token(issuer_url, token).await {
                        Ok(subject) => return Ok(Some(AuthIdentity {
                            subject,
                            method: "oidc".into(),
                        })),
                        Err(_) => continue,
                    }
                }
            }
            AuthMethodConfig::Mtls { .. } => {
                if let Some(cn) = client_cert_cn {
                    return Ok(Some(AuthIdentity {
                        subject: cn.to_string(),
                        method: "mtls".into(),
                    }));
                }
            }
        }
    }

    Err(AuthError::Unauthorized)
}

#[derive(Debug)]
pub enum AuthError {
    Unauthorized,
}

/// Call OIDC userinfo endpoint to validate a token.
async fn validate_oidc_token(issuer_url: &str, token: &str) -> Result<String, AuthError> {
    let userinfo_url = format!("{}/userinfo", issuer_url.trim_end_matches('/'));

    let client = reqwest::Client::new();
    let resp = client
        .get(&userinfo_url)
        .header("Authorization", format!("Bearer {}", token))
        .send()
        .await
        .map_err(|_| AuthError::Unauthorized)?;

    if !resp.status().is_success() {
        return Err(AuthError::Unauthorized);
    }

    let body: serde_json::Value = resp.json().await.map_err(|_| AuthError::Unauthorized)?;
    body.get("sub")
        .and_then(|v| v.as_str())
        .map(|s| s.to_string())
        .ok_or(AuthError::Unauthorized)
}