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