1use std::sync::Arc;
16use std::time::Duration as StdDuration;
17
18use axum::{
19 body::Body,
20 extract::{Query, State},
21 http::{header, StatusCode},
22 response::{IntoResponse, Json, Redirect, Response},
23};
24use axum_extra::extract::cookie::{Cookie, SameSite};
25use pep::oidc_client::OidcClient;
26use pep::oidc_resource_server::ResourceServerClient;
27use pep::oidc::pkce_cookie::PkceCookieManager;
28use pep::session_manager::WebSessionManager;
29use pep::{DevConfig, JwtClaims, JwtValidationOptions, OidcClientConfig};
30use serde::Deserialize;
31use time::Duration as TimeDuration;
32
33#[derive(Debug, Clone)]
39pub struct AuthConfig {
40 pub issuer_url: String,
42 pub client_id: String,
44 pub client_secret: Option<String>,
46 pub redirect_uri: String,
48 pub scope: String,
50 pub cookie_name: String,
52 pub dev_config: DevConfig,
54 pub validation_options: JwtValidationOptions,
56 pub pkce_cookie_secret: String,
58}
59
60impl AuthConfig {
61 pub fn from_toml(config_toml: &str) -> Option<Self> {
66 let table: toml::Table = toml::from_str(config_toml).ok()?;
67
68 let dev_config = table.get("dev").and_then(|d| d.as_table()).map(|d| {
70 DevConfig {
71 local_dev_mode: d.get("local_dev_mode").and_then(|v| v.as_bool()).unwrap_or(false),
72 local_dev_email: d.get("local_dev_email").and_then(|v| v.as_str()).map(String::from),
73 local_dev_name: d.get("local_dev_name").and_then(|v| v.as_str()).map(String::from),
74 local_dev_username: d.get("local_dev_username").and_then(|v| v.as_str()).map(String::from),
75 }
76 });
77
78 if let Some(ref dc) = dev_config {
80 if dc.local_dev_mode {
81 let oidc = Self::parse_oidc_section(&table);
83 return Some(Self {
84 issuer_url: oidc.as_ref().map(|o| o.0.clone()).unwrap_or_else(|| "https://auth.example.com".into()),
85 client_id: oidc.as_ref().map(|o| o.1.clone()).unwrap_or_else(|| "trustee".into()),
86 client_secret: oidc.as_ref().and_then(|o| o.2.clone()),
87 redirect_uri: oidc.as_ref().map(|o| o.3.clone()).unwrap_or_else(|| "http://localhost:3000/auth/callback".into()),
88 scope: oidc.as_ref().map(|o| o.4.clone()).unwrap_or_else(|| "openid profile email".into()),
89 cookie_name: "trustee_token".into(),
90 dev_config: dc.clone(),
91 validation_options: JwtValidationOptions::default(),
92 pkce_cookie_secret: oidc.as_ref().map(|o| o.6.clone()).unwrap_or_else(|| "trustee-default-pkce-secret-change-me".into()),
93 });
94 }
95 }
96
97 let (issuer_url, client_id, client_secret, redirect_uri, scope, validation_options, pkce_secret) =
99 Self::parse_oidc_section(&table)?;
100
101 Some(Self {
102 issuer_url,
103 client_id,
104 client_secret,
105 redirect_uri,
106 scope,
107 cookie_name: "trustee_token".into(),
108 dev_config: dev_config.unwrap_or_default(),
109 validation_options,
110 pkce_cookie_secret: pkce_secret,
111 })
112 }
113
114 fn parse_oidc_section(
117 table: &toml::Table,
118 ) -> Option<(String, String, Option<String>, String, String, JwtValidationOptions, String)> {
119 let oidc = table.get("oidc")?.as_table()?;
120
121 let issuer_url = oidc.get("issuer_url")?.as_str()?.to_string();
122 let client_id = oidc.get("client_id")?.as_str()?.to_string();
123 let client_secret = oidc.get("client_secret").and_then(|v| v.as_str()).map(String::from);
124 let redirect_uri = oidc
125 .get("redirect_url")
126 .and_then(|v| v.as_str())
127 .unwrap_or("http://localhost:3000/auth/callback")
128 .to_string();
129 let scope = oidc
130 .get("scope")
131 .and_then(|v| v.as_str())
132 .unwrap_or("openid profile email")
133 .to_string();
134
135 let mut validation_options = JwtValidationOptions::default();
136 if let Some(skip) = oidc.get("skip_issuer_validation").and_then(|v| v.as_bool()) {
137 validation_options.skip_issuer_validation = skip;
138 }
139 if let Some(skip) = oidc.get("skip_audience_validation").and_then(|v| v.as_bool()) {
140 validation_options.skip_audience_validation = skip;
141 }
142 validation_options.expected_audience = oidc
143 .get("expected_audience")
144 .and_then(|v| v.as_str())
145 .map(String::from);
146
147 let pkce_secret = oidc
148 .get("pkce_cookie_secret")
149 .and_then(|v| v.as_str())
150 .unwrap_or("trustee-default-pkce-secret-change-me")
151 .to_string();
152
153 Some((issuer_url, client_id, client_secret, redirect_uri, scope, validation_options, pkce_secret))
154 }
155
156 pub fn oidc_client_config(&self) -> OidcClientConfig {
158 OidcClientConfig {
159 issuer_url: self.issuer_url.clone(),
160 client_id: self.client_id.clone(),
161 client_secret: self.client_secret.clone(),
162 redirect_uri: self.redirect_uri.clone(),
163 scope: self.scope.clone(),
164 code_challenge_method: "S256".to_string(),
165 }
166 }
167}
168
169#[derive(Clone)]
171pub struct AuthState {
172 pub oidc_client: OidcClient,
174 pub resource_server: ResourceServerClient,
176 pub client_config: OidcClientConfig,
178 pub config: AuthConfig,
180 pub pkce_manager: PkceCookieManager,
182 pub session_manager: Arc<WebSessionManager>,
184}
185
186impl AuthState {
187 pub fn new(config: AuthConfig) -> Self {
189 let pkce_manager = PkceCookieManager::new(
190 config.pkce_cookie_secret.as_bytes(),
191 "trustee_pkce_state",
192 StdDuration::from_secs(600),
193 );
194
195 let session_manager = Arc::new(WebSessionManager::new(
196 OidcClient::new(),
197 config.issuer_url.clone(),
198 config.client_id.clone(),
199 config.client_secret.clone(),
200 config.scope.clone(),
201 ));
202
203 Self {
204 oidc_client: OidcClient::new(),
205 resource_server: ResourceServerClient::new(),
206 client_config: config.oidc_client_config(),
207 pkce_manager,
208 session_manager,
209 config,
210 }
211 }
212
213 pub fn is_dev_mode(&self) -> bool {
215 self.config.dev_config.local_dev_mode
216 }
217
218 pub async fn validate_token(&self, token: &str) -> anyhow::Result<JwtClaims> {
220 let mut claims = self
221 .resource_server
222 .validate_jwt_with_options(
223 token,
224 &self.config.issuer_url,
225 &self.config.client_id,
226 &self.config.validation_options,
227 )
228 .await
229 .map_err(|e| anyhow::anyhow!("Token validation failed: {}", e))?;
230
231 let _ = self
233 .resource_server
234 .enrich_claims_with_userinfo(&mut claims, token, &self.config.issuer_url, None)
235 .await;
236
237 if claims.name.is_none() || claims.email.is_none() {
240 self.fill_userinfo_fields(&mut claims, token).await;
241 }
242
243 Ok(claims)
244 }
245
246 async fn fill_userinfo_fields(&self, claims: &mut JwtClaims, token: &str) {
249 let userinfo_url = format!("{}/userinfo", self.config.issuer_url.trim_end_matches('/'));
253
254 let client = reqwest::Client::new();
255 let resp = client
256 .get(&userinfo_url)
257 .header("Authorization", format!("Bearer {}", token))
258 .header("Accept", "application/json")
259 .send()
260 .await;
261
262 let Ok(resp) = resp else {
263 tracing::debug!("Userinfo request failed for name/email enrichment");
264 return;
265 };
266
267 if !resp.status().is_success() {
268 tracing::debug!("Userinfo returned {} for name/email enrichment", resp.status());
269 return;
270 }
271
272 let Ok(userinfo): Result<serde_json::Map<String, serde_json::Value>, _> = resp.json().await else {
273 return;
274 };
275
276 tracing::debug!("Userinfo keys: {:?}", userinfo.keys().collect::<Vec<_>>());
277
278 if claims.name.is_none() {
279 if let Some(name) = userinfo.get("name").and_then(|v| v.as_str()) {
280 claims.name = Some(name.to_string());
281 }
282 }
283 if claims.email.is_none() {
284 if let Some(email) = userinfo.get("email").and_then(|v| v.as_str()) {
285 claims.email = Some(email.to_string());
286 }
287 }
288 if claims.preferred_username.is_none() {
289 if let Some(uname) = userinfo.get("preferred_username").and_then(|v| v.as_str()) {
290 claims.preferred_username = Some(uname.to_string());
291 }
292 }
293 }
294}
295
296#[derive(Debug, Clone)]
302pub struct AuthUser {
303 pub sub: String,
304 pub email: Option<String>,
305 pub name: Option<String>,
306 pub username: Option<String>,
307 pub is_dev: bool,
308}
309
310impl From<JwtClaims> for AuthUser {
311 fn from(claims: JwtClaims) -> Self {
312 Self {
313 sub: claims.sub,
314 email: claims.email,
315 name: claims.name,
316 username: claims.preferred_username,
317 is_dev: false,
318 }
319 }
320}
321
322const SESSION_COOKIE_MAX_AGE: StdDuration = StdDuration::from_secs(3600);
324
325pub async fn check_auth(
340 auth: &Option<Arc<AuthState>>,
341 headers: &axum::http::HeaderMap,
342) -> Result<Option<String>, StatusCode> {
343 let Some(auth) = auth.as_ref() else {
344 return Ok(None); };
346
347 if let Some(token) = headers
349 .get(header::AUTHORIZATION)
350 .and_then(|v| v.to_str().ok())
351 .and_then(|v| v.strip_prefix("Bearer "))
352 .map(|s| s.to_string())
353 {
354 if token.starts_with("dev:") {
356 if !auth.config.dev_config.local_dev_mode {
357 tracing::warn!("Dev token presented but dev mode is disabled — rejecting");
358 return Err(StatusCode::UNAUTHORIZED);
359 }
360 let parts: Vec<&str> = token.splitn(4, ':').collect();
361 return if parts.len() >= 4 {
362 Ok(None)
363 } else {
364 Err(StatusCode::UNAUTHORIZED)
365 };
366 }
367
368 return match auth.validate_token(&token).await {
369 Ok(_) => Ok(None),
370 Err(e) => {
371 tracing::warn!("Bearer token validation failed: {}", e);
372 Err(StatusCode::UNAUTHORIZED)
373 }
374 };
375 }
376
377 let session_id = headers
379 .get(header::COOKIE)
380 .and_then(|v| v.to_str().ok())
381 .and_then(|cookies| extract_token_from_cookies(cookies, &auth.config.cookie_name));
382
383 let Some(session_id) = session_id else {
384 tracing::warn!("No auth token found in request");
385 return Err(StatusCode::UNAUTHORIZED);
386 };
387
388 if session_id.starts_with("dev:") {
390 if !auth.config.dev_config.local_dev_mode {
391 tracing::warn!("Dev cookie presented but dev mode is disabled — rejecting");
392 return Err(StatusCode::UNAUTHORIZED);
393 }
394 let parts: Vec<&str> = session_id.splitn(4, ':').collect();
395 return if parts.len() >= 4 {
396 Ok(None)
397 } else {
398 Err(StatusCode::UNAUTHORIZED)
399 };
400 }
401
402 match auth.session_manager.get_token(&session_id).await {
404 Ok(access_token) => match auth.validate_token(&access_token).await {
405 Ok(_) => {
406 let secure = auth.client_config.redirect_uri.starts_with("https");
408 let cookie = create_auth_cookie(
409 &auth.config.cookie_name,
410 &session_id,
411 SESSION_COOKIE_MAX_AGE,
412 secure,
413 );
414 Ok(Some(cookie.to_string()))
415 }
416 Err(e) => {
417 tracing::warn!("Session token validation failed: {} — attempting force-refresh", e);
420 match auth.session_manager.force_refresh(&session_id).await {
421 Ok(new_token) => match auth.validate_token(&new_token).await {
422 Ok(_) => {
423 let secure = auth.client_config.redirect_uri.starts_with("https");
424 let cookie = create_auth_cookie(
425 &auth.config.cookie_name,
426 &session_id,
427 SESSION_COOKIE_MAX_AGE,
428 secure,
429 );
430 Ok(Some(cookie.to_string()))
431 }
432 Err(e2) => {
433 tracing::warn!("Session token still invalid after force-refresh: {}", e2);
434 Err(StatusCode::UNAUTHORIZED)
435 }
436 },
437 Err(e2) => {
438 tracing::warn!("Force-refresh failed: {}", e2);
439 Err(StatusCode::UNAUTHORIZED)
440 }
441 }
442 }
443 },
444 Err(e) => {
445 tracing::warn!("Session lookup/refresh failed: {}", e);
446 Err(StatusCode::UNAUTHORIZED)
447 }
448 }
449}
450
451async fn resolve_access_token(
457 auth: &AuthState,
458 headers: &axum::http::HeaderMap,
459) -> Result<String, StatusCode> {
460 if let Some(token) = headers
462 .get(header::AUTHORIZATION)
463 .and_then(|v| v.to_str().ok())
464 .and_then(|v| v.strip_prefix("Bearer "))
465 .map(|s| s.to_string())
466 {
467 return Ok(token);
468 }
469
470 let session_id = headers
472 .get(header::COOKIE)
473 .and_then(|v| v.to_str().ok())
474 .and_then(|cookies| extract_token_from_cookies(cookies, &auth.config.cookie_name));
475
476 match session_id {
477 Some(sid) if sid.starts_with("dev:") => {
478 if !auth.config.dev_config.local_dev_mode {
479 tracing::warn!("Dev cookie in resolve_access_token but dev mode is disabled — rejecting");
480 Err(StatusCode::UNAUTHORIZED)
481 } else {
482 Ok(sid)
483 }
484 }
485 Some(sid) => auth.session_manager.get_token(&sid).await.map_err(|e| {
486 tracing::warn!("Failed to resolve session token: {}", e);
487 StatusCode::UNAUTHORIZED
488 }),
489 None => Err(StatusCode::UNAUTHORIZED),
490 }
491}
492
493fn extract_token_from_cookies(cookie_header: &str, cookie_name: &str) -> Option<String> {
495 for cookie in cookie_header.split(';') {
496 let cookie = cookie.trim();
497 if let Some(value) = cookie.strip_prefix(&format!("{}=", cookie_name)) {
498 return Some(value.to_string());
499 }
500 }
501 None
502}
503
504pub fn auth_routes() -> axum::Router<crate::ServerState> {
510 axum::Router::new()
511 .route("/login", axum::routing::get(login_handler))
512 .route("/callback", axum::routing::get(callback_handler))
513 .route("/me", axum::routing::get(me_handler))
514 .route("/logout", axum::routing::post(logout_handler))
515 .route("/mcp/login", axum::routing::get(mcp_login_handler))
516 .route("/mcp/callback", axum::routing::get(mcp_callback_handler))
517 .route("/mcp/status", axum::routing::get(mcp_status_handler))
518 .route("/mcp/logout", axum::routing::post(mcp_logout_handler))
519}
520
521#[derive(Debug, Deserialize)]
523pub struct CallbackQuery {
524 pub code: Option<String>,
525 pub state: Option<String>,
526 pub error: Option<String>,
527 pub error_description: Option<String>,
528}
529
530async fn login_handler(
532 State(state): State<crate::ServerState>,
533) -> Result<Response, AuthError> {
534 let auth = state.auth.as_ref().ok_or(AuthError::AuthNotConfigured)?;
535
536 if auth.is_dev_mode() {
538 tracing::info!("Dev mode: creating dev session");
539 let dev = &auth.config.dev_config;
540 let dev_token = format!(
541 "dev:{}:{}:{}",
542 dev.local_dev_email.as_deref().unwrap_or("dev@localhost"),
543 dev.local_dev_name.as_deref().unwrap_or("Dev User"),
544 dev.local_dev_username.as_deref().unwrap_or("dev")
545 );
546 let cookie = create_auth_cookie(&auth.config.cookie_name, &dev_token, StdDuration::from_secs(86400), false);
547 return Ok(Response::builder()
548 .status(StatusCode::FOUND)
549 .header(header::LOCATION, "/")
550 .header(header::SET_COOKIE, cookie.to_string())
551 .body(Body::empty())
552 .unwrap());
553 }
554
555 let pkce_session = auth.pkce_manager.create();
557 let challenge = OidcClient::generate_code_challenge(&pkce_session.verifier);
558
559 let auth_url = auth
560 .oidc_client
561 .build_authorization_url(&auth.client_config, &pkce_session.state, Some(&challenge))
562 .await
563 .map_err(|e| AuthError::OidcError(e.to_string()))?;
564
565 let secure = auth.client_config.redirect_uri.starts_with("https");
569 let pkce_cookie = Cookie::build((
570 auth.pkce_manager.cookie_name().to_string(),
571 pkce_session.cookie_value,
572 ))
573 .path("/")
574 .http_only(true)
575 .same_site(SameSite::Lax)
576 .secure(secure)
577 .max_age(TimeDuration::seconds(auth.pkce_manager.ttl().as_secs() as i64))
578 .build();
579
580 Ok(Response::builder()
581 .status(StatusCode::TEMPORARY_REDIRECT)
582 .header(header::LOCATION, &auth_url)
583 .header(header::SET_COOKIE, pkce_cookie.to_string())
584 .body(Body::empty())
585 .unwrap())
586}
587
588async fn callback_handler(
590 State(state): State<crate::ServerState>,
591 Query(query): Query<CallbackQuery>,
592 headers: axum::http::HeaderMap,
593) -> Result<Response, AuthError> {
594 let auth = state.auth.as_ref().ok_or(AuthError::AuthNotConfigured)?;
595
596 if let Some(error) = query.error {
598 let desc = query.error_description.unwrap_or_default();
599 tracing::error!("OIDC error: {} - {}", error, desc);
600 return Ok(Redirect::temporary(&format!(
601 "/?error={}&error_description={}",
602 urlencoding::encode(&error),
603 urlencoding::encode(&desc)
604 ))
605 .into_response());
606 }
607
608 let code = query.code.ok_or(AuthError::MissingCode)?;
609 let oauth_state = query.state.ok_or(AuthError::MissingState)?;
610
611 let cookie_header = headers
613 .get(header::COOKIE)
614 .and_then(|v| v.to_str().ok())
615 .unwrap_or("");
616 let pkce_value = extract_token_from_cookies(cookie_header, auth.pkce_manager.cookie_name())
617 .ok_or(AuthError::InvalidState)?;
618
619 let verifier = auth
621 .pkce_manager
622 .verify(&pkce_value, &oauth_state)
623 .ok_or(AuthError::InvalidState)?;
624
625 tracing::info!("Exchanging authorization code for tokens");
627 let token_response = auth
628 .oidc_client
629 .exchange_code_for_tokens(&auth.client_config, &code, Some(&verifier))
630 .await
631 .map_err(|e| AuthError::TokenExchangeFailed(e.to_string()))?;
632
633 let session_id = auth
634 .session_manager
635 .create_session(&token_response)
636 .await
637 .map_err(|e| AuthError::TokenExchangeFailed(format!("Session creation failed: {}", e)))?;
638
639 let max_age = SESSION_COOKIE_MAX_AGE;
642
643 let secure = auth.client_config.redirect_uri.starts_with("https");
645 let cookie = create_auth_cookie(&auth.config.cookie_name, &session_id, max_age, secure);
646
647 let clear_pkce = Cookie::build((auth.pkce_manager.cookie_name().to_string(), ""))
649 .path("/")
650 .http_only(true)
651 .same_site(SameSite::Lax)
652 .max_age(TimeDuration::seconds(-1))
653 .build();
654
655 tracing::info!("Authentication successful, redirecting to /");
656
657 Ok(Response::builder()
658 .status(StatusCode::FOUND)
659 .header(header::LOCATION, "/")
660 .header(header::SET_COOKIE, cookie.to_string())
661 .header(header::SET_COOKIE, clear_pkce.to_string())
662 .body(Body::empty())
663 .unwrap())
664}
665
666async fn me_handler(
668 State(state): State<crate::ServerState>,
669 headers: axum::http::HeaderMap,
670) -> Response {
671 let Some(ref auth) = state.auth else {
672 return axum::Json(serde_json::json!({
674 "authenticated": true,
675 "auth_enabled": false
676 }))
677 .into_response();
678 };
679
680 let cookie_header = headers
681 .get(header::COOKIE)
682 .and_then(|v| v.to_str().ok())
683 .unwrap_or("");
684
685 let bearer = headers
687 .get(header::AUTHORIZATION)
688 .and_then(|v| v.to_str().ok())
689 .and_then(|v| v.strip_prefix("Bearer "))
690 .map(String::from);
691
692 let token = bearer.clone().or_else(|| extract_token_from_cookies(cookie_header, &auth.config.cookie_name));
693
694 let Some(cookie_value) = token else {
695 return axum::Json(serde_json::json!({
696 "authenticated": false,
697 "auth_enabled": true
698 }))
699 .into_response();
700 };
701
702 if cookie_value.starts_with("dev:") && auth.config.dev_config.local_dev_mode {
705 let parts: Vec<&str> = cookie_value.splitn(4, ':').collect();
706 if parts.len() >= 4 {
707 return axum::Json(serde_json::json!({
708 "authenticated": true,
709 "auth_enabled": true,
710 "email": parts[1],
711 "name": parts[2],
712 "username": parts[3],
713 "dev_mode": true
714 }))
715 .into_response();
716 }
717 }
718
719 let access_token = if bearer.is_some() {
721 cookie_value
723 } else {
724 match auth.session_manager.get_token(&cookie_value).await {
726 Ok(token) => token,
727 Err(e) => {
728 tracing::debug!("Session token resolution failed for /auth/me: {}", e);
729 return axum::Json(serde_json::json!({
730 "authenticated": false,
731 "auth_enabled": true
732 }))
733 .into_response();
734 }
735 }
736 };
737
738 match auth.validate_token(&access_token).await {
740 Ok(claims) => axum::Json(serde_json::json!({
741 "authenticated": true,
742 "auth_enabled": true,
743 "sub": claims.sub,
744 "email": claims.email,
745 "name": claims.name,
746 "username": claims.preferred_username,
747 "dev_mode": false
748 }))
749 .into_response(),
750 Err(e) => {
751 tracing::debug!("Token validation failed for /auth/me: {}", e);
752 axum::Json(serde_json::json!({
753 "authenticated": false,
754 "auth_enabled": true
755 }))
756 .into_response()
757 }
758 }
759}
760
761async fn logout_handler(
763 State(state): State<crate::ServerState>,
764 headers: axum::http::HeaderMap,
765) -> Response {
766 let cookie_name = state
767 .auth
768 .as_ref()
769 .map(|a| a.config.cookie_name.as_str())
770 .unwrap_or("trustee_token");
771
772 if let Some(ref auth) = state.auth {
774 if let Some(cookie_header) = headers.get(header::COOKIE).and_then(|v| v.to_str().ok()) {
775 if let Some(session_id) = extract_token_from_cookies(cookie_header, cookie_name) {
776 if !session_id.starts_with("dev:") {
777 let _ = auth.session_manager.destroy_session(&session_id);
778 }
779 }
780 }
781 }
782
783 let cookie = Cookie::build((cookie_name.to_string(), ""))
784 .path("/")
785 .http_only(true)
786 .same_site(SameSite::Lax)
787 .max_age(TimeDuration::seconds(-1))
788 .build();
789
790 Response::builder()
791 .status(StatusCode::FOUND)
792 .header(header::LOCATION, "/")
793 .header(header::SET_COOKIE, cookie.to_string())
794 .body(Body::empty())
795 .unwrap()
796}
797
798#[derive(Debug, Deserialize)]
804pub struct McpLoginQuery {
805 pub cred: String,
806}
807
808#[derive(Debug, Deserialize)]
810pub struct McpCallbackQuery {
811 pub code: Option<String>,
812 pub state: Option<String>,
813 pub error: Option<String>,
814 pub error_description: Option<String>,
815}
816
817async fn mcp_login_handler(
822 State(state): State<crate::ServerState>,
823 Query(query): Query<McpLoginQuery>,
824 headers: axum::http::HeaderMap,
825) -> Result<Response, AuthError> {
826 crate::auth::check_auth(&state.auth, &headers)
828 .await
829 .map_err(|_| AuthError::AuthNotConfigured)?;
830
831 let auth = state.auth.as_ref().ok_or(AuthError::AuthNotConfigured)?;
832
833 let cred_config = load_mcp_credential(&state, &query.cred).await?;
835
836 let (issuer_url, client_id, client_secret, scope) = match &cred_config {
837 McpCredentialInfo::WebInteractive {
838 issuer_url,
839 client_id,
840 client_secret,
841 scope,
842 } => (issuer_url.clone(), client_id.clone(), client_secret.clone(), scope.clone()),
843 _ => {
844 return Ok(Redirect::temporary(&format!(
845 "/?mcp_error={}",
846 urlencoding::encode(&format!("Credential '{}' is not web-interactive type", query.cred))
847 ))
848 .into_response());
849 }
850 };
851
852 let oidc_client = OidcClient::new();
854 let verifier = OidcClient::generate_code_verifier();
855 let challenge = OidcClient::generate_code_challenge(&verifier);
856 let oauth_state = OidcClient::generate_state();
857
858 let mcp_redirect_uri = format!(
860 "{}/auth/mcp/callback",
861 auth.client_config.redirect_uri.trim_end_matches('/').trim_end_matches("/auth/callback")
862 );
863
864 let mcp_client_config = OidcClientConfig {
865 issuer_url: issuer_url.clone(),
866 client_id: client_id.clone(),
867 client_secret: client_secret.clone(),
868 redirect_uri: mcp_redirect_uri.clone(),
869 scope: scope.clone(),
870 code_challenge_method: "S256".to_string(),
871 };
872
873 let auth_url = oidc_client
875 .build_authorization_url(&mcp_client_config, &oauth_state, Some(&challenge))
876 .await
877 .map_err(|e| AuthError::OidcError(e.to_string()))?;
878
879 mcp_pkce().insert(oauth_state.clone(), verifier.clone(), query.cred.clone()).await;
881
882 tracing::info!(
883 "Initiating MCP browser login for credential '{}' (issuer={})",
884 query.cred, issuer_url
885 );
886
887 Ok(Response::builder()
888 .status(StatusCode::TEMPORARY_REDIRECT)
889 .header(header::LOCATION, &auth_url)
890 .body(Body::empty())
891 .unwrap())
892}
893
894async fn mcp_callback_handler(
896 State(state): State<crate::ServerState>,
897 Query(query): Query<McpCallbackQuery>,
898 headers: axum::http::HeaderMap,
899) -> Result<Response, AuthError> {
900 let auth = state.auth.as_ref().ok_or(AuthError::AuthNotConfigured)?;
901
902 if let Some(error) = query.error {
904 let desc = query.error_description.unwrap_or_default();
905 tracing::error!("MCP OIDC error: {} - {}", error, desc);
906 return Ok(Redirect::temporary(&format!(
907 "/?mcp_error={}&error_description={}",
908 urlencoding::encode(&error),
909 urlencoding::encode(&desc)
910 ))
911 .into_response());
912 }
913
914 let code = query.code.ok_or(AuthError::MissingCode)?;
915 let oauth_state = query.state.ok_or(AuthError::MissingState)?;
916
917 let pkce_data = mcp_pkce().take(&oauth_state).await
919 .ok_or(AuthError::InvalidState)?;
920
921 let verifier = pkce_data.verifier;
922 let cred_name = &pkce_data.cred_name;
923
924 let cred_config = load_mcp_credential(&state, cred_name).await?;
926
927 let (issuer_url, client_id, client_secret, scope) = match &cred_config {
928 McpCredentialInfo::WebInteractive {
929 issuer_url,
930 client_id,
931 client_secret,
932 scope,
933 } => (issuer_url.clone(), client_id.clone(), client_secret.clone(), scope.clone()),
934 _ => {
935 return Ok(Redirect::temporary(&format!(
936 "/?mcp_error={}",
937 urlencoding::encode("Credential is not web-interactive type")
938 ))
939 .into_response());
940 }
941 };
942
943 let mcp_redirect_uri = format!(
945 "{}/auth/mcp/callback",
946 auth.client_config.redirect_uri.trim_end_matches('/').trim_end_matches("/auth/callback")
947 );
948
949 let mcp_client_config = OidcClientConfig {
950 issuer_url: issuer_url.clone(),
951 client_id: client_id.clone(),
952 client_secret: client_secret.clone(),
953 redirect_uri: mcp_redirect_uri,
954 scope: scope.clone(),
955 code_challenge_method: "S256".to_string(),
956 };
957
958 tracing::info!("Exchanging MCP authorization code for tokens (credential={})", cred_name);
960 let oidc_client = OidcClient::new();
961 let token_response = oidc_client
962 .exchange_code_for_tokens(&mcp_client_config, &code, Some(&verifier))
963 .await
964 .map_err(|e| AuthError::TokenExchangeFailed(e.to_string()))?;
965
966 let expires_at = {
968 let now = std::time::SystemTime::now()
969 .duration_since(std::time::UNIX_EPOCH)
970 .unwrap_or_default()
971 .as_secs();
972 let expires_epoch = now + token_response.expires_in.unwrap_or(900);
973 let days = expires_epoch / 86400;
974 let rem = expires_epoch % 86400;
975 let h = rem / 3600;
976 let m = (rem % 3600) / 60;
977 let s = rem % 60;
978 let z = days as i64 + 719468;
979 let era = if z >= 0 { z } else { z - 146096 } / 146097;
980 let doe = (z - era * 146097) as u64;
981 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
982 let y = yoe as i64 + era * 400;
983 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
984 let mp = (5 * doy + 2) / 153;
985 let d = doy - (153 * mp + 2) / 5 + 1;
986 let mon = if mp < 10 { mp + 3 } else { mp - 9 };
987 let yr = if mon <= 2 { y + 1 } else { y };
988 format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", yr, mon, d, h, m, s)
989 };
990
991 use pep::{FileTokenStore, StoredToken, TokenStore};
993
994 let stored = StoredToken::new(
995 &token_response.access_token,
996 token_response.refresh_token.clone(),
997 "Bearer",
998 &expires_at,
999 token_response.scope.clone(),
1000 );
1001
1002 let agent_name = {
1003 let user_key = state.resolve_user_key(&headers).await;
1004 let (_sid, session_arc, _, _) = state.ensure_active_session(&user_key).await;
1005 let session = session_arc.lock().await;
1006 session.agent_name.clone()
1007 };
1008 let token_store = FileTokenStore::new(&agent_name);
1009
1010 if let Err(e) = token_store.save(cred_name, &stored) {
1011 tracing::error!("Failed to store MCP token: {}", e);
1012 return Ok(Redirect::temporary(&format!(
1013 "/?mcp_error={}",
1014 urlencoding::encode(&format!("Failed to store token: {}", e))
1015 ))
1016 .into_response());
1017 }
1018
1019 tracing::info!(
1020 "MCP authentication successful for credential '{}' (expires {})",
1021 cred_name, expires_at
1022 );
1023
1024 Ok(Response::builder()
1025 .status(StatusCode::FOUND)
1026 .header(header::LOCATION, format!("/?mcp_connected={}", urlencoding::encode(cred_name)))
1027 .body(Body::empty())
1028 .unwrap())
1029}
1030
1031async fn mcp_status_handler(
1033 State(state): State<crate::ServerState>,
1034 headers: axum::http::HeaderMap,
1035) -> Response {
1036 use pep::{FileTokenStore, TokenStore};
1037
1038 if let Err(code) = crate::auth::check_auth(&state.auth, &headers).await {
1040 return (code, Json(serde_json::json!({"error": "Unauthorized"}))).into_response();
1041 }
1042
1043 let config_toml = {
1045 let user_key = state.resolve_user_key(&headers).await;
1046 let (_sid, session_arc, _, _) = state.ensure_active_session(&user_key).await;
1047 let session = session_arc.lock().await;
1048 match &session.config_toml {
1049 Some(t) => t.clone(),
1050 None => return (StatusCode::INTERNAL_SERVER_ERROR, "Config not loaded").into_response(),
1051 }
1052 };
1053
1054 let mcp_config: toml::Value = match toml::from_str(&config_toml) {
1055 Ok(v) => v,
1056 Err(_) => return Json(serde_json::json!([])).into_response(),
1057 };
1058
1059 let agent_name = {
1060 let user_key = state.resolve_user_key(&headers).await;
1061 let (_sid, session_arc, _, _) = state.ensure_active_session(&user_key).await;
1062 let session = session_arc.lock().await;
1063 session.agent_name.clone()
1064 };
1065 let token_store = FileTokenStore::new(&agent_name);
1066
1067 let servers = mcp_config
1069 .get("mcp")
1070 .and_then(|m| m.get("servers"))
1071 .and_then(|s| s.as_array());
1072 let credentials = mcp_config
1073 .get("mcp")
1074 .and_then(|m| m.get("credentials"))
1075 .and_then(|c| c.as_table());
1076
1077 let mut cred_servers: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();
1078 if let Some(servers) = servers {
1079 for server in servers {
1080 let name = server.get("name").and_then(|n| n.as_str()).unwrap_or("");
1081 let cred_ref = server.get("credentials").and_then(|c| c.as_str()).unwrap_or("");
1082 if !cred_ref.is_empty() {
1083 cred_servers
1084 .entry(cred_ref.to_string())
1085 .or_default()
1086 .push(name.to_string());
1087 }
1088 }
1089 }
1090
1091 let mut result = Vec::new();
1092
1093 if let Some(creds) = credentials {
1094 for (cred_name, cred_config) in creds {
1095 let cred_type = cred_config.get("type").and_then(|t| t.as_str()).unwrap_or("unknown");
1096 let servers_using = cred_servers.get(cred_name).cloned().unwrap_or_default();
1097
1098 if cred_type == "web-session" {
1099 let connected = state.auth.is_some();
1101 result.push(serde_json::json!({
1102 "credential": cred_name,
1103 "type": cred_type,
1104 "connected": connected,
1105 "servers": servers_using,
1106 }));
1107 } else if cred_type == "web-interactive" || cred_type == "interactive" {
1108 let status = match token_store.load(cred_name) {
1110 Ok(Some(token)) => {
1111 let expired = token.is_expired();
1112 serde_json::json!({
1113 "credential": cred_name,
1114 "type": cred_type,
1115 "connected": !expired,
1116 "expires_at": token.expires_at,
1117 "servers": servers_using,
1118 })
1119 }
1120 _ => serde_json::json!({
1121 "credential": cred_name,
1122 "type": cred_type,
1123 "connected": false,
1124 "servers": servers_using,
1125 }),
1126 };
1127 result.push(status);
1128 }
1129 }
1130 }
1131
1132 Json(serde_json::Value::Array(result)).into_response()
1133}
1134
1135async fn mcp_logout_handler(
1137 State(state): State<crate::ServerState>,
1138 Query(query): Query<McpLoginQuery>,
1139 headers: axum::http::HeaderMap,
1140) -> Response {
1141 use pep::{FileTokenStore, TokenStore};
1142
1143 if let Err(code) = crate::auth::check_auth(&state.auth, &headers).await {
1145 return (code, Json(serde_json::json!({"error": "Unauthorized"}))).into_response();
1146 }
1147
1148 let agent_name = {
1149 let user_key = state.resolve_user_key(&headers).await;
1150 let (_sid, session_arc, _, _) = state.ensure_active_session(&user_key).await;
1151 let session = session_arc.lock().await;
1152 session.agent_name.clone()
1153 };
1154 let token_store = FileTokenStore::new(&agent_name);
1155
1156 match token_store.delete(&query.cred) {
1157 Ok(()) => {
1158 tracing::info!("Removed MCP credentials for '{}'", query.cred);
1159 Json(serde_json::json!({"success": true})).into_response()
1160 }
1161 Err(e) => {
1162 tracing::error!("Failed to remove MCP credentials: {}", e);
1163 (
1164 StatusCode::INTERNAL_SERVER_ERROR,
1165 Json(serde_json::json!({"error": e.to_string()})),
1166 )
1167 .into_response()
1168 }
1169 }
1170}
1171
1172struct McpPkceStore {
1179 entries: tokio::sync::Mutex<std::collections::HashMap<String, McpPkceEntry>>,
1180}
1181
1182struct McpPkceEntry {
1183 verifier: String,
1184 cred_name: String,
1185 created_at: std::time::Instant,
1186}
1187
1188impl McpPkceStore {
1189 fn new() -> Self {
1190 Self {
1191 entries: tokio::sync::Mutex::new(std::collections::HashMap::new()),
1192 }
1193 }
1194
1195 async fn insert(&self, state: String, verifier: String, cred_name: String) {
1197 let mut map = self.entries.lock().await;
1198 let cutoff = std::time::Instant::now() - std::time::Duration::from_secs(600);
1200 map.retain(|_, v| v.created_at > cutoff);
1201 map.insert(state, McpPkceEntry {
1202 verifier,
1203 cred_name,
1204 created_at: std::time::Instant::now(),
1205 });
1206 }
1207
1208 async fn take(&self, state: &str) -> Option<McpPkceEntry> {
1210 let mut map = self.entries.lock().await;
1211 map.remove(state)
1212 }
1213}
1214
1215static MCP_PKCE: std::sync::OnceLock<McpPkceStore> = std::sync::OnceLock::new();
1217
1218fn mcp_pkce() -> &'static McpPkceStore {
1220 MCP_PKCE.get_or_init(McpPkceStore::new)
1221}
1222
1223enum McpCredentialInfo {
1225 WebInteractive {
1226 issuer_url: String,
1227 client_id: String,
1228 client_secret: Option<String>,
1229 scope: String,
1230 },
1231 Other(String),
1232}
1233
1234async fn load_mcp_credential(
1236 state: &crate::ServerState,
1237 cred_name: &str,
1238) -> Result<McpCredentialInfo, AuthError> {
1239 let config_toml = state
1240 .config_toml
1241 .clone()
1242 .ok_or(AuthError::AuthNotConfigured)?;
1243
1244 let config: toml::Value = toml::from_str(&config_toml)
1245 .map_err(|e| AuthError::OidcError(format!("Config parse error: {}", e)))?;
1246
1247 let cred = config
1248 .get("mcp")
1249 .and_then(|m| m.get("credentials"))
1250 .and_then(|c| c.as_table())
1251 .and_then(|c| c.get(cred_name))
1252 .ok_or_else(|| AuthError::OidcError(format!("Credential '{}' not found", cred_name)))?;
1253
1254 let cred_type = cred.get("type").and_then(|t| t.as_str()).unwrap_or("unknown");
1255
1256 match cred_type {
1257 "web-interactive" => {
1258 let issuer_url = cred
1259 .get("issuer_url")
1260 .and_then(|v| v.as_str())
1261 .ok_or_else(|| AuthError::OidcError("Missing issuer_url".into()))?
1262 .to_string();
1263 let client_id = cred
1264 .get("client_id")
1265 .and_then(|v| v.as_str())
1266 .ok_or_else(|| AuthError::OidcError("Missing client_id".into()))?
1267 .to_string();
1268 let client_secret = cred
1269 .get("client_secret")
1270 .and_then(|v| v.as_str())
1271 .map(String::from);
1272 let scope = cred
1273 .get("scope")
1274 .and_then(|v| v.as_str())
1275 .unwrap_or("openid profile email")
1276 .to_string();
1277
1278 Ok(McpCredentialInfo::WebInteractive {
1279 issuer_url,
1280 client_id,
1281 client_secret,
1282 scope,
1283 })
1284 }
1285 other => Ok(McpCredentialInfo::Other(other.to_string())),
1286 }
1287}
1288
1289fn create_auth_cookie(name: &str, value: &str, max_age: StdDuration, secure: bool) -> Cookie<'static> {
1295 Cookie::build((name.to_string(), value.to_string()))
1296 .path("/")
1297 .http_only(true)
1298 .same_site(SameSite::Lax)
1299 .secure(secure)
1300 .max_age(TimeDuration::seconds(max_age.as_secs() as i64))
1301 .build()
1302}
1303
1304#[derive(Debug)]
1310pub enum AuthError {
1311 MissingCode,
1312 MissingState,
1313 InvalidState,
1314 OidcError(String),
1315 TokenExchangeFailed(String),
1316 AuthNotConfigured,
1317}
1318
1319impl IntoResponse for AuthError {
1320 fn into_response(self) -> Response {
1321 let (_status, msg) = match self {
1322 AuthError::MissingCode => (StatusCode::BAD_REQUEST, "Missing authorization code"),
1323 AuthError::MissingState => (StatusCode::BAD_REQUEST, "Missing state parameter"),
1324 AuthError::InvalidState => (StatusCode::BAD_REQUEST, "Invalid or expired state"),
1325 AuthError::OidcError(_) => (StatusCode::SERVICE_UNAVAILABLE, "Authentication service error"),
1326 AuthError::TokenExchangeFailed(_) => (StatusCode::BAD_REQUEST, "Token exchange failed"),
1327 AuthError::AuthNotConfigured => (StatusCode::NOT_IMPLEMENTED, "Authentication not configured"),
1328 };
1329 Redirect::temporary(&format!("/?error={}", urlencoding::encode(msg))).into_response()
1330 }
1331}