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