systemprompt_cloud/
credentials.rs1use 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 #[serde(skip_serializing_if = "Option::is_none")]
24 #[validate(email(message = "User email must be a valid email address"))]
25 pub user_email: Option<String>,
26}
27
28impl CloudCredentials {
29 #[must_use]
30 pub fn new(api_token: String, api_url: String, user_email: Option<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::new();
81
82 let response = client
83 .get(format!("{}/api/v1/auth/me", self.api_url))
84 .header("Authorization", format!("Bearer {}", self.api_token))
85 .timeout(std::time::Duration::from_secs(10))
86 .send()
87 .await?;
88
89 Ok(response.status().is_success())
90 }
91
92 pub fn load_from_path(path: &Path) -> Result<Self> {
93 if !path.exists() {
94 return Err(CloudError::NotAuthenticated.into());
95 }
96
97 let content = fs::read_to_string(path)
98 .with_context(|| format!("Failed to read {}", path.display()))?;
99
100 let creds: Self = serde_json::from_str(&content)
101 .map_err(|e| CloudError::CredentialsCorrupted { source: e })?;
102
103 creds
104 .validate()
105 .map_err(|e| CloudError::CredentialsCorrupted {
106 source: serde_json::Error::io(std::io::Error::new(
107 std::io::ErrorKind::InvalidData,
108 e.to_string(),
109 )),
110 })?;
111
112 Ok(creds)
113 }
114
115 pub fn save_to_path(&self, path: &Path) -> Result<()> {
116 self.validate()
117 .map_err(|e| CloudError::CredentialsCorrupted {
118 source: serde_json::Error::io(std::io::Error::new(
119 std::io::ErrorKind::InvalidData,
120 e.to_string(),
121 )),
122 })?;
123
124 if let Some(dir) = path.parent() {
125 fs::create_dir_all(dir)?;
126
127 let gitignore_path = dir.join(".gitignore");
128 if !gitignore_path.exists() {
129 fs::write(&gitignore_path, "*\n")?;
130 }
131 }
132
133 let content = serde_json::to_string_pretty(self)?;
134 fs::write(path, content)?;
135
136 #[cfg(unix)]
137 {
138 use std::os::unix::fs::PermissionsExt;
139 let mut perms = fs::metadata(path)?.permissions();
140 perms.set_mode(0o600);
141 fs::set_permissions(path, perms)?;
142 }
143
144 Ok(())
145 }
146
147 pub fn delete_from_path(path: &Path) -> Result<()> {
148 if path.exists() {
149 fs::remove_file(path)?;
150 }
151 Ok(())
152 }
153}