1use 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
33const TOKEN_EXPIRY_MARGIN_SECS: i64 = 60;
40
41pub(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
62pub(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) = ¤t_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
102pub(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(¶ms)
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 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, ¤t_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(¤t_state) {
209 return Ok(token);
210 }
211 }
212
213 let well_known_document = fetch_discovery_document(&state).await?;
214
215 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(¶ms)
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 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 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 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}