1use crate::config::Config;
2use anyhow::{Context, Result};
3use reqwest::Client;
4use sha2::{Digest, Sha256};
5use std::fs;
6use std::path::PathBuf;
7use uuid::Uuid;
8
9#[derive(serde::Serialize, serde::Deserialize)]
10pub struct Session {
11 pub token: String,
12 pub expires_at: i64,
13}
14
15pub fn get_session_path() -> PathBuf {
16 crate::config::get_config_dir().join("session.json")
17}
18
19pub fn get_session() -> Result<Option<Session>> {
20 let path = get_session_path();
21 if !path.exists() {
22 return Ok(None);
23 }
24 let content = fs::read_to_string(&path).context("Failed to read session file")?;
25 let session: Session =
26 serde_json::from_str(&content).context("Failed to parse session file")?;
27 Ok(Some(session))
28}
29
30pub async fn login_flow(config: &Config) -> Result<()> {
31 eprintln!("Initiating login flow...");
32
33 let code_verifier = Uuid::new_v4().to_string() + &Uuid::new_v4().to_string(); let mut hasher = Sha256::new();
36 hasher.update(code_verifier.as_bytes());
37 let hash = hasher.finalize();
38
39 use base64::Engine;
40 let code_challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hash);
41 let state = Uuid::new_v4().to_string();
42
43 let listener = tokio::net::TcpListener::bind("127.0.0.1:38118")
45 .await
46 .context(
47 "Failed to bind local callback port 38118. Please check if it's already in use.",
48 )?;
49
50 let login_url = format!(
51 "{}/auth/login/github?code_challenge={}&state={}",
52 config.api_url, code_challenge, state
53 );
54
55 eprintln!("\nOpening browser for GitHub authentication...");
56 eprintln!("If browser does not open automatically, please visit this URL:\n");
57 eprintln!("👉 {}\n", login_url);
58
59 if !open_browser(&login_url) {
61 eprintln!("Browser launcher not detected. Waiting for manual authentication callback...");
62 }
63
64 let timeout_dur = std::time::Duration::from_secs(60);
66 let mut code_and_state = None;
67
68 match tokio::time::timeout(timeout_dur, async {
69 loop {
70 if let Ok((mut socket, _)) = listener.accept().await {
71 use tokio::io::{AsyncReadExt, AsyncWriteExt};
72 let mut buf = [0u8; 1024];
73 if let Ok(n) = socket.read(&mut buf).await {
74 let req_str = String::from_utf8_lossy(&buf[..n]);
75 if let Some(first_line) = req_str.lines().next() {
76 if first_line.starts_with("GET /callback") {
77 if let Some(params_start) = first_line.find('?') {
78 let params_end = first_line[params_start..].find(' ').unwrap_or(first_line.len() - params_start) + params_start;
79 let query_str = &first_line[params_start + 1 .. params_end];
80 let mut code = None;
81 let mut oauth_state = None;
82 for part in query_str.split('&') {
83 let mut kv = part.split('=');
84 let key = kv.next();
85 let val = kv.next();
86 if let (Some(k), Some(v)) = (key, val) {
87 if k == "code" {
88 code = Some(v.to_string());
89 } else if k == "state" {
90 oauth_state = Some(v.to_string());
91 }
92 }
93 }
94 if let (Some(c), Some(s)) = (code, oauth_state) {
95 let response = "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nConnection: close\r\n\r\n\
96 <html><head><title>CmdHub Login Success</title><style>body { font-family: sans-serif; text-align: center; margin-top: 10%; background: #0f172a; color: #f1f5f9; } h1 { color: #38bdf8; }</style></head>\
97 <body><h1>Login Success!</h1><p>You can now close this browser tab and return to your terminal.</p></body></html>";
98 let _ = socket.write_all(response.as_bytes()).await;
99 let _ = socket.flush().await;
100 return Some((c, s));
101 }
102 }
103 }
104 }
105 let response = "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\nInvalid callback request.";
106 let _ = socket.write_all(response.as_bytes()).await;
107 let _ = socket.flush().await;
108 }
109 }
110 }
111 }).await {
112 Ok(Some((c, s))) => {
113 code_and_state = Some((c, s));
114 }
115 _ => {
116 eprintln!("Authentication timed out (60 seconds). Please try again.");
117 }
118 }
119
120 let (code, callback_state) =
121 code_and_state.context("Authentication callback failed or timed out.")?;
122 if callback_state != state {
123 anyhow::bail!("Security Warning: State mismatch (request state: {}, callback state: {}). Potential CSRF attack blocked.", state, callback_state);
124 }
125
126 eprintln!("Exchanging authentication code for API access token...");
128 let client = Client::new();
129 let token_res = client
130 .post(format!("{}/auth/token", config.api_url))
131 .json(&serde_json::json!({
132 "code": code,
133 "state": state,
134 "code_verifier": code_verifier
135 }))
136 .send()
137 .await
138 .context("Failed to contact auth token endpoint")?;
139
140 if !token_res.status().is_success() {
141 let err_body = token_res.text().await.unwrap_or_default();
142 anyhow::bail!("Cloud authentication failed: {}", err_body);
143 }
144
145 #[derive(serde::Deserialize)]
146 struct TokenResponse {
147 token: String,
148 expires_in: usize,
149 }
150
151 let token_payload: TokenResponse = token_res
152 .json()
153 .await
154 .context("Failed to parse token payload")?;
155
156 let session = Session {
158 token: token_payload.token,
159 expires_at: chrono::Utc::now().timestamp() + token_payload.expires_in as i64,
160 };
161
162 let session_path = get_session_path();
163 let parent_dir = session_path.parent().unwrap();
164 fs::create_dir_all(parent_dir)?;
165
166 fs::write(&session_path, serde_json::to_string_pretty(&session)?)
167 .context("Failed to save credentials session file")?;
168
169 #[cfg(unix)]
170 {
171 use std::os::unix::fs::PermissionsExt;
172 let mut perms = fs::metadata(&session_path)?.permissions();
173 perms.set_mode(0o600);
174 fs::set_permissions(&session_path, perms)
175 .context("Failed to restrict session file permission (0600)")?;
176 }
177
178 println!("Welcome! You have successfully authenticated with CmdHub Cloud Registry.");
179 Ok(())
180}
181
182pub async fn logout_flow(config: &Config) -> Result<()> {
183 let session_opt = get_session()?;
184 if let Some(session) = session_opt {
185 eprintln!("Logging out from Cloud Registry...");
186
187 let client = Client::new();
188 let _ = client
189 .post(format!("{}/auth/logout", config.api_url))
190 .header("Authorization", format!("Bearer {}", session.token))
191 .send()
192 .await;
193
194 let path = get_session_path();
195 if path.exists() {
196 fs::remove_file(&path).context("Failed to delete local session file")?;
197 }
198 println!("Successfully logged out and cleared local credentials.");
199 } else {
200 println!("You are not currently logged in.");
201 }
202 Ok(())
203}
204
205fn open_browser(url: &str) -> bool {
206 if std::env::var("CMDHub_HEADLESS").is_ok() || std::env::var("CI").is_ok() {
207 return false;
208 }
209 #[cfg(target_os = "macos")]
210 let res = std::process::Command::new("open").arg(url).spawn();
211 #[cfg(target_os = "windows")]
212 let res = std::process::Command::new("cmd")
213 .args(["/C", "start", "", url])
214 .spawn();
215 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
216 let res = std::process::Command::new("xdg-open").arg(url).spawn();
217
218 res.is_ok()
219}