Skip to main content

soldeer_core/
auth.rs

1//! Registry authentication
2use 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/// Credentials to be used for login
14#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
15pub struct Credentials {
16    pub email: String,
17    pub password: String,
18}
19
20/// Response from the login endpoint
21#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
22pub struct LoginResponse {
23    pub status: String,
24    /// JWT token
25    pub token: String,
26}
27
28/// Get the JWT token from the environment or from the login file
29///
30/// Precedence is given to the `SOLDEER_API_TOKEN` environment variable.
31pub 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
48/// Get a header map with the bearer token set up if it exists
49pub 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
60/// Save an access token in the login file
61pub 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
67/// Retrieve user profile for the token to check its validity, returning the username
68pub 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
98/// Execute the login request and store the JWT token in the login file
99pub 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}