1use crate::jwt::DEVELOPMENT_JWT_PASSWORD;
2use anyhow::Context;
3use chrono::{DateTime, Utc};
4use secrecy::{ExposeSecret, SecretBox, SecretString};
5use std::sync::Arc;
6use std::{env, str::FromStr};
7use url::Url;
8
9pub fn bool_env_false_by_default(key: &str) -> bool {
11 match env::var(key) {
12 Ok(value) => {
13 let normalized = value.trim().to_ascii_lowercase();
14 !matches!(
15 normalized.as_str(),
16 "" | "false" | "0" | "no" | "off" | "disabled"
17 )
18 }
19 Err(_) => false,
20 }
21}
22
23fn non_empty_env(key: &str) -> Option<String> {
25 match env::var(key) {
26 Ok(value) if !value.trim().is_empty() => Some(value.trim().to_string()),
27 _ => None,
28 }
29}
30
31fn parse_join_base(url: &str) -> Result<Url, url::ParseError> {
38 let mut url = Url::parse(url)?;
39 if !url.path().ends_with('/') {
40 url.set_path(&format!("{}/", url.path()));
41 }
42 Ok(url)
43}
44
45#[derive(Clone)]
46pub struct ApplicationConfiguration {
47 pub base_url: String,
48 pub test_mode: bool,
49 pub test_chatbot: bool,
50 pub test_sisu: bool,
51 pub test_suotar: bool,
52 pub disable_embedding_vector_creation_when_seeding: bool,
53 pub development_uuid_login: 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 pub mcp_configuration: Option<McpConfiguration>,
66}
67
68impl ApplicationConfiguration {
69 pub fn public_url(&self, path: &str) -> String {
71 format!("{}{path}", self.base_url.trim_end_matches('/'))
72 }
73
74 pub fn oauth_issuer(&self) -> String {
77 self.public_url("/api/v0/main-frontend/oauth")
78 }
79
80 pub fn mcp_resource_url(&self) -> String {
82 self.public_url("/api/v0/mcp")
83 }
84
85 pub fn try_from_env() -> anyhow::Result<Self> {
87 let base_url = env::var("BASE_URL").context("BASE_URL must be defined")?;
88 let test_mode = bool_env_false_by_default("TEST_MODE");
89 let development_uuid_login = bool_env_false_by_default("DEVELOPMENT_UUID_LOGIN");
90 let enable_email_ownership_verification =
91 bool_env_false_by_default("ENABLE_EMAIL_OWNERSHIP_VERIFICATION");
92 let test_chatbot = test_mode
93 && (bool_env_false_by_default("USE_MOCK_AZURE_CONFIGURATION")
94 || env::var("AZURE_CHATBOT_API_KEY").is_err());
95
96 let test_sisu = test_mode && bool_env_false_by_default("USE_MOCK_SISU_ENDPOINT");
97
98 let test_suotar = test_mode && bool_env_false_by_default("USE_MOCK_SUOTAR_ENDPOINT");
100
101 let disable_embedding_vector_creation_when_seeding = false;
102
103 let azure_configuration = if test_chatbot {
104 AzureConfiguration::mock_conf()?
105 } else {
106 AzureConfiguration::try_from_env()?
107 };
108
109 let suotar_configuration = if test_suotar {
110 SuotarConfiguration::mock_conf(&base_url)?
111 } else {
112 SuotarConfiguration::try_from_env()?
113 };
114
115 let tmc_account_creation_origin = Some(
116 env::var("TMC_ACCOUNT_CREATION_ORIGIN")
117 .context("TMC_ACCOUNT_CREATION_ORIGIN must be defined")?,
118 );
119
120 let tmc_admin_access_token = SecretString::new(
121 std::env::var("TMC_ACCESS_TOKEN")
122 .unwrap_or_else(|_| {
123 if test_mode {
124 "mock-access-token".to_string()
125 } else {
126 panic!("TMC_ACCESS_TOKEN must be defined in production")
127 }
128 })
129 .into(),
130 );
131 let oauth_server_configuration = OAuthServerConfiguration::try_from_env()
132 .context("Failed to load OAuth server configuration")?;
133 let jwt_password = SecretString::new(
134 env::var("JWT_PASSWORD")
135 .context("JWT_PASSWORD must be defined")?
136 .into(),
137 );
138 let mcp_configuration = McpConfiguration::try_from_env()?;
139
140 Ok(Self {
141 base_url,
142 test_mode,
143 test_chatbot,
144 test_sisu,
145 test_suotar,
146 disable_embedding_vector_creation_when_seeding,
147 development_uuid_login,
148 enable_email_ownership_verification,
149 azure_configuration,
150 suotar_configuration,
151 tmc_account_creation_origin,
152 tmc_admin_access_token,
153 oauth_server_configuration,
154 jwt_password,
155 mcp_configuration,
156 })
157 }
158
159 pub fn mock_conf() -> anyhow::Result<Self> {
160 let test_mode = true;
161 let base_url = "http://project-331.local/".to_string();
162 let development_uuid_login = false;
163 let enable_email_ownership_verification = false;
164 let azure_configuration = AzureConfiguration::mock_conf()?;
165 let test_chatbot = true;
166 let test_sisu = true;
167 let test_suotar = false;
168 let disable_embedding_vector_creation_when_seeding = true;
169 let suotar_configuration = SuotarConfiguration::mock_conf("http://project-331.local")
170 .expect("Failed to build the mock Suotar configuration");
171 let tmc_account_creation_origin = None;
172 let tmc_admin_access_token = SecretString::new("mock-access-token".to_string().into());
173 let oauth_server_configuration = OAuthServerConfiguration {
174 rsa_public_key: "temp-change-when-needed".into(),
175 rsa_private_key: SecretString::new("test-change".into()),
176 oauth_token_hmac_key: SecretString::new("pippuri".into()),
177 dpop_nonce_key: std::sync::Arc::new(secrecy::SecretBox::new(Box::new(
178 "test-key".into(),
179 ))),
180 };
181 let jwt_password = SecretString::new(DEVELOPMENT_JWT_PASSWORD.to_string().into());
182 let mcp_configuration = None;
183 Ok(Self {
184 base_url,
185 test_mode,
186 test_chatbot,
187 test_sisu,
188 test_suotar,
189 disable_embedding_vector_creation_when_seeding,
190 development_uuid_login,
191 enable_email_ownership_verification,
192 azure_configuration,
193 suotar_configuration,
194 tmc_account_creation_origin,
195 tmc_admin_access_token,
196 oauth_server_configuration,
197 jwt_password,
198 mcp_configuration,
199 })
200 }
201}
202
203#[derive(Clone)]
205pub struct McpConfiguration {
206 pub cms_internal_url: Url,
209 pub agent_validator_secret: SecretString,
211}
212
213impl McpConfiguration {
214 pub fn try_from_env() -> anyhow::Result<Option<Self>> {
217 let (Some(url), Some(secret)) = (
218 non_empty_env("CMS_INTERNAL_URL"),
219 non_empty_env("AGENT_VALIDATOR_SECRET"),
220 ) else {
221 return Ok(None);
222 };
223 Ok(Some(Self {
224 cms_internal_url: parse_join_base(&url).context("Invalid URL in CMS_INTERNAL_URL")?,
225 agent_validator_secret: SecretString::from(secret),
226 }))
227 }
228}
229
230pub const SUOTAR_AUTH_SCHEME: &str = "Bearer";
232
233pub const MOCK_SUOTAR_TOKEN: &str = "mock-suotar-token";
235
236#[derive(Clone)]
239pub struct SuotarConfiguration {
240 pub api_base_url: Url,
242 pub api_token: SecretString,
243 pub account_linking_since: Option<DateTime<Utc>>,
247}
248
249impl SuotarConfiguration {
250 pub fn mock_conf(base_url: &str) -> anyhow::Result<Self> {
253 Ok(Self {
254 api_base_url: Url::parse(base_url)
255 .context("Invalid URL in BASE_URL")?
256 .join("/api/v0/mock-suotar/")?,
257 api_token: SecretString::new(MOCK_SUOTAR_TOKEN.to_string().into()),
258 account_linking_since: Self::account_linking_since_from_env()?,
259 })
260 }
261
262 pub fn try_from_env() -> anyhow::Result<Self> {
263 Self::from_values(
264 non_empty_env("SUOTAR_API_BASE_URL"),
265 non_empty_env("SUOTAR_API_KEY"),
266 Self::account_linking_since_from_env()?,
267 )
268 }
269
270 pub fn is_account_linking_enabled(&self) -> bool {
272 self.account_linking_since.is_some()
273 }
274
275 fn account_linking_since_from_env() -> anyhow::Result<Option<DateTime<Utc>>> {
276 non_empty_env("SUOTAR_ACCOUNT_LINKING_SINCE")
277 .map(|since| {
278 DateTime::parse_from_rfc3339(&since)
279 .map(|since| since.with_timezone(&Utc))
280 .context("SUOTAR_ACCOUNT_LINKING_SINCE must be an RFC 3339 timestamp")
281 })
282 .transpose()
283 }
284
285 fn from_values(
287 api_base_url: Option<String>,
288 api_token: Option<String>,
289 account_linking_since: Option<DateTime<Utc>>,
290 ) -> anyhow::Result<Self> {
291 let api_base_url = api_base_url.context(
292 "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.",
293 )?;
294 let api_token = api_token.context(
295 "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.",
296 )?;
297 Ok(Self {
298 api_base_url: parse_join_base(&api_base_url)
299 .context("Invalid URL in SUOTAR_API_BASE_URL")?,
300 api_token: SecretString::new(api_token.into()),
301 account_linking_since,
302 })
303 }
304}
305
306#[derive(Clone)]
307pub struct AzureChatbotConfiguration {
308 pub api_key: SecretString,
309 pub api_base: Url,
311 pub project_name: String,
312}
313
314impl AzureChatbotConfiguration {
315 pub fn try_from_env() -> anyhow::Result<Option<Self>> {
322 let api_key = env::var("AZURE_CHATBOT_API_KEY").ok();
323 let api_endpoint = env::var("AZURE_CHATBOT_API_ENDPOINT").ok();
324 let project_name = env::var("AZURE_PROJECT_NAME").ok();
325
326 if let (Some(api_key), Some(api_endpoint), Some(project_name)) =
327 (api_key, api_endpoint, project_name)
328 {
329 Ok(Some(Self::from_values(
330 api_key,
331 &api_endpoint,
332 project_name,
333 )?))
334 } else {
335 Ok(None)
336 }
337 }
338
339 fn from_values(
341 api_key: String,
342 api_endpoint: &str,
343 project_name: String,
344 ) -> anyhow::Result<Self> {
345 Ok(Self {
346 api_key: SecretString::new(api_key.into()),
347 api_base: parse_join_base(api_endpoint)
348 .context("Invalid URL in AZURE_CHATBOT_API_ENDPOINT")?,
349 project_name,
350 })
351 }
352
353 pub fn responses_endpoint(&self) -> anyhow::Result<Url> {
354 Ok(self.api_base.join(&format!(
355 "api/projects/{}/openai/v1/responses",
356 self.project_name
357 ))?)
358 }
359
360 pub fn embeddings_endpoint(&self) -> anyhow::Result<Url> {
361 Ok(self.api_base.join("openai/v1/embeddings")?)
362 }
363}
364
365#[derive(Clone)]
366pub struct AzureSearchConfiguration {
367 pub vectorizer_resource_uri: String,
368 pub vectorizer_deployment_id: String,
369 pub vectorizer_api_key: SecretString,
370 pub vectorizer_model_name: String,
371 pub search_endpoint: Url,
372 pub search_api_key: SecretString,
373 pub search_connection_id: String,
374}
375
376impl AzureSearchConfiguration {
377 pub fn try_from_env() -> anyhow::Result<Option<Self>> {
382 let vectorizer_resource_uri = env::var("AZURE_VECTORIZER_RESOURCE_URI").ok();
383 let vectorizer_deployment_id = env::var("AZURE_VECTORIZER_DEPLOYMENT_ID").ok();
384 let vectorizer_api_key = env::var("AZURE_VECTORIZER_API_KEY").ok();
385 let vectorizer_model_name = env::var("AZURE_VECTORIZER_MODEL_NAME").ok();
386 let search_endpoint_str = env::var("AZURE_SEARCH_ENDPOINT").ok();
387 let search_api_key = env::var("AZURE_SEARCH_API_KEY").ok();
388 let search_connection_id = env::var("AZURE_SEARCH_CONNECTION_ID").ok();
389
390 if let (
391 Some(vectorizer_resource_uri),
392 Some(vectorizer_deployment_id),
393 Some(vectorizer_api_key),
394 Some(vectorizer_model_name),
395 Some(search_endpoint_str),
396 Some(search_api_key),
397 Some(search_connection_id),
398 ) = (
399 vectorizer_resource_uri,
400 vectorizer_deployment_id,
401 vectorizer_api_key,
402 vectorizer_model_name,
403 search_endpoint_str,
404 search_api_key,
405 search_connection_id,
406 ) {
407 let search_endpoint =
408 Url::parse(&search_endpoint_str).context("Invalid URL in AZURE_SEARCH_ENDPOINT")?;
409 Ok(Some(AzureSearchConfiguration {
410 vectorizer_resource_uri,
411 vectorizer_deployment_id,
412 vectorizer_api_key: SecretString::new(vectorizer_api_key.into()),
413 vectorizer_model_name,
414 search_endpoint,
415 search_api_key: SecretString::new(search_api_key.into()),
416 search_connection_id,
417 }))
418 } else {
419 Ok(None)
420 }
421 }
422}
423
424#[derive(Clone)]
425pub struct AzureBlobStorageConfiguration {
426 pub storage_account: String,
427 pub access_key: SecretString,
428}
429
430impl AzureBlobStorageConfiguration {
431 pub fn try_from_env() -> anyhow::Result<Option<Self>> {
436 let storage_account = env::var("AZURE_BLOB_STORAGE_ACCOUNT").ok();
437 let access_key = env::var("AZURE_BLOB_STORAGE_ACCESS_KEY").ok();
438
439 if let (Some(storage_account), Some(access_key)) = (storage_account, access_key) {
440 Ok(Some(AzureBlobStorageConfiguration {
441 storage_account,
442 access_key: SecretString::new(access_key.into()),
443 }))
444 } else {
445 Ok(None)
446 }
447 }
448
449 pub fn connection_string(&self) -> anyhow::Result<SecretString> {
454 Ok(SecretString::new(
455 format!(
456 "DefaultEndpointsProtocol=https;AccountName={};AccountKey={};EndpointSuffix=core.windows.net",
457 self.storage_account,
458 self.access_key.expose_secret()
459 )
460 .into(),
461 ))
462 }
463}
464
465#[derive(Clone)]
466pub struct AzureConfiguration {
467 pub chatbot_config: Option<AzureChatbotConfiguration>,
468 pub search_config: Option<AzureSearchConfiguration>,
469 pub blob_storage_config: Option<AzureBlobStorageConfiguration>,
470}
471
472impl AzureConfiguration {
473 pub fn try_from_env() -> anyhow::Result<Option<Self>> {
477 let chatbot = AzureChatbotConfiguration::try_from_env()?;
478 let search_config = AzureSearchConfiguration::try_from_env()?;
479 let blob_storage_config = AzureBlobStorageConfiguration::try_from_env()?;
480 if chatbot.is_some() || search_config.is_some() || blob_storage_config.is_some() {
481 Ok(Some(AzureConfiguration {
482 chatbot_config: chatbot,
483 search_config,
484 blob_storage_config,
485 }))
486 } else {
487 Ok(None)
488 }
489 }
490
491 pub fn mock_conf() -> anyhow::Result<Option<Self>> {
496 let base_url =
497 env::var("BASE_URL").unwrap_or_else(|_| "http://project-331.local/".to_string());
498 let chatbot_config = Some(AzureChatbotConfiguration {
499 api_key: SecretString::new(String::new().into()),
500 api_base: Url::parse(&base_url)?.join("/api/v0/mock-azure/")?,
501 project_name: String::from("test"),
502 });
503
504 let search_config = Some(AzureSearchConfiguration {
505 vectorizer_resource_uri: "".to_string(),
506 vectorizer_deployment_id: "".to_string(),
507 vectorizer_api_key: SecretString::new(String::new().into()),
508 vectorizer_model_name: "".to_string(),
509 search_api_key: SecretString::new(String::new().into()),
510 search_endpoint: Url::from_str("https://example.com/does-not-exist/")?,
511 search_connection_id: "".to_string(),
512 });
513 let blob_storage_config = Some(AzureBlobStorageConfiguration {
514 storage_account: "".to_string(),
515 access_key: SecretString::new(String::new().into()),
516 });
517
518 Ok(Some(AzureConfiguration {
519 chatbot_config,
520 search_config,
521 blob_storage_config,
522 }))
523 }
524}
525
526#[derive(Clone)]
527pub struct OAuthServerConfiguration {
528 pub rsa_public_key: String,
529 pub rsa_private_key: SecretString,
532 pub oauth_token_hmac_key: SecretString,
534 pub dpop_nonce_key: Arc<SecretBox<String>>,
536}
537
538impl PartialEq for OAuthServerConfiguration {
539 fn eq(&self, other: &Self) -> bool {
540 self.rsa_public_key == other.rsa_public_key
541 && self.rsa_private_key.expose_secret() == other.rsa_private_key.expose_secret()
542 && self.oauth_token_hmac_key.expose_secret()
543 == other.oauth_token_hmac_key.expose_secret()
544 && self.dpop_nonce_key.expose_secret() == other.dpop_nonce_key.expose_secret()
545 }
546}
547
548impl OAuthServerConfiguration {
549 pub fn try_from_env() -> anyhow::Result<Self> {
553 let rsa_public_key =
554 env::var("OAUTH_RSA_PUBLIC_PEM").context("OAUTH_RSA_PUBLIC_KEY must be defined")?;
555 let rsa_private_key = SecretString::new(
556 env::var("OAUTH_RSA_PRIVATE_PEM")
557 .context("OAUTH_RSA_PRIVATE_KEY must be defined")?
558 .into(),
559 );
560 let oauth_token_hmac_key = SecretString::new(
561 env::var("OAUTH_TOKEN_HMAC_KEY")
562 .context("OAUTH_TOKEN_HMAC_KEY must be defined")?
563 .into(),
564 );
565 let dpop_nonce_key = Arc::new(SecretBox::new(Box::new(
566 env::var("OAUTH_DPOP_NONCE_KEY").context("OAUTH_DPOP_NONCE_KEY must be defined")?,
567 )));
568
569 Ok(Self {
570 rsa_public_key,
571 rsa_private_key,
572 oauth_token_hmac_key,
573 dpop_nonce_key,
574 })
575 }
576}
577
578#[cfg(test)]
579mod tests {
580 use super::*;
581
582 #[test]
583 fn suotar_configuration_has_no_mock_fallback() {
584 assert!(SuotarConfiguration::from_values(None, None, None).is_err());
585 assert!(
586 SuotarConfiguration::from_values(
587 Some("https://suotar.example.com/api".to_string()),
588 None,
589 None
590 )
591 .is_err()
592 );
593 assert!(SuotarConfiguration::from_values(None, Some("token".to_string()), None).is_err());
594 assert!(
595 SuotarConfiguration::from_values(
596 Some("https://suotar.example.com/api".to_string()),
597 Some("token".to_string()),
598 None
599 )
600 .is_ok()
601 );
602 }
603
604 #[test]
607 fn suotar_configuration_normalises_the_join_base() {
608 let conf = SuotarConfiguration::from_values(
609 Some("https://opetushallinto.cs.helsinki.fi/suoritustarkistin/api/moocfi".to_string()),
610 Some("token".to_string()),
611 None,
612 )
613 .expect("valid fixture values");
614 assert_eq!(
615 conf.api_base_url.as_str(),
616 "https://opetushallinto.cs.helsinki.fi/suoritustarkistin/api/moocfi/"
617 );
618 assert_eq!(
619 conf.api_base_url
620 .join("persons/resolve-by-student-numbers")
621 .expect("a relative join on a base ending in a slash")
622 .as_str(),
623 "https://opetushallinto.cs.helsinki.fi/suoritustarkistin/api/moocfi/persons/resolve-by-student-numbers"
624 );
625 }
626
627 fn azure_chatbot_conf(api_endpoint: &str) -> AzureChatbotConfiguration {
628 AzureChatbotConfiguration::from_values(
629 "key".to_string(),
630 api_endpoint,
631 "some-project".to_string(),
632 )
633 .expect("valid fixture values")
634 }
635
636 fn azure_chatbot_embeddings_endpoint(api_endpoint: &str) -> String {
637 azure_chatbot_conf(api_endpoint)
638 .embeddings_endpoint()
639 .expect("a relative join on a base ending in a slash")
640 .to_string()
641 }
642
643 #[test]
644 fn azure_chatbot_configuration_normalises_the_join_base() {
645 let conf = azure_chatbot_conf("https://example.services.ai.azure.com/foundry");
646 assert_eq!(
647 conf.api_base.as_str(),
648 "https://example.services.ai.azure.com/foundry/"
649 );
650 assert_eq!(
651 conf.responses_endpoint()
652 .expect("a relative join on a base ending in a slash")
653 .as_str(),
654 "https://example.services.ai.azure.com/foundry/api/projects/some-project/openai/v1/responses"
655 );
656 assert_eq!(
657 conf.embeddings_endpoint()
658 .expect("a relative join on a base ending in a slash")
659 .as_str(),
660 "https://example.services.ai.azure.com/foundry/openai/v1/embeddings"
661 );
662 }
663
664 #[test]
667 fn a_query_a_fragment_or_stray_whitespace_does_not_move_the_join_base() {
668 assert_eq!(
669 azure_chatbot_embeddings_endpoint(
670 "https://example.services.ai.azure.com/foundry?api-version=2024-10-21"
671 ),
672 "https://example.services.ai.azure.com/foundry/openai/v1/embeddings"
673 );
674 assert_eq!(
675 azure_chatbot_embeddings_endpoint(
676 "https://example.services.ai.azure.com/foundry#anchor"
677 ),
678 "https://example.services.ai.azure.com/foundry/openai/v1/embeddings"
679 );
680 assert_eq!(
681 azure_chatbot_embeddings_endpoint("https://example.services.ai.azure.com/foundry "),
682 "https://example.services.ai.azure.com/foundry/openai/v1/embeddings"
683 );
684 assert_eq!(
685 azure_chatbot_embeddings_endpoint("https://example.services.ai.azure.com/foundry/ "),
686 "https://example.services.ai.azure.com/foundry/openai/v1/embeddings"
687 );
688 }
689
690 #[test]
691 fn a_join_base_that_already_ends_in_a_slash_is_left_as_it_is() {
692 assert_eq!(
693 azure_chatbot_conf("https://example.services.ai.azure.com/foundry/")
694 .api_base
695 .as_str(),
696 "https://example.services.ai.azure.com/foundry/"
697 );
698 assert_eq!(
699 azure_chatbot_conf("https://example.services.ai.azure.com/foundry//")
700 .api_base
701 .as_str(),
702 "https://example.services.ai.azure.com/foundry//"
703 );
704 assert_eq!(
705 azure_chatbot_conf("https://example.services.ai.azure.com")
706 .api_base
707 .as_str(),
708 "https://example.services.ai.azure.com/"
709 );
710 }
711
712 #[test]
713 fn mock_conf_points_at_our_own_mock_controller() {
714 let conf = SuotarConfiguration::mock_conf("http://project-331.local")
715 .expect("valid fixture values");
716 assert_eq!(
717 conf.api_base_url.as_str(),
718 "http://project-331.local/api/v0/mock-suotar/"
719 );
720 assert_eq!(conf.api_token.expose_secret(), MOCK_SUOTAR_TOKEN);
721 }
722}