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
74fn 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
164pub(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 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(¶ms)
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 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, ¤t_state.secrets)?;
305
306 println!(
307 "Login successful. Profile '{}' is now authenticated.",
308 profile_name
309 );
310
311 Ok(())
312}