Skip to main content

haste_health/cli/
secrets.rs

1//! Storage for CLI secret material (OIDC client secrets, cached OAuth tokens).
2//!
3//! Kept in its own file, separate from [`crate::cli::config`], so that secrets never show
4//! up when a user inspects their config (e.g. `haste-health config show-profile`) and so
5//! the two files can be handled differently (e.g. excluded from dotfile backups/sync).
6
7use haste_fhir_model::r4::generated::terminology::IssueType;
8use haste_fhir_operation_error::OperationOutcomeError;
9use serde::{Deserialize, Serialize};
10use std::{collections::HashMap, path::PathBuf};
11
12/// Cached OAuth tokens for a profile using the `authorization_code` flow, populated by
13/// `haste-health login`.
14#[derive(Serialize, Deserialize, Debug, Clone)]
15pub(crate) struct StoredTokens {
16    pub(crate) access_token: String,
17    pub(crate) refresh_token: Option<String>,
18    pub(crate) id_token: Option<String>,
19    /// Unix timestamp (seconds) the access token expires at.
20    pub(crate) expires_at: i64,
21}
22
23/// Secret material for a single profile.
24#[derive(Serialize, Deserialize, Debug, Clone, Default)]
25pub(crate) struct ProfileSecrets {
26    /// OIDC client secret, set for `client-credentials` profiles.
27    #[serde(default)]
28    pub(crate) client_secret: Option<String>,
29    /// Cached tokens, set once `login` succeeds for `authorization-code` profiles.
30    #[serde(default)]
31    pub(crate) tokens: Option<StoredTokens>,
32}
33
34/// All CLI secrets, keyed by profile name. Persisted separately from `CliConfiguration`.
35#[derive(Serialize, Deserialize, Debug, Default)]
36pub(crate) struct CliSecrets {
37    #[serde(default)]
38    pub(crate) profiles: HashMap<String, ProfileSecrets>,
39}
40
41impl CliSecrets {
42    pub(crate) fn profile(&self, name: &str) -> Option<&ProfileSecrets> {
43        self.profiles.get(name)
44    }
45
46    pub(crate) fn profile_mut(&mut self, name: &str) -> &mut ProfileSecrets {
47        self.profiles.entry(name.to_string()).or_default()
48    }
49
50    pub(crate) fn remove_profile(&mut self, name: &str) {
51        self.profiles.remove(name);
52    }
53}
54
55fn read_existing_secrets(location: &PathBuf) -> Result<CliSecrets, OperationOutcomeError> {
56    let secrets_str = std::fs::read_to_string(location).map_err(|_| {
57        OperationOutcomeError::error(
58            IssueType::exception(),
59            format!(
60                "Failed to read secrets file at location '{}'",
61                location.to_string_lossy()
62            ),
63        )
64    })?;
65
66    toml::from_str::<CliSecrets>(&secrets_str).map_err(|_| {
67        OperationOutcomeError::error(
68            IssueType::exception(),
69            format!(
70                "Failed to parse secrets file at location '{}'",
71                location.to_string_lossy()
72            ),
73        )
74    })
75}
76
77/// Loads the secrets file, creating an empty one on disk if it doesn't exist yet.
78pub(crate) fn load_secrets(location: &PathBuf) -> CliSecrets {
79    if let Ok(secrets) = read_existing_secrets(location) {
80        return secrets;
81    }
82
83    let secrets = CliSecrets::default();
84    write_secrets(location, &secrets).expect("Failed to write default secrets file");
85    secrets
86}
87
88pub(crate) fn write_secrets(
89    location: &PathBuf,
90    secrets: &CliSecrets,
91) -> Result<(), OperationOutcomeError> {
92    std::fs::write(location, toml::to_string(secrets).unwrap()).map_err(|_| {
93        OperationOutcomeError::error(
94            IssueType::exception(),
95            format!(
96                "Failed to write secrets file at location '{}'",
97                location.to_string_lossy()
98            ),
99        )
100    })
101}