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