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;
9
10use axum::extract::{Query, State};
11use axum::http::StatusCode;
12use axum::response::Redirect;
13use axum::{Extension, Json};
14use axum_extra::extract::PrivateCookieJar;
15use kellnr_appstate::{AppState, DbState, SettingsState};
16use kellnr_auth::oauth2::{OAuth2Handler, generate_unique_username};
17use serde::{Deserialize, Serialize};
18use tracing::{error, trace, warn};
19use utoipa::ToSchema;
20
21use crate::error::RouteError;
22use crate::session::create_session_jar;
23
24/// Type alias for the `OAuth2` handler extension
25pub type OAuth2Ext = Extension<Option<Arc<OAuth2Handler>>>;
26
27/// `OAuth2` configuration response for the UI
28#[derive(Debug, Serialize, ToSchema)]
29pub struct OAuth2Config {
30    /// Whether `OAuth2` authentication is enabled
31    pub enabled: bool,
32    /// Text to display on the login button
33    pub button_text: String,
34}
35
36/// Query parameters received in the `OAuth2` callback
37#[derive(Debug, Deserialize, ToSchema, utoipa::IntoParams)]
38pub struct CallbackQuery {
39    /// Authorization code from the provider
40    pub code: String,
41    /// CSRF protection state
42    pub state: String,
43}
44
45/// Get `OAuth2` configuration for the UI
46///
47/// Returns whether `OAuth2` is enabled and the button text to display.
48/// This endpoint is always accessible (no auth required).
49#[utoipa::path(
50    get,
51    path = "/config",
52    tag = "oauth2",
53    responses(
54        (status = 200, description = "OAuth2 configuration", body = OAuth2Config)
55    )
56)]
57#[allow(clippy::unused_async)]
58pub async fn get_config(State(settings): SettingsState) -> Json<OAuth2Config> {
59    Json(OAuth2Config {
60        enabled: settings.oauth2.enabled,
61        button_text: settings.oauth2.button_text.clone(),
62    })
63}
64
65/// Initiate `OAuth2` login flow
66///
67/// This endpoint:
68/// 1. Generates PKCE challenge and state for CSRF protection
69/// 2. Stores state/PKCE/nonce in the database
70/// 3. Redirects the user to the `OAuth2` provider's authorization endpoint
71#[utoipa::path(
72    get,
73    path = "/login",
74    tag = "oauth2",
75    responses(
76        (status = 302, description = "Redirect to OAuth2 provider"),
77        (status = 404, description = "OAuth2 not enabled")
78    )
79)]
80pub async fn login(
81    State(db): DbState,
82    Extension(oauth2_handler): OAuth2Ext,
83) -> Result<Redirect, RouteError> {
84    trace!("OAuth2 login initiated");
85
86    // Check if OAuth2 is enabled
87    let handler = oauth2_handler.as_ref().ok_or_else(|| {
88        warn!("OAuth2 login attempted but OAuth2 is not enabled");
89        RouteError::Status(StatusCode::NOT_FOUND)
90    })?;
91
92    // Generate authorization URL with PKCE
93    let auth_request = handler.generate_auth_url();
94
95    // Store state in database for verification in callback
96    db.store_oauth2_state(
97        &auth_request.state,
98        &auth_request.pkce_verifier,
99        &auth_request.nonce,
100    )
101    .await
102    .map_err(|e| {
103        error!("Failed to store OAuth2 state: {}", e);
104        RouteError::Status(StatusCode::INTERNAL_SERVER_ERROR)
105    })?;
106
107    // Redirect to provider
108    Ok(Redirect::to(auth_request.auth_url.as_str()))
109}
110
111/// Handle `OAuth2` callback from the provider
112///
113/// This endpoint:
114/// 1. Validates the CSRF state
115/// 2. Exchanges the authorization code for tokens
116/// 3. Validates the ID token
117/// 4. Creates or links the user account
118/// 5. Creates a session and sets the session cookie
119/// 6. Redirects to the UI
120#[utoipa::path(
121    get,
122    path = "/callback",
123    tag = "oauth2",
124    params(CallbackQuery),
125    responses(
126        (status = 302, description = "Redirect to UI after successful login"),
127        (status = 400, description = "Invalid state"),
128        (status = 401, description = "Token exchange failed"),
129        (status = 403, description = "User not found and auto-provisioning disabled"),
130        (status = 404, description = "OAuth2 not enabled")
131    )
132)]
133pub async fn callback(
134    cookies: PrivateCookieJar,
135    Query(query): Query<CallbackQuery>,
136    State(app_state): AppState,
137    Extension(oauth2_handler): OAuth2Ext,
138) -> Result<(PrivateCookieJar, Redirect), RouteError> {
139    trace!(state = %query.state, "OAuth2 callback received");
140
141    // Check if OAuth2 is enabled
142    let handler = oauth2_handler.as_ref().ok_or_else(|| {
143        warn!("OAuth2 callback received but OAuth2 is not enabled");
144        RouteError::Status(StatusCode::NOT_FOUND)
145    })?;
146
147    // Retrieve and validate state from database
148    let state_data = app_state
149        .db
150        .get_and_delete_oauth2_state(&query.state)
151        .await
152        .map_err(|e| {
153            error!("Failed to retrieve OAuth2 state: {}", e);
154            RouteError::Status(StatusCode::INTERNAL_SERVER_ERROR)
155        })?
156        .ok_or_else(|| {
157            warn!(
158                "OAuth2 callback with invalid or expired state: {}",
159                query.state
160            );
161            RouteError::Status(StatusCode::BAD_REQUEST)
162        })?;
163
164    // Exchange code for tokens and validate
165    let token_result = handler
166        .exchange_and_validate(&query.code, &state_data.pkce_verifier, &state_data.nonce)
167        .await
168        .map_err(|e| {
169            error!("Failed to exchange OAuth2 code: {}", e);
170            RouteError::Status(StatusCode::UNAUTHORIZED)
171        })?;
172
173    // Extract user information from token
174    let user_info = handler.extract_user_info(&token_result);
175    let issuer = handler.issuer_url();
176
177    trace!(
178        "OAuth2 authentication successful for subject: {}, email: {:?}",
179        user_info.subject, user_info.email
180    );
181
182    // Look up or create user
183    #[allow(clippy::single_match_else)]
184    let user = match app_state
185        .db
186        .get_user_by_oauth2_identity(issuer, &user_info.subject)
187        .await
188        .map_err(|e| {
189            error!("Failed to look up OAuth2 identity: {}", e);
190            RouteError::Status(StatusCode::INTERNAL_SERVER_ERROR)
191        })? {
192        Some(user) => {
193            trace!("Found existing user '{}' for OAuth2 identity", user.name);
194            user
195        }
196        None => {
197            // Check if auto-provisioning is enabled
198            if !handler.settings().auto_provision_users {
199                warn!(
200                    "OAuth2 user not found and auto-provisioning is disabled: {}",
201                    user_info.subject
202                );
203                return Err(RouteError::Status(StatusCode::FORBIDDEN));
204            }
205
206            // Generate unique username
207            let username = generate_unique_username(&user_info, |name| {
208                let db = app_state.db.clone();
209                async move { db.is_username_available(&name).await.unwrap_or(false) }
210            })
211            .await;
212
213            trace!(
214                "Creating new OAuth2 user '{}' (admin: {}, read_only: {})",
215                username, user_info.is_admin, user_info.is_read_only
216            );
217
218            // Create new user with OAuth2 identity
219            app_state
220                .db
221                .create_oauth2_user(
222                    &username,
223                    issuer,
224                    &user_info.subject,
225                    user_info.email.clone(),
226                    user_info.is_admin,
227                    user_info.is_read_only,
228                )
229                .await
230                .map_err(|e| {
231                    error!("Failed to create OAuth2 user: {}", e);
232                    RouteError::Status(StatusCode::INTERNAL_SERVER_ERROR)
233                })?
234        }
235    };
236
237    let jar = create_session_jar(cookies, &app_state, &user.name).await?;
238    trace!("Created session for OAuth2 user: {}", user.name);
239
240    // Redirect to UI root
241    let mut base_path = app_state.settings.origin.path.clone();
242    if !base_path.ends_with('/') {
243        base_path.push('/');
244    }
245    if !base_path.starts_with('/') {
246        base_path.insert(0, '/');
247    }
248    Ok((jar, Redirect::to(&base_path)))
249}
250
251/// Error callback for `OAuth2` flow
252///
253/// Handles errors from the `OAuth2` provider (e.g., user cancelled, access denied)
254#[derive(Debug, Deserialize, ToSchema, utoipa::IntoParams)]
255pub struct ErrorQuery {
256    pub error: String,
257    #[serde(default)]
258    pub error_description: Option<String>,
259}
260
261/// Handle `OAuth2` error callback
262#[utoipa::path(
263    get,
264    path = "/error",
265    tag = "oauth2",
266    params(ErrorQuery),
267    responses(
268        (status = 302, description = "Redirect to login page with error")
269    )
270)]
271#[allow(clippy::unused_async)]
272pub async fn error_callback(Query(query): Query<ErrorQuery>) -> Redirect {
273    warn!(
274        "OAuth2 error callback: {} - {:?}",
275        query.error, query.error_description
276    );
277
278    // Redirect to login page with error parameter
279    let error_msg = query.error_description.as_deref().unwrap_or(&query.error);
280    let encoded_error = urlencoding::encode(error_msg);
281    Redirect::to(&format!("/login?error={encoded_error}"))
282}