Skip to main content

txtx_cloud/
login.rs

1use std::collections::HashMap;
2
3use actix_cors::Cors;
4use actix_web::error::QueryPayloadError;
5use actix_web::http::header;
6use actix_web::web::{self, Data};
7use actix_web::{middleware, App, FromRequest, HttpRequest, HttpResponse, HttpServer, Responder};
8use base64::Engine;
9use dialoguer::theme::ColorfulTheme;
10use dialoguer::Confirm;
11
12use hiro_system_kit::{green, yellow};
13use serde::de::Error;
14use txtx_core::kit::channel::{Receiver, Sender};
15
16use serde::{Deserialize, Serialize};
17use txtx_core::kit::futures::future::{ready, Ready};
18use txtx_core::kit::{channel, reqwest};
19
20use crate::auth::jwt::JwtManager;
21use crate::auth::AuthUser;
22use crate::LoginCommand;
23
24use super::auth::AuthConfig;
25
26#[derive(Debug, Clone, Deserialize, Serialize)]
27#[serde(rename_all = "camelCase")]
28pub struct LoginCallbackResult {
29    access_token: String,
30    exp: u64,
31    refresh_token: String,
32    pat: String,
33    user: AuthUser,
34}
35
36#[derive(Debug, Clone, Deserialize)]
37#[serde(rename_all = "camelCase")]
38struct LoginCallbackError {
39    message: String,
40}
41
42#[derive(Debug, Clone, Deserialize)]
43#[serde(untagged)]
44enum LoginCallbackServerEvent {
45    AuthCallback(LoginCallbackResult),
46    AuthError(LoginCallbackError),
47}
48// The actix_web `Query<>` extractor was having a hard time with the enums and nested objects here,
49// and we wanted to base64 encode the data, so we implemented our own `FromRequest` extractor.
50impl FromRequest for LoginCallbackServerEvent {
51    type Error = QueryPayloadError;
52    type Future = Ready<Result<Self, Self::Error>>;
53
54    fn from_request(req: &HttpRequest, _: &mut actix_web::dev::Payload) -> Self::Future {
55        // Extract the query string from the request
56        let query_string = req.query_string();
57
58        let decoded = match base64::engine::general_purpose::URL_SAFE.decode(query_string) {
59            Ok(decoded) => decoded,
60            Err(err) => {
61                let error = QueryPayloadError::Deserialize(serde_urlencoded::de::Error::custom(
62                    format!("Base64 decode error: {}", err),
63                ));
64                return ready(Err(error));
65            }
66        };
67
68        // Convert decoded bytes to a string
69        let decoded_str = match String::from_utf8(decoded) {
70            Ok(s) => s,
71            Err(err) => {
72                let error = QueryPayloadError::Deserialize(serde_urlencoded::de::Error::custom(
73                    format!("UTF-8 conversion error: {}", err),
74                ));
75                return ready(Err(error));
76            }
77        };
78
79        let mut params: HashMap<String, String> = match serde_urlencoded::from_str(&decoded_str) {
80            Ok(params) => params,
81            Err(err) => {
82                let error = QueryPayloadError::Deserialize(err);
83                return ready(Err(error));
84            }
85        };
86        // Handle the `user` field separately if it exists
87        if let Some(user_json) = params.remove("user") {
88            let user: AuthUser = match serde_json::from_str(&user_json) {
89                Ok(user) => user,
90                Err(err) => {
91                    let error =
92                        QueryPayloadError::Deserialize(serde_urlencoded::de::Error::custom(
93                            format!("Failed to parse 'user' field: {}", err),
94                        ));
95                    return ready(Err(error));
96                }
97            };
98
99            // Reconstruct `LoginCallbackServerEvent` with the parsed `user`
100            if params.contains_key("accessToken")
101                && params.contains_key("exp")
102                && params.contains_key("refreshToken")
103                && params.contains_key("pat")
104            {
105                let result = LoginCallbackResult {
106                    access_token: params.remove("accessToken").unwrap(),
107                    exp: params.remove("exp").unwrap().parse().unwrap_or_default(),
108                    refresh_token: params.remove("refreshToken").unwrap(),
109                    pat: params.remove("pat").unwrap(),
110                    user,
111                };
112
113                return ready(Ok(LoginCallbackServerEvent::AuthCallback(result)));
114            }
115        }
116
117        // If no matching variant is found, return an error
118        ready(Err(QueryPayloadError::Deserialize(serde_urlencoded::de::Error::custom(
119            "Data did not match any variant",
120        ))))
121    }
122}
123
124#[derive(Debug, Clone)]
125struct LoginCallbackServerContext {
126    tx: Sender<LoginCallbackServerEvent>,
127}
128
129impl LoginCallbackServerContext {
130    fn new() -> (Self, Receiver<LoginCallbackServerEvent>) {
131        let (tx, rx) = channel::unbounded::<LoginCallbackServerEvent>();
132        (Self { tx }, rx)
133    }
134}
135
136/// ## Arguments
137///
138/// * `cmd` - The login command containing user-provided credentials or options.
139/// * `auth_service_url` - The URL of the frontend service used to authenticate the user.
140/// * `auth_callback_port` - The port for the callback server used during login.
141/// * `id_service_url` - The URL of the ID service.
142pub async fn handle_login_command(
143    cmd: &LoginCommand,
144    auth_service_url: &str,
145    auth_callback_port: &str,
146    id_service_url: &str,
147) -> Result<(), String> {
148    let auth_config = AuthConfig::read_from_system_config()?;
149
150    let jwt_manager = crate::auth::jwt::JwtManager::initialize(id_service_url)
151        .await
152        .map_err(|e| format!("Failed to initialize JWT manager: {}", e))?;
153
154    if let Some(mut auth_config) = auth_config {
155        if auth_config.is_access_token_expired() {
156            match auth_config.refresh_session_if_needed(id_service_url).await {
157                Ok(()) => {
158                    println!("{} Logged in as {}.", green!("✓"), auth_config.user.display_name);
159                    return Ok(());
160                }
161                Err(_e) => {
162                    if let Some(pat) = &auth_config.pat {
163                        if let Ok(auth_config) = pat_login(id_service_url, &jwt_manager, &pat).await
164                        {
165                            auth_config.write_to_system_config()?;
166                            println!(
167                                "{} Logged in as {}.",
168                                green!("✓"),
169                                auth_config.user.display_name
170                            );
171                            return Ok(());
172                        }
173                    }
174                    println!("{} Auth data already found for user, but failed to refresh session; attempting login.", yellow!("-"));
175                }
176            }
177        } else {
178            println!("{} Logged in as {}.", green!("✓"), auth_config.user.display_name);
179            return Ok(());
180        }
181    }
182
183    let auth_config = if let Some(email) = &cmd.email {
184        let password =
185            cmd.password.as_ref().ok_or("Password is required when email is provided")?;
186        user_pass_login(id_service_url, &jwt_manager, email, password).await?
187    } else if let Some(pat) = &cmd.pat {
188        pat_login(id_service_url, &jwt_manager, &pat).await?
189    } else {
190        let Some(res) = auth_service_login(auth_service_url, auth_callback_port).await? else {
191            return Ok(());
192        };
193        let auth_config =
194            AuthConfig::new(res.access_token, res.exp, res.refresh_token, Some(res.pat), res.user);
195        auth_config
196    };
197
198    auth_config.write_to_system_config()?;
199    Ok(())
200}
201
202/// Starts a server that will only receive a POST request from the ID service with the user's auth data.
203/// Directs the user to the ID service login page.
204/// Upon login, the ID service will send a POST request to the server with the user's auth data.
205async fn auth_service_login(
206    auth_service_url: &str,
207    auth_callback_port: &str,
208) -> Result<Option<LoginCallbackResult>, String> {
209    let redirect_url = format!("localhost:{}", auth_callback_port);
210
211    let auth_service_url = reqwest::Url::parse(&format!(
212        "{}?redirectUrl=http://{}/api/v1/auth",
213        auth_service_url, redirect_url
214    ))
215    .map_err(|e| format!("Invalid auth service URL: {e}"))?;
216
217    let allowed_origin = auth_service_url.origin().ascii_serialization();
218    let (ctx, rx) = LoginCallbackServerContext::new();
219    let ctx = Data::new(ctx);
220    let server = HttpServer::new(move || {
221        App::new()
222            .app_data(ctx.clone())
223            .wrap(
224                Cors::default()
225                    .allowed_origin(&allowed_origin)
226                    .allowed_methods(vec!["GET", "OPTIONS"])
227                    .allowed_headers(vec![header::CONTENT_TYPE, header::ACCEPT])
228            )
229            .wrap(middleware::Compress::default())
230            .wrap(middleware::Logger::default())
231            .service(
232                web::scope("/api/v1")
233                .route("/auth", web::get().to(auth_callback))
234            )
235    })
236    .workers(1)
237    .bind(redirect_url)
238    .map_err(|e| format!("Failed to start auth callback server: failed to bind to port {auth_callback_port}: {e}"))?
239    .run();
240    let handle = server.handle();
241    tokio::spawn(server);
242
243    let confirm = Confirm::with_theme(&ColorfulTheme::default())
244        .with_prompt(format!("Open {} in your browser to log in?", auth_service_url))
245        .default(true)
246        .interact();
247
248    let Ok(true) = confirm else {
249        handle.stop(true).await;
250        println!("\nLogin cancelled");
251        return Ok(None);
252    };
253
254    if let Err(_) = open::that(auth_service_url.as_str()) {
255        println!("Failed to automatically open your browser. Please open the following URL in your browser: {}", auth_service_url);
256    };
257
258    let res = rx.recv();
259    handle.stop(true).await;
260    match res {
261        Ok(event) => match event {
262            LoginCallbackServerEvent::AuthCallback(auth_callback_result) => {
263                Ok(Some(auth_callback_result))
264            }
265            LoginCallbackServerEvent::AuthError(auth_callback_error) => {
266                Err(format!("Authentication failed: {}", auth_callback_error.message))
267            }
268        },
269        Err(e) => Err(format!("Failed to receive auth callback event: {e}")),
270    }
271}
272
273async fn auth_callback(
274    _req: HttpRequest,
275    ctx: Data<LoginCallbackServerContext>,
276    payload: LoginCallbackServerEvent,
277) -> actix_web::Result<impl Responder> {
278    let body = match &payload {
279        LoginCallbackServerEvent::AuthCallback(_) => include_str!("./callback.html").to_string(),
280        LoginCallbackServerEvent::AuthError(e) => format!("Authentication failed: {}", e.message),
281    };
282    ctx.tx.send(payload).map_err(|_| {
283        actix_web::error::ErrorInternalServerError("Failed to send auth callback event")
284    })?;
285    Ok(HttpResponse::Ok().body(body))
286}
287
288/// Sends a POST request to the auth service to log in with an email and password.
289async fn user_pass_login(
290    id_service_url: &str,
291    jwt_manager: &JwtManager,
292    email: &str,
293    password: &str,
294) -> Result<AuthConfig, String> {
295    let client = reqwest::Client::new();
296    let res = client
297        .post(&format!("{}/signin/email-password", id_service_url))
298        .json(&serde_json::json!({
299            "email": email,
300            "password": password,
301        }))
302        .send()
303        .await
304        .map_err(|e| format!("Failed to send username/password login request: {}", e))?;
305
306    if res.status().is_success() {
307        let res = res
308            .json::<LoginResponse>()
309            .await
310            .map_err(|e| format!("Failed to parse username/password login response: {}", e))?;
311
312        let access_token_claims =
313            jwt_manager.decode_jwt(&res.session.access_token, true).map_err(|e| {
314                format!("Failed to decode JWT from username/password login response: {}", e)
315            })?;
316
317        let auth_config = AuthConfig::new(
318            res.session.access_token,
319            access_token_claims.exp,
320            res.session.refresh_token,
321            None,
322            res.session.user,
323        );
324        return Ok(auth_config);
325    } else {
326        let err = res.text().await.unwrap_or_else(|_| "Unknown error".to_string());
327        return Err(format!("Failed to login with username + password: {}", err));
328    }
329}
330
331pub async fn pat_login(
332    id_service_url: &str,
333    jwt_manager: &JwtManager,
334    pat: &str,
335) -> Result<AuthConfig, String> {
336    let client = reqwest::Client::new();
337    let res = client
338        .post(&format!("{}/signin/pat", id_service_url))
339        .json(&serde_json::json!({
340            "personalAccessToken": pat,
341        }))
342        .send()
343        .await
344        .map_err(|e| format!("Failed to send PAT login request: {}", e))?;
345
346    if res.status().is_success() {
347        let res = res
348            .json::<LoginResponse>()
349            .await
350            .map_err(|e| format!("Failed to parse PAT login response: {}", e))?;
351
352        let access_token_claims = jwt_manager
353            .decode_jwt(&res.session.access_token, true)
354            .map_err(|e| format!("Failed to decode JWT from PAT login response: {}", e))?;
355
356        let auth_config = AuthConfig::new(
357            res.session.access_token,
358            access_token_claims.exp,
359            res.session.refresh_token,
360            Some(pat.to_string()),
361            res.session.user,
362        );
363        return Ok(auth_config);
364    } else {
365        let err = res.text().await.unwrap_or_else(|_| "Unknown error".to_string());
366        return Err(format!("Failed to login with PAT: {}", err));
367    }
368}
369
370#[derive(Debug, Clone, Deserialize, Serialize)]
371#[serde(rename_all = "camelCase")]
372pub struct LoginResponse {
373    pub session: Session,
374}
375
376#[derive(Debug, Clone, Deserialize, Serialize)]
377#[serde(rename_all = "camelCase")]
378pub struct Session {
379    pub access_token: String,
380    pub refresh_token: String,
381    pub user: AuthUser,
382}