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