Skip to main content

kellnr_web_ui/
oauth2.rs

1//! `OAuth2`/`OpenID` Connect web routes for Kellnr
2//!
3//! Provides the following endpoints:
4//! - `/api/v1/oauth2/config` - GET - Returns `OAuth2` configuration for the UI
5//! - `/api/v1/oauth2/login` - GET - Initiates `OAuth2` flow, redirects to provider
6//! - `/api/v1/oauth2/callback` - GET - Handles callback from provider
7
8use 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
30/// Type alias for the `OAuth2` handler extension
31pub type OAuth2Ext = Extension<Option<Arc<OAuth2Handler>>>;
32
33/// How long a client should wait before it retries a login while the
34/// provider has not been discovered yet.
35const PROVIDER_RETRY_AFTER: Duration = Duration::from_secs(30);
36
37/// `OAuth2` configuration response for the UI
38#[derive(Debug, Serialize, ToSchema)]
39#[allow(clippy::struct_excessive_bools)]
40pub struct OAuth2Config {
41    /// Whether `OAuth2` authentication is enabled
42    pub enabled: bool,
43    /// Whether the OIDC provider has been discovered, so that SSO login is
44    /// possible. `false` while an enabled provider is unreachable.
45    pub available: bool,
46    /// Text to display on the login button
47    pub button_text: String,
48    /// Whether local password login is disabled (SSO-only)
49    pub enforced: bool,
50    /// Whether the login page should redirect straight to the SSO provider
51    pub auto_redirect: bool,
52}
53
54/// Query parameters received in the `OAuth2` callback. The provider sends
55/// either `code` on success or `error` if the login failed or was cancelled.
56#[derive(Debug, Deserialize, ToSchema, utoipa::IntoParams)]
57pub struct CallbackParams {
58    /// Authorization code from the provider
59    pub code: Option<String>,
60    /// CSRF protection state
61    pub state: Option<String>,
62    /// Error code from the provider, e.g. `access_denied`
63    pub error: Option<String>,
64    /// Human readable error from the provider
65    pub error_description: Option<String>,
66}
67
68/// The `OAuth2` callback query, read from [`CallbackParams`]. An `error`
69/// wins over a `code`; a query with neither `error` nor `code` and `state`
70/// is rejected with 400.
71#[derive(Debug, PartialEq, Eq, Deserialize)]
72#[serde(try_from = "CallbackParams")]
73pub enum CallbackQuery {
74    /// The user logged in at the provider.
75    Success { code: String, state: String },
76    /// The login failed or was cancelled at the provider.
77    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/// Get `OAuth2` configuration for the UI
110///
111/// Returns whether `OAuth2` is enabled and the button text to display.
112/// This endpoint is always accessible (no auth required).
113#[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/// Initiate `OAuth2` login flow
138///
139/// This endpoint:
140/// 1. Generates PKCE challenge and state for CSRF protection
141/// 2. Stores state/PKCE/nonce in the database and binds the state to the
142///    browser with a short-lived encrypted cookie
143/// 3. Redirects the user to the `OAuth2` provider's authorization endpoint
144#[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    // Check if OAuth2 is enabled
163    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    // Generate authorization URL with PKCE
169    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    // Store state in database for verification in callback
175    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    // Redirect to provider
192    Ok((jar, Redirect::to(auth_request.auth_url.as_str())))
193}
194
195/// Cookie binding the `OAuth2` flow to the browser that started it. Without
196/// it, an attacker could make a victim's browser complete the attacker's own
197/// login (login CSRF). `SameSite=Lax` because a strict cookie would not be
198/// sent on the redirect back from the identity provider.
199fn 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
210/// Checks that the callback belongs to the login flow started by this
211/// browser and removes the single-use state cookie.
212fn 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/// Handle `OAuth2` callback from the provider
227///
228/// This endpoint:
229/// 1. Validates the CSRF state against the database and the browser cookie
230/// 2. Exchanges the authorization code for tokens
231/// 3. Validates the ID token
232/// 4. Creates or links the user account
233/// 5. Creates a session and sets the session cookie
234/// 6. Redirects to the UI
235#[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        // The login was cancelled or rejected at the provider.
261        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    // Check if OAuth2 is enabled
278    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    // Retrieve and validate state from database
286    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    // Exchange code for tokens and validate
300    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    // Extract user information from token
312    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    // Look up or create user
321    #[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            // Re-sync admin / read-only state from the IdP on every login so
333            // that changes to the user's group membership take effect. Only
334            // flags whose group claim is configured are governed by the IdP;
335            // an unconfigured claim leaves the existing DB value untouched, so
336            // that admins are not silently demoted and manual changes stick.
337            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            // Check if auto-provisioning is enabled
348            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            // Generate unique username
357            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            // Create new user with OAuth2 identity
377            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    // Redirect to UI root
407    let base_path = format!("{}/", app_state.settings.origin.path_prefix());
408    Ok((jar, Redirect::to(&base_path)))
409}
410
411/// Re-sync an existing user's admin / read-only state from the current `IdP`
412/// token claims.
413///
414/// A flag is only updated when its group claim is configured (the `IdP` is then
415/// authoritative for it); an unconfigured claim leaves the existing value
416/// untouched, so unconfigured deployments never demote users and manual admin
417/// changes are preserved. When anything changed, the auth-token cache is
418/// invalidated so existing cargo tokens immediately reflect the new
419/// privileges; session auth reads the DB per request and needs no
420/// invalidation.
421async 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/// Query parameters of the `OAuth2` error callback
474#[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/// Error callback for `OAuth2` flow
482///
483/// Handles errors from the `OAuth2` provider (e.g., user cancelled, access denied)
484#[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
507/// Redirect to the login page of the UI, which shows `error`.
508fn 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    /// App state with the user "alice" stored with the given flags.
544    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    /// Syncs the stored "alice" with `user_info` and returns the synced user
555    /// together with the user as stored afterwards.
556    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        // Claims not governed by the IdP: the differing claim values must be
592        // ignored and the stored user kept as it is.
593        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        // Lax, not Strict: the cookie must survive the redirect back from
642        // the identity provider.
643        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    /// A callback carrying a state the browser never started, for example
663    /// one crafted by an attacker for their own account, must be rejected.
664    #[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        // An error wins over a code
742        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    /// The provider redirects to the callback with `error` instead of `code`
760    /// when the user cancels the login.
761    #[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    /// A handler for a provider that has not been discovered.
811    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}