Skip to main content

cmdhub_cli/
auth.rs

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    // 1. Generate PKCE verifier and challenge
34    let code_verifier = Uuid::new_v4().to_string() + &Uuid::new_v4().to_string(); // high entropy
35    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    // 2. Start local TCP Listener
44    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    // Try to open browser
60    if !open_browser(&login_url) {
61        eprintln!("Browser launcher not detected. Waiting for manual authentication callback...");
62    }
63
64    // 3. Receive OAuth callback via local web server
65    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    // 4. Exchange code for JWT
127    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    // 5. Store session securely (0600 file permission)
157    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}