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, PartialEq)]
86pub struct AzureChatbotConfiguration {
87    pub api_key: String,
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,
105                api_endpoint,
106            }))
107        } else {
108            Ok(None)
109        }
110    }
111}
112
113#[derive(Clone, PartialEq)]
114pub struct AzureSearchConfiguration {
115    pub vectorizer_resource_uri: String,
116    pub vectorizer_deployment_id: String,
117    pub vectorizer_api_key: String,
118    pub vectorizer_model_name: String,
119    pub search_endpoint: Url,
120    pub search_api_key: String,
121}
122
123impl AzureSearchConfiguration {
124    /// Attempts to create an AzureSearchConfiguration from environment variables.
125    /// Returns `Ok(Some(AzureSearchConfiguration))` if all related environment variables are set.
126    /// Returns `Ok(None)` if no environment variables are set for search and vectorizer.
127    /// Returns an error if set environment variables fail to parse.
128    pub fn try_from_env() -> anyhow::Result<Option<Self>> {
129        let vectorizer_resource_uri = env::var("AZURE_VECTORIZER_RESOURCE_URI").ok();
130        let vectorizer_deployment_id = env::var("AZURE_VECTORIZER_DEPLOYMENT_ID").ok();
131        let vectorizer_api_key = env::var("AZURE_VECTORIZER_API_KEY").ok();
132        let vectorizer_model_name = env::var("AZURE_VECTORIZER_MODEL_NAME").ok();
133        let search_endpoint_str = env::var("AZURE_SEARCH_ENDPOINT").ok();
134        let search_api_key = env::var("AZURE_SEARCH_API_KEY").ok();
135
136        if let (
137            Some(vectorizer_resource_uri),
138            Some(vectorizer_deployment_id),
139            Some(vectorizer_api_key),
140            Some(vectorizer_model_name),
141            Some(search_endpoint_str),
142            Some(search_api_key),
143        ) = (
144            vectorizer_resource_uri,
145            vectorizer_deployment_id,
146            vectorizer_api_key,
147            vectorizer_model_name,
148            search_endpoint_str,
149            search_api_key,
150        ) {
151            let search_endpoint =
152                Url::parse(&search_endpoint_str).context("Invalid URL in AZURE_SEARCH_ENDPOINT")?;
153            Ok(Some(AzureSearchConfiguration {
154                vectorizer_resource_uri,
155                vectorizer_deployment_id,
156                vectorizer_api_key,
157                vectorizer_model_name,
158                search_endpoint,
159                search_api_key,
160            }))
161        } else {
162            Ok(None)
163        }
164    }
165}
166
167#[derive(Clone, PartialEq)]
168pub struct AzureBlobStorageConfiguration {
169    pub storage_account: String,
170    pub access_key: String,
171}
172
173impl AzureBlobStorageConfiguration {
174    /// Attempts to create an AzureBlobStorageConfiguration from environment variables.
175    /// Returns `Ok(Some(AzureBlobStorageConfiguration))` if both environment variables are set.
176    /// Returns `Ok(None)` if no environment variables are set for blob storage.
177    /// Returns an error if set environment variables fail to parse.
178    pub fn try_from_env() -> anyhow::Result<Option<Self>> {
179        let storage_account = env::var("AZURE_BLOB_STORAGE_ACCOUNT").ok();
180        let access_key = env::var("AZURE_BLOB_STORAGE_ACCESS_KEY").ok();
181
182        if let (Some(storage_account), Some(access_key)) = (storage_account, access_key) {
183            Ok(Some(AzureBlobStorageConfiguration {
184                storage_account,
185                access_key,
186            }))
187        } else {
188            Ok(None)
189        }
190    }
191
192    pub fn connection_string(&self) -> anyhow::Result<String> {
193        Ok(format!(
194            "DefaultEndpointsProtocol=https;AccountName={};AccountKey={};EndpointSuffix=core.windows.net",
195            self.storage_account, self.access_key
196        ))
197    }
198}
199
200#[derive(Clone, PartialEq)]
201pub struct AzureConfiguration {
202    pub chatbot_config: Option<AzureChatbotConfiguration>,
203    pub search_config: Option<AzureSearchConfiguration>,
204    pub blob_storage_config: Option<AzureBlobStorageConfiguration>,
205}
206
207impl AzureConfiguration {
208    /// Attempts to create an AzureConfiguration by calling the individual try_from_env functions.
209    /// Returns `Ok(Some(AzureConfiguration))` if any of the configurations are set.
210    /// Returns `Ok(None)` if no relevant environment variables are set.
211    pub fn try_from_env() -> anyhow::Result<Option<Self>> {
212        let chatbot = AzureChatbotConfiguration::try_from_env()?;
213        let search_config = AzureSearchConfiguration::try_from_env()?;
214        let blob_storage_config = AzureBlobStorageConfiguration::try_from_env()?;
215
216        if chatbot.is_some() || search_config.is_some() || blob_storage_config.is_some() {
217            Ok(Some(AzureConfiguration {
218                chatbot_config: chatbot,
219                search_config,
220                blob_storage_config,
221            }))
222        } else {
223            Ok(None)
224        }
225    }
226
227    /// Creates an AzureConfiguration with empty and mock values to be used in testing and dev
228    /// environments when Azure access is not needed. Enables the azure chatbot functionality to be
229    /// mocked with the api_endpoint from our application.
230    /// Returns `Ok(Some(AzureConfiguration))`
231    pub fn mock_conf() -> anyhow::Result<Option<Self>> {
232        let base_url = env::var("BASE_URL").context("BASE_URL must be defined")?;
233        let chatbot_config = Some(AzureChatbotConfiguration {
234            api_key: "".to_string(),
235            api_endpoint: Url::parse(&base_url)?.join("/api/v0/mock-azure/test/")?,
236        });
237        let search_config = Some(AzureSearchConfiguration {
238            vectorizer_resource_uri: "".to_string(),
239            vectorizer_deployment_id: "".to_string(),
240            vectorizer_api_key: "".to_string(),
241            vectorizer_model_name: "".to_string(),
242            search_api_key: "".to_string(),
243            search_endpoint: Url::from_str("https://example.com/does-not-exist/")?,
244        });
245        let blob_storage_config = Some(AzureBlobStorageConfiguration {
246            storage_account: "".to_string(),
247            access_key: "".to_string(),
248        });
249
250        Ok(Some(AzureConfiguration {
251            chatbot_config,
252            search_config,
253            blob_storage_config,
254        }))
255    }
256}
257
258#[derive(Clone)]
259pub struct OAuthServerConfiguration {
260    pub rsa_public_key: String,
261    pub rsa_private_key: String,
262    /// Secret key for HMAC-SHA-256 hashing of OAuth tokens (access tokens, refresh tokens, auth codes).
263    pub oauth_token_hmac_key: String,
264    /// Secret key for signing DPoP nonces (HMAC).
265    pub dpop_nonce_key: Arc<SecretBox<String>>,
266}
267
268impl PartialEq for OAuthServerConfiguration {
269    fn eq(&self, other: &Self) -> bool {
270        self.rsa_public_key == other.rsa_public_key
271            && self.rsa_private_key == other.rsa_private_key
272            && self.oauth_token_hmac_key == other.oauth_token_hmac_key
273            && self.dpop_nonce_key.expose_secret() == other.dpop_nonce_key.expose_secret()
274    }
275}
276
277impl OAuthServerConfiguration {
278    /// Attempts to create an OAuthServerConfiguration.
279    /// Return `Ok(Some(OAuthConfiguration))` if all configurations are set.
280    /// Return `Err` if any is not set.
281    pub fn try_from_env() -> anyhow::Result<Self> {
282        let rsa_public_key =
283            env::var("OAUTH_RSA_PUBLIC_PEM").context("OAUTH_RSA_PUBLIC_KEY must be defined")?;
284        let rsa_private_key =
285            env::var("OAUTH_RSA_PRIVATE_PEM").context("OAUTH_RSA_PRIVATE_KEY must be defined")?;
286        let oauth_token_hmac_key =
287            env::var("OAUTH_TOKEN_HMAC_KEY").context("OAUTH_TOKEN_HMAC_KEY must be defined")?;
288        let dpop_nonce_key = Arc::new(SecretBox::new(Box::new(
289            env::var("OAUTH_DPOP_NONCE_KEY").context("OAUTH_DPOP_NONCE_KEY must be defined")?,
290        )));
291
292        Ok(Self {
293            rsa_public_key,
294            rsa_private_key,
295            oauth_token_hmac_key,
296            dpop_nonce_key,
297        })
298    }
299}