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