1use anyhow::Context;
2use secrecy::{ExposeSecret, SecretBox, SecretString};
3use std::sync::Arc;
4use std::{env, str::FromStr};
5use url::Url;
6
7pub 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
21fn 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
29fn 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 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 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
121pub const SUOTAR_AUTH_SCHEME: &str = "Basic";
124
125pub const MOCK_SUOTAR_TOKEN: &str = "mock-suotar-token";
127
128const FAST_TRACK_EMAIL_MATCH_ENABLED_DEFAULT: bool = false;
131
132const FAST_TRACK_MAX_EMAIL_VERIFICATION_AGE_DAYS_DEFAULT: i64 = 365;
135
136#[derive(Clone)]
137pub struct SuotarConfiguration {
138 pub api_base_url: Url,
140 pub api_token: SecretString,
141 pub fast_track_email_match_enabled: bool,
143 pub fast_track_max_email_verification_age_days: i64,
145}
146
147impl SuotarConfiguration {
148 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 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 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 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 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 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 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 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 pub rsa_private_key: SecretString,
405 pub oauth_token_hmac_key: SecretString,
407 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 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 #[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}