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::{DEFAULT_CLIENT_CREDENTIALS_SCOPE, 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
33/// How long before expiry a cached token stops being handed out.
34///
35/// A token refreshed at the instant it expires still races the request it was
36/// fetched for: the round trip takes time, and the server's clock need not
37/// agree with ours to the second. Renewing a minute early costs one extra
38/// token request per lifetime and removes both problems.
39const TOKEN_EXPIRY_MARGIN_SECS: i64 = 60;
40
41/// The cached access token, if it is still good.
42///
43/// Returns `None` once the token is within [`TOKEN_EXPIRY_MARGIN_SECS`] of
44/// expiring, or when its lifetime is unknown, so the caller fetches a new one.
45/// Without this the first cached token was reused for the life of the process,
46/// which a one-shot CLI command never notices and a long-running one -- the
47/// MLLP listener -- fails on for every request after expiry.
48pub(crate) fn cached_access_token(state: &CliState) -> Option<String> {
49    let token = state.access_token.as_ref()?;
50    let expires_at = state.access_token_expires_at?;
51
52    (unix_now() + TOKEN_EXPIRY_MARGIN_SECS < expires_at).then(|| token.clone())
53}
54
55pub(crate) fn unix_now() -> i64 {
56    SystemTime::now()
57        .duration_since(UNIX_EPOCH)
58        .unwrap_or_default()
59        .as_secs() as i64
60}
61
62/// Fetches (and caches on `CliState`) the OIDC discovery document for the active profile.
63pub(crate) async fn fetch_discovery_document(
64    state: &Arc<Mutex<CliState>>,
65) -> Result<WellKnownDiscoveryDocument, OperationOutcomeError> {
66    let mut current_state = state.lock().await;
67
68    if let Some(well_known_doc) = &current_state.well_known_document {
69        return Ok(well_known_doc.clone());
70    }
71
72    let active_profile = current_state.config.require_current_profile()?.clone();
73
74    let res = reqwest::get(&active_profile.oidc_discovery_uri)
75        .await
76        .map_err(|e| {
77            OperationOutcomeError::error(
78                IssueType::exception(),
79                format!("Failed to fetch OIDC discovery document: {}", e),
80            )
81        })?;
82
83    let well_known_document =
84        serde_json::from_slice::<WellKnownDiscoveryDocument>(&res.bytes().await.map_err(|e| {
85            OperationOutcomeError::error(
86                IssueType::exception(),
87                format!("Failed to read OIDC discovery document: {}", e),
88            )
89        })?)
90        .map_err(|e| {
91            OperationOutcomeError::error(
92                IssueType::exception(),
93                format!("Failed to parse OIDC discovery document: {}", e),
94            )
95        })?;
96
97    current_state.well_known_document = Some(well_known_document.clone());
98
99    Ok(well_known_document)
100}
101
102/// Exchanges a refresh token for a new access token, persisting the refreshed tokens to disk.
103pub(crate) async fn refresh_access_token(
104    state: &Arc<Mutex<CliState>>,
105    client_id: &str,
106    profile_name: &str,
107    refresh_token: &str,
108) -> Result<String, OperationOutcomeError> {
109    let well_known_document = fetch_discovery_document(state).await?;
110
111    let params = [
112        ("grant_type", "refresh_token"),
113        ("client_id", client_id),
114        ("refresh_token", refresh_token),
115    ];
116
117    let res = reqwest::Client::new()
118        .post(&well_known_document.token_endpoint)
119        .form(&params)
120        .send()
121        .await
122        .map_err(|e| {
123            OperationOutcomeError::error(
124                IssueType::exception(),
125                format!("Failed to refresh access token: {}", e),
126            )
127        })?;
128
129    if !res.status().is_success() {
130        return Err(OperationOutcomeError::error(
131            IssueType::forbidden(),
132            format!(
133                "Failed to refresh access token: HTTP '{}'. Run `haste-health login` again.",
134                res.status(),
135            ),
136        ));
137    }
138
139    let token_response: TokenResponseBody = res.json().await.map_err(|e| {
140        OperationOutcomeError::error(
141            IssueType::exception(),
142            format!("Failed to parse refresh token response: {}", e),
143        )
144    })?;
145
146    let expires_at = unix_now() + token_response.expires_in;
147
148    let mut current_state = state.lock().await;
149    current_state.access_token = Some(token_response.access_token.clone());
150    // Kept with the token, so a process that outlives this lifetime refreshes
151    // again rather than reusing it.
152    current_state.access_token_expires_at = Some(expires_at);
153
154    current_state.secrets.profile_mut(profile_name).tokens = Some(StoredTokens {
155        access_token: token_response.access_token.clone(),
156        refresh_token: token_response
157            .refresh_token
158            .or(Some(refresh_token.to_string())),
159        id_token: token_response.id_token,
160        expires_at,
161    });
162
163    crate::cli::secrets::write_secrets(&SECRETS_LOCATION, &current_state.secrets)?;
164
165    Ok(token_response.access_token)
166}
167
168async fn config_to_fhir_http_state(
169    state: Arc<Mutex<CliState>>,
170) -> Result<FHIRHttpState, OperationOutcomeError> {
171    let current_state = state.lock().await;
172    let active_profile = current_state.config.require_current_profile()?.clone();
173
174    let profile_name = active_profile.name.clone();
175    let client_secret = current_state
176        .secrets
177        .profile(&profile_name)
178        .and_then(|s| s.client_secret.clone());
179    drop(current_state);
180
181    let state = state.clone();
182    let http_state = FHIRHttpState::new(
183        &active_profile.r4_url.clone(),
184        match active_profile.auth {
185            ProfileAuth::Public {} => None,
186            ProfileAuth::ClientCredentails { client_id, scope } => {
187                let Some(client_secret) = client_secret else {
188                    return Err(OperationOutcomeError::error(
189                        IssueType::invalid(),
190                        format!(
191                            "No client secret stored for profile '{}'. Recreate it with `haste-health profile create`.",
192                            profile_name
193                        ),
194                    ));
195                };
196
197                Some(FHIRHttpAuthenticationMethod::BearerToken(Arc::new(
198                    move || {
199                        let state = state.clone();
200                        let client_id = client_id.clone();
201                        let client_secret = client_secret.clone();
202                        let scope = scope
203                            .clone()
204                            .unwrap_or_else(|| DEFAULT_CLIENT_CREDENTIALS_SCOPE.to_string());
205                        Box::pin(async move {
206                            {
207                                let current_state = state.lock().await;
208                                if let Some(token) = cached_access_token(&current_state) {
209                                    return Ok(token);
210                                }
211                            }
212
213                            let well_known_document = fetch_discovery_document(&state).await?;
214
215                            // Post for JWT Token
216                            let params = [
217                                ("grant_type", "client_credentials"),
218                                ("client_id", &client_id),
219                                ("client_secret", &client_secret),
220                                ("scope", &scope),
221                            ];
222
223                            let res: reqwest::Response = reqwest::Client::new()
224                                .post(&well_known_document.token_endpoint)
225                                .form(&params)
226                                .send()
227                                .await
228                                .map_err(|e| {
229                                    OperationOutcomeError::error(
230                                        IssueType::exception(),
231                                        format!("Failed to fetch access token: {}", e),
232                                    )
233                                })?;
234
235                            if !res.status().is_success() {
236                                return Err(OperationOutcomeError::error(
237                                    IssueType::forbidden(),
238                                    format!(
239                                        "Failed to fetch access token: HTTP '{}'",
240                                        res.status(),
241                                    ),
242                                ));
243                            }
244
245                            let token_response: serde_json::Value =
246                                res.json().await.map_err(|e| {
247                                    OperationOutcomeError::error(
248                                        IssueType::exception(),
249                                        format!("Failed to parse access token response: {}", e),
250                                    )
251                                })?;
252
253                            let access_token = token_response
254                                .get("access_token")
255                                .and_then(|v| v.as_str())
256                                .ok_or_else(|| {
257                                    OperationOutcomeError::error(
258                                        IssueType::exception(),
259                                        "No access_token field in token response".to_string(),
260                                    )
261                                })?
262                                .to_string();
263
264                            // `expires_in` is seconds from now. A server that
265                            // omits it leaves the lifetime unknown, which is
266                            // recorded as `None` so the next request fetches
267                            // again rather than reusing a token of unknown age.
268                            let expires_at = token_response
269                                .get("expires_in")
270                                .and_then(serde_json::Value::as_i64)
271                                .map(|expires_in| unix_now() + expires_in);
272
273                            {
274                                let mut current_state = state.lock().await;
275                                current_state.access_token = Some(access_token.clone());
276                                current_state.access_token_expires_at = expires_at;
277                            }
278
279                            Ok(access_token)
280                        })
281                    },
282                )))
283            }
284            ProfileAuth::AuthorizationCode {
285                client_id,
286                redirect_uri: _,
287                scope: _,
288            } => {
289                Some(FHIRHttpAuthenticationMethod::BearerToken(Arc::new(
290                    move || {
291                        let state = state.clone();
292                        let client_id = client_id.clone();
293                        let profile_name = profile_name.clone();
294                        Box::pin(async move {
295                            // Expiry-aware: returning the in-memory token
296                            // unconditionally skipped the stored-token expiry
297                            // check below, so a refresh never happened once a
298                            // token had been cached once.
299                            if let Some(token) = cached_access_token(&*state.lock().await) {
300                                return Ok(token);
301                            }
302
303                            let stored_tokens = {
304                                let current_state = state.lock().await;
305                                current_state
306                                    .secrets
307                                    .profile(&profile_name)
308                                    .and_then(|s| s.tokens.clone())
309                            };
310
311                            let Some(tokens) = stored_tokens else {
312                                return Err(OperationOutcomeError::error(
313                                    IssueType::forbidden(),
314                                    "Not logged in. Run `haste-health login` first.".to_string(),
315                                ));
316                            };
317
318                            if tokens.expires_at > unix_now() + TOKEN_EXPIRY_MARGIN_SECS {
319                                let mut current_state = state.lock().await;
320                                current_state.access_token = Some(tokens.access_token.clone());
321                                // Cached with its expiry, so the check above
322                                // applies on the next request too.
323                                current_state.access_token_expires_at = Some(tokens.expires_at);
324                                return Ok(tokens.access_token);
325                            }
326
327                            let Some(refresh_token) = tokens.refresh_token else {
328                                return Err(OperationOutcomeError::error(
329                                    IssueType::forbidden(),
330                                    "Login session expired. Run `haste-health login` again."
331                                        .to_string(),
332                                ));
333                            };
334
335                            refresh_access_token(&state, &client_id, &profile_name, &refresh_token)
336                                .await
337                        })
338                    },
339                )))
340            }
341            ProfileAuth::Basic { username } => {
342                let Some(password) = client_secret else {
343                    return Err(OperationOutcomeError::error(
344                        IssueType::invalid(),
345                        format!(
346                            "No password stored for profile '{}'. Recreate it with `haste-health profile create`.",
347                            profile_name
348                        ),
349                    ));
350                };
351
352                Some(FHIRHttpAuthenticationMethod::Basic(Arc::new(move || {
353                    let username = username.clone();
354                    let password = password.clone();
355
356                    Box::pin(async move { Ok(BasicCredentials { username, password }) })
357                })))
358            }
359        },
360    )?;
361
362    Ok(http_state)
363}
364
365pub(crate) async fn fhir_client<CTX>(
366    state: Arc<Mutex<CliState>>,
367) -> Result<Arc<FHIRHttpClient<CTX>>, OperationOutcomeError>
368where
369    CTX: 'static + Send + Sync + std::fmt::Debug + HttpRequestHeaders,
370{
371    let http_state = config_to_fhir_http_state(state).await?;
372    let fhir_client = Arc::new(FHIRHttpClient::<CTX>::new(http_state));
373
374    Ok(fhir_client)
375}