1use std::sync::Arc;
9use std::time::Duration;
10
11use axum::extract::{Query, State};
12use axum::http::StatusCode;
13use axum::response::Redirect;
14use axum::{Extension, Json};
15use axum_extra::extract::PrivateCookieJar;
16use axum_extra::extract::cookie::{Cookie, SameSite};
17use kellnr_common::account_name::UserName;
18use kellnr_db::{User, UserFlags};
19use kellnr_http::error::ApiError;
20use kellnr_http::state::{AppState, AppStateData, DbState, SettingsState};
21use kellnr_oauth2::{OAuth2Error, OAuth2Handler, UserInfo, generate_unique_username};
22use kellnr_settings::UrlPathPrefix;
23use kellnr_settings::constants::{COOKIE_OAUTH2_STATE, COOKIE_OIDC_ID_TOKEN, OAUTH2_STATE_MAX_AGE};
24use serde::{Deserialize, Serialize};
25use tracing::{error, trace, warn};
26use utoipa::ToSchema;
27
28use crate::session::{create_session_jar, session_cookie};
29
30pub type OAuth2Ext = Extension<Option<Arc<OAuth2Handler>>>;
32
33const PROVIDER_RETRY_AFTER: Duration = Duration::from_secs(30);
36
37#[derive(Debug, Serialize, ToSchema)]
39#[allow(clippy::struct_excessive_bools)]
40pub struct OAuth2Config {
41 pub enabled: bool,
43 pub available: bool,
46 pub button_text: String,
48 pub enforced: bool,
50 pub auto_redirect: bool,
52}
53
54#[derive(Debug, Deserialize, ToSchema, utoipa::IntoParams)]
57pub struct CallbackParams {
58 pub code: Option<String>,
60 pub state: Option<String>,
62 pub error: Option<String>,
64 pub error_description: Option<String>,
66}
67
68#[derive(Debug, PartialEq, Eq, Deserialize)]
72#[serde(try_from = "CallbackParams")]
73pub enum CallbackQuery {
74 Success { code: String, state: String },
76 ProviderError {
78 error: String,
79 error_description: Option<String>,
80 state: Option<String>,
81 },
82}
83
84impl TryFrom<CallbackParams> for CallbackQuery {
85 type Error = &'static str;
86
87 fn try_from(params: CallbackParams) -> Result<Self, Self::Error> {
88 match params {
89 CallbackParams {
90 error: Some(error),
91 error_description,
92 state,
93 ..
94 } => Ok(Self::ProviderError {
95 error,
96 error_description,
97 state,
98 }),
99 CallbackParams {
100 code: Some(code),
101 state: Some(state),
102 ..
103 } => Ok(Self::Success { code, state }),
104 _ => Err("OAuth2 callback without code or state"),
105 }
106 }
107}
108
109#[utoipa::path(
114 get,
115 path = "/config",
116 tag = "oauth2",
117 responses(
118 (status = 200, description = "OAuth2 configuration", body = OAuth2Config)
119 )
120)]
121#[allow(clippy::unused_async)]
122pub async fn get_config(
123 State(settings): SettingsState,
124 Extension(oauth2_handler): OAuth2Ext,
125) -> Json<OAuth2Config> {
126 Json(OAuth2Config {
127 enabled: settings.oauth2.enabled,
128 available: oauth2_handler
129 .as_ref()
130 .is_some_and(|handler| handler.is_available()),
131 button_text: settings.oauth2.button_text.clone(),
132 enforced: settings.oauth2.enforced,
133 auto_redirect: settings.oauth2.auto_redirect,
134 })
135}
136
137#[utoipa::path(
145 get,
146 path = "/login",
147 tag = "oauth2",
148 responses(
149 (status = 302, description = "Redirect to OAuth2 provider"),
150 (status = 404, description = "OAuth2 not enabled"),
151 (status = 503, description = "OIDC provider not reachable yet, retry after the `Retry-After` seconds")
152 )
153)]
154pub async fn login(
155 cookies: PrivateCookieJar,
156 State(db): DbState,
157 State(settings): SettingsState,
158 Extension(oauth2_handler): OAuth2Ext,
159) -> Result<(PrivateCookieJar, Redirect), ApiError> {
160 trace!("OAuth2 login initiated");
161
162 let handler = oauth2_handler.as_ref().ok_or_else(|| {
164 warn!("OAuth2 login attempted but OAuth2 is not enabled");
165 ApiError::from(StatusCode::NOT_FOUND)
166 })?;
167
168 let auth_request = handler.generate_auth_url().map_err(|e| {
170 warn!("OAuth2 login attempted but the OIDC provider is unavailable: {e}");
171 provider_unavailable()
172 })?;
173
174 db.store_oauth2_state(
176 &auth_request.state,
177 &auth_request.pkce_verifier,
178 &auth_request.nonce,
179 )
180 .await
181 .map_err(|e| {
182 error!("Failed to store OAuth2 state: {}", e);
183 ApiError::from(StatusCode::INTERNAL_SERVER_ERROR)
184 })?;
185
186 let jar = cookies.add(state_cookie(
187 auth_request.state,
188 settings.origin.secure_cookies(),
189 ));
190
191 Ok((jar, Redirect::to(auth_request.auth_url.as_str())))
193}
194
195fn state_cookie(state: String, secure: bool) -> Cookie<'static> {
200 let max_age = time::Duration::try_from(OAUTH2_STATE_MAX_AGE).unwrap_or(time::Duration::ZERO);
201 Cookie::build((COOKIE_OAUTH2_STATE, state))
202 .max_age(max_age)
203 .same_site(SameSite::Lax)
204 .http_only(true)
205 .secure(secure)
206 .path("/")
207 .into()
208}
209
210fn take_state_cookie(
213 jar: PrivateCookieJar,
214 query_state: &str,
215) -> Result<PrivateCookieJar, ApiError> {
216 let matches = jar
217 .get(COOKIE_OAUTH2_STATE)
218 .is_some_and(|c| c.value() == query_state);
219 if !matches {
220 warn!("OAuth2 callback state does not match the state cookie of this browser");
221 return Err(ApiError::from(StatusCode::BAD_REQUEST));
222 }
223 Ok(jar.remove(Cookie::build((COOKIE_OAUTH2_STATE, "")).path("/")))
224}
225
226#[utoipa::path(
236 get,
237 path = "/callback",
238 tag = "oauth2",
239 params(CallbackParams),
240 responses(
241 (status = 302, description = "Redirect to UI after successful login or to the login page on a provider error"),
242 (status = 400, description = "Invalid state or missing code"),
243 (status = 401, description = "Token exchange failed"),
244 (status = 403, description = "User not found and auto-provisioning disabled"),
245 (status = 404, description = "OAuth2 not enabled"),
246 (status = 503, description = "OIDC provider not reachable yet, retry after the `Retry-After` seconds")
247 )
248)]
249pub async fn callback(
250 cookies: PrivateCookieJar,
251 Query(query): Query<CallbackQuery>,
252 State(app_state): AppState,
253 Extension(oauth2_handler): OAuth2Ext,
254) -> Result<(PrivateCookieJar, Redirect), ApiError> {
255 let (code, query_state) = match query {
256 CallbackQuery::Success { code, state } => {
257 trace!(state = ?state, "OAuth2 callback received");
258 (code, state)
259 }
260 CallbackQuery::ProviderError {
262 error,
263 error_description,
264 state,
265 } => {
266 trace!(state = ?state, "OAuth2 callback received");
267 warn!("OAuth2 provider returned an error: {error} - {error_description:?}");
268 let cookies = cookies.remove(Cookie::build((COOKIE_OAUTH2_STATE, "")).path("/"));
269 let redirect = login_error_redirect(
270 &app_state.settings.origin.path_prefix(),
271 error_description.as_deref().unwrap_or(&error),
272 );
273 return Ok((cookies, redirect));
274 }
275 };
276
277 let handler = oauth2_handler.as_ref().ok_or_else(|| {
279 warn!("OAuth2 callback received but OAuth2 is not enabled");
280 ApiError::from(StatusCode::NOT_FOUND)
281 })?;
282
283 let cookies = take_state_cookie(cookies, &query_state)?;
284
285 let state_data = app_state
287 .db
288 .get_and_delete_oauth2_state(&query_state)
289 .await
290 .map_err(|e| {
291 error!("Failed to retrieve OAuth2 state: {}", e);
292 ApiError::from(StatusCode::INTERNAL_SERVER_ERROR)
293 })?
294 .ok_or_else(|| {
295 warn!("OAuth2 callback with invalid or expired state: {query_state}");
296 ApiError::from(StatusCode::BAD_REQUEST)
297 })?;
298
299 let token_result = handler
301 .exchange_and_validate(&code, &state_data.pkce_verifier, &state_data.nonce)
302 .await
303 .map_err(|e| {
304 error!("Failed to exchange OAuth2 code: {}", e);
305 match e {
306 OAuth2Error::ProviderUnavailable => provider_unavailable(),
307 _ => ApiError::from(StatusCode::UNAUTHORIZED),
308 }
309 })?;
310
311 let user_info = handler.extract_user_info(&token_result);
313 let issuer = handler.issuer_url();
314
315 trace!(
316 "OAuth2 authentication successful for subject: {}, email: {:?}",
317 user_info.subject, user_info.email
318 );
319
320 #[allow(clippy::single_match_else)]
322 let user = match app_state
323 .db
324 .get_user_by_oauth2_identity(issuer, &user_info.subject)
325 .await
326 .map_err(|e| {
327 error!("Failed to look up OAuth2 identity: {}", e);
328 ApiError::from(StatusCode::INTERNAL_SERVER_ERROR)
329 })? {
330 Some(user) => {
331 trace!("Found existing user '{}' for OAuth2 identity", user.name);
332 sync_oauth2_privileges(
338 &app_state,
339 handler.admin_claim_configured(),
340 handler.read_only_claim_configured(),
341 user,
342 &user_info,
343 )
344 .await?
345 }
346 None => {
347 if !handler.settings().auto_provision_users {
349 warn!(
350 "OAuth2 user not found and auto-provisioning is disabled: {}",
351 user_info.subject
352 );
353 return Err(ApiError::from(StatusCode::FORBIDDEN));
354 }
355
356 let username = generate_unique_username(&user_info, |name| {
358 let db = app_state.db.clone();
359 async move {
360 db.is_username_available(&UserName::from_unchecked(name))
361 .await
362 }
363 })
364 .await
365 .map_err(|e| {
366 error!("Failed to check if a user name is available: {e}");
367 ApiError::from(StatusCode::INTERNAL_SERVER_ERROR)
368 })?;
369 let username = UserName::from_unchecked(username);
370
371 trace!(
372 "Creating new OAuth2 user '{}' (admin: {}, read_only: {})",
373 username, user_info.is_admin, user_info.is_read_only
374 );
375
376 app_state
378 .db
379 .create_oauth2_user(
380 &username,
381 issuer,
382 &user_info.subject,
383 user_info.email.clone(),
384 UserFlags {
385 admin: user_info.is_admin,
386 read_only: user_info.is_read_only,
387 },
388 )
389 .await
390 .map_err(|e| {
391 error!("Failed to create OAuth2 user: {}", e);
392 ApiError::from(StatusCode::INTERNAL_SERVER_ERROR)
393 })?
394 }
395 };
396
397 let jar = create_session_jar(cookies, &app_state, &user.name).await?;
398 let jar = jar.add(session_cookie(
399 COOKIE_OIDC_ID_TOKEN,
400 token_result.id_token.clone(),
401 app_state.settings.registry.session_age().cookie_max_age(),
402 app_state.settings.origin.secure_cookies(),
403 ));
404 trace!("Created session for OAuth2 user: {}", user.name);
405
406 let base_path = format!("{}/", app_state.settings.origin.path_prefix());
408 Ok((jar, Redirect::to(&base_path)))
409}
410
411async fn sync_oauth2_privileges(
422 app_state: &AppStateData,
423 admin_governed: bool,
424 read_only_governed: bool,
425 mut user: User,
426 user_info: &UserInfo,
427) -> Result<User, ApiError> {
428 let mut changed = false;
429
430 if admin_governed && user.is_admin != user_info.is_admin {
431 app_state
432 .db
433 .change_admin_state(&user.name, user_info.is_admin)
434 .await
435 .map_err(|e| {
436 error!(
437 "Failed to sync OAuth2 admin state for '{}': {}",
438 user.name, e
439 );
440 ApiError::from(StatusCode::INTERNAL_SERVER_ERROR)
441 })?;
442 user.is_admin = user_info.is_admin;
443 changed = true;
444 }
445
446 if read_only_governed && user.is_read_only != user_info.is_read_only {
447 app_state
448 .db
449 .change_read_only_state(&user.name, user_info.is_read_only)
450 .await
451 .map_err(|e| {
452 error!(
453 "Failed to sync OAuth2 read-only state for '{}': {}",
454 user.name, e
455 );
456 ApiError::from(StatusCode::INTERNAL_SERVER_ERROR)
457 })?;
458 user.is_read_only = user_info.is_read_only;
459 changed = true;
460 }
461
462 if changed {
463 app_state.token_cache.invalidate_all();
464 trace!(
465 "Synced OAuth2 privileges for '{}' (admin: {}, read_only: {})",
466 user.name, user.is_admin, user.is_read_only
467 );
468 }
469
470 Ok(user)
471}
472
473#[derive(Debug, Deserialize, ToSchema, utoipa::IntoParams)]
475pub struct ErrorQuery {
476 pub error: String,
477 #[serde(default)]
478 pub error_description: Option<String>,
479}
480
481#[utoipa::path(
485 get,
486 path = "/error",
487 tag = "oauth2",
488 params(ErrorQuery),
489 responses(
490 (status = 302, description = "Redirect to login page with error")
491 )
492)]
493#[allow(clippy::unused_async)]
494pub async fn error_callback(
495 State(settings): SettingsState,
496 Query(query): Query<ErrorQuery>,
497) -> Redirect {
498 warn!(
499 "OAuth2 error callback: {} - {:?}",
500 query.error, query.error_description
501 );
502
503 let error_msg = query.error_description.as_deref().unwrap_or(&query.error);
504 login_error_redirect(&settings.origin.path_prefix(), error_msg)
505}
506
507fn provider_unavailable() -> ApiError {
509 ApiError::service_unavailable(
510 "Single sign-on is temporarily unavailable",
511 PROVIDER_RETRY_AFTER,
512 )
513}
514
515fn login_error_redirect(path_prefix: &UrlPathPrefix, error: &str) -> Redirect {
516 let encoded_error = urlencoding::encode(error);
517 Redirect::to(&format!("{path_prefix}/login?error={encoded_error}"))
518}
519
520#[cfg(test)]
521mod sync_tests {
522 use kellnr_db::DbProvider;
523 use kellnr_http::test_support::sqlite_test_state;
524 use kellnr_test_db::TestDb;
525
526 use super::*;
527
528 fn user_info(is_admin: bool, is_read_only: bool) -> UserInfo {
529 UserInfo {
530 subject: "sub".to_string(),
531 email: None,
532 preferred_username: None,
533 groups: vec![],
534 is_admin,
535 is_read_only,
536 }
537 }
538
539 fn alice() -> UserName {
540 UserName::new("alice").unwrap()
541 }
542
543 async fn state_with_alice(is_admin: bool, is_read_only: bool) -> (AppStateData, TestDb) {
545 let (state, db) = sqlite_test_state().await;
546 let flags = UserFlags {
547 admin: is_admin,
548 read_only: is_read_only,
549 };
550 db.add_user(&alice(), "pwd", flags).await.unwrap();
551 (state, db)
552 }
553
554 async fn sync(
557 stored: (bool, bool),
558 admin_governed: bool,
559 read_only_governed: bool,
560 user_info: &UserInfo,
561 ) -> (User, User) {
562 let (state, db) = state_with_alice(stored.0, stored.1).await;
563 let user = db.get_user(&alice()).await.unwrap();
564
565 let result =
566 sync_oauth2_privileges(&state, admin_governed, read_only_governed, user, user_info)
567 .await
568 .unwrap();
569
570 (result, db.get_user(&alice()).await.unwrap())
571 }
572
573 #[tokio::test]
574 async fn read_only_governed_promotes_user() {
575 let (result, stored) = sync((false, false), false, true, &user_info(false, true)).await;
576
577 assert!(result.is_read_only);
578 assert!(stored.is_read_only);
579 }
580
581 #[tokio::test]
582 async fn admin_governed_demotes_user() {
583 let (result, stored) = sync((true, false), true, false, &user_info(false, false)).await;
584
585 assert!(!result.is_admin);
586 assert!(!stored.is_admin);
587 }
588
589 #[tokio::test]
590 async fn unconfigured_claim_leaves_user_untouched() {
591 let (result, stored) = sync((true, false), false, false, &user_info(false, true)).await;
594
595 assert!(result.is_admin);
596 assert!(!result.is_read_only);
597 assert!(stored.is_admin);
598 assert!(!stored.is_read_only);
599 }
600
601 #[tokio::test]
602 async fn governed_but_unchanged_is_noop() {
603 let (result, stored) = sync((true, true), true, true, &user_info(true, true)).await;
604
605 assert!(result.is_admin);
606 assert!(result.is_read_only);
607 assert!(stored.is_admin);
608 assert!(stored.is_read_only);
609 }
610}
611
612#[cfg(test)]
613mod state_cookie_tests {
614 use axum::http::{HeaderMap, header};
615 use axum_extra::extract::cookie::Key;
616 use kellnr_http::test_support::{TEST_KEY, encode_cookies};
617
618 use super::*;
619
620 fn jar_with_state(state: &'static str) -> PrivateCookieJar {
621 let mut headers = HeaderMap::new();
622 headers.insert(
623 header::COOKIE,
624 encode_cookies([(COOKIE_OAUTH2_STATE, state)])
625 .parse()
626 .unwrap(),
627 );
628 PrivateCookieJar::from_headers(&headers, Key::from(TEST_KEY))
629 }
630
631 fn empty_jar() -> PrivateCookieJar {
632 PrivateCookieJar::from_headers(&HeaderMap::new(), Key::from(TEST_KEY))
633 }
634
635 #[test]
636 fn state_cookie_attributes() {
637 let cookie = state_cookie("abc".to_string(), true);
638
639 assert_eq!(cookie.name(), COOKIE_OAUTH2_STATE);
640 assert_eq!(cookie.value(), "abc");
641 assert_eq!(cookie.same_site(), Some(SameSite::Lax));
644 assert_eq!(cookie.http_only(), Some(true));
645 assert_eq!(cookie.secure(), Some(true));
646 assert_eq!(cookie.path(), Some("/"));
647 assert_eq!(cookie.max_age(), Some(time::Duration::minutes(10)));
648 }
649
650 #[test]
651 fn state_cookie_not_secure_over_http() {
652 assert_eq!(state_cookie("abc".to_string(), false).secure(), Some(false));
653 }
654
655 #[test]
656 fn matching_state_is_accepted_and_cookie_removed() {
657 let jar = take_state_cookie(jar_with_state("abc"), "abc").unwrap();
658
659 assert!(jar.get(COOKIE_OAUTH2_STATE).is_none());
660 }
661
662 #[test]
665 fn mismatching_state_is_rejected() {
666 let result = take_state_cookie(jar_with_state("abc"), "attacker");
667
668 assert!(
669 result
670 .as_ref()
671 .is_err_and(|e| e.status() == StatusCode::BAD_REQUEST)
672 );
673 }
674
675 #[test]
676 fn missing_state_cookie_is_rejected() {
677 let result = take_state_cookie(empty_jar(), "abc");
678
679 assert!(
680 result
681 .as_ref()
682 .is_err_and(|e| e.status() == StatusCode::BAD_REQUEST)
683 );
684 }
685}
686
687#[cfg(test)]
688mod callback_tests {
689 use axum::http::{HeaderMap, header};
690 use axum::response::IntoResponse;
691 use axum_extra::extract::cookie::Key;
692 use kellnr_http::test_support::TEST_KEY;
693 use kellnr_settings::Settings;
694
695 use super::*;
696
697 fn state_with_origin_path(path: &str) -> AppStateData {
698 let mut settings = Settings::clone(&kellnr_http::test_support::test_state().settings);
699 settings.origin.path = path.to_string();
700 AppStateData {
701 settings: Arc::new(settings),
702 ..kellnr_http::test_support::test_state()
703 }
704 }
705
706 fn location(response: impl IntoResponse) -> String {
707 response.into_response().headers()[header::LOCATION]
708 .to_str()
709 .unwrap()
710 .to_string()
711 }
712
713 fn parse_query(query: &str) -> Option<CallbackQuery> {
714 let uri: axum::http::Uri = format!("/callback?{query}").parse().unwrap();
715 Query::<CallbackQuery>::try_from_uri(&uri)
716 .ok()
717 .map(|Query(query)| query)
718 }
719
720 #[test]
721 fn callback_query_with_code_and_state_is_success() {
722 assert_eq!(
723 parse_query("code=c&state=s&unknown=x"),
724 Some(CallbackQuery::Success {
725 code: "c".to_string(),
726 state: "s".to_string()
727 })
728 );
729 }
730
731 #[test]
732 fn callback_query_with_error_is_provider_error() {
733 assert_eq!(
734 parse_query("error=access_denied&error_description=User%20cancelled&state=s"),
735 Some(CallbackQuery::ProviderError {
736 error: "access_denied".to_string(),
737 error_description: Some("User cancelled".to_string()),
738 state: Some("s".to_string()),
739 })
740 );
741 assert_eq!(
743 parse_query("error=server_error&code=c"),
744 Some(CallbackQuery::ProviderError {
745 error: "server_error".to_string(),
746 error_description: None,
747 state: None,
748 })
749 );
750 }
751
752 #[test]
753 fn callback_query_without_code_or_state_is_rejected() {
754 assert_eq!(parse_query(""), None);
755 assert_eq!(parse_query("state=s"), None);
756 assert_eq!(parse_query("code=c"), None);
757 }
758
759 #[tokio::test]
762 async fn provider_error_redirects_to_login_page() {
763 let jar = PrivateCookieJar::from_headers(&HeaderMap::new(), Key::from(TEST_KEY));
764
765 let result = callback(
766 jar,
767 Query(parse_query("error=access_denied&state=abc").unwrap()),
768 State(state_with_origin_path("/kellnr")),
769 Extension(None),
770 )
771 .await;
772
773 let Ok((_, redirect)) = result else {
774 panic!("expected a redirect");
775 };
776 assert_eq!(location(redirect), "/kellnr/login?error=access_denied");
777 }
778
779 #[tokio::test]
780 async fn error_callback_redirects_below_origin_path() {
781 let settings = state_with_origin_path("/kellnr").settings;
782
783 let redirect = error_callback(
784 State(settings),
785 Query(ErrorQuery {
786 error: "access_denied".to_string(),
787 error_description: Some("User cancelled".to_string()),
788 }),
789 )
790 .await;
791
792 assert_eq!(location(redirect), "/kellnr/login?error=User%20cancelled");
793 }
794}
795
796#[cfg(test)]
797mod availability_tests {
798 use axum::http::{HeaderMap, header};
799 use axum::response::IntoResponse;
800 use axum_extra::extract::cookie::Key;
801 use kellnr_http::test_support::{TEST_KEY, test_state};
802 use kellnr_settings::OAuth2 as OAuth2Settings;
803
804 use super::*;
805
806 fn empty_jar() -> PrivateCookieJar {
807 PrivateCookieJar::from_headers(&HeaderMap::new(), Key::from(TEST_KEY))
808 }
809
810 fn undiscovered_handler() -> Arc<OAuth2Handler> {
812 let settings = OAuth2Settings {
813 enabled: true,
814 issuer_url: Some("http://127.0.0.1:9".to_string()),
815 client_id: Some("kellnr".to_string()),
816 client_secret: Some("secret".to_string()),
817 ..Default::default()
818 };
819 let handler = OAuth2Handler::new(&settings, "http://kellnr/api/v1/oauth2/callback")
820 .expect("configuration is valid");
821 Arc::new(handler)
822 }
823
824 async fn login_response(handler: Option<Arc<OAuth2Handler>>) -> axum::response::Response {
825 let state = test_state();
826 login(
827 empty_jar(),
828 State(state.db),
829 State(state.settings),
830 Extension(handler),
831 )
832 .await
833 .into_response()
834 }
835
836 #[tokio::test]
837 async fn login_without_oauth2_is_not_found() {
838 let response = login_response(None).await;
839
840 assert_eq!(StatusCode::NOT_FOUND, response.status());
841 }
842
843 #[tokio::test]
844 async fn login_with_undiscovered_provider_is_unavailable() {
845 let response = login_response(Some(undiscovered_handler())).await;
846
847 assert_eq!(StatusCode::SERVICE_UNAVAILABLE, response.status());
848 assert_eq!(
849 PROVIDER_RETRY_AFTER.as_secs().to_string(),
850 response.headers()[header::RETRY_AFTER]
851 );
852 }
853
854 #[tokio::test]
855 async fn config_reports_undiscovered_provider_as_unavailable() {
856 let Json(config) = get_config(
857 State(test_state().settings),
858 Extension(Some(undiscovered_handler())),
859 )
860 .await;
861
862 assert!(!config.available);
863 }
864
865 #[tokio::test]
866 async fn config_reports_disabled_oauth2_as_unavailable() {
867 let Json(config) = get_config(State(test_state().settings), Extension(None)).await;
868
869 assert!(!config.available);
870 }
871}