mecha10-auth 0.8.0

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};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Credentials {
    pub api_key: String,

    pub user_id: String,

    pub email: String,

    #[serde(default)]
    pub name: Option<String>,

    pub authenticated_at: DateTime<Utc>,

    pub auth_url: String,
}

impl Credentials {
    /// Check if credentials appear valid (non-empty api_key).
    ///
    /// No longer requires a `mecha_` prefix: a `CredentialsService::load()` call can now
    /// synthesize `Credentials` from `MECHA10_API_KEY` (control-plane service key, an
    /// opaque shared secret with no fixed prefix convention) as well as from a real
    /// device-code-login `UserApiKey` (which does happen to be `mecha_`-prefixed). Both are
    /// "appears valid" in the same sense here - actual key validity is always determined
    /// server-side (`mecha10-api`'s `jobs::auth::AuthUser`), never locally.
    pub fn is_valid(&self) -> bool {
        !self.api_key.is_empty()
    }

    /// 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)
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceCodeResponse {
    pub device_code: String,

    pub user_code: String,

    pub verification_uri: String,

    #[serde(default)]
    pub verification_uri_complete: Option<String>,

    pub expires_in: u32,

    pub interval: u32,
}

impl DeviceCodeResponse {
    /// The URL to actually open in a browser: `verification_uri_complete` when the backend
    /// provided one (pre-fills `user_code` so the user doesn't have to type it), falling back to
    /// the bare `verification_uri` otherwise.
    pub fn browser_url(&self) -> &str {
        self.verification_uri_complete
            .as_deref()
            .unwrap_or(&self.verification_uri)
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum DeviceCodeStatus {
    Pending,

    Authorized {
        api_key: String,
        user_id: String,
        email: String,
        name: Option<String>,
    },

    Denied,

    Expired,
}

impl DeviceCodeStatus {}

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

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

    #[error("Device code expired")]
    ExpiredCode,

    #[error("Access denied by user")]
    AccessDenied,

    #[error("Invalid credentials: {message}")]
    InvalidCredentials { message: String },

    #[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() {
        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() {
        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(),
            verification_uri_complete: Some("https://mecha.industries/device?code=ABCD-1234".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.verification_uri_complete,
            response.verification_uri_complete
        );
        assert_eq!(deserialized.expires_in, response.expires_in);
        assert_eq!(deserialized.interval, response.interval);
    }

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

        let response: DeviceCodeResponse = serde_json::from_str(json).unwrap();
        assert_eq!(response.verification_uri_complete, None);
        assert_eq!(response.browser_url(), "https://mecha.industries/device");
    }

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

        assert_eq!(response.browser_url(), "https://mecha.industries/device?code=ABCD-1234");
    }

    #[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),
        }
    }
}