Skip to main content

mecha10_auth/
types.rs

1//! Authentication type definitions
2//!
3//! Types for storing and managing user credentials and device code flow.
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct Credentials {
10    pub api_key: String,
11
12    pub user_id: String,
13
14    pub email: String,
15
16    #[serde(default)]
17    pub name: Option<String>,
18
19    pub authenticated_at: DateTime<Utc>,
20
21    pub auth_url: String,
22}
23
24impl Credentials {
25    /// Check if credentials appear valid (non-empty api_key).
26    ///
27    /// No longer requires a `mecha_` prefix: a `CredentialsService::load()` call can now
28    /// synthesize `Credentials` from `MECHA10_API_KEY` (control-plane service key, an
29    /// opaque shared secret with no fixed prefix convention) as well as from a real
30    /// device-code-login `UserApiKey` (which does happen to be `mecha_`-prefixed). Both are
31    /// "appears valid" in the same sense here - actual key validity is always determined
32    /// server-side (`mecha10-api`'s `jobs::auth::AuthUser`), never locally.
33    pub fn is_valid(&self) -> bool {
34        !self.api_key.is_empty()
35    }
36
37    /// Get a masked version of the API key for display
38    pub fn masked_api_key(&self) -> String {
39        if self.api_key.len() > 12 {
40            format!("{}...{}", &self.api_key[..12], &self.api_key[self.api_key.len() - 4..])
41        } else {
42            "***".to_string()
43        }
44    }
45
46    /// Get display name (name if available, otherwise email)
47    pub fn display_name(&self) -> &str {
48        self.name.as_deref().unwrap_or(&self.email)
49    }
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct DeviceCodeResponse {
54    pub device_code: String,
55
56    pub user_code: String,
57
58    pub verification_uri: String,
59
60    #[serde(default)]
61    pub verification_uri_complete: Option<String>,
62
63    pub expires_in: u32,
64
65    pub interval: u32,
66}
67
68impl DeviceCodeResponse {
69    /// The URL to actually open in a browser: `verification_uri_complete` when the backend
70    /// provided one (pre-fills `user_code` so the user doesn't have to type it), falling back to
71    /// the bare `verification_uri` otherwise.
72    pub fn browser_url(&self) -> &str {
73        self.verification_uri_complete
74            .as_deref()
75            .unwrap_or(&self.verification_uri)
76    }
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80#[serde(tag = "status", rename_all = "snake_case")]
81pub enum DeviceCodeStatus {
82    Pending,
83
84    Authorized {
85        api_key: String,
86        user_id: String,
87        email: String,
88        name: Option<String>,
89    },
90
91    Denied,
92
93    Expired,
94}
95
96impl DeviceCodeStatus {}
97
98#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)]
99#[serde(tag = "error", rename_all = "snake_case")]
100pub enum AuthError {
101    #[error("Network error: {message}")]
102    NetworkError { message: String },
103
104    #[error("Server error ({status_code:?}): {message}")]
105    ServerError { message: String, status_code: Option<u16> },
106
107    #[error("Device code expired")]
108    ExpiredCode,
109
110    #[error("Access denied by user")]
111    AccessDenied,
112
113    #[error("Invalid credentials: {message}")]
114    InvalidCredentials { message: String },
115
116    #[error("Rate limited, retry after {retry_after:?} seconds")]
117    RateLimited { retry_after: Option<u32> },
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    fn make_credentials(api_key: &str, name: Option<&str>) -> Credentials {
125        Credentials {
126            api_key: api_key.to_string(),
127            user_id: "usr_123".to_string(),
128            email: "user@example.com".to_string(),
129            name: name.map(|n| n.to_string()),
130            authenticated_at: DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
131                .unwrap()
132                .with_timezone(&Utc),
133            auth_url: "https://mecha.industries/api/auth".to_string(),
134        }
135    }
136
137    #[test]
138    fn masked_api_key_masks_long_keys() {
139        let creds = make_credentials("mecha_1234567890abcdef", None);
140        assert_eq!(creds.masked_api_key(), "mecha_123456...cdef");
141    }
142
143    #[test]
144    fn masked_api_key_fully_masks_short_keys() {
145        let creds = make_credentials("mecha_abcdef", None);
146        assert_eq!(creds.masked_api_key(), "***");
147
148        let creds = make_credentials("short", None);
149        assert_eq!(creds.masked_api_key(), "***");
150    }
151
152    #[test]
153    fn display_name_prefers_name_over_email() {
154        let creds = make_credentials("mecha_1234567890abcdef", Some("Ada Lovelace"));
155        assert_eq!(creds.display_name(), "Ada Lovelace");
156    }
157
158    #[test]
159    fn display_name_falls_back_to_email_when_no_name() {
160        let creds = make_credentials("mecha_1234567890abcdef", None);
161        assert_eq!(creds.display_name(), "user@example.com");
162    }
163
164    #[test]
165    fn credentials_roundtrip_serialization() {
166        let creds = make_credentials("mecha_1234567890abcdef", Some("Ada Lovelace"));
167        let json = serde_json::to_string(&creds).unwrap();
168        let deserialized: Credentials = serde_json::from_str(&json).unwrap();
169
170        assert_eq!(deserialized.api_key, creds.api_key);
171        assert_eq!(deserialized.user_id, creds.user_id);
172        assert_eq!(deserialized.email, creds.email);
173        assert_eq!(deserialized.name, creds.name);
174        assert_eq!(deserialized.auth_url, creds.auth_url);
175    }
176
177    #[test]
178    fn credentials_deserialization_defaults_missing_name() {
179        let json = r#"{
180            "api_key": "mecha_abc123",
181            "user_id": "usr_1",
182            "email": "user@example.com",
183            "authenticated_at": "2024-01-01T00:00:00Z",
184            "auth_url": "https://mecha.industries/api/auth"
185        }"#;
186
187        let creds: Credentials = serde_json::from_str(json).unwrap();
188        assert_eq!(creds.name, None);
189    }
190
191    #[test]
192    fn device_code_response_roundtrip_serialization() {
193        let response = DeviceCodeResponse {
194            device_code: "devcode123".to_string(),
195            user_code: "ABCD-1234".to_string(),
196            verification_uri: "https://mecha.industries/device".to_string(),
197            verification_uri_complete: Some("https://mecha.industries/device?code=ABCD-1234".to_string()),
198            expires_in: 900,
199            interval: 5,
200        };
201
202        let json = serde_json::to_string(&response).unwrap();
203        let deserialized: DeviceCodeResponse = serde_json::from_str(&json).unwrap();
204
205        assert_eq!(deserialized.device_code, response.device_code);
206        assert_eq!(deserialized.user_code, response.user_code);
207        assert_eq!(deserialized.verification_uri, response.verification_uri);
208        assert_eq!(
209            deserialized.verification_uri_complete,
210            response.verification_uri_complete
211        );
212        assert_eq!(deserialized.expires_in, response.expires_in);
213        assert_eq!(deserialized.interval, response.interval);
214    }
215
216    #[test]
217    fn device_code_response_deserializes_without_verification_uri_complete() {
218        let json = r#"{
219            "device_code": "devcode123",
220            "user_code": "ABCD-1234",
221            "verification_uri": "https://mecha.industries/device",
222            "expires_in": 900,
223            "interval": 5
224        }"#;
225
226        let response: DeviceCodeResponse = serde_json::from_str(json).unwrap();
227        assert_eq!(response.verification_uri_complete, None);
228        assert_eq!(response.browser_url(), "https://mecha.industries/device");
229    }
230
231    #[test]
232    fn browser_url_prefers_verification_uri_complete_when_present() {
233        let response = DeviceCodeResponse {
234            device_code: "devcode123".to_string(),
235            user_code: "ABCD-1234".to_string(),
236            verification_uri: "https://mecha.industries/device".to_string(),
237            verification_uri_complete: Some("https://mecha.industries/device?code=ABCD-1234".to_string()),
238            expires_in: 900,
239            interval: 5,
240        };
241
242        assert_eq!(response.browser_url(), "https://mecha.industries/device?code=ABCD-1234");
243    }
244
245    #[test]
246    fn device_code_status_serializes_pending_with_tag() {
247        let status = DeviceCodeStatus::Pending;
248        let json = serde_json::to_value(&status).unwrap();
249        assert_eq!(json, serde_json::json!({ "status": "pending" }));
250    }
251
252    #[test]
253    fn device_code_status_serializes_denied_and_expired() {
254        assert_eq!(
255            serde_json::to_value(DeviceCodeStatus::Denied).unwrap(),
256            serde_json::json!({ "status": "denied" })
257        );
258        assert_eq!(
259            serde_json::to_value(DeviceCodeStatus::Expired).unwrap(),
260            serde_json::json!({ "status": "expired" })
261        );
262    }
263
264    #[test]
265    fn device_code_status_roundtrips_authorized_variant() {
266        let status = DeviceCodeStatus::Authorized {
267            api_key: "mecha_abc123".to_string(),
268            user_id: "usr_1".to_string(),
269            email: "user@example.com".to_string(),
270            name: Some("Ada Lovelace".to_string()),
271        };
272
273        let json = serde_json::to_string(&status).unwrap();
274        let deserialized: DeviceCodeStatus = serde_json::from_str(&json).unwrap();
275
276        match deserialized {
277            DeviceCodeStatus::Authorized {
278                api_key,
279                user_id,
280                email,
281                name,
282            } => {
283                assert_eq!(api_key, "mecha_abc123");
284                assert_eq!(user_id, "usr_1");
285                assert_eq!(email, "user@example.com");
286                assert_eq!(name, Some("Ada Lovelace".to_string()));
287            }
288            other => panic!("expected Authorized variant, got {:?}", other),
289        }
290    }
291
292    #[test]
293    fn device_code_status_deserializes_from_status_tag() {
294        let json = r#"{"status": "pending"}"#;
295        let status: DeviceCodeStatus = serde_json::from_str(json).unwrap();
296        assert!(matches!(status, DeviceCodeStatus::Pending));
297    }
298
299    #[test]
300    fn auth_error_display_messages() {
301        assert_eq!(
302            AuthError::NetworkError {
303                message: "connection refused".to_string()
304            }
305            .to_string(),
306            "Network error: connection refused"
307        );
308
309        assert_eq!(
310            AuthError::ServerError {
311                message: "boom".to_string(),
312                status_code: Some(500)
313            }
314            .to_string(),
315            "Server error (Some(500)): boom"
316        );
317
318        assert_eq!(AuthError::ExpiredCode.to_string(), "Device code expired");
319        assert_eq!(AuthError::AccessDenied.to_string(), "Access denied by user");
320
321        assert_eq!(
322            AuthError::InvalidCredentials {
323                message: "bad json".to_string()
324            }
325            .to_string(),
326            "Invalid credentials: bad json"
327        );
328
329        assert_eq!(
330            AuthError::RateLimited { retry_after: Some(30) }.to_string(),
331            "Rate limited, retry after Some(30) seconds"
332        );
333    }
334
335    #[test]
336    fn auth_error_roundtrip_serialization() {
337        let err = AuthError::RateLimited { retry_after: Some(15) };
338        let json = serde_json::to_string(&err).unwrap();
339        let deserialized: AuthError = serde_json::from_str(&json).unwrap();
340
341        match deserialized {
342            AuthError::RateLimited { retry_after } => assert_eq!(retry_after, Some(15)),
343            other => panic!("expected RateLimited variant, got {:?}", other),
344        }
345    }
346}