1use crate::{errors::AuthError, registry::api_url, utils::login_file_path};
3use log::{debug, info, warn};
4use reqwest::{
5 Client, StatusCode,
6 header::{AUTHORIZATION, HeaderMap, HeaderValue},
7};
8use serde::{Deserialize, Serialize};
9use std::{env, fs, path::PathBuf};
10
11pub type Result<T> = std::result::Result<T, AuthError>;
12
13#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
15pub struct Credentials {
16 pub email: String,
17 pub password: String,
18}
19
20#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
22pub struct LoginResponse {
23 pub status: String,
24 pub token: String,
26}
27
28pub fn get_token() -> Result<String> {
32 if let Ok(token) = env::var("SOLDEER_API_TOKEN") &&
33 !token.is_empty()
34 {
35 return Ok(token)
36 }
37 let token_path = login_file_path()?;
38 let jwt =
39 fs::read_to_string(&token_path).map_err(|_| AuthError::MissingToken)?.trim().to_string();
40 if jwt.is_empty() {
41 debug!(token_path:?; "token file exists but is empty");
42 return Err(AuthError::MissingToken);
43 }
44 debug!(token_path:?; "token retrieved from file");
45 Ok(jwt)
46}
47
48pub fn get_auth_headers() -> Result<HeaderMap> {
50 let mut headers: HeaderMap = HeaderMap::new();
51 let Ok(token) = get_token() else {
52 return Ok(headers);
53 };
54 let header_value =
55 HeaderValue::from_str(&format!("Bearer {token}")).map_err(|_| AuthError::InvalidToken)?;
56 headers.insert(AUTHORIZATION, header_value);
57 Ok(headers)
58}
59
60pub fn save_token(token: &str) -> Result<PathBuf> {
62 let token_path = login_file_path()?;
63 fs::write(&token_path, token)?;
64 Ok(token_path)
65}
66
67pub async fn check_token(token: &str) -> Result<String> {
69 let client = Client::new();
70 let url = api_url("v1", "auth/validate-cli-token", &[]);
71 let mut headers: HeaderMap = HeaderMap::new();
72 let header_value =
73 HeaderValue::from_str(&format!("Bearer {token}")).map_err(|_| AuthError::InvalidToken)?;
74 headers.insert(AUTHORIZATION, header_value);
75 let response = client.get(url).headers(headers).send().await?;
76 match response.status() {
77 s if s.is_success() => {
78 #[derive(Deserialize)]
79 struct User {
80 id: String,
81 username: String,
82 }
83 #[derive(Deserialize)]
84 struct UserResponse {
85 data: User,
86 }
87 let res: UserResponse = response.json().await?;
88 debug!("token is valid for user {} with ID {}", res.data.username, res.data.id);
89 Ok(res.data.username)
90 }
91 StatusCode::UNAUTHORIZED => Err(AuthError::InvalidToken),
92 _ => Err(AuthError::HttpError(
93 response.error_for_status().expect_err("result should be an error"),
94 )),
95 }
96}
97
98pub async fn execute_login(login: &Credentials) -> Result<PathBuf> {
100 warn!(
101 "the option to login via email and password will be removed in a future version of Soldeer. Please update your usage by either using `soldeer login --token [YOUR CLI TOKEN]` or passing the `SOLDEER_API_TOKEN` environment variable to the `push` command."
102 );
103
104 let token_path = login_file_path()?;
105 let url = api_url("v1", "auth/login", &[]);
106 let client = Client::new();
107 let res = client.post(url).json(login).send().await?;
108 match res.status() {
109 s if s.is_success() => {
110 debug!("login request completed");
111 let response: LoginResponse = res.json().await?;
112 fs::write(&token_path, response.token)?;
113 info!(token_path:?; "login successful");
114 Ok(token_path)
115 }
116 StatusCode::UNAUTHORIZED => Err(AuthError::InvalidCredentials),
117 _ => Err(AuthError::HttpError(
118 res.error_for_status().expect_err("result should be an error"),
119 )),
120 }
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126 use temp_env::{async_with_vars, with_var};
127 use testdir::testdir;
128
129 #[tokio::test]
130 async fn test_login_success() {
131 let mut server = mockito::Server::new_async().await;
132 server
133 .mock("POST", "/api/v1/auth/login")
134 .with_status(201)
135 .with_header("content-type", "application/json")
136 .with_body(r#"{"status":"200","token":"jwt_token_example"}"#)
137 .create_async()
138 .await;
139
140 let test_file = testdir!().join("test_save_jwt");
141 let res = async_with_vars(
142 [
143 ("SOLDEER_API_URL", Some(server.url())),
144 ("SOLDEER_LOGIN_FILE", Some(test_file.to_string_lossy().to_string())),
145 ],
146 execute_login(&Credentials {
147 email: "test@test.com".to_string(),
148 password: "1234".to_string(),
149 }),
150 )
151 .await;
152 assert!(res.is_ok(), "{res:?}");
153 assert_eq!(fs::canonicalize(res.unwrap()).unwrap(), fs::canonicalize(&test_file).unwrap());
154 assert_eq!(fs::read_to_string(test_file).unwrap(), "jwt_token_example");
155 }
156
157 #[tokio::test]
158 async fn test_login_401() {
159 let mut server = mockito::Server::new_async().await;
160 server
161 .mock("POST", "/api/v1/auth/login")
162 .with_status(401)
163 .with_header("content-type", "application/json")
164 .with_body(r#"{"status":"401"}"#)
165 .create_async()
166 .await;
167
168 let test_file = testdir!().join("test_save_jwt");
169 let res = async_with_vars(
170 [
171 ("SOLDEER_API_URL", Some(server.url())),
172 ("SOLDEER_LOGIN_FILE", Some(test_file.to_string_lossy().to_string())),
173 ],
174 execute_login(&Credentials {
175 email: "test@test.com".to_string(),
176 password: "1234".to_string(),
177 }),
178 )
179 .await;
180 assert!(matches!(res, Err(AuthError::InvalidCredentials)), "{res:?}");
181 }
182
183 #[tokio::test]
184 async fn test_login_500() {
185 let mut server = mockito::Server::new_async().await;
186 server
187 .mock("POST", "/api/v1/auth/login")
188 .with_status(500)
189 .with_header("content-type", "application/json")
190 .with_body(r#"{"status":"500"}"#)
191 .create_async()
192 .await;
193
194 let test_file = testdir!().join("test_save_jwt");
195 let res = async_with_vars(
196 [
197 ("SOLDEER_API_URL", Some(server.url())),
198 ("SOLDEER_LOGIN_FILE", Some(test_file.to_string_lossy().to_string())),
199 ],
200 execute_login(&Credentials {
201 email: "test@test.com".to_string(),
202 password: "1234".to_string(),
203 }),
204 )
205 .await;
206 assert!(matches!(res, Err(AuthError::HttpError(_))), "{res:?}");
207 }
208
209 #[tokio::test]
210 async fn test_check_token_success() {
211 let mut server = mockito::Server::new_async().await;
212 server
213 .mock("GET", "/api/v1/auth/validate-cli-token")
214 .with_status(200)
215 .with_header("content-type", "application/json")
216 .with_body(
217 r#"{"status":"success","data":{"created_at": "2024-08-04T14:21:31.622589Z","email": "test@test.net","id": "b6d56bf0-00a5-474f-b732-f416bef53e92","organization": "test","role": "owner","updated_at": "2024-08-04T14:21:31.622589Z","username": "test","verified": true}}"#,
218 )
219 .create_async()
220 .await;
221
222 let res =
223 async_with_vars([("SOLDEER_API_URL", Some(server.url()))], check_token("eyJ0..."))
224 .await;
225 assert!(res.is_ok(), "{res:?}");
226 assert_eq!(res.unwrap(), "test");
227 }
228
229 #[tokio::test]
230 async fn test_check_token_failure() {
231 let mut server = mockito::Server::new_async().await;
232 server
233 .mock("GET", "/api/v1/auth/validate-cli-token")
234 .with_status(401)
235 .with_header("content-type", "application/json")
236 .with_body(r#"{"status":"fail","message":"Invalid token"}"#)
237 .create_async()
238 .await;
239
240 let res =
241 async_with_vars([("SOLDEER_API_URL", Some(server.url()))], check_token("foobar")).await;
242 assert!(res.is_err(), "{res:?}");
243 }
244
245 #[test]
246 fn test_get_token_env() {
247 let res = with_var("SOLDEER_API_TOKEN", Some("test"), get_token);
248 assert!(res.is_ok(), "{res:?}");
249 assert_eq!(res.unwrap(), "test");
250 }
251}