sunbeam-g2v 0.4.0

Sunbeam Service Framework - A ConnectRPC-based framework for building microservices
//! OAuth 2.0 token introspection-backed session client.
//!
//! Validates bearer tokens by calling an introspection endpoint such as
//! Hydra's `/oauth2/introspect`.  The normalized response is shaped like a
//! Kratos session so the rest of the auth middleware can stay agnostic.

use serde_json::{Value, json};

use super::error::AuthError;
use super::session::SessionClient;

/// Configuration for an introspection endpoint.
#[derive(Debug, Clone)]
pub struct IntrospectionConfig {
    /// Full URL of the introspection endpoint.
    pub url: String,
    /// OAuth2 client id used to authenticate the introspection request.
    pub client_id: String,
    /// OAuth2 client secret used to authenticate the introspection request.
    pub client_secret: String,
}

/// Session client that validates tokens via OAuth 2.0 introspection.
#[derive(Debug, Clone)]
pub struct IntrospectionSessionClient {
    client: reqwest::Client,
    config: IntrospectionConfig,
}

impl IntrospectionSessionClient {
    /// Create a client from configuration.
    pub fn new(config: IntrospectionConfig) -> Self {
        Self {
            client: reqwest::Client::new(),
            config,
        }
    }

    /// Convenience constructor from individual fields.
    pub fn new_with(
        url: impl Into<String>,
        client_id: impl Into<String>,
        client_secret: impl Into<String>,
    ) -> Self {
        Self::new(IntrospectionConfig {
            url: url.into(),
            client_id: client_id.into(),
            client_secret: client_secret.into(),
        })
    }
}

#[async_trait::async_trait]
impl SessionClient for IntrospectionSessionClient {
    async fn to_session(
        &self,
        _cookie: Option<&str>,
        token: Option<&str>,
    ) -> Result<Value, AuthError> {
        let token = token.ok_or_else(|| AuthError::InvalidSession("missing token".to_string()))?;

        let response = self
            .client
            .post(&self.config.url)
            .basic_auth(&self.config.client_id, Some(&self.config.client_secret))
            .form(&[("token", token)])
            .send()
            .await
            .map_err(|e| AuthError::InvalidSession(format!("introspection request failed: {e}")))?;

        if !response.status().is_success() {
            return Err(AuthError::InvalidSession(format!(
                "introspection returned {}",
                response.status()
            )));
        }

        let body: Value = response.json().await.map_err(|e| {
            AuthError::InvalidSession(format!("invalid introspection response: {e}"))
        })?;

        let active = body
            .get("active")
            .and_then(|v| v.as_bool())
            .unwrap_or(false);
        if !active {
            return Err(AuthError::InvalidSession("token inactive".to_string()));
        }

        let subject = body
            .get("sub")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();
        if subject.is_empty() {
            return Err(AuthError::InvalidSession(
                "introspection response missing sub".to_string(),
            ));
        }

        // Normalize to the same shape the Kratos session client produces so the
        // middleware can extract `identity.id` unchanged.
        Ok(json!({
            "identity": { "id": subject },
            "scope": body.get("scope").cloned().unwrap_or_else(|| json!("")),
        }))
    }
}