Skip to main content

haste_health/commands/
login.rs

1use crate::cli::{
2    client::{TokenResponseBody, fetch_discovery_document, unix_now},
3    config::ProfileAuth,
4    secrets::StoredTokens,
5    state::{CliState, SECRETS_LOCATION},
6};
7use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
8use haste_fhir_model::r4::generated::terminology::IssueType;
9use haste_fhir_operation_error::OperationOutcomeError;
10use sha2::{Digest, Sha256};
11use std::{
12    io::{BufRead, BufReader, Write},
13    net::TcpListener,
14    sync::Arc,
15};
16use tokio::sync::Mutex;
17
18fn random_url_safe_string(byte_len: usize) -> String {
19    let bytes: Vec<u8> = (0..byte_len).map(|_| rand::random::<u8>()).collect();
20    URL_SAFE_NO_PAD.encode(bytes)
21}
22
23fn code_challenge_s256(code_verifier: &str) -> String {
24    let mut hasher = Sha256::new();
25    hasher.update(code_verifier.as_bytes());
26    URL_SAFE_NO_PAD.encode(hasher.finalize())
27}
28
29fn open_in_browser(url: &str) {
30    let result = if cfg!(target_os = "macos") {
31        std::process::Command::new("open").arg(url).status()
32    } else if cfg!(target_os = "windows") {
33        std::process::Command::new("cmd")
34            .args(["/C", "start", "", url])
35            .status()
36    } else {
37        std::process::Command::new("xdg-open").arg(url).status()
38    };
39
40    if !result.is_ok_and(|s| s.success()) {
41        println!("Could not open a browser automatically. Please open this URL manually:\n{url}");
42    }
43}
44
45fn parse_redirect_port(redirect_uri: &str) -> Result<u16, OperationOutcomeError> {
46    let url = reqwest::Url::parse(redirect_uri).map_err(|e| {
47        OperationOutcomeError::error(
48            IssueType::exception(),
49            format!("Invalid redirect_uri in profile: {}", e),
50        )
51    })?;
52
53    url.port().ok_or_else(|| {
54        OperationOutcomeError::error(
55            IssueType::exception(),
56            "Profile's redirect_uri must include an explicit port, e.g. 'http://127.0.0.1:8976/callback'."
57                .to_string(),
58        )
59    })
60}
61
62fn bind_callback_listener(port: u16) -> Result<TcpListener, OperationOutcomeError> {
63    TcpListener::bind(("127.0.0.1", port)).map_err(|e| {
64        OperationOutcomeError::error(
65            IssueType::exception(),
66            format!(
67                "Failed to bind local callback listener on port {}: {}",
68                port, e
69            ),
70        )
71    })
72}
73
74/// Blocks waiting for the browser to redirect back with `?code=&state=`, on a single
75/// connection to the loopback listener. Returns (code, state).
76fn wait_for_callback(
77    listener: TcpListener,
78    expected_path: &str,
79) -> Result<(String, String), OperationOutcomeError> {
80    let (stream, _) = listener.accept().map_err(|e| {
81        OperationOutcomeError::error(
82            IssueType::exception(),
83            format!("Failed to accept local callback connection: {}", e),
84        )
85    })?;
86
87    let mut reader = BufReader::new(stream.try_clone().map_err(|e| {
88        OperationOutcomeError::error(
89            IssueType::exception(),
90            format!("Failed to read callback request: {}", e),
91        )
92    })?);
93
94    let mut request_line = String::new();
95    reader.read_line(&mut request_line).map_err(|e| {
96        OperationOutcomeError::error(
97            IssueType::exception(),
98            format!("Failed to read callback request: {}", e),
99        )
100    })?;
101
102    let path = request_line
103        .split_whitespace()
104        .nth(1)
105        .unwrap_or("")
106        .to_string();
107
108    let mut stream = stream;
109    let body = "<html><body><h3>Login complete.</h3><p>You can close this window and return to the terminal.</p></body></html>";
110    let response = format!(
111        "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
112        body.len(),
113        body
114    );
115    let _ = stream.write_all(response.as_bytes());
116
117    let full_url = format!("http://127.0.0.1{}", path);
118    let parsed = reqwest::Url::parse(&full_url).map_err(|e| {
119        OperationOutcomeError::error(
120            IssueType::exception(),
121            format!("Failed to parse callback URL: {}", e),
122        )
123    })?;
124
125    if parsed.path() != expected_path {
126        return Err(OperationOutcomeError::error(
127            IssueType::exception(),
128            format!("Received callback on unexpected path '{}'.", parsed.path()),
129        ));
130    }
131
132    if let Some((_, error)) = parsed.query_pairs().find(|(k, _)| k == "error") {
133        return Err(OperationOutcomeError::error(
134            IssueType::security(),
135            format!("Authorization failed: {}", error),
136        ));
137    }
138
139    let code = parsed
140        .query_pairs()
141        .find(|(k, _)| k == "code")
142        .map(|(_, v)| v.to_string())
143        .ok_or_else(|| {
144            OperationOutcomeError::error(
145                IssueType::exception(),
146                "No 'code' parameter present on callback.".to_string(),
147            )
148        })?;
149
150    let state = parsed
151        .query_pairs()
152        .find(|(k, _)| k == "state")
153        .map(|(_, v)| v.to_string())
154        .ok_or_else(|| {
155            OperationOutcomeError::error(
156                IssueType::exception(),
157                "No 'state' parameter present on callback.".to_string(),
158            )
159        })?;
160
161    Ok((code, state))
162}
163
164/// Runs the `login` command: the browser-based authorization_code + PKCE login flow for
165/// the active profile, caching the resulting tokens in the secrets file. With
166/// `no_browser`, prints the authorization URL instead of opening it.
167pub(crate) async fn run(
168    state: Arc<Mutex<CliState>>,
169    no_browser: bool,
170) -> Result<(), OperationOutcomeError> {
171    let (client_id, redirect_uri, scope, profile_name) = {
172        let current_state = state.lock().await;
173        let profile = current_state.config.require_current_profile()?.clone();
174
175        match profile.auth {
176            ProfileAuth::AuthorizationCode {
177                client_id,
178                redirect_uri,
179                scope,
180            } => (client_id, redirect_uri, scope, profile.name),
181            _ => {
182                return Err(OperationOutcomeError::error(
183                    IssueType::invalid(),
184                    "The active profile is not configured for authorization-code login. Create one with `haste-health profile create --auth-mode authorization-code`."
185                        .to_string(),
186                ));
187            }
188        }
189    };
190
191    let well_known_document = fetch_discovery_document(&state).await?;
192
193    let code_verifier = random_url_safe_string(64);
194    let code_challenge = code_challenge_s256(&code_verifier);
195    let oauth_state = random_url_safe_string(32);
196
197    let port = parse_redirect_port(&redirect_uri)?;
198    let expected_path = reqwest::Url::parse(&redirect_uri)
199        .map(|u| u.path().to_string())
200        .unwrap_or_else(|_| "/callback".to_string());
201
202    let mut authorize_url = reqwest::Url::parse(&well_known_document.authorization_endpoint)
203        .map_err(|e| {
204            OperationOutcomeError::error(
205                IssueType::exception(),
206                format!(
207                    "Invalid authorization_endpoint in discovery document: {}",
208                    e
209                ),
210            )
211        })?;
212    authorize_url
213        .query_pairs_mut()
214        .append_pair("client_id", &client_id)
215        .append_pair("response_type", "code")
216        .append_pair("redirect_uri", &redirect_uri)
217        .append_pair("scope", &scope)
218        .append_pair("state", &oauth_state)
219        .append_pair("code_challenge", &code_challenge)
220        .append_pair("code_challenge_method", "S256");
221
222    // Bound before the URL is shown, so the redirect can't arrive first.
223    let listener = bind_callback_listener(port)?;
224
225    if no_browser {
226        println!("Open this URL to log in: {}", authorize_url);
227    } else {
228        println!("Opening your browser to log in...");
229        println!("If it doesn't open automatically, visit: {}", authorize_url);
230        open_in_browser(authorize_url.as_str());
231    }
232
233    let (code, returned_state) =
234        tokio::task::spawn_blocking(move || wait_for_callback(listener, &expected_path))
235            .await
236            .map_err(|e| {
237                OperationOutcomeError::error(
238                    IssueType::exception(),
239                    format!("Login callback task failed: {}", e),
240                )
241            })??;
242
243    if returned_state != oauth_state {
244        return Err(OperationOutcomeError::error(
245            IssueType::security(),
246            "State mismatch on login callback; aborting.".to_string(),
247        ));
248    }
249
250    let params = [
251        ("grant_type", "authorization_code"),
252        ("client_id", client_id.as_str()),
253        ("code", code.as_str()),
254        ("code_verifier", code_verifier.as_str()),
255        ("redirect_uri", redirect_uri.as_str()),
256    ];
257
258    let res = reqwest::Client::new()
259        .post(&well_known_document.token_endpoint)
260        .form(&params)
261        .send()
262        .await
263        .map_err(|e| {
264            OperationOutcomeError::error(
265                IssueType::exception(),
266                format!("Failed to exchange authorization code: {}", e),
267            )
268        })?;
269
270    if !res.status().is_success() {
271        let status = res.status();
272        let body = res.text().await.unwrap_or_default();
273        return Err(OperationOutcomeError::error(
274            IssueType::forbidden(),
275            format!(
276                "Failed to exchange authorization code: HTTP {} - {}",
277                status, body
278            ),
279        ));
280    }
281
282    let token_response: TokenResponseBody = res.json().await.map_err(|e| {
283        OperationOutcomeError::error(
284            IssueType::exception(),
285            format!("Failed to parse token response: {}", e),
286        )
287    })?;
288
289    let expires_at = unix_now() + token_response.expires_in;
290
291    let mut current_state = state.lock().await;
292    current_state.access_token = Some(token_response.access_token.clone());
293    // Recorded with the token so the client refreshes it rather than reusing it
294    // past its lifetime.
295    current_state.access_token_expires_at = Some(expires_at);
296
297    current_state.secrets.profile_mut(&profile_name).tokens = Some(StoredTokens {
298        access_token: token_response.access_token,
299        refresh_token: token_response.refresh_token,
300        id_token: token_response.id_token,
301        expires_at,
302    });
303
304    crate::cli::secrets::write_secrets(&SECRETS_LOCATION, &current_state.secrets)?;
305
306    println!(
307        "Login successful. Profile '{}' is now authenticated.",
308        profile_name
309    );
310
311    Ok(())
312}