1use crate::cli::{
5 config::ProfileAuth,
6 secrets::StoredTokens,
7 state::{CliState, SECRETS_LOCATION},
8};
9use haste_fhir_client::http::{
10 BasicCredentials, FHIRHttpAuthenticationMethod, FHIRHttpClient, FHIRHttpState,
11 HttpRequestHeaders,
12};
13use haste_fhir_model::r4::generated::terminology::IssueType;
14use haste_fhir_operation_error::OperationOutcomeError;
15use haste_server::auth_n::oidc::routes::discovery::WellKnownDiscoveryDocument;
16use serde::Deserialize;
17use std::{
18 sync::Arc,
19 time::{SystemTime, UNIX_EPOCH},
20};
21use tokio::sync::Mutex;
22
23#[derive(Deserialize)]
24pub(crate) struct TokenResponseBody {
25 pub(crate) access_token: String,
26 #[serde(default)]
27 pub(crate) refresh_token: Option<String>,
28 #[serde(default)]
29 pub(crate) id_token: Option<String>,
30 pub(crate) expires_in: i64,
31}
32
33pub(crate) fn unix_now() -> i64 {
34 SystemTime::now()
35 .duration_since(UNIX_EPOCH)
36 .unwrap_or_default()
37 .as_secs() as i64
38}
39
40pub(crate) async fn fetch_discovery_document(
42 state: &Arc<Mutex<CliState>>,
43) -> Result<WellKnownDiscoveryDocument, OperationOutcomeError> {
44 let mut current_state = state.lock().await;
45
46 if let Some(well_known_doc) = ¤t_state.well_known_document {
47 return Ok(well_known_doc.clone());
48 }
49
50 let Some(active_profile) = current_state.config.current_profile().cloned() else {
51 return Err(OperationOutcomeError::error(
52 IssueType::invalid(),
53 "No active profile set. Please set an active profile using the config command."
54 .to_string(),
55 ));
56 };
57
58 let res = reqwest::get(&active_profile.oidc_discovery_uri)
59 .await
60 .map_err(|e| {
61 OperationOutcomeError::error(
62 IssueType::exception(),
63 format!("Failed to fetch OIDC discovery document: {}", e),
64 )
65 })?;
66
67 let well_known_document =
68 serde_json::from_slice::<WellKnownDiscoveryDocument>(&res.bytes().await.map_err(|e| {
69 OperationOutcomeError::error(
70 IssueType::exception(),
71 format!("Failed to read OIDC discovery document: {}", e),
72 )
73 })?)
74 .map_err(|e| {
75 OperationOutcomeError::error(
76 IssueType::exception(),
77 format!("Failed to parse OIDC discovery document: {}", e),
78 )
79 })?;
80
81 current_state.well_known_document = Some(well_known_document.clone());
82
83 Ok(well_known_document)
84}
85
86pub(crate) async fn refresh_access_token(
88 state: &Arc<Mutex<CliState>>,
89 client_id: &str,
90 profile_name: &str,
91 refresh_token: &str,
92) -> Result<String, OperationOutcomeError> {
93 let well_known_document = fetch_discovery_document(state).await?;
94
95 let params = [
96 ("grant_type", "refresh_token"),
97 ("client_id", client_id),
98 ("refresh_token", refresh_token),
99 ];
100
101 let res = reqwest::Client::new()
102 .post(&well_known_document.token_endpoint)
103 .form(¶ms)
104 .send()
105 .await
106 .map_err(|e| {
107 OperationOutcomeError::error(
108 IssueType::exception(),
109 format!("Failed to refresh access token: {}", e),
110 )
111 })?;
112
113 if !res.status().is_success() {
114 return Err(OperationOutcomeError::error(
115 IssueType::forbidden(),
116 format!(
117 "Failed to refresh access token: HTTP '{}'. Run `haste-health login` again.",
118 res.status(),
119 ),
120 ));
121 }
122
123 let token_response: TokenResponseBody = res.json().await.map_err(|e| {
124 OperationOutcomeError::error(
125 IssueType::exception(),
126 format!("Failed to parse refresh token response: {}", e),
127 )
128 })?;
129
130 let mut current_state = state.lock().await;
131 current_state.access_token = Some(token_response.access_token.clone());
132
133 current_state.secrets.profile_mut(profile_name).tokens = Some(StoredTokens {
134 access_token: token_response.access_token.clone(),
135 refresh_token: token_response
136 .refresh_token
137 .or(Some(refresh_token.to_string())),
138 id_token: token_response.id_token,
139 expires_at: unix_now() + token_response.expires_in,
140 });
141
142 crate::cli::secrets::write_secrets(&SECRETS_LOCATION, ¤t_state.secrets)?;
143
144 Ok(token_response.access_token)
145}
146
147async fn config_to_fhir_http_state(
148 state: Arc<Mutex<CliState>>,
149) -> Result<FHIRHttpState, OperationOutcomeError> {
150 let current_state = state.lock().await;
151 let Some(active_profile) = current_state.config.current_profile().cloned() else {
152 return Err(OperationOutcomeError::error(
153 IssueType::invalid(),
154 "No active profile set. Please set an active profile using the config command."
155 .to_string(),
156 ));
157 };
158
159 let profile_name = active_profile.name.clone();
160 let client_secret = current_state
161 .secrets
162 .profile(&profile_name)
163 .and_then(|s| s.client_secret.clone());
164 drop(current_state);
165
166 let state = state.clone();
167 let http_state = FHIRHttpState::new(
168 &active_profile.r4_url.clone(),
169 match active_profile.auth {
170 ProfileAuth::Public {} => None,
171 ProfileAuth::ClientCredentails { client_id } => {
172 let Some(client_secret) = client_secret else {
173 return Err(OperationOutcomeError::error(
174 IssueType::invalid(),
175 format!(
176 "No client secret stored for profile '{}'. Recreate it with `haste-health config create-profile`.",
177 profile_name
178 ),
179 ));
180 };
181
182 Some(FHIRHttpAuthenticationMethod::BearerToken(Arc::new(
183 move || {
184 let state = state.clone();
185 let client_id = client_id.clone();
186 let client_secret = client_secret.clone();
187 Box::pin(async move {
188 {
189 let current_state = state.lock().await;
190 if let Some(token) = current_state.access_token.clone() {
191 return Ok(token);
192 }
193 }
194
195 let well_known_document = fetch_discovery_document(&state).await?;
196
197 let params = [
199 ("grant_type", "client_credentials"),
200 ("client_id", &client_id),
201 ("client_secret", &client_secret),
202 ("scope", "openid system/*.*"),
203 ];
204
205 let res: reqwest::Response = reqwest::Client::new()
206 .post(&well_known_document.token_endpoint)
207 .form(¶ms)
208 .send()
209 .await
210 .map_err(|e| {
211 OperationOutcomeError::error(
212 IssueType::exception(),
213 format!("Failed to fetch access token: {}", e),
214 )
215 })?;
216
217 if !res.status().is_success() {
218 return Err(OperationOutcomeError::error(
219 IssueType::forbidden(),
220 format!(
221 "Failed to fetch access token: HTTP '{}'",
222 res.status(),
223 ),
224 ));
225 }
226
227 let token_response: serde_json::Value =
228 res.json().await.map_err(|e| {
229 OperationOutcomeError::error(
230 IssueType::exception(),
231 format!("Failed to parse access token response: {}", e),
232 )
233 })?;
234
235 let access_token = token_response
236 .get("access_token")
237 .and_then(|v| v.as_str())
238 .ok_or_else(|| {
239 OperationOutcomeError::error(
240 IssueType::exception(),
241 "No access_token field in token response".to_string(),
242 )
243 })?
244 .to_string();
245
246 state.lock().await.access_token = Some(access_token.clone());
247
248 Ok(access_token)
249 })
250 },
251 )))
252 }
253 ProfileAuth::AuthorizationCode {
254 client_id,
255 redirect_uri: _,
256 scope: _,
257 } => {
258 Some(FHIRHttpAuthenticationMethod::BearerToken(Arc::new(
259 move || {
260 let state = state.clone();
261 let client_id = client_id.clone();
262 let profile_name = profile_name.clone();
263 Box::pin(async move {
264 if let Some(token) = state.lock().await.access_token.clone() {
265 return Ok(token);
266 }
267
268 let stored_tokens = {
269 let current_state = state.lock().await;
270 current_state
271 .secrets
272 .profile(&profile_name)
273 .and_then(|s| s.tokens.clone())
274 };
275
276 let Some(tokens) = stored_tokens else {
277 return Err(OperationOutcomeError::error(
278 IssueType::forbidden(),
279 "Not logged in. Run `haste-health login` first.".to_string(),
280 ));
281 };
282
283 if tokens.expires_at > unix_now() + 30 {
285 state.lock().await.access_token = Some(tokens.access_token.clone());
286 return Ok(tokens.access_token);
287 }
288
289 let Some(refresh_token) = tokens.refresh_token else {
290 return Err(OperationOutcomeError::error(
291 IssueType::forbidden(),
292 "Login session expired. Run `haste-health login` again."
293 .to_string(),
294 ));
295 };
296
297 refresh_access_token(&state, &client_id, &profile_name, &refresh_token)
298 .await
299 })
300 },
301 )))
302 }
303 ProfileAuth::Basic { username } => {
304 let Some(password) = client_secret else {
305 return Err(OperationOutcomeError::error(
306 IssueType::invalid(),
307 format!(
308 "No password stored for profile '{}'. Recreate it with `haste-health config create-profile`.",
309 profile_name
310 ),
311 ));
312 };
313
314 Some(FHIRHttpAuthenticationMethod::Basic(Arc::new(move || {
315 let username = username.clone();
316 let password = password.clone();
317
318 Box::pin(async move { Ok(BasicCredentials { username, password }) })
319 })))
320 }
321 },
322 )?;
323
324 Ok(http_state)
325}
326
327pub(crate) async fn fhir_client<CTX>(
328 state: Arc<Mutex<CliState>>,
329) -> Result<Arc<FHIRHttpClient<CTX>>, OperationOutcomeError>
330where
331 CTX: 'static + Send + Sync + std::fmt::Debug + HttpRequestHeaders,
332{
333 let http_state = config_to_fhir_http_state(state).await?;
334 let fhir_client = Arc::new(FHIRHttpClient::<CTX>::new(http_state));
335
336 Ok(fhir_client)
337}