mecha10-auth 0.6.3

Authentication services for Mecha10 - shared between CLI and launcher
Documentation
//! Authentication type definitions
//!
//! Types for storing and managing user credentials and device code flow.

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

/// Stored credentials for authenticated user
///
/// Saved to ~/.mecha10/credentials.json after successful login.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Credentials {
    /// User's API key for authenticating with control plane
    pub api_key: String,

    /// User ID from the auth system
    pub user_id: String,

    /// User's email address
    pub email: String,

    /// User's display name
    #[serde(default)]
    pub name: Option<String>,

    /// When the credentials were obtained
    pub authenticated_at: DateTime<Utc>,

    /// The auth server URL used for authentication
    pub auth_url: String,
}

impl Credentials {
    /// Check if credentials appear valid (non-empty api_key)
    pub fn is_valid(&self) -> bool {
        !self.api_key.is_empty() && self.api_key.starts_with("mecha_")
    }

    /// Get a masked version of the API key for display
    pub fn masked_api_key(&self) -> String {
        if self.api_key.len() > 12 {
            format!("{}...{}", &self.api_key[..12], &self.api_key[self.api_key.len() - 4..])
        } else {
            "***".to_string()
        }
    }

    /// Get display name (name if available, otherwise email)
    pub fn display_name(&self) -> &str {
        self.name.as_deref().unwrap_or(&self.email)
    }
}

/// Response from device code request
///
/// Returned by POST /auth/device/code
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceCodeResponse {
    /// The device code for backend polling
    pub device_code: String,

    /// User-facing code to enter in browser
    pub user_code: String,

    /// URL for user to visit
    pub verification_uri: String,

    /// Seconds until the code expires
    pub expires_in: u32,

    /// Seconds to wait between poll attempts
    pub interval: u32,
}

/// Status of a device code authorization
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum DeviceCodeStatus {
    /// User hasn't completed authorization yet
    Pending,

    /// User authorized - includes credentials
    Authorized {
        api_key: String,
        user_id: String,
        email: String,
        name: Option<String>,
    },

    /// User denied the authorization
    Denied,

    /// Device code has expired
    Expired,
}

impl DeviceCodeStatus {}

/// Error types for authentication operations
#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)]
#[serde(tag = "error", rename_all = "snake_case")]
pub enum AuthError {
    /// Network or connection error
    #[error("Network error: {message}")]
    NetworkError { message: String },

    /// Server returned an error
    #[error("Server error ({status_code:?}): {message}")]
    ServerError { message: String, status_code: Option<u16> },

    /// Device code expired before user completed auth
    #[error("Device code expired")]
    ExpiredCode,

    /// User denied the authorization request
    #[error("Access denied by user")]
    AccessDenied,

    /// Credentials file corrupted or invalid
    #[error("Invalid credentials: {message}")]
    InvalidCredentials { message: String },

    /// Rate limited by server
    #[error("Rate limited, retry after {retry_after:?} seconds")]
    RateLimited { retry_after: Option<u32> },
}

#[cfg(test)]
mod tests {
    use super::*;

    fn make_credentials(api_key: &str, name: Option<&str>) -> Credentials {
        Credentials {
            api_key: api_key.to_string(),
            user_id: "usr_123".to_string(),
            email: "user@example.com".to_string(),
            name: name.map(|n| n.to_string()),
            authenticated_at: DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
                .unwrap()
                .with_timezone(&Utc),
            auth_url: "https://mecha.industries/api/auth".to_string(),
        }
    }

    #[test]
    fn masked_api_key_masks_long_keys() {
        let creds = make_credentials("mecha_1234567890abcdef", None);
        assert_eq!(creds.masked_api_key(), "mecha_123456...cdef");
    }

    #[test]
    fn masked_api_key_fully_masks_short_keys() {
        // Exactly 12 chars is not `> 12`, so it should fall into the fully-masked branch.
        let creds = make_credentials("mecha_abcdef", None);
        assert_eq!(creds.masked_api_key(), "***");

        let creds = make_credentials("short", None);
        assert_eq!(creds.masked_api_key(), "***");
    }

    #[test]
    fn display_name_prefers_name_over_email() {
        let creds = make_credentials("mecha_1234567890abcdef", Some("Ada Lovelace"));
        assert_eq!(creds.display_name(), "Ada Lovelace");
    }

    #[test]
    fn display_name_falls_back_to_email_when_no_name() {
        let creds = make_credentials("mecha_1234567890abcdef", None);
        assert_eq!(creds.display_name(), "user@example.com");
    }

    #[test]
    fn credentials_roundtrip_serialization() {
        let creds = make_credentials("mecha_1234567890abcdef", Some("Ada Lovelace"));
        let json = serde_json::to_string(&creds).unwrap();
        let deserialized: Credentials = serde_json::from_str(&json).unwrap();

        assert_eq!(deserialized.api_key, creds.api_key);
        assert_eq!(deserialized.user_id, creds.user_id);
        assert_eq!(deserialized.email, creds.email);
        assert_eq!(deserialized.name, creds.name);
        assert_eq!(deserialized.auth_url, creds.auth_url);
    }

    #[test]
    fn credentials_deserialization_defaults_missing_name() {
        // `name` is `#[serde(default)]`, so it should be optional in the JSON payload.
        let json = r#"{
            "api_key": "mecha_abc123",
            "user_id": "usr_1",
            "email": "user@example.com",
            "authenticated_at": "2024-01-01T00:00:00Z",
            "auth_url": "https://mecha.industries/api/auth"
        }"#;

        let creds: Credentials = serde_json::from_str(json).unwrap();
        assert_eq!(creds.name, None);
    }

    #[test]
    fn device_code_response_roundtrip_serialization() {
        let response = DeviceCodeResponse {
            device_code: "devcode123".to_string(),
            user_code: "ABCD-1234".to_string(),
            verification_uri: "https://mecha.industries/device".to_string(),
            expires_in: 900,
            interval: 5,
        };

        let json = serde_json::to_string(&response).unwrap();
        let deserialized: DeviceCodeResponse = serde_json::from_str(&json).unwrap();

        assert_eq!(deserialized.device_code, response.device_code);
        assert_eq!(deserialized.user_code, response.user_code);
        assert_eq!(deserialized.verification_uri, response.verification_uri);
        assert_eq!(deserialized.expires_in, response.expires_in);
        assert_eq!(deserialized.interval, response.interval);
    }

    #[test]
    fn device_code_status_serializes_pending_with_tag() {
        let status = DeviceCodeStatus::Pending;
        let json = serde_json::to_value(&status).unwrap();
        assert_eq!(json, serde_json::json!({ "status": "pending" }));
    }

    #[test]
    fn device_code_status_serializes_denied_and_expired() {
        assert_eq!(
            serde_json::to_value(DeviceCodeStatus::Denied).unwrap(),
            serde_json::json!({ "status": "denied" })
        );
        assert_eq!(
            serde_json::to_value(DeviceCodeStatus::Expired).unwrap(),
            serde_json::json!({ "status": "expired" })
        );
    }

    #[test]
    fn device_code_status_roundtrips_authorized_variant() {
        let status = DeviceCodeStatus::Authorized {
            api_key: "mecha_abc123".to_string(),
            user_id: "usr_1".to_string(),
            email: "user@example.com".to_string(),
            name: Some("Ada Lovelace".to_string()),
        };

        let json = serde_json::to_string(&status).unwrap();
        let deserialized: DeviceCodeStatus = serde_json::from_str(&json).unwrap();

        match deserialized {
            DeviceCodeStatus::Authorized {
                api_key,
                user_id,
                email,
                name,
            } => {
                assert_eq!(api_key, "mecha_abc123");
                assert_eq!(user_id, "usr_1");
                assert_eq!(email, "user@example.com");
                assert_eq!(name, Some("Ada Lovelace".to_string()));
            }
            other => panic!("expected Authorized variant, got {:?}", other),
        }
    }

    #[test]
    fn device_code_status_deserializes_from_status_tag() {
        let json = r#"{"status": "pending"}"#;
        let status: DeviceCodeStatus = serde_json::from_str(json).unwrap();
        assert!(matches!(status, DeviceCodeStatus::Pending));
    }

    #[test]
    fn auth_error_display_messages() {
        assert_eq!(
            AuthError::NetworkError {
                message: "connection refused".to_string()
            }
            .to_string(),
            "Network error: connection refused"
        );

        assert_eq!(
            AuthError::ServerError {
                message: "boom".to_string(),
                status_code: Some(500)
            }
            .to_string(),
            "Server error (Some(500)): boom"
        );

        assert_eq!(AuthError::ExpiredCode.to_string(), "Device code expired");
        assert_eq!(AuthError::AccessDenied.to_string(), "Access denied by user");

        assert_eq!(
            AuthError::InvalidCredentials {
                message: "bad json".to_string()
            }
            .to_string(),
            "Invalid credentials: bad json"
        );

        assert_eq!(
            AuthError::RateLimited { retry_after: Some(30) }.to_string(),
            "Rate limited, retry after Some(30) seconds"
        );
    }

    #[test]
    fn auth_error_roundtrip_serialization() {
        let err = AuthError::RateLimited { retry_after: Some(15) };
        let json = serde_json::to_string(&err).unwrap();
        let deserialized: AuthError = serde_json::from_str(&json).unwrap();

        match deserialized {
            AuthError::RateLimited { retry_after } => assert_eq!(retry_after, Some(15)),
            other => panic!("expected RateLimited variant, got {:?}", other),
        }
    }
}