1use crate::jwt::DEVELOPMENT_JWT_PASSWORD;
2use anyhow::Context;
3use secrecy::{ExposeSecret, SecretBox, SecretString};
4use std::sync::Arc;
5use std::{env, str::FromStr};
6use url::Url;
7
8pub fn bool_env_false_by_default(key: &str) -> bool {
10 match env::var(key) {
11 Ok(value) => {
12 let normalized = value.trim().to_ascii_lowercase();
13 !matches!(
14 normalized.as_str(),
15 "" | "false" | "0" | "no" | "off" | "disabled"
16 )
17 }
18 Err(_) => false,
19 }
20}
21
22fn non_empty_env(key: &str) -> Option<String> {
24 match env::var(key) {
25 Ok(value) if !value.trim().is_empty() => Some(value.trim().to_string()),
26 _ => None,
27 }
28}
29
30fn parse_join_base(url: &str) -> Result<Url, url::ParseError> {
37 let mut url = Url::parse(url)?;
38 if !url.path().ends_with('/') {
39 url.set_path(&format!("{}/", url.path()));
40 }
41 Ok(url)
42}
43
44#[derive(Clone)]
45pub struct ApplicationConfiguration {
46 pub base_url: String,
47 pub test_mode: bool,
48 pub test_chatbot: bool,
49 pub test_sisu: bool,
50 pub test_suotar: bool,
51 pub disable_embedding_vector_creation_when_seeding: bool,
52 pub development_uuid_login: bool,
53 pub enable_admin_email_verification: bool,
54 pub enable_email_ownership_verification: bool,
55 pub azure_configuration: Option<AzureConfiguration>,
56 pub suotar_configuration: SuotarConfiguration,
57 pub tmc_account_creation_origin: Option<String>,
58 pub tmc_admin_access_token: SecretString,
59 pub oauth_server_configuration: OAuthServerConfiguration,
60 pub jwt_password: SecretString,
65}
66
67impl ApplicationConfiguration {
68 pub fn try_from_env() -> anyhow::Result<Self> {
70 let base_url = env::var("BASE_URL").context("BASE_URL must be defined")?;
71 let test_mode = bool_env_false_by_default("TEST_MODE");
72 let development_uuid_login = bool_env_false_by_default("DEVELOPMENT_UUID_LOGIN");
73 let enable_admin_email_verification =
74 bool_env_false_by_default("ENABLE_ADMIN_EMAIL_VERIFICATION");
75 let enable_email_ownership_verification =
76 bool_env_false_by_default("ENABLE_EMAIL_OWNERSHIP_VERIFICATION");
77 let test_chatbot = test_mode
78 && (bool_env_false_by_default("USE_MOCK_AZURE_CONFIGURATION")
79 || env::var("AZURE_CHATBOT_API_KEY").is_err());
80
81 let test_sisu = test_mode && bool_env_false_by_default("USE_MOCK_SISU_ENDPOINT");
82
83 let test_suotar = test_mode && bool_env_false_by_default("USE_MOCK_SUOTAR_ENDPOINT");
85
86 let disable_embedding_vector_creation_when_seeding = false;
87
88 let azure_configuration = if test_chatbot {
89 AzureConfiguration::mock_conf()?
90 } else {
91 AzureConfiguration::try_from_env()?
92 };
93
94 let suotar_configuration = if test_suotar {
95 SuotarConfiguration::mock_conf(&base_url)?
96 } else {
97 SuotarConfiguration::try_from_env()?
98 };
99
100 let tmc_account_creation_origin = Some(
101 env::var("TMC_ACCOUNT_CREATION_ORIGIN")
102 .context("TMC_ACCOUNT_CREATION_ORIGIN must be defined")?,
103 );
104
105 let tmc_admin_access_token = SecretString::new(
106 std::env::var("TMC_ACCESS_TOKEN")
107 .unwrap_or_else(|_| {
108 if test_mode {
109 "mock-access-token".to_string()
110 } else {
111 panic!("TMC_ACCESS_TOKEN must be defined in production")
112 }
113 })
114 .into(),
115 );
116 let oauth_server_configuration = OAuthServerConfiguration::try_from_env()
117 .context("Failed to load OAuth server configuration")?;
118 let jwt_password = SecretString::new(
119 env::var("JWT_PASSWORD")
120 .context("JWT_PASSWORD must be defined")?
121 .into(),
122 );
123
124 Ok(Self {
125 base_url,
126 test_mode,
127 test_chatbot,
128 test_sisu,
129 test_suotar,
130 disable_embedding_vector_creation_when_seeding,
131 development_uuid_login,
132 enable_admin_email_verification,
133 enable_email_ownership_verification,
134 azure_configuration,
135 suotar_configuration,
136 tmc_account_creation_origin,
137 tmc_admin_access_token,
138 oauth_server_configuration,
139 jwt_password,
140 })
141 }
142
143 pub fn mock_conf() -> anyhow::Result<Self> {
144 let test_mode = true;
145 let base_url = "http://project-331.local/".to_string();
146 let development_uuid_login = false;
147 let enable_admin_email_verification = false;
148 let enable_email_ownership_verification = false;
149 let azure_configuration = AzureConfiguration::mock_conf()?;
150 let test_chatbot = true;
151 let test_sisu = true;
152 let test_suotar = false;
153 let disable_embedding_vector_creation_when_seeding = true;
154 let suotar_configuration = SuotarConfiguration::mock_conf("http://project-331.local")
155 .expect("Failed to build the mock Suotar configuration");
156 let tmc_account_creation_origin = None;
157 let tmc_admin_access_token = SecretString::new("mock-access-token".to_string().into());
158 let oauth_server_configuration = OAuthServerConfiguration {
159 rsa_public_key: "temp-change-when-needed".into(),
160 rsa_private_key: SecretString::new("test-change".into()),
161 oauth_token_hmac_key: SecretString::new("pippuri".into()),
162 dpop_nonce_key: std::sync::Arc::new(secrecy::SecretBox::new(Box::new(
163 "test-key".into(),
164 ))),
165 };
166 let jwt_password = SecretString::new(DEVELOPMENT_JWT_PASSWORD.to_string().into());
167 Ok(Self {
168 base_url,
169 test_mode,
170 test_chatbot,
171 test_sisu,
172 test_suotar,
173 disable_embedding_vector_creation_when_seeding,
174 development_uuid_login,
175 enable_admin_email_verification,
176 enable_email_ownership_verification,
177 azure_configuration,
178 suotar_configuration,
179 tmc_account_creation_origin,
180 tmc_admin_access_token,
181 oauth_server_configuration,
182 jwt_password,
183 })
184 }
185}
186
187pub const SUOTAR_AUTH_SCHEME: &str = "Bearer";
189
190pub const MOCK_SUOTAR_TOKEN: &str = "mock-suotar-token";
192
193const ACCOUNT_LINKING_ENABLED_DEFAULT: bool = false;
196
197#[derive(Clone)]
200pub struct SuotarConfiguration {
201 pub api_base_url: Url,
203 pub api_token: SecretString,
204 pub account_linking_enabled: bool,
205}
206
207impl SuotarConfiguration {
208 pub fn mock_conf(base_url: &str) -> anyhow::Result<Self> {
211 Ok(Self {
212 api_base_url: Url::parse(base_url)
213 .context("Invalid URL in BASE_URL")?
214 .join("/api/v0/mock-suotar/")?,
215 api_token: SecretString::new(MOCK_SUOTAR_TOKEN.to_string().into()),
216 account_linking_enabled: Self::account_linking_enabled_from_env(),
217 })
218 }
219
220 pub fn try_from_env() -> anyhow::Result<Self> {
221 Self::from_values(
222 non_empty_env("SUOTAR_API_BASE_URL"),
223 non_empty_env("SUOTAR_API_KEY"),
224 Self::account_linking_enabled_from_env(),
225 )
226 }
227
228 fn account_linking_enabled_from_env() -> bool {
229 match non_empty_env("SUOTAR_ACCOUNT_LINKING_ENABLED") {
230 Some(_) => bool_env_false_by_default("SUOTAR_ACCOUNT_LINKING_ENABLED"),
231 None => ACCOUNT_LINKING_ENABLED_DEFAULT,
232 }
233 }
234
235 fn from_values(
237 api_base_url: Option<String>,
238 api_token: Option<String>,
239 account_linking_enabled: bool,
240 ) -> anyhow::Result<Self> {
241 let api_base_url = api_base_url.context(
242 "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.",
243 )?;
244 let api_token = api_token.context(
245 "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.",
246 )?;
247 Ok(Self {
248 api_base_url: parse_join_base(&api_base_url)
249 .context("Invalid URL in SUOTAR_API_BASE_URL")?,
250 api_token: SecretString::new(api_token.into()),
251 account_linking_enabled,
252 })
253 }
254}
255
256#[derive(Clone)]
257pub struct AzureChatbotConfiguration {
258 pub api_key: SecretString,
259 pub api_base: Url,
261 pub project_name: String,
262}
263
264impl AzureChatbotConfiguration {
265 pub fn try_from_env() -> anyhow::Result<Option<Self>> {
272 let api_key = env::var("AZURE_CHATBOT_API_KEY").ok();
273 let api_endpoint = env::var("AZURE_CHATBOT_API_ENDPOINT").ok();
274 let project_name = env::var("AZURE_PROJECT_NAME").ok();
275
276 if let (Some(api_key), Some(api_endpoint), Some(project_name)) =
277 (api_key, api_endpoint, project_name)
278 {
279 Ok(Some(Self::from_values(
280 api_key,
281 &api_endpoint,
282 project_name,
283 )?))
284 } else {
285 Ok(None)
286 }
287 }
288
289 fn from_values(
291 api_key: String,
292 api_endpoint: &str,
293 project_name: String,
294 ) -> anyhow::Result<Self> {
295 Ok(Self {
296 api_key: SecretString::new(api_key.into()),
297 api_base: parse_join_base(api_endpoint)
298 .context("Invalid URL in AZURE_CHATBOT_API_ENDPOINT")?,
299 project_name,
300 })
301 }
302
303 pub fn responses_endpoint(&self) -> anyhow::Result<Url> {
304 Ok(self.api_base.join(&format!(
305 "api/projects/{}/openai/v1/responses",
306 self.project_name
307 ))?)
308 }
309
310 pub fn embeddings_endpoint(&self) -> anyhow::Result<Url> {
311 Ok(self.api_base.join("openai/v1/embeddings")?)
312 }
313}
314
315#[derive(Clone)]
316pub struct AzureSearchConfiguration {
317 pub vectorizer_resource_uri: String,
318 pub vectorizer_deployment_id: String,
319 pub vectorizer_api_key: SecretString,
320 pub vectorizer_model_name: String,
321 pub search_endpoint: Url,
322 pub search_api_key: SecretString,
323 pub search_connection_id: String,
324}
325
326impl AzureSearchConfiguration {
327 pub fn try_from_env() -> anyhow::Result<Option<Self>> {
332 let vectorizer_resource_uri = env::var("AZURE_VECTORIZER_RESOURCE_URI").ok();
333 let vectorizer_deployment_id = env::var("AZURE_VECTORIZER_DEPLOYMENT_ID").ok();
334 let vectorizer_api_key = env::var("AZURE_VECTORIZER_API_KEY").ok();
335 let vectorizer_model_name = env::var("AZURE_VECTORIZER_MODEL_NAME").ok();
336 let search_endpoint_str = env::var("AZURE_SEARCH_ENDPOINT").ok();
337 let search_api_key = env::var("AZURE_SEARCH_API_KEY").ok();
338 let search_connection_id = env::var("AZURE_SEARCH_CONNECTION_ID").ok();
339
340 if let (
341 Some(vectorizer_resource_uri),
342 Some(vectorizer_deployment_id),
343 Some(vectorizer_api_key),
344 Some(vectorizer_model_name),
345 Some(search_endpoint_str),
346 Some(search_api_key),
347 Some(search_connection_id),
348 ) = (
349 vectorizer_resource_uri,
350 vectorizer_deployment_id,
351 vectorizer_api_key,
352 vectorizer_model_name,
353 search_endpoint_str,
354 search_api_key,
355 search_connection_id,
356 ) {
357 let search_endpoint =
358 Url::parse(&search_endpoint_str).context("Invalid URL in AZURE_SEARCH_ENDPOINT")?;
359 Ok(Some(AzureSearchConfiguration {
360 vectorizer_resource_uri,
361 vectorizer_deployment_id,
362 vectorizer_api_key: SecretString::new(vectorizer_api_key.into()),
363 vectorizer_model_name,
364 search_endpoint,
365 search_api_key: SecretString::new(search_api_key.into()),
366 search_connection_id,
367 }))
368 } else {
369 Ok(None)
370 }
371 }
372}
373
374#[derive(Clone)]
375pub struct AzureBlobStorageConfiguration {
376 pub storage_account: String,
377 pub access_key: SecretString,
378}
379
380impl AzureBlobStorageConfiguration {
381 pub fn try_from_env() -> anyhow::Result<Option<Self>> {
386 let storage_account = env::var("AZURE_BLOB_STORAGE_ACCOUNT").ok();
387 let access_key = env::var("AZURE_BLOB_STORAGE_ACCESS_KEY").ok();
388
389 if let (Some(storage_account), Some(access_key)) = (storage_account, access_key) {
390 Ok(Some(AzureBlobStorageConfiguration {
391 storage_account,
392 access_key: SecretString::new(access_key.into()),
393 }))
394 } else {
395 Ok(None)
396 }
397 }
398
399 pub fn connection_string(&self) -> anyhow::Result<SecretString> {
404 Ok(SecretString::new(
405 format!(
406 "DefaultEndpointsProtocol=https;AccountName={};AccountKey={};EndpointSuffix=core.windows.net",
407 self.storage_account,
408 self.access_key.expose_secret()
409 )
410 .into(),
411 ))
412 }
413}
414
415#[derive(Clone)]
416pub struct AzureConfiguration {
417 pub chatbot_config: Option<AzureChatbotConfiguration>,
418 pub search_config: Option<AzureSearchConfiguration>,
419 pub blob_storage_config: Option<AzureBlobStorageConfiguration>,
420}
421
422impl AzureConfiguration {
423 pub fn try_from_env() -> anyhow::Result<Option<Self>> {
427 let chatbot = AzureChatbotConfiguration::try_from_env()?;
428 let search_config = AzureSearchConfiguration::try_from_env()?;
429 let blob_storage_config = AzureBlobStorageConfiguration::try_from_env()?;
430 if chatbot.is_some() || search_config.is_some() || blob_storage_config.is_some() {
431 Ok(Some(AzureConfiguration {
432 chatbot_config: chatbot,
433 search_config,
434 blob_storage_config,
435 }))
436 } else {
437 Ok(None)
438 }
439 }
440
441 pub fn mock_conf() -> anyhow::Result<Option<Self>> {
446 let base_url =
447 env::var("BASE_URL").unwrap_or_else(|_| "http://project-331.local/".to_string());
448 let chatbot_config = Some(AzureChatbotConfiguration {
449 api_key: SecretString::new(String::new().into()),
450 api_base: Url::parse(&base_url)?.join("/api/v0/mock-azure/")?,
451 project_name: String::from("test"),
452 });
453
454 let search_config = Some(AzureSearchConfiguration {
455 vectorizer_resource_uri: "".to_string(),
456 vectorizer_deployment_id: "".to_string(),
457 vectorizer_api_key: SecretString::new(String::new().into()),
458 vectorizer_model_name: "".to_string(),
459 search_api_key: SecretString::new(String::new().into()),
460 search_endpoint: Url::from_str("https://example.com/does-not-exist/")?,
461 search_connection_id: "".to_string(),
462 });
463 let blob_storage_config = Some(AzureBlobStorageConfiguration {
464 storage_account: "".to_string(),
465 access_key: SecretString::new(String::new().into()),
466 });
467
468 Ok(Some(AzureConfiguration {
469 chatbot_config,
470 search_config,
471 blob_storage_config,
472 }))
473 }
474}
475
476#[derive(Clone)]
477pub struct OAuthServerConfiguration {
478 pub rsa_public_key: String,
479 pub rsa_private_key: SecretString,
482 pub oauth_token_hmac_key: SecretString,
484 pub dpop_nonce_key: Arc<SecretBox<String>>,
486}
487
488impl PartialEq for OAuthServerConfiguration {
489 fn eq(&self, other: &Self) -> bool {
490 self.rsa_public_key == other.rsa_public_key
491 && self.rsa_private_key.expose_secret() == other.rsa_private_key.expose_secret()
492 && self.oauth_token_hmac_key.expose_secret()
493 == other.oauth_token_hmac_key.expose_secret()
494 && self.dpop_nonce_key.expose_secret() == other.dpop_nonce_key.expose_secret()
495 }
496}
497
498impl OAuthServerConfiguration {
499 pub fn try_from_env() -> anyhow::Result<Self> {
503 let rsa_public_key =
504 env::var("OAUTH_RSA_PUBLIC_PEM").context("OAUTH_RSA_PUBLIC_KEY must be defined")?;
505 let rsa_private_key = SecretString::new(
506 env::var("OAUTH_RSA_PRIVATE_PEM")
507 .context("OAUTH_RSA_PRIVATE_KEY must be defined")?
508 .into(),
509 );
510 let oauth_token_hmac_key = SecretString::new(
511 env::var("OAUTH_TOKEN_HMAC_KEY")
512 .context("OAUTH_TOKEN_HMAC_KEY must be defined")?
513 .into(),
514 );
515 let dpop_nonce_key = Arc::new(SecretBox::new(Box::new(
516 env::var("OAUTH_DPOP_NONCE_KEY").context("OAUTH_DPOP_NONCE_KEY must be defined")?,
517 )));
518
519 Ok(Self {
520 rsa_public_key,
521 rsa_private_key,
522 oauth_token_hmac_key,
523 dpop_nonce_key,
524 })
525 }
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531
532 #[test]
533 fn suotar_configuration_has_no_mock_fallback() {
534 assert!(SuotarConfiguration::from_values(None, None, false).is_err());
535 assert!(
536 SuotarConfiguration::from_values(
537 Some("https://suotar.example.com/api".to_string()),
538 None,
539 false
540 )
541 .is_err()
542 );
543 assert!(SuotarConfiguration::from_values(None, Some("token".to_string()), false).is_err());
544 assert!(
545 SuotarConfiguration::from_values(
546 Some("https://suotar.example.com/api".to_string()),
547 Some("token".to_string()),
548 false
549 )
550 .is_ok()
551 );
552 }
553
554 #[test]
557 fn suotar_configuration_normalises_the_join_base() {
558 let conf = SuotarConfiguration::from_values(
559 Some("https://opetushallinto.cs.helsinki.fi/suoritustarkistin/api/moocfi".to_string()),
560 Some("token".to_string()),
561 false,
562 )
563 .expect("valid fixture values");
564 assert_eq!(
565 conf.api_base_url.as_str(),
566 "https://opetushallinto.cs.helsinki.fi/suoritustarkistin/api/moocfi/"
567 );
568 assert_eq!(
569 conf.api_base_url
570 .join("persons/resolve-by-student-numbers")
571 .expect("a relative join on a base ending in a slash")
572 .as_str(),
573 "https://opetushallinto.cs.helsinki.fi/suoritustarkistin/api/moocfi/persons/resolve-by-student-numbers"
574 );
575 }
576
577 fn azure_chatbot_conf(api_endpoint: &str) -> AzureChatbotConfiguration {
578 AzureChatbotConfiguration::from_values(
579 "key".to_string(),
580 api_endpoint,
581 "some-project".to_string(),
582 )
583 .expect("valid fixture values")
584 }
585
586 fn azure_chatbot_embeddings_endpoint(api_endpoint: &str) -> String {
587 azure_chatbot_conf(api_endpoint)
588 .embeddings_endpoint()
589 .expect("a relative join on a base ending in a slash")
590 .to_string()
591 }
592
593 #[test]
594 fn azure_chatbot_configuration_normalises_the_join_base() {
595 let conf = azure_chatbot_conf("https://example.services.ai.azure.com/foundry");
596 assert_eq!(
597 conf.api_base.as_str(),
598 "https://example.services.ai.azure.com/foundry/"
599 );
600 assert_eq!(
601 conf.responses_endpoint()
602 .expect("a relative join on a base ending in a slash")
603 .as_str(),
604 "https://example.services.ai.azure.com/foundry/api/projects/some-project/openai/v1/responses"
605 );
606 assert_eq!(
607 conf.embeddings_endpoint()
608 .expect("a relative join on a base ending in a slash")
609 .as_str(),
610 "https://example.services.ai.azure.com/foundry/openai/v1/embeddings"
611 );
612 }
613
614 #[test]
617 fn a_query_a_fragment_or_stray_whitespace_does_not_move_the_join_base() {
618 assert_eq!(
619 azure_chatbot_embeddings_endpoint(
620 "https://example.services.ai.azure.com/foundry?api-version=2024-10-21"
621 ),
622 "https://example.services.ai.azure.com/foundry/openai/v1/embeddings"
623 );
624 assert_eq!(
625 azure_chatbot_embeddings_endpoint(
626 "https://example.services.ai.azure.com/foundry#anchor"
627 ),
628 "https://example.services.ai.azure.com/foundry/openai/v1/embeddings"
629 );
630 assert_eq!(
631 azure_chatbot_embeddings_endpoint("https://example.services.ai.azure.com/foundry "),
632 "https://example.services.ai.azure.com/foundry/openai/v1/embeddings"
633 );
634 assert_eq!(
635 azure_chatbot_embeddings_endpoint("https://example.services.ai.azure.com/foundry/ "),
636 "https://example.services.ai.azure.com/foundry/openai/v1/embeddings"
637 );
638 }
639
640 #[test]
641 fn a_join_base_that_already_ends_in_a_slash_is_left_as_it_is() {
642 assert_eq!(
643 azure_chatbot_conf("https://example.services.ai.azure.com/foundry/")
644 .api_base
645 .as_str(),
646 "https://example.services.ai.azure.com/foundry/"
647 );
648 assert_eq!(
649 azure_chatbot_conf("https://example.services.ai.azure.com/foundry//")
650 .api_base
651 .as_str(),
652 "https://example.services.ai.azure.com/foundry//"
653 );
654 assert_eq!(
655 azure_chatbot_conf("https://example.services.ai.azure.com")
656 .api_base
657 .as_str(),
658 "https://example.services.ai.azure.com/"
659 );
660 }
661
662 #[test]
663 fn mock_conf_points_at_our_own_mock_controller() {
664 let conf = SuotarConfiguration::mock_conf("http://project-331.local")
665 .expect("valid fixture values");
666 assert_eq!(
667 conf.api_base_url.as_str(),
668 "http://project-331.local/api/v0/mock-suotar/"
669 );
670 assert_eq!(conf.api_token.expose_secret(), MOCK_SUOTAR_TOKEN);
671 }
672}