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/// Reads an env var, treating a blank value the same as an unset one.
22fn non_empty_env(key: &str) -> Option<String> {
23    match env::var(key) {
24        Ok(value) if !value.trim().is_empty() => Some(value.trim().to_string()),
25        _ => None,
26    }
27}
28
29/// Reads an integer env var, falling back to `default` when unset, blank or unparseable.
30fn i64_env_or(key: &str, default: i64) -> i64 {
31    non_empty_env(key)
32        .and_then(|value| value.parse().ok())
33        .unwrap_or(default)
34}
35
36#[derive(Clone)]
37pub struct ApplicationConfiguration {
38    pub base_url: String,
39    pub test_mode: bool,
40    pub test_chatbot: bool,
41    pub test_sisu: bool,
42    pub test_suotar: bool,
43    pub development_uuid_login: bool,
44    pub enable_admin_email_verification: bool,
45    pub enable_email_ownership_verification: bool,
46    pub azure_configuration: Option<AzureConfiguration>,
47    pub suotar_configuration: SuotarConfiguration,
48    pub tmc_account_creation_origin: Option<String>,
49    pub tmc_admin_access_token: SecretString,
50    pub oauth_server_configuration: OAuthServerConfiguration,
51}
52
53impl ApplicationConfiguration {
54    /// Attempts to create an ApplicationConfiguration from environment variables.
55    pub fn try_from_env() -> anyhow::Result<Self> {
56        let base_url = env::var("BASE_URL").context("BASE_URL must be defined")?;
57        let test_mode = bool_env_false_by_default("TEST_MODE");
58        let development_uuid_login = bool_env_false_by_default("DEVELOPMENT_UUID_LOGIN");
59        let enable_admin_email_verification =
60            bool_env_false_by_default("ENABLE_ADMIN_EMAIL_VERIFICATION");
61        let enable_email_ownership_verification =
62            bool_env_false_by_default("ENABLE_EMAIL_OWNERSHIP_VERIFICATION");
63        let test_chatbot = test_mode
64            && (bool_env_false_by_default("USE_MOCK_AZURE_CONFIGURATION")
65                || env::var("AZURE_CHATBOT_API_KEY").is_err());
66
67        let test_sisu = test_mode && bool_env_false_by_default("USE_MOCK_SISU_ENDPOINT");
68
69        // No mock fallback unlike Azure: credit registration writes to the real student registry.
70        let test_suotar = test_mode && bool_env_false_by_default("USE_MOCK_SUOTAR_ENDPOINT");
71
72        let azure_configuration = if test_chatbot {
73            AzureConfiguration::mock_conf()?
74        } else {
75            AzureConfiguration::try_from_env()?
76        };
77
78        let suotar_configuration = if test_suotar {
79            SuotarConfiguration::mock_conf(&base_url)?
80        } else {
81            SuotarConfiguration::try_from_env()?
82        };
83
84        let tmc_account_creation_origin = Some(
85            env::var("TMC_ACCOUNT_CREATION_ORIGIN")
86                .context("TMC_ACCOUNT_CREATION_ORIGIN must be defined")?,
87        );
88
89        let tmc_admin_access_token = SecretString::new(
90            std::env::var("TMC_ACCESS_TOKEN")
91                .unwrap_or_else(|_| {
92                    if test_mode {
93                        "mock-access-token".to_string()
94                    } else {
95                        panic!("TMC_ACCESS_TOKEN must be defined in production")
96                    }
97                })
98                .into(),
99        );
100        let oauth_server_configuration = OAuthServerConfiguration::try_from_env()
101            .context("Failed to load OAuth server configuration")?;
102
103        Ok(Self {
104            base_url,
105            test_mode,
106            test_chatbot,
107            test_sisu,
108            test_suotar,
109            development_uuid_login,
110            enable_admin_email_verification,
111            enable_email_ownership_verification,
112            azure_configuration,
113            suotar_configuration,
114            tmc_account_creation_origin,
115            tmc_admin_access_token,
116            oauth_server_configuration,
117        })
118    }
119}
120
121/// TODO: Suotar has not confirmed whether they want `Basic` or `Bearer`; `Basic` is what they
122/// already accept on the legacy study-registry path.
123pub const SUOTAR_AUTH_SCHEME: &str = "Basic";
124
125/// The only token the mock Suotar accepts. Public on purpose: never a real credential.
126pub const MOCK_SUOTAR_TOKEN: &str = "mock-suotar-token";
127
128/// Auto-links a student number when Sisu's address matches a verified account email. Off until that
129/// fast track exists; then it is the incident kill switch for it.
130const FAST_TRACK_EMAIL_MATCH_ENABLED_DEFAULT: bool = false;
131
132/// Days an `email_verified_at` may be old and still count as fast-track proof. Bounded because a
133/// deprovisioned university address can be reissued to somebody else.
134const FAST_TRACK_MAX_EMAIL_VERIFICATION_AGE_DAYS_DEFAULT: i64 = 365;
135
136#[derive(Clone)]
137pub struct SuotarConfiguration {
138    /// Ends in `/` because it is a [`Url::join`] base and joined paths must be relative.
139    pub api_base_url: Url,
140    pub api_token: SecretString,
141    /// Nothing reads it yet.
142    pub fast_track_email_match_enabled: bool,
143    /// Nothing reads it yet.
144    pub fast_track_max_email_verification_age_days: i64,
145}
146
147impl SuotarConfiguration {
148    /// Points the client at our own mock controller. Only reachable with `TEST_MODE` and
149    /// `USE_MOCK_SUOTAR_ENDPOINT` both on.
150    pub fn mock_conf(base_url: &str) -> anyhow::Result<Self> {
151        Ok(Self {
152            api_base_url: Url::parse(base_url)
153                .context("Invalid URL in BASE_URL")?
154                .join("/api/v0/mock-suotar/")?,
155            api_token: SecretString::new(MOCK_SUOTAR_TOKEN.to_string().into()),
156            fast_track_email_match_enabled: Self::fast_track_enabled_from_env(),
157            fast_track_max_email_verification_age_days: Self::fast_track_max_age_from_env(),
158        })
159    }
160
161    pub fn try_from_env() -> anyhow::Result<Self> {
162        Self::from_values(
163            non_empty_env("SUOTAR_API_BASE_URL"),
164            non_empty_env("SUOTAR_API_KEY"),
165            Self::fast_track_enabled_from_env(),
166            Self::fast_track_max_age_from_env(),
167        )
168    }
169
170    fn fast_track_enabled_from_env() -> bool {
171        match non_empty_env("SUOTAR_FAST_TRACK_EMAIL_MATCH_ENABLED") {
172            Some(_) => bool_env_false_by_default("SUOTAR_FAST_TRACK_EMAIL_MATCH_ENABLED"),
173            None => FAST_TRACK_EMAIL_MATCH_ENABLED_DEFAULT,
174        }
175    }
176
177    fn fast_track_max_age_from_env() -> i64 {
178        i64_env_or(
179            "SUOTAR_FAST_TRACK_MAX_EMAIL_VERIFICATION_AGE_DAYS",
180            FAST_TRACK_MAX_EMAIL_VERIFICATION_AGE_DAYS_DEFAULT,
181        )
182    }
183
184    /// Pure so the no-mock-fallback rule can be tested without touching process env.
185    fn from_values(
186        api_base_url: Option<String>,
187        api_token: Option<String>,
188        fast_track_email_match_enabled: bool,
189        fast_track_max_email_verification_age_days: i64,
190    ) -> anyhow::Result<Self> {
191        let api_base_url = api_base_url.context(
192            "SUOTAR_API_BASE_URL must be defined unless TEST_MODE and USE_MOCK_SUOTAR_ENDPOINT are both on. Credit registration writes to the real student registry, so there is no mock fallback.",
193        )?;
194        let api_token = api_token.context(
195            "SUOTAR_API_KEY must be defined unless TEST_MODE and USE_MOCK_SUOTAR_ENDPOINT are both on. Credit registration writes to the real student registry, so there is no mock fallback.",
196        )?;
197        let api_base_url = if api_base_url.ends_with('/') {
198            api_base_url
199        } else {
200            format!("{api_base_url}/")
201        };
202        Ok(Self {
203            api_base_url: Url::parse(&api_base_url)
204                .context("Invalid URL in SUOTAR_API_BASE_URL")?,
205            api_token: SecretString::new(api_token.into()),
206            fast_track_email_match_enabled,
207            fast_track_max_email_verification_age_days,
208        })
209    }
210}
211
212#[derive(Clone)]
213pub struct AzureChatbotConfiguration {
214    pub api_key: SecretString,
215    pub api_endpoint: Url,
216}
217
218impl AzureChatbotConfiguration {
219    /// Attempts to create an AzureChatbotConfiguration from environment variables.
220    /// Returns `Ok(Some(AzureChatbotConfiguration))` if both environment variables are set.
221    /// Returns `Ok(None)` if no environment variables are set for chatbot.
222    /// Returns an error if set environment variables fail to parse.
223    pub fn try_from_env() -> anyhow::Result<Option<Self>> {
224        let api_key = env::var("AZURE_CHATBOT_API_KEY").ok();
225        let api_endpoint_str = env::var("AZURE_CHATBOT_API_ENDPOINT").ok();
226
227        if let (Some(api_key), Some(api_endpoint_str)) = (api_key, api_endpoint_str) {
228            let api_endpoint = Url::parse(&api_endpoint_str)
229                .context("Invalid URL in AZURE_CHATBOT_API_ENDPOINT")?;
230            Ok(Some(AzureChatbotConfiguration {
231                api_key: SecretString::new(api_key.into()),
232                api_endpoint,
233            }))
234        } else {
235            Ok(None)
236        }
237    }
238}
239
240#[derive(Clone)]
241pub struct AzureSearchConfiguration {
242    pub vectorizer_resource_uri: String,
243    pub vectorizer_deployment_id: String,
244    pub vectorizer_api_key: SecretString,
245    pub vectorizer_model_name: String,
246    pub search_endpoint: Url,
247    pub search_api_key: SecretString,
248    pub search_connection_id: String,
249}
250
251impl AzureSearchConfiguration {
252    /// Attempts to create an AzureSearchConfiguration from environment variables.
253    /// Returns `Ok(Some(AzureSearchConfiguration))` if all related environment variables are set.
254    /// Returns `Ok(None)` if no environment variables are set for search and vectorizer.
255    /// Returns an error if set environment variables fail to parse.
256    pub fn try_from_env() -> anyhow::Result<Option<Self>> {
257        let vectorizer_resource_uri = env::var("AZURE_VECTORIZER_RESOURCE_URI").ok();
258        let vectorizer_deployment_id = env::var("AZURE_VECTORIZER_DEPLOYMENT_ID").ok();
259        let vectorizer_api_key = env::var("AZURE_VECTORIZER_API_KEY").ok();
260        let vectorizer_model_name = env::var("AZURE_VECTORIZER_MODEL_NAME").ok();
261        let search_endpoint_str = env::var("AZURE_SEARCH_ENDPOINT").ok();
262        let search_api_key = env::var("AZURE_SEARCH_API_KEY").ok();
263        let search_connection_id = env::var("AZURE_SEARCH_CONNECTION_ID").ok();
264
265        if let (
266            Some(vectorizer_resource_uri),
267            Some(vectorizer_deployment_id),
268            Some(vectorizer_api_key),
269            Some(vectorizer_model_name),
270            Some(search_endpoint_str),
271            Some(search_api_key),
272            Some(search_connection_id),
273        ) = (
274            vectorizer_resource_uri,
275            vectorizer_deployment_id,
276            vectorizer_api_key,
277            vectorizer_model_name,
278            search_endpoint_str,
279            search_api_key,
280            search_connection_id,
281        ) {
282            let search_endpoint =
283                Url::parse(&search_endpoint_str).context("Invalid URL in AZURE_SEARCH_ENDPOINT")?;
284            Ok(Some(AzureSearchConfiguration {
285                vectorizer_resource_uri,
286                vectorizer_deployment_id,
287                vectorizer_api_key: SecretString::new(vectorizer_api_key.into()),
288                vectorizer_model_name,
289                search_endpoint,
290                search_api_key: SecretString::new(search_api_key.into()),
291                search_connection_id,
292            }))
293        } else {
294            Ok(None)
295        }
296    }
297}
298
299#[derive(Clone)]
300pub struct AzureBlobStorageConfiguration {
301    pub storage_account: String,
302    pub access_key: SecretString,
303}
304
305impl AzureBlobStorageConfiguration {
306    /// Attempts to create an AzureBlobStorageConfiguration from environment variables.
307    /// Returns `Ok(Some(AzureBlobStorageConfiguration))` if both environment variables are set.
308    /// Returns `Ok(None)` if no environment variables are set for blob storage.
309    /// Returns an error if set environment variables fail to parse.
310    pub fn try_from_env() -> anyhow::Result<Option<Self>> {
311        let storage_account = env::var("AZURE_BLOB_STORAGE_ACCOUNT").ok();
312        let access_key = env::var("AZURE_BLOB_STORAGE_ACCESS_KEY").ok();
313
314        if let (Some(storage_account), Some(access_key)) = (storage_account, access_key) {
315            Ok(Some(AzureBlobStorageConfiguration {
316                storage_account,
317                access_key: SecretString::new(access_key.into()),
318            }))
319        } else {
320            Ok(None)
321        }
322    }
323
324    /// Builds the Azure storage connection string. The result embeds the account
325    /// access key, so it is returned wrapped in `SecretString` (zeroized on drop,
326    /// redacted from `Debug`); call `.expose_secret()` only at the point it is handed
327    /// to the Azure SDK.
328    pub fn connection_string(&self) -> anyhow::Result<SecretString> {
329        Ok(SecretString::new(
330            format!(
331                "DefaultEndpointsProtocol=https;AccountName={};AccountKey={};EndpointSuffix=core.windows.net",
332                self.storage_account,
333                self.access_key.expose_secret()
334            )
335            .into(),
336        ))
337    }
338}
339
340#[derive(Clone)]
341pub struct AzureConfiguration {
342    pub chatbot_config: Option<AzureChatbotConfiguration>,
343    pub search_config: Option<AzureSearchConfiguration>,
344    pub blob_storage_config: Option<AzureBlobStorageConfiguration>,
345}
346
347impl AzureConfiguration {
348    /// Attempts to create an AzureConfiguration by calling the individual try_from_env functions.
349    /// Returns `Ok(Some(AzureConfiguration))` if any of the configurations are set.
350    /// Returns `Ok(None)` if no relevant environment variables are set.
351    pub fn try_from_env() -> anyhow::Result<Option<Self>> {
352        let chatbot = AzureChatbotConfiguration::try_from_env()?;
353        let search_config = AzureSearchConfiguration::try_from_env()?;
354        let blob_storage_config = AzureBlobStorageConfiguration::try_from_env()?;
355
356        if chatbot.is_some() || search_config.is_some() || blob_storage_config.is_some() {
357            Ok(Some(AzureConfiguration {
358                chatbot_config: chatbot,
359                search_config,
360                blob_storage_config,
361            }))
362        } else {
363            Ok(None)
364        }
365    }
366
367    /// Creates an AzureConfiguration with empty and mock values to be used in testing and dev
368    /// environments when Azure access is not needed. Enables the azure chatbot functionality to be
369    /// mocked with the api_endpoint from our application.
370    /// Returns `Ok(Some(AzureConfiguration))`
371    pub fn mock_conf() -> anyhow::Result<Option<Self>> {
372        let base_url = env::var("BASE_URL").context("BASE_URL must be defined")?;
373        let chatbot_config = Some(AzureChatbotConfiguration {
374            api_key: SecretString::new(String::new().into()),
375            api_endpoint: Url::parse(&base_url)?.join("/api/v0/mock-azure/test/v1/responses")?,
376        });
377        let search_config = Some(AzureSearchConfiguration {
378            vectorizer_resource_uri: "".to_string(),
379            vectorizer_deployment_id: "".to_string(),
380            vectorizer_api_key: SecretString::new(String::new().into()),
381            vectorizer_model_name: "".to_string(),
382            search_api_key: SecretString::new(String::new().into()),
383            search_endpoint: Url::from_str("https://example.com/does-not-exist/")?,
384            search_connection_id: "".to_string(),
385        });
386        let blob_storage_config = Some(AzureBlobStorageConfiguration {
387            storage_account: "".to_string(),
388            access_key: SecretString::new(String::new().into()),
389        });
390
391        Ok(Some(AzureConfiguration {
392            chatbot_config,
393            search_config,
394            blob_storage_config,
395        }))
396    }
397}
398
399#[derive(Clone)]
400pub struct OAuthServerConfiguration {
401    pub rsa_public_key: String,
402    /// RSA private key (PEM) used to sign OAuth/OIDC tokens. Secret: zeroized on drop,
403    /// redacted from `Debug`; only exposed when handed to the signing key builder.
404    pub rsa_private_key: SecretString,
405    /// Secret key for HMAC-SHA-256 hashing of OAuth tokens (access tokens, refresh tokens, auth codes).
406    pub oauth_token_hmac_key: SecretString,
407    /// Secret key for signing DPoP nonces (HMAC).
408    pub dpop_nonce_key: Arc<SecretBox<String>>,
409}
410
411impl PartialEq for OAuthServerConfiguration {
412    fn eq(&self, other: &Self) -> bool {
413        self.rsa_public_key == other.rsa_public_key
414            && self.rsa_private_key.expose_secret() == other.rsa_private_key.expose_secret()
415            && self.oauth_token_hmac_key.expose_secret()
416                == other.oauth_token_hmac_key.expose_secret()
417            && self.dpop_nonce_key.expose_secret() == other.dpop_nonce_key.expose_secret()
418    }
419}
420
421impl OAuthServerConfiguration {
422    /// Attempts to create an OAuthServerConfiguration.
423    /// Return `Ok(Some(OAuthConfiguration))` if all configurations are set.
424    /// Return `Err` if any is not set.
425    pub fn try_from_env() -> anyhow::Result<Self> {
426        let rsa_public_key =
427            env::var("OAUTH_RSA_PUBLIC_PEM").context("OAUTH_RSA_PUBLIC_KEY must be defined")?;
428        let rsa_private_key = SecretString::new(
429            env::var("OAUTH_RSA_PRIVATE_PEM")
430                .context("OAUTH_RSA_PRIVATE_KEY must be defined")?
431                .into(),
432        );
433        let oauth_token_hmac_key = SecretString::new(
434            env::var("OAUTH_TOKEN_HMAC_KEY")
435                .context("OAUTH_TOKEN_HMAC_KEY must be defined")?
436                .into(),
437        );
438        let dpop_nonce_key = Arc::new(SecretBox::new(Box::new(
439            env::var("OAUTH_DPOP_NONCE_KEY").context("OAUTH_DPOP_NONCE_KEY must be defined")?,
440        )));
441
442        Ok(Self {
443            rsa_public_key,
444            rsa_private_key,
445            oauth_token_hmac_key,
446            dpop_nonce_key,
447        })
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454
455    #[test]
456    fn suotar_configuration_has_no_mock_fallback() {
457        assert!(SuotarConfiguration::from_values(None, None, false, 365).is_err());
458        assert!(
459            SuotarConfiguration::from_values(
460                Some("https://suotar.example.com/api".to_string()),
461                None,
462                false,
463                365
464            )
465            .is_err()
466        );
467        assert!(
468            SuotarConfiguration::from_values(None, Some("token".to_string()), false, 365).is_err()
469        );
470        assert!(
471            SuotarConfiguration::from_values(
472                Some("https://suotar.example.com/api".to_string()),
473                Some("token".to_string()),
474                false,
475                365
476            )
477            .is_ok()
478        );
479    }
480
481    /// `Url::join` replaces the whole path unless the base ends in `/`, so a base without one
482    /// silently drops the `/api` prefix from every call.
483    #[test]
484    fn suotar_configuration_normalises_the_join_base() {
485        let conf = SuotarConfiguration::from_values(
486            Some("https://suotar.example.com/api".to_string()),
487            Some("token".to_string()),
488            false,
489            365,
490        )
491        .expect("valid fixture values");
492        assert_eq!(
493            conf.api_base_url.as_str(),
494            "https://suotar.example.com/api/"
495        );
496        assert_eq!(
497            conf.api_base_url
498                .join("persons/resolve-by-student-numbers")
499                .expect("a relative join on a base ending in a slash")
500                .as_str(),
501            "https://suotar.example.com/api/persons/resolve-by-student-numbers"
502        );
503    }
504
505    #[test]
506    fn mock_conf_points_at_our_own_mock_controller() {
507        let conf = SuotarConfiguration::mock_conf("http://project-331.local")
508            .expect("valid fixture values");
509        assert_eq!(
510            conf.api_base_url.as_str(),
511            "http://project-331.local/api/v0/mock-suotar/"
512        );
513        assert_eq!(conf.api_token.expose_secret(), MOCK_SUOTAR_TOKEN);
514    }
515
516    #[test]
517    fn fast_track_defaults_are_off_and_a_year() {
518        let conf = SuotarConfiguration::mock_conf("http://project-331.local")
519            .expect("valid fixture values");
520        assert!(!conf.fast_track_email_match_enabled);
521        assert_eq!(conf.fast_track_max_email_verification_age_days, 365);
522    }
523}