vtx-sdk 0.1.14

Official SDK for developing VTX plugins using Rust and WebAssembly.
Documentation
//! Host-side auth helpers.

use crate::bindings::vtx::api::vtx_auth_types::UserContext;
use crate::error::{VtxError, VtxResult};

/// Authentication request helper class.
///
/// Responsibilities:
/// Encapsulates the raw Header list and provides convenient methods for Token extraction and validation.
pub struct AuthRequest<'a> {
    headers: &'a [(String, String)],
}

impl<'a> AuthRequest<'a> {
    /// Instantiates an AuthRequest.
    pub fn new(headers: &'a [(String, String)]) -> Self {
        Self { headers }
    }

    /// Retrieves a Header value (Case-insensitive).
    pub fn header(&self, key: &str) -> Option<&str> {
        let search_key = key.to_lowercase();
        for (k, v) in self.headers {
            if k.to_lowercase() == search_key {
                return Some(v.as_str());
            }
        }
        None
    }

    /// Retrieves a required Header value.
    ///
    /// Behavior:
    /// Returns an `AuthDenied(401)` error if the Header does not exist.
    pub fn require_header(&self, key: &str) -> VtxResult<&str> {
        self.header(key).ok_or({
            // Note: Information about which specific header is missing will be lost when converted to u16,
            // but it may be useful during debugging or future log extensions.
            VtxError::AuthDenied(401)
        })
    }

    /// Extracts the Bearer Token.
    ///
    /// Format support: `Authorization: Bearer <token>` (case-insensitive for 'Bearer').
    pub fn bearer_token(&self) -> Option<&str> {
        let val = self.header("Authorization")?;
        if val.starts_with("Bearer ") || val.starts_with("bearer ") {
            Some(&val[7..])
        } else {
            None
        }
    }

    /// Retrieves the required Bearer Token.
    ///
    /// Behavior:
    /// Returns `AuthDenied(401)` if the Authorization header is missing or incorrectly formatted.
    pub fn require_bearer_token(&self) -> VtxResult<&str> {
        self.bearer_token().ok_or(VtxError::AuthDenied(401))
    }

    /// Extracts Basic Auth credentials.
    pub fn basic_auth(&self) -> Option<&str> {
        let val = self.header("Authorization")?;
        if val.starts_with("Basic ") || val.starts_with("basic ") {
            Some(&val[6..])
        } else {
            None
        }
    }
}

/// User context builder (Builder Pattern).
///
/// Responsibilities:
/// Constructs `UserContext` objects with support for chained calls.
pub struct UserBuilder {
    user_id: String,
    username: String,
    groups: Vec<String>,
    metadata: serde_json::Map<String, serde_json::Value>,
}

impl UserBuilder {
    /// Initializes the builder.
    ///
    /// Required parameters: User ID and Username.
    pub fn new(id: impl Into<String>, name: impl Into<String>) -> Self {
        Self {
            user_id: id.into(),
            username: name.into(),
            groups: Vec::new(),
            metadata: serde_json::Map::new(),
        }
    }

    /// Adds a group/role the user belongs to.
    pub fn group(mut self, group: impl Into<String>) -> Self {
        self.groups.push(group.into());
        self
    }

    /// Adds a metadata key-value pair.
    ///
    /// If `value` fails to serialize, the field will be silently ignored.
    pub fn meta<V: serde::Serialize>(mut self, key: &str, value: V) -> Self {
        if let Ok(val) = serde_json::to_value(value) {
            self.metadata.insert(key.to_string(), val);
        }
        self
    }

    /// Builds the UserContext.
    ///
    /// The result contains the serialized metadata JSON string.
    pub fn build(self) -> UserContext {
        UserContext {
            user_id: self.user_id,
            username: self.username,
            groups: self.groups,
            metadata: serde_json::to_string(&self.metadata).unwrap_or_else(|_| "{}".to_string()),
        }
    }
}

/// Authentication result conversion extension trait.
///
/// Responsibilities:
/// Converts the standard SDK `VtxResult<UserContext>` into the `Result<UserContext, u16>` required by the WIT interface.
/// This allows developers to handle DB or logic errors uniformly using the `?` operator in the `authenticate` implementation.
pub trait IntoAuthResult {
    fn into_auth_result(self) -> Result<UserContext, u16>;
}

impl IntoAuthResult for VtxResult<UserContext> {
    fn into_auth_result(self) -> Result<UserContext, u16> {
        match self {
            Ok(ctx) => Ok(ctx),
            Err(e) => {
                // Error degradation strategy: Maps rich error types to HTTP status codes.
                let status_code = match e {
                    VtxError::AuthDenied(code) => code,
                    VtxError::PermissionDenied(_) => 403,
                    VtxError::NotFound(_) => 404,
                    // Database errors, serialization errors, or internal errors are treated uniformly as 500.
                    VtxError::DatabaseError(_)
                    | VtxError::SerializationError(_)
                    | VtxError::Internal(_) => 500,
                };
                Err(status_code)
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::{AuthRequest, IntoAuthResult, UserBuilder};
    use crate::error::VtxError;
    use serde::Serialize;

    #[test]
    fn header_lookup_is_case_insensitive() {
        let headers = vec![
            ("authorization".to_string(), "Bearer token".to_string()),
            ("X-Trace-Id".to_string(), "abc".to_string()),
        ];
        let req = AuthRequest::new(&headers);
        assert_eq!(req.header("Authorization"), Some("Bearer token"));
        assert_eq!(req.header("x-trace-id"), Some("abc"));
        assert_eq!(req.header("missing"), None);
    }

    #[test]
    fn bearer_and_basic_auth_parsing() {
        let headers = vec![("Authorization".to_string(), "Bearer abc".to_string())];
        let req = AuthRequest::new(&headers);
        assert_eq!(req.bearer_token(), Some("abc"));
        assert!(req.basic_auth().is_none());

        let headers = vec![(
            "Authorization".to_string(),
            "Basic Zm9vOmJhcg==".to_string(),
        )];
        let req = AuthRequest::new(&headers);
        assert_eq!(req.basic_auth(), Some("Zm9vOmJhcg=="));
        assert!(req.bearer_token().is_none());
    }

    #[test]
    fn require_bearer_token_rejects_missing_or_invalid() {
        let headers = vec![("Authorization".to_string(), "Token abc".to_string())];
        let req = AuthRequest::new(&headers);
        let err = req.require_bearer_token().unwrap_err();
        assert!(matches!(err, VtxError::AuthDenied(401)));
    }

    #[test]
    fn user_builder_ignores_unserializable_meta() {
        struct BadSerialize;
        impl Serialize for BadSerialize {
            fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error>
            where
                S: serde::Serializer,
            {
                Err(serde::ser::Error::custom("nope"))
            }
        }

        let ctx = UserBuilder::new("u1", "tester")
            .group("admin")
            .meta("good", 123)
            .meta("bad", BadSerialize)
            .build();

        let meta: serde_json::Value =
            serde_json::from_str(&ctx.metadata).expect("valid metadata json");
        assert_eq!(meta["good"], 123);
        assert!(meta.get("bad").is_none());
    }

    #[test]
    fn into_auth_result_maps_errors_to_status_codes() {
        let err = Err(VtxError::PermissionDenied("nope".to_string())).into_auth_result();
        assert!(matches!(err, Err(403)));

        let err = Err(VtxError::NotFound("missing".to_string())).into_auth_result();
        assert!(matches!(err, Err(404)));

        let err = Err(VtxError::Internal("boom".to_string())).into_auth_result();
        assert!(matches!(err, Err(500)));
    }
}