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 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
162pub(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(¶ms)
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, ¤t_state.secrets)?;
293
294 println!(
295 "Login successful. Profile '{}' is now authenticated.",
296 profile_name
297 );
298
299 Ok(())
300}