Skip to main content

haste_health/cli/
client.rs

1//! Builds the authenticated FHIR HTTP client used by commands that talk to a server
2//! (`api`, `testscript`, `hl7v2`), based on the active profile's auth mode.
3
4use 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
40/// Fetches (and caches on `CliState`) the OIDC discovery document for the active profile.
41pub(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) = &current_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
86/// Exchanges a refresh token for a new access token, persisting the refreshed tokens to disk.
87pub(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(&params)
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, &current_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                            // Post for JWT Token
198                            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(&params)
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                            // Small buffer so a token doesn't expire mid-request.
284                            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}