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
62/// Blocks waiting for the browser to redirect back with `?code=&state=`, on a single
63/// connection to the loopback listener. Returns (code, state).
64fn wait_for_callback(
65    port: u16,
66    expected_path: &str,
67) -> Result<(String, String), OperationOutcomeError> {
68    let listener = TcpListener::bind(("127.0.0.1", port)).map_err(|e| {
69        OperationOutcomeError::error(
70            IssueType::exception(),
71            format!(
72                "Failed to bind local callback listener on port {}: {}",
73                port, e
74            ),
75        )
76    })?;
77
78    let (stream, _) = listener.accept().map_err(|e| {
79        OperationOutcomeError::error(
80            IssueType::exception(),
81            format!("Failed to accept local callback connection: {}", e),
82        )
83    })?;
84
85    let mut reader = BufReader::new(stream.try_clone().map_err(|e| {
86        OperationOutcomeError::error(
87            IssueType::exception(),
88            format!("Failed to read callback request: {}", e),
89        )
90    })?);
91
92    let mut request_line = String::new();
93    reader.read_line(&mut request_line).map_err(|e| {
94        OperationOutcomeError::error(
95            IssueType::exception(),
96            format!("Failed to read callback request: {}", e),
97        )
98    })?;
99
100    let path = request_line
101        .split_whitespace()
102        .nth(1)
103        .unwrap_or("")
104        .to_string();
105
106    let mut stream = stream;
107    let body = "<html><body><h3>Login complete.</h3><p>You can close this window and return to the terminal.</p></body></html>";
108    let response = format!(
109        "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
110        body.len(),
111        body
112    );
113    let _ = stream.write_all(response.as_bytes());
114
115    let full_url = format!("http://127.0.0.1{}", path);
116    let parsed = reqwest::Url::parse(&full_url).map_err(|e| {
117        OperationOutcomeError::error(
118            IssueType::exception(),
119            format!("Failed to parse callback URL: {}", e),
120        )
121    })?;
122
123    if parsed.path() != expected_path {
124        return Err(OperationOutcomeError::error(
125            IssueType::exception(),
126            format!("Received callback on unexpected path '{}'.", parsed.path()),
127        ));
128    }
129
130    if let Some((_, error)) = parsed.query_pairs().find(|(k, _)| k == "error") {
131        return Err(OperationOutcomeError::error(
132            IssueType::security(),
133            format!("Authorization failed: {}", error),
134        ));
135    }
136
137    let code = parsed
138        .query_pairs()
139        .find(|(k, _)| k == "code")
140        .map(|(_, v)| v.to_string())
141        .ok_or_else(|| {
142            OperationOutcomeError::error(
143                IssueType::exception(),
144                "No 'code' parameter present on callback.".to_string(),
145            )
146        })?;
147
148    let state = parsed
149        .query_pairs()
150        .find(|(k, _)| k == "state")
151        .map(|(_, v)| v.to_string())
152        .ok_or_else(|| {
153            OperationOutcomeError::error(
154                IssueType::exception(),
155                "No 'state' parameter present on callback.".to_string(),
156            )
157        })?;
158
159    Ok((code, state))
160}
161
162/// Runs the `login` command: the browser-based authorization_code + PKCE login flow for
163/// the active profile, caching the resulting tokens in the secrets file.
164pub(crate) async fn run(state: Arc<Mutex<CliState>>) -> Result<(), OperationOutcomeError> {
165    let (client_id, redirect_uri, scope, profile_name) = {
166        let current_state = state.lock().await;
167        let Some(profile) = current_state.config.current_profile().cloned() else {
168            return Err(OperationOutcomeError::error(
169                IssueType::invalid(),
170                "No active profile set. Please set an active profile using the config command."
171                    .to_string(),
172            ));
173        };
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 config create-profile --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    println!("Opening your browser to log in...");
223    println!("If it doesn't open automatically, visit: {}", authorize_url);
224    open_in_browser(authorize_url.as_str());
225
226    let (code, returned_state) =
227        tokio::task::spawn_blocking(move || wait_for_callback(port, &expected_path))
228            .await
229            .map_err(|e| {
230                OperationOutcomeError::error(
231                    IssueType::exception(),
232                    format!("Login callback task failed: {}", e),
233                )
234            })??;
235
236    if returned_state != oauth_state {
237        return Err(OperationOutcomeError::error(
238            IssueType::security(),
239            "State mismatch on login callback; aborting.".to_string(),
240        ));
241    }
242
243    let params = [
244        ("grant_type", "authorization_code"),
245        ("client_id", client_id.as_str()),
246        ("code", code.as_str()),
247        ("code_verifier", code_verifier.as_str()),
248        ("redirect_uri", redirect_uri.as_str()),
249    ];
250
251    let res = reqwest::Client::new()
252        .post(&well_known_document.token_endpoint)
253        .form(&params)
254        .send()
255        .await
256        .map_err(|e| {
257            OperationOutcomeError::error(
258                IssueType::exception(),
259                format!("Failed to exchange authorization code: {}", e),
260            )
261        })?;
262
263    if !res.status().is_success() {
264        let status = res.status();
265        let body = res.text().await.unwrap_or_default();
266        return Err(OperationOutcomeError::error(
267            IssueType::forbidden(),
268            format!(
269                "Failed to exchange authorization code: HTTP {} - {}",
270                status, body
271            ),
272        ));
273    }
274
275    let token_response: TokenResponseBody = res.json().await.map_err(|e| {
276        OperationOutcomeError::error(
277            IssueType::exception(),
278            format!("Failed to parse token response: {}", e),
279        )
280    })?;
281
282    let mut current_state = state.lock().await;
283    current_state.access_token = Some(token_response.access_token.clone());
284
285    current_state.secrets.profile_mut(&profile_name).tokens = Some(StoredTokens {
286        access_token: token_response.access_token,
287        refresh_token: token_response.refresh_token,
288        id_token: token_response.id_token,
289        expires_at: unix_now() + token_response.expires_in,
290    });
291
292    crate::cli::secrets::write_secrets(&SECRETS_LOCATION, &current_state.secrets)?;
293
294    println!(
295        "Login successful. Profile '{}' is now authenticated.",
296        profile_name
297    );
298
299    Ok(())
300}