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}
48impl 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 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 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 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 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 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
136pub 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
202async 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
288async 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}