1use 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
24pub type OAuth2Ext = Extension<Option<Arc<OAuth2Handler>>>;
26
27#[derive(Debug, Serialize, ToSchema)]
29pub struct OAuth2Config {
30 pub enabled: bool,
32 pub button_text: String,
34}
35
36#[derive(Debug, Deserialize, ToSchema, utoipa::IntoParams)]
38pub struct CallbackQuery {
39 pub code: String,
41 pub state: String,
43}
44
45#[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#[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 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 let auth_request = handler.generate_auth_url();
94
95 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 Ok(Redirect::to(auth_request.auth_url.as_str()))
109}
110
111#[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 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 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 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 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 #[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 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 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 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 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#[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#[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 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}