Skip to main content

systemprompt_cloud/
credentials.rs

1use anyhow::{Context, Result};
2use chrono::{DateTime, Duration, Utc};
3use serde::{Deserialize, Serialize};
4use std::fs;
5use std::path::Path;
6use systemprompt_identifiers::CloudAuthToken;
7use systemprompt_logging::CliService;
8use systemprompt_models::net::{HTTP_AUTH_VERIFY_TIMEOUT, HTTP_CONNECT_TIMEOUT};
9use validator::Validate;
10
11use crate::auth;
12use crate::error::CloudError;
13
14#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
15pub struct CloudCredentials {
16    #[validate(length(min = 1, message = "API token cannot be empty"))]
17    pub api_token: String,
18
19    #[validate(url(message = "API URL must be a valid URL"))]
20    pub api_url: String,
21
22    pub authenticated_at: DateTime<Utc>,
23
24    #[validate(email(message = "User email must be a valid email address"))]
25    pub user_email: String,
26}
27
28impl CloudCredentials {
29    #[must_use]
30    pub fn new(api_token: String, api_url: String, user_email: String) -> Self {
31        Self {
32            api_token,
33            api_url,
34            authenticated_at: Utc::now(),
35            user_email,
36        }
37    }
38
39    pub fn token(&self) -> CloudAuthToken {
40        CloudAuthToken::new(&self.api_token)
41    }
42
43    #[must_use]
44    pub fn is_token_expired(&self) -> bool {
45        auth::is_expired(&self.token())
46    }
47
48    #[must_use]
49    pub fn expires_within(&self, duration: Duration) -> bool {
50        auth::expires_within(&self.token(), duration)
51    }
52
53    pub fn load_and_validate_from_path(path: &Path) -> Result<Self> {
54        let creds = Self::load_from_path(path)?;
55
56        creds
57            .validate()
58            .map_err(|e| CloudError::CredentialsCorrupted {
59                source: serde_json::Error::io(std::io::Error::new(
60                    std::io::ErrorKind::InvalidData,
61                    e.to_string(),
62                )),
63            })?;
64
65        if creds.is_token_expired() {
66            return Err(CloudError::TokenExpired.into());
67        }
68
69        if creds.expires_within(Duration::hours(1)) {
70            CliService::warning(
71                "Cloud token will expire soon. Consider running 'systemprompt cloud login' to \
72                 refresh.",
73            );
74        }
75
76        Ok(creds)
77    }
78
79    pub async fn validate_with_api(&self) -> Result<bool> {
80        let client = reqwest::Client::builder()
81            .connect_timeout(HTTP_CONNECT_TIMEOUT)
82            .timeout(HTTP_AUTH_VERIFY_TIMEOUT)
83            .build()
84            .context("Failed to build credentials validation HTTP client")?;
85
86        let response = client
87            .get(format!("{}/api/v1/auth/me", self.api_url))
88            .header("Authorization", format!("Bearer {}", self.api_token))
89            .send()
90            .await?;
91
92        Ok(response.status().is_success())
93    }
94
95    pub fn load_from_path(path: &Path) -> Result<Self> {
96        if !path.exists() {
97            return Err(CloudError::NotAuthenticated.into());
98        }
99
100        let content = fs::read_to_string(path)
101            .with_context(|| format!("Failed to read {}", path.display()))?;
102
103        let creds: Self = serde_json::from_str(&content)
104            .map_err(|e| CloudError::CredentialsCorrupted { source: e })?;
105
106        creds
107            .validate()
108            .map_err(|e| CloudError::CredentialsCorrupted {
109                source: serde_json::Error::io(std::io::Error::new(
110                    std::io::ErrorKind::InvalidData,
111                    e.to_string(),
112                )),
113            })?;
114
115        Ok(creds)
116    }
117
118    pub fn save_to_path(&self, path: &Path) -> Result<()> {
119        self.validate()
120            .map_err(|e| CloudError::CredentialsCorrupted {
121                source: serde_json::Error::io(std::io::Error::new(
122                    std::io::ErrorKind::InvalidData,
123                    e.to_string(),
124                )),
125            })?;
126
127        if let Some(dir) = path.parent() {
128            fs::create_dir_all(dir)?;
129
130            let gitignore_path = dir.join(".gitignore");
131            if !gitignore_path.exists() {
132                fs::write(&gitignore_path, "*\n")?;
133            }
134        }
135
136        let content = serde_json::to_string_pretty(self)?;
137        fs::write(path, content)?;
138
139        #[cfg(unix)]
140        {
141            use std::os::unix::fs::PermissionsExt;
142            let mut perms = fs::metadata(path)?.permissions();
143            perms.set_mode(0o600);
144            fs::set_permissions(path, perms)?;
145        }
146
147        Ok(())
148    }
149
150    pub fn delete_from_path(path: &Path) -> Result<()> {
151        if path.exists() {
152            fs::remove_file(path)?;
153        }
154        Ok(())
155    }
156}