Skip to main content

headless_lms_base/
config.rs

1use anyhow::Context;
2use secrecy::{ExposeSecret, SecretBox, SecretString};
3use std::sync::Arc;
4use std::{env, str::FromStr};
5use url::Url;
6
7/// Reads a boolean env var where missing values default to false.
8pub fn bool_env_false_by_default(key: &str) -> bool {
9    match env::var(key) {
10        Ok(value) => {
11            let normalized = value.trim().to_ascii_lowercase();
12            !matches!(
13                normalized.as_str(),
14                "" | "false" | "0" | "no" | "off" | "disabled"
15            )
16        }
17        Err(_) => false,
18    }
19}
20
21#[derive(Clone)]
22pub struct ApplicationConfiguration {
23    pub base_url: String,
24    pub test_mode: bool,
25    pub test_chatbot: bool,
26    pub development_uuid_login: bool,
27    pub enable_admin_email_verification: bool,
28    pub azure_configuration: Option<AzureConfiguration>,
29    pub tmc_account_creation_origin: Option<String>,
30    pub tmc_admin_access_token: SecretString,
31    pub oauth_server_configuration: OAuthServerConfiguration,
32}
33
34impl ApplicationConfiguration {
35    /// Attempts to create an ApplicationConfiguration from environment variables.
36    pub fn try_from_env() -> anyhow::Result<Self> {
37        let base_url = env::var("BASE_URL").context("BASE_URL must be defined")?;
38        let test_mode = bool_env_false_by_default("TEST_MODE");
39        let development_uuid_login = bool_env_false_by_default("DEVELOPMENT_UUID_LOGIN");
40        let enable_admin_email_verification =
41            bool_env_false_by_default("ENABLE_ADMIN_EMAIL_VERIFICATION");
42        let test_chatbot = test_mode
43            && (bool_env_false_by_default("USE_MOCK_AZURE_CONFIGURATION")
44                || env::var("AZURE_CHATBOT_API_KEY").is_err());
45
46        let azure_configuration = if test_chatbot {
47            AzureConfiguration::mock_conf()?
48        } else {
49            AzureConfiguration::try_from_env()?
50        };
51
52        let tmc_account_creation_origin = Some(
53            env::var("TMC_ACCOUNT_CREATION_ORIGIN")
54                .context("TMC_ACCOUNT_CREATION_ORIGIN must be defined")?,
55        );
56
57        let tmc_admin_access_token = SecretString::new(
58            std::env::var("TMC_ACCESS_TOKEN")
59                .unwrap_or_else(|_| {
60                    if test_mode {
61                        "mock-access-token".to_string()
62                    } else {
63                        panic!("TMC_ACCESS_TOKEN must be defined in production")
64                    }
65                })
66                .into(),
67        );
68        let oauth_server_configuration = OAuthServerConfiguration::try_from_env()
69            .context("Failed to load OAuth server configuration")?;
70
71        Ok(Self {
72            base_url,
73            test_mode,
74            test_chatbot,
75            development_uuid_login,
76            enable_admin_email_verification,
77            azure_configuration,
78            tmc_account_creation_origin,
79            tmc_admin_access_token,
80            oauth_server_configuration,
81        })
82    }
83}
84
85#[derive(Clone)]
86pub struct AzureChatbotConfiguration {
87    pub api_key: SecretString,
88    pub api_endpoint: Url,
89}
90
91impl AzureChatbotConfiguration {
92    /// Attempts to create an AzureChatbotConfiguration from environment variables.
93    /// Returns `Ok(Some(AzureChatbotConfiguration))` if both environment variables are set.
94    /// Returns `Ok(None)` if no environment variables are set for chatbot.
95    /// Returns an error if set environment variables fail to parse.
96    pub fn try_from_env() -> anyhow::Result<Option<Self>> {
97        let api_key = env::var("AZURE_CHATBOT_API_KEY").ok();
98        let api_endpoint_str = env::var("AZURE_CHATBOT_API_ENDPOINT").ok();
99
100        if let (Some(api_key), Some(api_endpoint_str)) = (api_key, api_endpoint_str) {
101            let api_endpoint = Url::parse(&api_endpoint_str)
102                .context("Invalid URL in AZURE_CHATBOT_API_ENDPOINT")?;
103            Ok(Some(AzureChatbotConfiguration {
104                api_key: SecretString::new(api_key.into()),
105                api_endpoint,
106            }))
107        } else {
108            Ok(None)
109        }
110    }
111}
112
113#[derive(Clone)]
114pub struct AzureSearchConfiguration {
115    pub vectorizer_resource_uri: String,
116    pub vectorizer_deployment_id: String,
117    pub vectorizer_api_key: SecretString,
118    pub vectorizer_model_name: String,
119    pub search_endpoint: Url,
120    pub search_api_key: SecretString,
121    pub search_connection_id: String,
122}
123
124impl AzureSearchConfiguration {
125    /// Attempts to create an AzureSearchConfiguration from environment variables.
126    /// Returns `Ok(Some(AzureSearchConfiguration))` if all related environment variables are set.
127    /// Returns `Ok(None)` if no environment variables are set for search and vectorizer.
128    /// Returns an error if set environment variables fail to parse.
129    pub fn try_from_env() -> anyhow::Result<Option<Self>> {
130        let vectorizer_resource_uri = env::var("AZURE_VECTORIZER_RESOURCE_URI").ok();
131        let vectorizer_deployment_id = env::var("AZURE_VECTORIZER_DEPLOYMENT_ID").ok();
132        let vectorizer_api_key = env::var("AZURE_VECTORIZER_API_KEY").ok();
133        let vectorizer_model_name = env::var("AZURE_VECTORIZER_MODEL_NAME").ok();
134        let search_endpoint_str = env::var("AZURE_SEARCH_ENDPOINT").ok();
135        let search_api_key = env::var("AZURE_SEARCH_API_KEY").ok();
136        let search_connection_id = env::var("AZURE_SEARCH_CONNECTION_ID").ok();
137
138        if let (
139            Some(vectorizer_resource_uri),
140            Some(vectorizer_deployment_id),
141            Some(vectorizer_api_key),
142            Some(vectorizer_model_name),
143            Some(search_endpoint_str),
144            Some(search_api_key),
145            Some(search_connection_id),
146        ) = (
147            vectorizer_resource_uri,
148            vectorizer_deployment_id,
149            vectorizer_api_key,
150            vectorizer_model_name,
151            search_endpoint_str,
152            search_api_key,
153            search_connection_id,
154        ) {
155            let search_endpoint =
156                Url::parse(&search_endpoint_str).context("Invalid URL in AZURE_SEARCH_ENDPOINT")?;
157            Ok(Some(AzureSearchConfiguration {
158                vectorizer_resource_uri,
159                vectorizer_deployment_id,
160                vectorizer_api_key: SecretString::new(vectorizer_api_key.into()),
161                vectorizer_model_name,
162                search_endpoint,
163                search_api_key: SecretString::new(search_api_key.into()),
164                search_connection_id,
165            }))
166        } else {
167            Ok(None)
168        }
169    }
170}
171
172#[derive(Clone)]
173pub struct AzureBlobStorageConfiguration {
174    pub storage_account: String,
175    pub access_key: SecretString,
176}
177
178impl AzureBlobStorageConfiguration {
179    /// Attempts to create an AzureBlobStorageConfiguration from environment variables.
180    /// Returns `Ok(Some(AzureBlobStorageConfiguration))` if both environment variables are set.
181    /// Returns `Ok(None)` if no environment variables are set for blob storage.
182    /// Returns an error if set environment variables fail to parse.
183    pub fn try_from_env() -> anyhow::Result<Option<Self>> {
184        let storage_account = env::var("AZURE_BLOB_STORAGE_ACCOUNT").ok();
185        let access_key = env::var("AZURE_BLOB_STORAGE_ACCESS_KEY").ok();
186
187        if let (Some(storage_account), Some(access_key)) = (storage_account, access_key) {
188            Ok(Some(AzureBlobStorageConfiguration {
189                storage_account,
190                access_key: SecretString::new(access_key.into()),
191            }))
192        } else {
193            Ok(None)
194        }
195    }
196
197    /// Builds the Azure storage connection string. The result embeds the account
198    /// access key, so it is returned wrapped in `SecretString` (zeroized on drop,
199    /// redacted from `Debug`); call `.expose_secret()` only at the point it is handed
200    /// to the Azure SDK.
201    pub fn connection_string(&self) -> anyhow::Result<SecretString> {
202        Ok(SecretString::new(
203            format!(
204                "DefaultEndpointsProtocol=https;AccountName={};AccountKey={};EndpointSuffix=core.windows.net",
205                self.storage_account,
206                self.access_key.expose_secret()
207            )
208            .into(),
209        ))
210    }
211}
212
213#[derive(Clone)]
214pub struct AzureConfiguration {
215    pub chatbot_config: Option<AzureChatbotConfiguration>,
216    pub search_config: Option<AzureSearchConfiguration>,
217    pub blob_storage_config: Option<AzureBlobStorageConfiguration>,
218}
219
220impl AzureConfiguration {
221    /// Attempts to create an AzureConfiguration by calling the individual try_from_env functions.
222    /// Returns `Ok(Some(AzureConfiguration))` if any of the configurations are set.
223    /// Returns `Ok(None)` if no relevant environment variables are set.
224    pub fn try_from_env() -> anyhow::Result<Option<Self>> {
225        let chatbot = AzureChatbotConfiguration::try_from_env()?;
226        let search_config = AzureSearchConfiguration::try_from_env()?;
227        let blob_storage_config = AzureBlobStorageConfiguration::try_from_env()?;
228
229        if chatbot.is_some() || search_config.is_some() || blob_storage_config.is_some() {
230            Ok(Some(AzureConfiguration {
231                chatbot_config: chatbot,
232                search_config,
233                blob_storage_config,
234            }))
235        } else {
236            Ok(None)
237        }
238    }
239
240    /// Creates an AzureConfiguration with empty and mock values to be used in testing and dev
241    /// environments when Azure access is not needed. Enables the azure chatbot functionality to be
242    /// mocked with the api_endpoint from our application.
243    /// Returns `Ok(Some(AzureConfiguration))`
244    pub fn mock_conf() -> anyhow::Result<Option<Self>> {
245        let base_url = env::var("BASE_URL").context("BASE_URL must be defined")?;
246        let chatbot_config = Some(AzureChatbotConfiguration {
247            api_key: SecretString::new(String::new().into()),
248            api_endpoint: Url::parse(&base_url)?.join("/api/v0/mock-azure/test/v1/responses")?,
249        });
250        let search_config = Some(AzureSearchConfiguration {
251            vectorizer_resource_uri: "".to_string(),
252            vectorizer_deployment_id: "".to_string(),
253            vectorizer_api_key: SecretString::new(String::new().into()),
254            vectorizer_model_name: "".to_string(),
255            search_api_key: SecretString::new(String::new().into()),
256            search_endpoint: Url::from_str("https://example.com/does-not-exist/")?,
257            search_connection_id: "".to_string(),
258        });
259        let blob_storage_config = Some(AzureBlobStorageConfiguration {
260            storage_account: "".to_string(),
261            access_key: SecretString::new(String::new().into()),
262        });
263
264        Ok(Some(AzureConfiguration {
265            chatbot_config,
266            search_config,
267            blob_storage_config,
268        }))
269    }
270}
271
272#[derive(Clone)]
273pub struct OAuthServerConfiguration {
274    pub rsa_public_key: String,
275    /// RSA private key (PEM) used to sign OAuth/OIDC tokens. Secret: zeroized on drop,
276    /// redacted from `Debug`; only exposed when handed to the signing key builder.
277    pub rsa_private_key: SecretString,
278    /// Secret key for HMAC-SHA-256 hashing of OAuth tokens (access tokens, refresh tokens, auth codes).
279    pub oauth_token_hmac_key: SecretString,
280    /// Secret key for signing DPoP nonces (HMAC).
281    pub dpop_nonce_key: Arc<SecretBox<String>>,
282}
283
284impl PartialEq for OAuthServerConfiguration {
285    fn eq(&self, other: &Self) -> bool {
286        self.rsa_public_key == other.rsa_public_key
287            && self.rsa_private_key.expose_secret() == other.rsa_private_key.expose_secret()
288            && self.oauth_token_hmac_key.expose_secret()
289                == other.oauth_token_hmac_key.expose_secret()
290            && self.dpop_nonce_key.expose_secret() == other.dpop_nonce_key.expose_secret()
291    }
292}
293
294impl OAuthServerConfiguration {
295    /// Attempts to create an OAuthServerConfiguration.
296    /// Return `Ok(Some(OAuthConfiguration))` if all configurations are set.
297    /// Return `Err` if any is not set.
298    pub fn try_from_env() -> anyhow::Result<Self> {
299        let rsa_public_key =
300            env::var("OAUTH_RSA_PUBLIC_PEM").context("OAUTH_RSA_PUBLIC_KEY must be defined")?;
301        let rsa_private_key = SecretString::new(
302            env::var("OAUTH_RSA_PRIVATE_PEM")
303                .context("OAUTH_RSA_PRIVATE_KEY must be defined")?
304                .into(),
305        );
306        let oauth_token_hmac_key = SecretString::new(
307            env::var("OAUTH_TOKEN_HMAC_KEY")
308                .context("OAUTH_TOKEN_HMAC_KEY must be defined")?
309                .into(),
310        );
311        let dpop_nonce_key = Arc::new(SecretBox::new(Box::new(
312            env::var("OAUTH_DPOP_NONCE_KEY").context("OAUTH_DPOP_NONCE_KEY must be defined")?,
313        )));
314
315        Ok(Self {
316            rsa_public_key,
317            rsa_private_key,
318            oauth_token_hmac_key,
319            dpop_nonce_key,
320        })
321    }
322}