1use std::collections::HashMap;
7use std::sync::Arc;
8
9use axum::extract::{Json, Query, State};
10use axum::http::{HeaderMap, StatusCode};
11use axum::response::{IntoResponse, Redirect, Response};
12use axum::routing::{get, post};
13use axum::Router;
14use serde::{Deserialize, Serialize};
15use tokio::sync::RwLock;
16
17use crate::audit::{AuditAction, AuditEntry, SharedAuditLogger};
18use crate::users::SharedSessionManager;
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct UserInfo {
27 pub provider_id: String,
29 pub name: String,
31 pub login: String,
33 pub email: String,
35 pub avatar: String,
37}
38
39#[derive(Debug)]
43pub struct OAuthError(pub String);
44
45impl std::fmt::Display for OAuthError {
46 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 write!(f, "OAuth error: {}", self.0)
48 }
49}
50
51impl std::error::Error for OAuthError {}
52
53#[async_trait::async_trait]
59pub trait AuthProvider: Send + Sync {
60 fn name(&self) -> &str;
62
63 fn authorize_url(&self, redirect_uri: &str) -> String;
65
66 async fn exchange_code(&self, code: &str, redirect_uri: &str) -> Result<UserInfo, OAuthError>;
68}
69
70#[derive(Debug, Clone)]
76pub struct OAuthConfig {
77 pub github_client_id: String,
78 pub github_client_secret: String,
79 pub jwt_secret: String,
80 pub frontend_url: String,
82 pub server_url: String,
84}
85
86impl OAuthConfig {
87 pub fn from_env() -> Option<Self> {
90 let client_id = std::env::var("GITHUB_CLIENT_ID").ok()?;
91 let client_secret = std::env::var("GITHUB_CLIENT_SECRET").ok()?;
92 let jwt_secret =
93 std::env::var("JWT_SECRET").unwrap_or_else(|_| crate::auth::generate_api_key());
94 let frontend_url =
95 std::env::var("FRONTEND_URL").unwrap_or_else(|_| "http://localhost:5173".to_string());
96 let server_url =
97 std::env::var("SERVER_URL").unwrap_or_else(|_| "http://localhost:9000".to_string());
98
99 Some(Self {
100 github_client_id: client_id,
101 github_client_secret: client_secret,
102 jwt_secret,
103 frontend_url,
104 server_url,
105 })
106 }
107}
108
109#[derive(Debug, Serialize, Deserialize)]
114pub struct Claims {
115 pub sub: String, pub name: String, pub login: String, pub avatar: String, pub email: String, pub exp: usize, pub iat: usize, #[serde(default)]
123 pub user_id: String, #[serde(default)]
125 pub org_id: String, #[serde(default)]
127 pub role: String, #[serde(default)]
129 pub session_id: String, #[serde(default)]
131 pub auth_method: String, #[serde(default)]
133 pub org_role: String, }
135
136#[derive(Debug)]
142pub struct GitHubOAuth {
143 pub client_id: String,
144 pub client_secret: String,
145 http_client: reqwest::Client,
146}
147
148impl GitHubOAuth {
149 pub fn new(client_id: String, client_secret: String) -> Self {
150 Self {
151 client_id,
152 client_secret,
153 http_client: reqwest::Client::new(),
154 }
155 }
156}
157
158#[async_trait::async_trait]
159impl AuthProvider for GitHubOAuth {
160 fn name(&self) -> &'static str {
161 "github"
162 }
163
164 fn authorize_url(&self, redirect_uri: &str) -> String {
165 format!(
166 "https://github.com/login/oauth/authorize?client_id={}&redirect_uri={}&scope=read:user%20user:email",
167 self.client_id,
168 urlencoding::encode(redirect_uri),
169 )
170 }
171
172 async fn exchange_code(&self, code: &str, redirect_uri: &str) -> Result<UserInfo, OAuthError> {
173 let token_resp = self
175 .http_client
176 .post("https://github.com/login/oauth/access_token")
177 .header("Accept", "application/json")
178 .form(&[
179 ("client_id", self.client_id.as_str()),
180 ("client_secret", self.client_secret.as_str()),
181 ("code", code),
182 ("redirect_uri", redirect_uri),
183 ])
184 .send()
185 .await
186 .map_err(|e| OAuthError(format!("GitHub token exchange failed: {e}")))?;
187
188 let token_data: GitHubTokenResponse = token_resp
189 .json()
190 .await
191 .map_err(|e| OAuthError(format!("Failed to parse GitHub token response: {e}")))?;
192
193 let user: GitHubUser = self
195 .http_client
196 .get("https://api.github.com/user")
197 .header(
198 "Authorization",
199 format!("Bearer {}", token_data.access_token),
200 )
201 .header("User-Agent", "Varpulis")
202 .send()
203 .await
204 .map_err(|e| OAuthError(format!("GitHub user fetch failed: {e}")))?
205 .json()
206 .await
207 .map_err(|e| OAuthError(format!("Failed to parse GitHub user: {e}")))?;
208
209 Ok(UserInfo {
210 provider_id: user.id.to_string(),
211 name: user.name.clone().unwrap_or_else(|| user.login.clone()),
212 login: user.login,
213 email: user.email.unwrap_or_default(),
214 avatar: user.avatar_url,
215 })
216 }
217}
218
219#[derive(Debug, Deserialize)]
224struct GitHubTokenResponse {
225 access_token: String,
226 #[allow(dead_code)]
227 token_type: String,
228}
229
230#[derive(Debug, Deserialize)]
231struct GitHubUser {
232 id: u64,
233 login: String,
234 name: Option<String>,
235 avatar_url: String,
236 email: Option<String>,
237}
238
239#[derive(Debug)]
247pub struct SessionStore {
248 revoked: HashMap<String, std::time::Instant>,
250}
251
252impl Default for SessionStore {
253 fn default() -> Self {
254 Self::new()
255 }
256}
257
258impl SessionStore {
259 pub fn new() -> Self {
260 Self {
261 revoked: HashMap::new(),
262 }
263 }
264
265 pub fn revoke(&mut self, token_hash: String) {
266 self.revoked.insert(token_hash, std::time::Instant::now());
267 }
268
269 pub fn is_revoked(&self, token_hash: &str) -> bool {
270 self.revoked.contains_key(token_hash)
271 }
272
273 pub fn cleanup(&mut self) {
275 if let Some(cutoff) =
276 std::time::Instant::now().checked_sub(std::time::Duration::from_secs(86400))
277 {
278 self.revoked.retain(|_, instant| *instant > cutoff);
279 }
280 }
282}
283
284pub type SharedOAuthState = Arc<OAuthState>;
289
290#[derive(Debug)]
291pub struct OAuthState {
292 pub config: OAuthConfig,
293 pub sessions: RwLock<SessionStore>,
294 pub http_client: reqwest::Client,
295 #[cfg(feature = "saas")]
296 pub db_pool: Option<varpulis_db::PgPool>,
297 pub audit_logger: Option<SharedAuditLogger>,
298 pub session_manager: Option<SharedSessionManager>,
299 #[cfg(feature = "saas")]
300 pub email_sender: Option<crate::email::SharedEmailSender>,
301}
302
303impl OAuthState {
304 pub fn new(config: OAuthConfig) -> Self {
305 Self {
306 config,
307 sessions: RwLock::new(SessionStore::new()),
308 http_client: reqwest::Client::new(),
309 #[cfg(feature = "saas")]
310 db_pool: None,
311 audit_logger: None,
312 session_manager: None,
313 #[cfg(feature = "saas")]
314 email_sender: None,
315 }
316 }
317
318 pub fn with_audit_logger(mut self, logger: Option<SharedAuditLogger>) -> Self {
319 self.audit_logger = logger;
320 self
321 }
322
323 pub fn with_session_manager(mut self, mgr: SharedSessionManager) -> Self {
324 self.session_manager = Some(mgr);
325 self
326 }
327
328 #[cfg(feature = "saas")]
329 pub fn with_db_pool(mut self, pool: varpulis_db::PgPool) -> Self {
330 self.db_pool = Some(pool);
331 self
332 }
333
334 #[cfg(feature = "saas")]
335 pub fn with_email_sender(mut self, sender: Option<crate::email::SharedEmailSender>) -> Self {
336 self.email_sender = sender;
337 self
338 }
339}
340
341fn create_jwt(
346 config: &OAuthConfig,
347 user: &GitHubUser,
348 user_id: &str,
349 org_id: &str,
350 org_role: &str,
351) -> Result<String, jsonwebtoken::errors::Error> {
352 use jsonwebtoken::{encode, EncodingKey, Header};
353
354 let now = chrono::Utc::now().timestamp() as usize;
355 let claims = Claims {
356 sub: user.id.to_string(),
357 name: user.name.clone().unwrap_or_else(|| user.login.clone()),
358 login: user.login.clone(),
359 avatar: user.avatar_url.clone(),
360 email: user.email.clone().unwrap_or_default(),
361 exp: now + 86400 * 7, iat: now,
363 user_id: user_id.to_string(),
364 org_id: org_id.to_string(),
365 role: String::new(),
366 session_id: String::new(),
367 auth_method: "github".to_string(),
368 org_role: org_role.to_string(),
369 };
370
371 encode(
372 &Header::default(),
373 &claims,
374 &EncodingKey::from_secret(config.jwt_secret.as_bytes()),
375 )
376}
377
378#[allow(clippy::too_many_arguments)]
380pub fn create_jwt_for_local_user(
381 config: &OAuthConfig,
382 user_id: &str,
383 username: &str,
384 display_name: &str,
385 email: &str,
386 role: &str,
387 session_id: &str,
388 ttl_secs: usize,
389 org_id: &str,
390) -> Result<String, jsonwebtoken::errors::Error> {
391 use jsonwebtoken::{encode, EncodingKey, Header};
392
393 let now = chrono::Utc::now().timestamp() as usize;
394 let claims = Claims {
395 sub: user_id.to_string(),
396 name: display_name.to_string(),
397 login: username.to_string(),
398 avatar: String::new(),
399 email: email.to_string(),
400 exp: now + ttl_secs,
401 iat: now,
402 user_id: user_id.to_string(),
403 org_id: org_id.to_string(),
404 role: role.to_string(),
405 session_id: session_id.to_string(),
406 auth_method: "local".to_string(),
407 org_role: String::new(),
408 };
409
410 encode(
411 &Header::default(),
412 &claims,
413 &EncodingKey::from_secret(config.jwt_secret.as_bytes()),
414 )
415}
416
417pub fn verify_jwt(
418 config: &OAuthConfig,
419 token: &str,
420) -> Result<Claims, jsonwebtoken::errors::Error> {
421 use jsonwebtoken::{decode, DecodingKey, Validation};
422
423 let token_data = decode::<Claims>(
424 token,
425 &DecodingKey::from_secret(config.jwt_secret.as_bytes()),
426 &Validation::default(),
427 )?;
428
429 Ok(token_data.claims)
430}
431
432pub fn token_hash(token: &str) -> String {
434 use sha2::Digest;
435 hex::encode(sha2::Sha256::digest(token.as_bytes()))
436}
437
438const COOKIE_NAME: &str = "varpulis_session";
443
444fn create_session_cookie(jwt: &str, max_age_secs: u64) -> String {
446 format!(
447 "{COOKIE_NAME}={jwt}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age={max_age_secs}"
448 )
449}
450
451fn clear_session_cookie() -> String {
453 format!("{COOKIE_NAME}=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0")
454}
455
456pub fn extract_jwt_from_cookie(cookie_header: &str) -> Option<String> {
458 for cookie in cookie_header.split(';') {
459 let cookie = cookie.trim();
460 if let Some(value) = cookie.strip_prefix("varpulis_session=") {
461 let value = value.trim();
462 if !value.is_empty() {
463 return Some(value.to_string());
464 }
465 }
466 }
467 None
468}
469
470async fn handle_github_redirect(State(state): State<Option<SharedOAuthState>>) -> Response {
476 let state = match state {
477 Some(s) => s,
478 None => {
479 return (
480 StatusCode::SERVICE_UNAVAILABLE,
481 Json(serde_json::json!({"error": "OAuth not configured"})),
482 )
483 .into_response();
484 }
485 };
486
487 let redirect_uri = format!("{}/auth/github/callback", state.config.server_url);
488 let url = format!(
489 "https://github.com/login/oauth/authorize?client_id={}&redirect_uri={}&scope=read:user%20user:email",
490 state.config.github_client_id,
491 urlencoding::encode(&redirect_uri),
492 );
493
494 Redirect::temporary(&url).into_response()
495}
496
497#[derive(Debug, Deserialize)]
499struct CallbackQuery {
500 code: String,
501}
502
503async fn handle_github_callback(
505 State(state): State<Option<SharedOAuthState>>,
506 Query(query): Query<CallbackQuery>,
507) -> Response {
508 let state = match state {
509 Some(s) => s,
510 None => {
511 return (
512 StatusCode::SERVICE_UNAVAILABLE,
513 Json(serde_json::json!({"error": "OAuth not configured"})),
514 )
515 .into_response();
516 }
517 };
518
519 let redirect_uri = format!("{}/auth/github/callback", state.config.server_url);
520
521 let token_resp = match state
523 .http_client
524 .post("https://github.com/login/oauth/access_token")
525 .header("Accept", "application/json")
526 .form(&[
527 ("client_id", state.config.github_client_id.as_str()),
528 ("client_secret", state.config.github_client_secret.as_str()),
529 ("code", query.code.as_str()),
530 ("redirect_uri", redirect_uri.as_str()),
531 ])
532 .send()
533 .await
534 {
535 Ok(resp) => resp,
536 Err(e) => {
537 tracing::error!("GitHub token exchange failed: {}", e);
538 return (
539 StatusCode::BAD_GATEWAY,
540 Json(serde_json::json!({"error": "GitHub token exchange failed"})),
541 )
542 .into_response();
543 }
544 };
545
546 let token_data: GitHubTokenResponse = match token_resp.json().await {
547 Ok(data) => data,
548 Err(e) => {
549 tracing::error!("Failed to parse GitHub token response: {}", e);
550 return (
551 StatusCode::BAD_GATEWAY,
552 Json(serde_json::json!({"error": "Failed to parse GitHub token response"})),
553 )
554 .into_response();
555 }
556 };
557
558 let user: GitHubUser = match state
560 .http_client
561 .get("https://api.github.com/user")
562 .header(
563 "Authorization",
564 format!("Bearer {}", token_data.access_token),
565 )
566 .header("User-Agent", "Varpulis")
567 .send()
568 .await
569 {
570 Ok(resp) => match resp.json().await {
571 Ok(user) => user,
572 Err(e) => {
573 tracing::error!("Failed to parse GitHub user: {}", e);
574 return (
575 StatusCode::BAD_GATEWAY,
576 Json(serde_json::json!({"error": "Failed to parse GitHub user"})),
577 )
578 .into_response();
579 }
580 },
581 Err(e) => {
582 tracing::error!("GitHub user fetch failed: {}", e);
583 return (
584 StatusCode::BAD_GATEWAY,
585 Json(serde_json::json!({"error": "GitHub user fetch failed"})),
586 )
587 .into_response();
588 }
589 };
590
591 let (db_user_id, db_org_id) = {
593 #[cfg(feature = "saas")]
594 {
595 if let Some(ref pool) = state.db_pool {
596 match upsert_user_and_org(pool, &user).await {
597 Ok((uid, oid)) => (uid, oid),
598 Err(e) => {
599 tracing::error!("DB user/org upsert failed: {}", e);
600 (String::new(), String::new())
601 }
602 }
603 } else {
604 (String::new(), String::new())
605 }
606 }
607 #[cfg(not(feature = "saas"))]
608 {
609 (String::new(), String::new())
610 }
611 };
612
613 let jwt = match create_jwt(&state.config, &user, &db_user_id, &db_org_id, "owner") {
615 Ok(token) => token,
616 Err(e) => {
617 tracing::error!("JWT creation failed: {}", e);
618 return (
619 StatusCode::INTERNAL_SERVER_ERROR,
620 Json(serde_json::json!({"error": "JWT creation failed"})),
621 )
622 .into_response();
623 }
624 };
625
626 tracing::info!("OAuth login: {} ({})", user.login, user.id);
627
628 if let Some(ref logger) = state.audit_logger {
630 logger
631 .log(
632 AuditEntry::new(&user.login, AuditAction::Login, "/auth/github/callback")
633 .with_detail(format!("GitHub user ID: {}", user.id)),
634 )
635 .await;
636 }
637
638 let redirect_url = format!("{}/?token={}", state.config.frontend_url, jwt);
640 Redirect::temporary(&redirect_url).into_response()
641}
642
643#[cfg(feature = "saas")]
645async fn upsert_user_and_org(
646 pool: &varpulis_db::PgPool,
647 github_user: &GitHubUser,
648) -> Result<(String, String), String> {
649 let db_user = varpulis_db::repo::create_or_update_user(
650 pool,
651 &github_user.id.to_string(),
652 github_user.email.as_deref().unwrap_or(""),
653 github_user.name.as_deref().unwrap_or(&github_user.login),
654 &github_user.avatar_url,
655 )
656 .await
657 .map_err(|e| e.to_string())?;
658
659 let orgs = varpulis_db::repo::get_user_organizations(pool, db_user.id)
660 .await
661 .map_err(|e| e.to_string())?;
662
663 let org = if orgs.is_empty() {
664 let org_name = format!("{}'s org", github_user.login);
665 varpulis_db::repo::create_organization(pool, db_user.id, &org_name)
666 .await
667 .map_err(|e| e.to_string())?
668 } else {
669 orgs.into_iter().next().unwrap()
670 };
671
672 tracing::info!(
673 "DB upsert: user={} org={} ({})",
674 db_user.id,
675 org.id,
676 org.name
677 );
678
679 Ok((db_user.id.to_string(), org.id.to_string()))
680}
681
682async fn handle_logout(
684 State(state): State<Option<SharedOAuthState>>,
685 headers: HeaderMap,
686) -> Response {
687 let state = match state {
688 Some(s) => s,
689 None => {
690 return (
691 StatusCode::SERVICE_UNAVAILABLE,
692 Json(serde_json::json!({"error": "OAuth not configured"})),
693 )
694 .into_response();
695 }
696 };
697
698 let auth_header = headers
699 .get("authorization")
700 .and_then(|v| v.to_str().ok())
701 .map(|s| s.to_string());
702 let cookie_header = headers
703 .get("cookie")
704 .and_then(|v| v.to_str().ok())
705 .map(|s| s.to_string());
706
707 let token = cookie_header
709 .as_deref()
710 .and_then(extract_jwt_from_cookie)
711 .or_else(|| {
712 auth_header
713 .as_ref()
714 .map(|h| h.strip_prefix("Bearer ").unwrap_or(h).trim().to_string())
715 });
716
717 if let Some(token) = token {
718 if !token.is_empty() {
719 if let Ok(claims) = verify_jwt(&state.config, &token) {
721 if claims.auth_method == "local" && !claims.session_id.is_empty() {
722 if let Some(ref session_mgr) = state.session_manager {
723 session_mgr.write().await.revoke_session(&claims.session_id);
724 }
725 }
726 }
727
728 let hash = token_hash(&token);
729 state.sessions.write().await.revoke(hash);
730
731 if let Some(ref logger) = state.audit_logger {
733 logger
734 .log(AuditEntry::new(
735 "session",
736 AuditAction::Logout,
737 "/auth/logout",
738 ))
739 .await;
740 }
741 }
742 }
743
744 (
745 StatusCode::OK,
746 [("set-cookie", clear_session_cookie())],
747 Json(serde_json::json!({ "ok": true })),
748 )
749 .into_response()
750}
751
752async fn handle_me(State(state): State<Option<SharedOAuthState>>, headers: HeaderMap) -> Response {
754 let state = match state {
755 Some(s) => s,
756 None => {
757 return (
758 StatusCode::SERVICE_UNAVAILABLE,
759 Json(serde_json::json!({"error": "OAuth not configured"})),
760 )
761 .into_response();
762 }
763 };
764
765 let auth_header = headers
766 .get("authorization")
767 .and_then(|v| v.to_str().ok())
768 .map(|s| s.to_string());
769 let cookie_header = headers
770 .get("cookie")
771 .and_then(|v| v.to_str().ok())
772 .map(|s| s.to_string());
773
774 let token = cookie_header
776 .as_deref()
777 .and_then(extract_jwt_from_cookie)
778 .or_else(|| {
779 auth_header
780 .as_ref()
781 .map(|h| h.strip_prefix("Bearer ").unwrap_or(h).trim().to_string())
782 });
783
784 let token = match token {
785 Some(t) if !t.is_empty() => t,
786 _ => {
787 return (
788 StatusCode::UNAUTHORIZED,
789 Json(serde_json::json!({ "error": "No token provided" })),
790 )
791 .into_response();
792 }
793 };
794
795 let hash = token_hash(&token);
797 if state.sessions.read().await.is_revoked(&hash) {
798 return (
799 StatusCode::UNAUTHORIZED,
800 Json(serde_json::json!({ "error": "Token revoked" })),
801 )
802 .into_response();
803 }
804
805 match verify_jwt(&state.config, &token) {
807 Ok(claims) => {
808 #[allow(unused_mut)]
809 let mut response = serde_json::json!({
810 "id": claims.sub,
811 "name": claims.name,
812 "login": claims.login,
813 "avatar": claims.avatar,
814 "email": claims.email,
815 "user_id": claims.user_id,
816 "org_id": claims.org_id,
817 "role": claims.role,
818 "auth_method": claims.auth_method,
819 });
820
821 #[cfg(feature = "saas")]
823 if let Some(ref pool) = state.db_pool {
824 if !claims.user_id.is_empty() {
825 if let Ok(user_uuid) = claims.user_id.parse::<uuid::Uuid>() {
826 if let Ok(orgs) =
827 varpulis_db::repo::get_user_organizations(pool, user_uuid).await
828 {
829 let orgs_json: Vec<serde_json::Value> = orgs
830 .iter()
831 .map(|o| {
832 serde_json::json!({
833 "id": o.id.to_string(),
834 "name": o.name,
835 "tier": o.tier,
836 })
837 })
838 .collect();
839 response["organizations"] = serde_json::json!(orgs_json);
840 }
841 }
842 }
843 }
844
845 (StatusCode::OK, Json(response)).into_response()
846 }
847 Err(e) => {
848 tracing::debug!("JWT verification failed: {}", e);
849 (
850 StatusCode::UNAUTHORIZED,
851 Json(serde_json::json!({ "error": "Invalid token" })),
852 )
853 .into_response()
854 }
855 }
856}
857
858#[derive(Debug, Deserialize)]
864#[allow(dead_code)]
865struct LoginRequest {
866 username: String,
867 password: String,
868}
869
870async fn handle_login(
872 State(state): State<Option<SharedOAuthState>>,
873 Json(body): Json<LoginRequest>,
874) -> Response {
875 let state = match state {
876 Some(s) => s,
877 None => {
878 return (
879 StatusCode::SERVICE_UNAVAILABLE,
880 Json(serde_json::json!({ "error": "OAuth not configured" })),
881 )
882 .into_response();
883 }
884 };
885
886 #[cfg(feature = "saas")]
888 let db_user = {
889 let pool = match &state.db_pool {
890 Some(p) => p,
891 None => {
892 return (
893 StatusCode::SERVICE_UNAVAILABLE,
894 Json(serde_json::json!({ "error": "Database not configured" })),
895 )
896 .into_response();
897 }
898 };
899 match varpulis_db::repo::get_user_by_username(pool, &body.username).await {
900 Ok(Some(u)) => u,
901 Ok(None) | Err(_) => {
902 if let Some(ref logger) = state.audit_logger {
903 logger
904 .log(
905 AuditEntry::new(&body.username, AuditAction::Login, "/auth/login")
906 .with_outcome(crate::audit::AuditOutcome::Failure)
907 .with_detail("Invalid username or password".to_string()),
908 )
909 .await;
910 }
911 return (
912 StatusCode::UNAUTHORIZED,
913 Json(serde_json::json!({ "error": "Invalid username or password" })),
914 )
915 .into_response();
916 }
917 }
918 };
919 #[cfg(not(feature = "saas"))]
920 {
921 let _ = (&body, &state);
922 (
923 StatusCode::SERVICE_UNAVAILABLE,
924 Json(serde_json::json!({ "error": "Local auth requires saas feature" })),
925 )
926 .into_response()
927 }
928
929 #[cfg(feature = "saas")]
930 {
931 if db_user.disabled {
933 return (
934 StatusCode::UNAUTHORIZED,
935 Json(serde_json::json!({ "error": "Account is disabled" })),
936 )
937 .into_response();
938 }
939
940 if !db_user.email_verified {
942 return (
943 StatusCode::FORBIDDEN,
944 Json(serde_json::json!({ "error": "Please verify your email before logging in" })),
945 )
946 .into_response();
947 }
948
949 let password_hash = match &db_user.password_hash {
951 Some(h) => h.clone(),
952 None => {
953 return (
954 StatusCode::UNAUTHORIZED,
955 Json(serde_json::json!({ "error": "Invalid username or password" })),
956 )
957 .into_response();
958 }
959 };
960 match crate::users::verify_password(&body.password, &password_hash) {
961 Ok(true) => {}
962 _ => {
963 if let Some(ref logger) = state.audit_logger {
964 logger
965 .log(
966 AuditEntry::new(&body.username, AuditAction::Login, "/auth/login")
967 .with_outcome(crate::audit::AuditOutcome::Failure)
968 .with_detail("Invalid username or password".to_string()),
969 )
970 .await;
971 }
972 return (
973 StatusCode::UNAUTHORIZED,
974 Json(serde_json::json!({ "error": "Invalid username or password" })),
975 )
976 .into_response();
977 }
978 }
979
980 let session_mgr = match &state.session_manager {
982 Some(m) => m.clone(),
983 None => {
984 return (
985 StatusCode::SERVICE_UNAVAILABLE,
986 Json(serde_json::json!({ "error": "Session manager not configured" })),
987 )
988 .into_response();
989 }
990 };
991
992 let mut mgr = session_mgr.write().await;
993 let user_id_str = db_user.id.to_string();
994 let username = db_user.username.as_deref().unwrap_or("");
995 let session = mgr.create_session(&user_id_str, username, &db_user.role);
996 let ttl_secs = mgr.session_config().absolute_timeout.as_secs() as usize;
997 drop(mgr);
998
999 let org_id = {
1001 let pool = state.db_pool.as_ref().unwrap();
1002 match varpulis_db::repo::get_user_organizations(pool, db_user.id).await {
1003 Ok(orgs) if !orgs.is_empty() => orgs[0].id.to_string(),
1004 _ => String::new(),
1005 }
1006 };
1007
1008 let jwt = match create_jwt_for_local_user(
1009 &state.config,
1010 &user_id_str,
1011 username,
1012 &db_user.display_name,
1013 &db_user.email,
1014 &db_user.role,
1015 &session.session_id,
1016 ttl_secs,
1017 &org_id,
1018 ) {
1019 Ok(token) => token,
1020 Err(e) => {
1021 tracing::error!("JWT creation failed: {}", e);
1022 return (
1023 StatusCode::INTERNAL_SERVER_ERROR,
1024 Json(serde_json::json!({ "error": "Internal server error" })),
1025 )
1026 .into_response();
1027 }
1028 };
1029
1030 if let Some(ref logger) = state.audit_logger {
1032 logger
1033 .log(
1034 AuditEntry::new(username, AuditAction::Login, "/auth/login")
1035 .with_detail(format!("session: {}", session.session_id)),
1036 )
1037 .await;
1038 }
1039
1040 let cookie = create_session_cookie(&jwt, ttl_secs as u64);
1041 let response = serde_json::json!({
1042 "ok": true,
1043 "user": {
1044 "id": user_id_str,
1045 "username": username,
1046 "display_name": db_user.display_name,
1047 "email": db_user.email,
1048 "role": db_user.role,
1049 },
1050 "token": jwt,
1051 });
1052
1053 (StatusCode::OK, [("set-cookie", cookie)], Json(response)).into_response()
1054 }
1055}
1056
1057async fn handle_renew(
1059 State(state): State<Option<SharedOAuthState>>,
1060 headers: HeaderMap,
1061) -> Response {
1062 let state = match state {
1063 Some(s) => s,
1064 None => {
1065 return (
1066 StatusCode::SERVICE_UNAVAILABLE,
1067 Json(serde_json::json!({"error": "OAuth not configured"})),
1068 )
1069 .into_response();
1070 }
1071 };
1072
1073 let auth_header = headers
1074 .get("authorization")
1075 .and_then(|v| v.to_str().ok())
1076 .map(|s| s.to_string());
1077 let cookie_header = headers
1078 .get("cookie")
1079 .and_then(|v| v.to_str().ok())
1080 .map(|s| s.to_string());
1081
1082 let token = cookie_header
1084 .as_deref()
1085 .and_then(extract_jwt_from_cookie)
1086 .or_else(|| {
1087 auth_header
1088 .as_ref()
1089 .map(|h| h.strip_prefix("Bearer ").unwrap_or(h).trim().to_string())
1090 });
1091
1092 let token = match token {
1093 Some(t) if !t.is_empty() => t,
1094 _ => {
1095 return (
1096 StatusCode::UNAUTHORIZED,
1097 Json(serde_json::json!({ "error": "No session token" })),
1098 )
1099 .into_response();
1100 }
1101 };
1102
1103 let claims = match verify_jwt(&state.config, &token) {
1105 Ok(c) => c,
1106 Err(_) => {
1107 return (
1108 StatusCode::UNAUTHORIZED,
1109 Json(serde_json::json!({ "error": "Invalid or expired token" })),
1110 )
1111 .into_response();
1112 }
1113 };
1114
1115 if claims.auth_method != "local" || claims.session_id.is_empty() {
1117 return (
1118 StatusCode::BAD_REQUEST,
1119 Json(serde_json::json!({ "error": "Session renewal not applicable" })),
1120 )
1121 .into_response();
1122 }
1123
1124 let session_mgr = match &state.session_manager {
1125 Some(m) => m.clone(),
1126 None => {
1127 return (
1128 StatusCode::SERVICE_UNAVAILABLE,
1129 Json(serde_json::json!({ "error": "Session manager not configured" })),
1130 )
1131 .into_response();
1132 }
1133 };
1134
1135 let mut mgr = session_mgr.write().await;
1136
1137 if mgr.validate_session(&claims.session_id).is_none() {
1139 return (
1140 StatusCode::UNAUTHORIZED,
1141 Json(serde_json::json!({ "error": "Session expired or revoked" })),
1142 )
1143 .into_response();
1144 }
1145
1146 let ttl_secs = mgr.session_config().absolute_timeout.as_secs() as usize;
1147 drop(mgr);
1148
1149 let (username, display_name, email, role, org_id) = {
1151 #[cfg(feature = "saas")]
1152 {
1153 if let Some(ref pool) = state.db_pool {
1154 if let Ok(user_uuid) = claims.sub.parse::<uuid::Uuid>() {
1155 match varpulis_db::repo::get_user_by_id(pool, user_uuid).await {
1156 Ok(Some(u)) => {
1157 let oid =
1158 match varpulis_db::repo::get_user_organizations(pool, u.id).await {
1159 Ok(orgs) if !orgs.is_empty() => orgs[0].id.to_string(),
1160 _ => claims.org_id.clone(),
1161 };
1162 (
1163 u.username.unwrap_or_else(|| claims.login.clone()),
1164 u.display_name,
1165 u.email,
1166 u.role,
1167 oid,
1168 )
1169 }
1170 _ => (
1171 claims.login.clone(),
1172 claims.name.clone(),
1173 claims.email.clone(),
1174 claims.role.clone(),
1175 claims.org_id.clone(),
1176 ),
1177 }
1178 } else {
1179 (
1180 claims.login.clone(),
1181 claims.name.clone(),
1182 claims.email.clone(),
1183 claims.role.clone(),
1184 claims.org_id.clone(),
1185 )
1186 }
1187 } else {
1188 (
1189 claims.login.clone(),
1190 claims.name.clone(),
1191 claims.email.clone(),
1192 claims.role.clone(),
1193 claims.org_id.clone(),
1194 )
1195 }
1196 }
1197 #[cfg(not(feature = "saas"))]
1198 {
1199 (
1200 claims.login.clone(),
1201 claims.name.clone(),
1202 claims.email.clone(),
1203 claims.role.clone(),
1204 claims.org_id.clone(),
1205 )
1206 }
1207 };
1208
1209 let hash = token_hash(&token);
1211 state.sessions.write().await.revoke(hash);
1212
1213 let jwt = match create_jwt_for_local_user(
1214 &state.config,
1215 &claims.sub,
1216 &username,
1217 &display_name,
1218 &email,
1219 &role,
1220 &claims.session_id,
1221 ttl_secs,
1222 &org_id,
1223 ) {
1224 Ok(t) => t,
1225 Err(e) => {
1226 tracing::error!("JWT renewal failed: {}", e);
1227 return (
1228 StatusCode::INTERNAL_SERVER_ERROR,
1229 Json(serde_json::json!({ "error": "Internal server error" })),
1230 )
1231 .into_response();
1232 }
1233 };
1234
1235 if let Some(ref logger) = state.audit_logger {
1236 logger
1237 .log(AuditEntry::new(
1238 &username,
1239 AuditAction::SessionRenew,
1240 "/auth/renew",
1241 ))
1242 .await;
1243 }
1244
1245 let cookie = create_session_cookie(&jwt, ttl_secs as u64);
1246
1247 (
1248 StatusCode::OK,
1249 [("set-cookie", cookie)],
1250 Json(serde_json::json!({
1251 "ok": true,
1252 "token": jwt,
1253 })),
1254 )
1255 .into_response()
1256}
1257
1258#[derive(Debug, Deserialize)]
1260#[allow(dead_code)]
1261struct CreateUserRequest {
1262 username: String,
1263 password: String,
1264 display_name: String,
1265 #[serde(default)]
1266 email: String,
1267 #[serde(default = "default_role")]
1268 role: String,
1269}
1270
1271fn default_role() -> String {
1272 "viewer".to_string()
1273}
1274
1275async fn handle_create_user(
1277 State(state): State<Option<SharedOAuthState>>,
1278 headers: HeaderMap,
1279 Json(body): Json<CreateUserRequest>,
1280) -> Response {
1281 let state = match state {
1282 Some(s) => s,
1283 None => {
1284 return (
1285 StatusCode::SERVICE_UNAVAILABLE,
1286 Json(serde_json::json!({"error": "OAuth not configured"})),
1287 )
1288 .into_response();
1289 }
1290 };
1291
1292 let auth_header = headers.get("authorization").and_then(|v| v.to_str().ok());
1293 let cookie_header = headers.get("cookie").and_then(|v| v.to_str().ok());
1294
1295 let claims = match extract_and_verify_claims(&state, auth_header, cookie_header).await {
1297 Ok(c) => c,
1298 Err(resp) => return resp,
1299 };
1300
1301 if claims.role != "admin" {
1302 return (
1303 StatusCode::FORBIDDEN,
1304 Json(serde_json::json!({ "error": "Admin access required" })),
1305 )
1306 .into_response();
1307 }
1308
1309 if body.username.is_empty() || body.username.len() > 64 {
1311 return (
1312 StatusCode::BAD_REQUEST,
1313 Json(serde_json::json!({ "error": "Username must be 1-64 characters" })),
1314 )
1315 .into_response();
1316 }
1317 if body.password.len() < 8 {
1318 return (
1319 StatusCode::BAD_REQUEST,
1320 Json(serde_json::json!({ "error": "Password must be at least 8 characters" })),
1321 )
1322 .into_response();
1323 }
1324
1325 let password_hash = match crate::users::hash_password(&body.password) {
1327 Ok(h) => h,
1328 Err(e) => {
1329 tracing::error!("Password hashing failed: {}", e);
1330 return (
1331 StatusCode::INTERNAL_SERVER_ERROR,
1332 Json(serde_json::json!({ "error": "Internal server error" })),
1333 )
1334 .into_response();
1335 }
1336 };
1337
1338 #[cfg(feature = "saas")]
1339 {
1340 let pool = match &state.db_pool {
1341 Some(p) => p,
1342 None => {
1343 return (
1344 StatusCode::SERVICE_UNAVAILABLE,
1345 Json(serde_json::json!({ "error": "Database not configured" })),
1346 )
1347 .into_response();
1348 }
1349 };
1350
1351 match varpulis_db::repo::create_local_user(
1352 pool,
1353 &body.username,
1354 &password_hash,
1355 &body.display_name,
1356 &body.email,
1357 &body.role,
1358 )
1359 .await
1360 {
1361 Ok(user) => {
1362 if let Some(ref logger) = state.audit_logger {
1363 logger
1364 .log(
1365 AuditEntry::new(&claims.login, AuditAction::UserCreate, "/auth/users")
1366 .with_detail(format!(
1367 "Created user: {} ({})",
1368 body.username, body.role
1369 )),
1370 )
1371 .await;
1372 }
1373
1374 (
1375 StatusCode::CREATED,
1376 Json(serde_json::json!({
1377 "id": user.id.to_string(),
1378 "username": user.username,
1379 "display_name": user.display_name,
1380 "email": user.email,
1381 "role": user.role,
1382 })),
1383 )
1384 .into_response()
1385 }
1386 Err(e) => {
1387 let msg = e.to_string();
1388 let status = if msg.contains("duplicate") || msg.contains("unique") {
1389 StatusCode::CONFLICT
1390 } else {
1391 StatusCode::BAD_REQUEST
1392 };
1393 (status, Json(serde_json::json!({ "error": msg }))).into_response()
1394 }
1395 }
1396 }
1397 #[cfg(not(feature = "saas"))]
1398 {
1399 let _ = password_hash;
1400 (
1401 StatusCode::SERVICE_UNAVAILABLE,
1402 Json(serde_json::json!({ "error": "Requires saas feature" })),
1403 )
1404 .into_response()
1405 }
1406}
1407
1408async fn handle_list_users(
1410 State(state): State<Option<SharedOAuthState>>,
1411 headers: HeaderMap,
1412) -> Response {
1413 let state = match state {
1414 Some(s) => s,
1415 None => {
1416 return (
1417 StatusCode::SERVICE_UNAVAILABLE,
1418 Json(serde_json::json!({"error": "OAuth not configured"})),
1419 )
1420 .into_response();
1421 }
1422 };
1423
1424 let auth_header = headers.get("authorization").and_then(|v| v.to_str().ok());
1425 let cookie_header = headers.get("cookie").and_then(|v| v.to_str().ok());
1426
1427 let claims = match extract_and_verify_claims(&state, auth_header, cookie_header).await {
1428 Ok(c) => c,
1429 Err(resp) => return resp,
1430 };
1431
1432 if claims.role != "admin" {
1433 return (
1434 StatusCode::FORBIDDEN,
1435 Json(serde_json::json!({ "error": "Admin access required" })),
1436 )
1437 .into_response();
1438 }
1439
1440 #[cfg(feature = "saas")]
1441 {
1442 let pool = match &state.db_pool {
1443 Some(p) => p,
1444 None => {
1445 return (
1446 StatusCode::SERVICE_UNAVAILABLE,
1447 Json(serde_json::json!({ "error": "Database not configured" })),
1448 )
1449 .into_response();
1450 }
1451 };
1452
1453 match varpulis_db::repo::list_users(pool).await {
1454 Ok(db_users) => {
1455 let users: Vec<crate::users::UserSummary> = db_users
1456 .iter()
1457 .map(|u| crate::users::UserSummary {
1458 id: u.id.to_string(),
1459 username: u.username.clone().unwrap_or_default(),
1460 display_name: u.display_name.clone(),
1461 email: u.email.clone(),
1462 role: u.role.clone(),
1463 disabled: u.disabled,
1464 created_at: u.created_at,
1465 })
1466 .collect();
1467 (StatusCode::OK, Json(serde_json::json!({ "users": users }))).into_response()
1468 }
1469 Err(e) => {
1470 tracing::error!("Failed to list users: {}", e);
1471 (
1472 StatusCode::INTERNAL_SERVER_ERROR,
1473 Json(serde_json::json!({ "error": "Internal error" })),
1474 )
1475 .into_response()
1476 }
1477 }
1478 }
1479 #[cfg(not(feature = "saas"))]
1480 {
1481 (
1482 StatusCode::SERVICE_UNAVAILABLE,
1483 Json(serde_json::json!({ "error": "Requires saas feature" })),
1484 )
1485 .into_response()
1486 }
1487}
1488
1489async fn extract_and_verify_claims(
1491 state: &SharedOAuthState,
1492 auth_header: Option<&str>,
1493 cookie_header: Option<&str>,
1494) -> Result<Claims, Response> {
1495 let token = cookie_header
1496 .and_then(extract_jwt_from_cookie)
1497 .or_else(|| auth_header.map(|h| h.strip_prefix("Bearer ").unwrap_or(h).trim().to_string()));
1498
1499 let token = match token {
1500 Some(t) if !t.is_empty() => t,
1501 _ => {
1502 return Err((
1503 StatusCode::UNAUTHORIZED,
1504 Json(serde_json::json!({ "error": "Authentication required" })),
1505 )
1506 .into_response());
1507 }
1508 };
1509
1510 let hash = token_hash(&token);
1512 if state.sessions.read().await.is_revoked(&hash) {
1513 return Err((
1514 StatusCode::UNAUTHORIZED,
1515 Json(serde_json::json!({ "error": "Token revoked" })),
1516 )
1517 .into_response());
1518 }
1519
1520 verify_jwt(&state.config, &token).map_err(|_| {
1521 (
1522 StatusCode::UNAUTHORIZED,
1523 Json(serde_json::json!({ "error": "Invalid or expired token" })),
1524 )
1525 .into_response()
1526 })
1527}
1528
1529#[derive(Debug, Deserialize)]
1535#[allow(dead_code)]
1536struct ChangePasswordRequest {
1537 current_password: String,
1538 new_password: String,
1539}
1540
1541async fn handle_change_password(
1543 State(state): State<Option<SharedOAuthState>>,
1544 headers: HeaderMap,
1545 Json(body): Json<ChangePasswordRequest>,
1546) -> Response {
1547 let state = match state {
1548 Some(s) => s,
1549 None => {
1550 return (
1551 StatusCode::SERVICE_UNAVAILABLE,
1552 Json(serde_json::json!({"error": "OAuth not configured"})),
1553 )
1554 .into_response();
1555 }
1556 };
1557
1558 let auth_header = headers.get("authorization").and_then(|v| v.to_str().ok());
1559 let cookie_header = headers.get("cookie").and_then(|v| v.to_str().ok());
1560
1561 let claims = match extract_and_verify_claims(&state, auth_header, cookie_header).await {
1562 Ok(c) => c,
1563 Err(resp) => return resp,
1564 };
1565
1566 #[cfg(feature = "saas")]
1567 {
1568 let pool = match &state.db_pool {
1569 Some(p) => p,
1570 None => {
1571 return (
1572 StatusCode::SERVICE_UNAVAILABLE,
1573 Json(serde_json::json!({"error": "Database not configured"})),
1574 )
1575 .into_response();
1576 }
1577 };
1578
1579 if body.new_password.len() < 8 {
1581 return (
1582 StatusCode::BAD_REQUEST,
1583 Json(serde_json::json!({"error": "New password must be at least 8 characters"})),
1584 )
1585 .into_response();
1586 }
1587
1588 let user_id = match claims.user_id.parse::<uuid::Uuid>() {
1590 Ok(id) => id,
1591 Err(_) => {
1592 return (
1593 StatusCode::BAD_REQUEST,
1594 Json(serde_json::json!({"error": "Invalid user ID"})),
1595 )
1596 .into_response();
1597 }
1598 };
1599
1600 let db_user = match varpulis_db::repo::get_user_by_id(pool, user_id).await {
1601 Ok(Some(u)) => u,
1602 _ => {
1603 return (
1604 StatusCode::NOT_FOUND,
1605 Json(serde_json::json!({"error": "User not found"})),
1606 )
1607 .into_response();
1608 }
1609 };
1610
1611 let password_hash = match &db_user.password_hash {
1613 Some(h) => h.clone(),
1614 None => {
1615 return (
1616 StatusCode::BAD_REQUEST,
1617 Json(serde_json::json!({"error": "Account uses external authentication"})),
1618 )
1619 .into_response();
1620 }
1621 };
1622
1623 match crate::users::verify_password(&body.current_password, &password_hash) {
1624 Ok(true) => {}
1625 _ => {
1626 return (
1627 StatusCode::UNAUTHORIZED,
1628 Json(serde_json::json!({"error": "Current password is incorrect"})),
1629 )
1630 .into_response();
1631 }
1632 }
1633
1634 let new_hash = match crate::users::hash_password(&body.new_password) {
1636 Ok(h) => h,
1637 Err(e) => {
1638 tracing::error!("Password hash failed: {}", e);
1639 return (
1640 StatusCode::INTERNAL_SERVER_ERROR,
1641 Json(serde_json::json!({"error": "Internal error"})),
1642 )
1643 .into_response();
1644 }
1645 };
1646
1647 if let Err(e) = varpulis_db::repo::update_password_hash(pool, user_id, &new_hash).await {
1648 tracing::error!("Failed to update password: {}", e);
1649 return (
1650 StatusCode::INTERNAL_SERVER_ERROR,
1651 Json(serde_json::json!({"error": "Failed to update password"})),
1652 )
1653 .into_response();
1654 }
1655
1656 (
1657 StatusCode::OK,
1658 Json(serde_json::json!({"ok": true, "message": "Password changed successfully"})),
1659 )
1660 .into_response()
1661 }
1662
1663 #[cfg(not(feature = "saas"))]
1664 {
1665 let _ = (&body, &claims);
1666 (
1667 StatusCode::SERVICE_UNAVAILABLE,
1668 Json(serde_json::json!({"error": "Password change requires saas feature"})),
1669 )
1670 .into_response()
1671 }
1672}
1673
1674#[derive(Debug, Deserialize)]
1680#[allow(dead_code)]
1681struct RegisterRequest {
1682 username: String,
1683 email: String,
1684 password: String,
1685 org_name: String,
1686}
1687
1688#[allow(unused_variables)]
1690async fn handle_register(
1691 State(state): State<Option<SharedOAuthState>>,
1692 Json(body): Json<RegisterRequest>,
1693) -> Response {
1694 let state = match state {
1695 Some(s) => s,
1696 None => {
1697 return (
1698 StatusCode::SERVICE_UNAVAILABLE,
1699 Json(serde_json::json!({ "error": "OAuth not configured" })),
1700 )
1701 .into_response();
1702 }
1703 };
1704
1705 if body.username.is_empty() || body.username.len() > 64 {
1707 return (
1708 StatusCode::BAD_REQUEST,
1709 Json(serde_json::json!({ "error": "Username must be 1-64 characters" })),
1710 )
1711 .into_response();
1712 }
1713 if body.password.len() < 8 {
1714 return (
1715 StatusCode::BAD_REQUEST,
1716 Json(serde_json::json!({ "error": "Password must be at least 8 characters" })),
1717 )
1718 .into_response();
1719 }
1720 if !body.email.contains('@') || body.email.len() < 3 {
1721 return (
1722 StatusCode::BAD_REQUEST,
1723 Json(serde_json::json!({ "error": "Invalid email address" })),
1724 )
1725 .into_response();
1726 }
1727
1728 #[cfg(feature = "saas")]
1729 {
1730 let pool = match &state.db_pool {
1731 Some(p) => p,
1732 None => {
1733 return (
1734 StatusCode::SERVICE_UNAVAILABLE,
1735 Json(serde_json::json!({ "error": "Database not configured" })),
1736 )
1737 .into_response();
1738 }
1739 };
1740
1741 match varpulis_db::repo::get_user_by_email(pool, &body.email).await {
1743 Ok(Some(_)) => {
1744 return (
1745 StatusCode::CONFLICT,
1746 Json(serde_json::json!({ "error": "Email already registered" })),
1747 )
1748 .into_response();
1749 }
1750 Err(e) => {
1751 tracing::error!("DB error checking email: {}", e);
1752 return (
1753 StatusCode::INTERNAL_SERVER_ERROR,
1754 Json(serde_json::json!({ "error": "Internal server error" })),
1755 )
1756 .into_response();
1757 }
1758 Ok(None) => {}
1759 }
1760
1761 match varpulis_db::repo::get_user_by_username(pool, &body.username).await {
1763 Ok(Some(_)) => {
1764 return (
1765 StatusCode::CONFLICT,
1766 Json(serde_json::json!({ "error": "Username already taken" })),
1767 )
1768 .into_response();
1769 }
1770 Err(e) => {
1771 tracing::error!("DB error checking username: {}", e);
1772 return (
1773 StatusCode::INTERNAL_SERVER_ERROR,
1774 Json(serde_json::json!({ "error": "Internal server error" })),
1775 )
1776 .into_response();
1777 }
1778 Ok(None) => {}
1779 }
1780
1781 let password_hash = match crate::users::hash_password(&body.password) {
1783 Ok(h) => h,
1784 Err(e) => {
1785 tracing::error!("Password hashing failed: {}", e);
1786 return (
1787 StatusCode::INTERNAL_SERVER_ERROR,
1788 Json(serde_json::json!({ "error": "Internal server error" })),
1789 )
1790 .into_response();
1791 }
1792 };
1793
1794 let token = crate::email::generate_verification_token();
1796 let expires_at = chrono::Utc::now() + chrono::Duration::hours(24);
1797
1798 let user = match varpulis_db::repo::create_local_user_with_verification(
1800 pool,
1801 &body.username,
1802 &password_hash,
1803 &body.username,
1804 &body.email,
1805 "operator",
1806 &token,
1807 expires_at,
1808 )
1809 .await
1810 {
1811 Ok(u) => u,
1812 Err(e) => {
1813 let msg = e.to_string();
1814 let status = if msg.contains("duplicate") || msg.contains("unique") {
1815 StatusCode::CONFLICT
1816 } else {
1817 StatusCode::BAD_REQUEST
1818 };
1819 return (status, Json(serde_json::json!({ "error": msg }))).into_response();
1820 }
1821 };
1822
1823 let org_name = if body.org_name.is_empty() {
1825 format!("{}'s org", body.username)
1826 } else {
1827 body.org_name.clone()
1828 };
1829 let new_org = varpulis_db::repo::create_trial_organization(pool, user.id, &org_name).await;
1830 match &new_org {
1831 Ok(org) => {
1832 if let Ok(templates) = varpulis_db::repo::list_deployed_global_templates(pool).await
1834 {
1835 for t in &templates {
1836 if let Err(e) = varpulis_db::repo::create_global_pipeline_copy(
1837 pool,
1838 org.id,
1839 t.id,
1840 &t.name,
1841 &t.vpl_source,
1842 )
1843 .await
1844 {
1845 tracing::warn!(
1846 "Failed to copy global pipeline '{}' to new org {}: {}",
1847 t.name,
1848 org.id,
1849 e
1850 );
1851 }
1852 }
1853 }
1854 }
1855 Err(e) => {
1856 tracing::error!("Failed to create org for new user: {}", e);
1857 }
1858 }
1859
1860 match &state.email_sender {
1862 Some(sender) => {
1863 if let Err(e) = sender
1864 .send_verification_email(&body.email, &body.username, &token)
1865 .await
1866 {
1867 tracing::error!("Failed to send verification email: {}", e);
1868 }
1869 }
1870 None => {
1871 if let Some(pool) = &state.db_pool {
1873 match varpulis_db::repo::get_user_by_verification_token(pool, &token).await {
1874 Ok(Some(u)) => {
1875 if let Err(e) = varpulis_db::repo::verify_user_email(pool, u.id).await {
1876 tracing::warn!("Auto-verify failed: {}", e);
1877 } else {
1878 tracing::info!(
1879 "Auto-verified user '{}' (SMTP not configured)",
1880 body.username
1881 );
1882 }
1883 }
1884 Ok(None) => tracing::warn!("Auto-verify: token not found"),
1885 Err(e) => tracing::warn!("Auto-verify lookup failed: {}", e),
1886 }
1887 }
1888 }
1889 }
1890
1891 if let Some(ref logger) = state.audit_logger {
1893 logger
1894 .log(
1895 crate::audit::AuditEntry::new(
1896 &body.username,
1897 crate::audit::AuditAction::UserCreate,
1898 "/auth/register",
1899 )
1900 .with_detail("Self-service signup".to_string()),
1901 )
1902 .await;
1903 }
1904
1905 let msg = if state.email_sender.is_some() {
1906 "Check your email to verify your account"
1907 } else {
1908 "Account created successfully"
1909 };
1910
1911 (
1912 StatusCode::CREATED,
1913 Json(serde_json::json!({
1914 "ok": true,
1915 "message": msg,
1916 })),
1917 )
1918 .into_response()
1919 }
1920
1921 #[cfg(not(feature = "saas"))]
1922 {
1923 (
1924 StatusCode::SERVICE_UNAVAILABLE,
1925 Json(serde_json::json!({ "error": "Registration requires saas feature" })),
1926 )
1927 .into_response()
1928 }
1929}
1930
1931#[derive(Debug, Deserialize)]
1933#[allow(dead_code)]
1934struct VerifyQuery {
1935 token: String,
1936}
1937
1938#[allow(unused_variables)]
1940async fn handle_verify_email(
1941 State(state): State<Option<SharedOAuthState>>,
1942 Query(query): Query<VerifyQuery>,
1943) -> Response {
1944 let state = match state {
1945 Some(s) => s,
1946 None => {
1947 return (
1948 StatusCode::SERVICE_UNAVAILABLE,
1949 Json(serde_json::json!({ "error": "OAuth not configured" })),
1950 )
1951 .into_response();
1952 }
1953 };
1954
1955 #[cfg(feature = "saas")]
1956 {
1957 let pool = match &state.db_pool {
1958 Some(p) => p,
1959 None => {
1960 return (
1961 StatusCode::SERVICE_UNAVAILABLE,
1962 Json(serde_json::json!({ "error": "Database not configured" })),
1963 )
1964 .into_response();
1965 }
1966 };
1967
1968 let user = match varpulis_db::repo::get_user_by_verification_token(pool, &query.token).await
1969 {
1970 Ok(Some(u)) => u,
1971 Ok(None) => {
1972 return (
1973 StatusCode::BAD_REQUEST,
1974 Json(serde_json::json!({ "error": "Invalid or expired verification token" })),
1975 )
1976 .into_response();
1977 }
1978 Err(e) => {
1979 tracing::error!("DB error looking up verification token: {}", e);
1980 return (
1981 StatusCode::INTERNAL_SERVER_ERROR,
1982 Json(serde_json::json!({ "error": "Internal server error" })),
1983 )
1984 .into_response();
1985 }
1986 };
1987
1988 if let Some(expires_at) = user.verification_expires_at {
1990 if chrono::Utc::now() > expires_at {
1991 return (
1992 StatusCode::BAD_REQUEST,
1993 Json(serde_json::json!({ "error": "Verification token has expired" })),
1994 )
1995 .into_response();
1996 }
1997 }
1998
1999 if let Err(e) = varpulis_db::repo::verify_user_email(pool, user.id).await {
2001 tracing::error!("Failed to verify user email: {}", e);
2002 return (
2003 StatusCode::INTERNAL_SERVER_ERROR,
2004 Json(serde_json::json!({ "error": "Internal server error" })),
2005 )
2006 .into_response();
2007 }
2008
2009 tracing::info!(
2010 "Email verified for user: {} ({})",
2011 user.username.as_deref().unwrap_or("?"),
2012 user.email
2013 );
2014
2015 (
2016 StatusCode::OK,
2017 Json(serde_json::json!({
2018 "ok": true,
2019 "message": "Email verified. You can now log in.",
2020 })),
2021 )
2022 .into_response()
2023 }
2024
2025 #[cfg(not(feature = "saas"))]
2026 {
2027 let _ = query;
2028 (
2029 StatusCode::SERVICE_UNAVAILABLE,
2030 Json(serde_json::json!({ "error": "Requires saas feature" })),
2031 )
2032 .into_response()
2033 }
2034}
2035
2036pub fn oauth_routes(state: Option<SharedOAuthState>) -> Router {
2042 Router::new()
2043 .route("/auth/github", get(handle_github_redirect))
2045 .route("/auth/github/callback", get(handle_github_callback))
2047 .route("/auth/login", post(handle_login))
2049 .route("/auth/register", post(handle_register))
2051 .route("/auth/verify", get(handle_verify_email))
2053 .route("/auth/change-password", post(handle_change_password))
2055 .route("/auth/renew", post(handle_renew))
2057 .route("/auth/logout", post(handle_logout))
2059 .route("/api/v1/me", get(handle_me))
2061 .route(
2064 "/auth/users",
2065 post(handle_create_user).get(handle_list_users),
2066 )
2067 .with_state(state)
2068}
2069
2070pub fn spawn_session_cleanup(state: SharedOAuthState) {
2072 tokio::spawn(async move {
2073 let mut interval = tokio::time::interval(std::time::Duration::from_secs(3600));
2074 loop {
2075 interval.tick().await;
2076 state.sessions.write().await.cleanup();
2077 }
2078 });
2079}
2080
2081#[cfg(test)]
2086mod tests {
2087 use axum::body::Body;
2088 use axum::http::Request;
2089 use tower::ServiceExt;
2090
2091 use super::*;
2092
2093 fn get_req(uri: &str) -> Request<Body> {
2094 Request::builder()
2095 .method("GET")
2096 .uri(uri)
2097 .body(Body::empty())
2098 .unwrap()
2099 }
2100
2101 #[test]
2102 fn test_jwt_roundtrip() {
2103 let config = OAuthConfig {
2104 github_client_id: "test".to_string(),
2105 github_client_secret: "test".to_string(),
2106 jwt_secret: "super-secret-key-for-testing".to_string(),
2107 frontend_url: "http://localhost:5173".to_string(),
2108 server_url: "http://localhost:9000".to_string(),
2109 };
2110
2111 let user = GitHubUser {
2112 id: 12345,
2113 login: "testuser".to_string(),
2114 name: Some("Test User".to_string()),
2115 avatar_url: "https://example.com/avatar.png".to_string(),
2116 email: Some("test@example.com".to_string()),
2117 };
2118
2119 let token = create_jwt(&config, &user, "", "", "").expect("JWT creation should succeed");
2120 let claims = verify_jwt(&config, &token).expect("JWT verification should succeed");
2121
2122 assert_eq!(claims.sub, "12345");
2123 assert_eq!(claims.login, "testuser");
2124 assert_eq!(claims.name, "Test User");
2125 assert_eq!(claims.email, "test@example.com");
2126 }
2127
2128 #[test]
2129 fn test_jwt_invalid_secret() {
2130 let config = OAuthConfig {
2131 github_client_id: "test".to_string(),
2132 github_client_secret: "test".to_string(),
2133 jwt_secret: "secret-1".to_string(),
2134 frontend_url: "http://localhost:5173".to_string(),
2135 server_url: "http://localhost:9000".to_string(),
2136 };
2137
2138 let user = GitHubUser {
2139 id: 1,
2140 login: "u".to_string(),
2141 name: None,
2142 avatar_url: String::new(),
2143 email: None,
2144 };
2145
2146 let token = create_jwt(&config, &user, "", "", "").unwrap();
2147
2148 let config2 = OAuthConfig {
2150 jwt_secret: "secret-2".to_string(),
2151 ..config
2152 };
2153 assert!(verify_jwt(&config2, &token).is_err());
2154 }
2155
2156 #[test]
2157 fn test_session_store_revoke() {
2158 let mut store = SessionStore::new();
2159 let hash = "abc123".to_string();
2160
2161 assert!(!store.is_revoked(&hash));
2162 store.revoke(hash.clone());
2163 assert!(store.is_revoked(&hash));
2164 }
2165
2166 #[test]
2167 fn test_token_hash_deterministic() {
2168 let h1 = token_hash("my-token");
2169 let h2 = token_hash("my-token");
2170 assert_eq!(h1, h2);
2171 }
2172
2173 #[test]
2174 fn test_token_hash_different_for_different_tokens() {
2175 let h1 = token_hash("token-a");
2176 let h2 = token_hash("token-b");
2177 assert_ne!(h1, h2);
2178 }
2179
2180 #[tokio::test]
2181 async fn test_me_endpoint_no_token() {
2182 let config = OAuthConfig {
2183 github_client_id: "test".to_string(),
2184 github_client_secret: "test".to_string(),
2185 jwt_secret: "test-secret".to_string(),
2186 frontend_url: "http://localhost:5173".to_string(),
2187 server_url: "http://localhost:9000".to_string(),
2188 };
2189 let state = Arc::new(OAuthState::new(config));
2190 let app = oauth_routes(Some(state));
2191
2192 let res = app.oneshot(get_req("/api/v1/me")).await.unwrap();
2193
2194 assert_eq!(res.status(), 401);
2195 }
2196
2197 #[tokio::test]
2198 async fn test_me_endpoint_valid_token() {
2199 let config = OAuthConfig {
2200 github_client_id: "test".to_string(),
2201 github_client_secret: "test".to_string(),
2202 jwt_secret: "test-secret".to_string(),
2203 frontend_url: "http://localhost:5173".to_string(),
2204 server_url: "http://localhost:9000".to_string(),
2205 };
2206
2207 let user = GitHubUser {
2208 id: 42,
2209 login: "octocat".to_string(),
2210 name: Some("Octocat".to_string()),
2211 avatar_url: "https://github.com/octocat.png".to_string(),
2212 email: Some("octocat@github.com".to_string()),
2213 };
2214
2215 let token = create_jwt(&config, &user, "", "", "").unwrap();
2216 let state = Arc::new(OAuthState::new(config));
2217 let app = oauth_routes(Some(state));
2218
2219 let req: Request<Body> = Request::builder()
2220 .method("GET")
2221 .uri("/api/v1/me")
2222 .header("authorization", format!("Bearer {token}"))
2223 .body(Body::empty())
2224 .unwrap();
2225 let res = app.oneshot(req).await.unwrap();
2226
2227 assert_eq!(res.status(), 200);
2228 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
2229 .await
2230 .unwrap();
2231 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
2232 assert_eq!(body["login"], "octocat");
2233 assert_eq!(body["name"], "Octocat");
2234 }
2235
2236 #[tokio::test]
2237 async fn test_me_endpoint_revoked_token() {
2238 let config = OAuthConfig {
2239 github_client_id: "test".to_string(),
2240 github_client_secret: "test".to_string(),
2241 jwt_secret: "test-secret".to_string(),
2242 frontend_url: "http://localhost:5173".to_string(),
2243 server_url: "http://localhost:9000".to_string(),
2244 };
2245
2246 let user = GitHubUser {
2247 id: 42,
2248 login: "octocat".to_string(),
2249 name: Some("Octocat".to_string()),
2250 avatar_url: "https://github.com/octocat.png".to_string(),
2251 email: Some("octocat@github.com".to_string()),
2252 };
2253
2254 let token = create_jwt(&config, &user, "", "", "").unwrap();
2255 let state = Arc::new(OAuthState::new(config));
2256
2257 let hash = token_hash(&token);
2259 state.sessions.write().await.revoke(hash);
2260
2261 let app = oauth_routes(Some(state));
2262
2263 let req: Request<Body> = Request::builder()
2264 .method("GET")
2265 .uri("/api/v1/me")
2266 .header("authorization", format!("Bearer {token}"))
2267 .body(Body::empty())
2268 .unwrap();
2269 let res = app.oneshot(req).await.unwrap();
2270
2271 assert_eq!(res.status(), 401);
2272 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
2273 .await
2274 .unwrap();
2275 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
2276 assert_eq!(body["error"], "Token revoked");
2277 }
2278
2279 #[tokio::test]
2280 async fn test_logout_endpoint() {
2281 let config = OAuthConfig {
2282 github_client_id: "test".to_string(),
2283 github_client_secret: "test".to_string(),
2284 jwt_secret: "test-secret".to_string(),
2285 frontend_url: "http://localhost:5173".to_string(),
2286 server_url: "http://localhost:9000".to_string(),
2287 };
2288 let state = Arc::new(OAuthState::new(config));
2289 let app = oauth_routes(Some(state));
2290
2291 let req: Request<Body> = Request::builder()
2292 .method("POST")
2293 .uri("/auth/logout")
2294 .header("authorization", "Bearer some-token")
2295 .body(Body::empty())
2296 .unwrap();
2297 let res = app.oneshot(req).await.unwrap();
2298
2299 assert_eq!(res.status(), 200);
2300 let set_cookie = res.headers().get("set-cookie").unwrap().to_str().unwrap();
2301 assert!(set_cookie.contains("Max-Age=0"));
2302 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
2303 .await
2304 .unwrap();
2305 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
2306 assert_eq!(body["ok"], true);
2307 }
2308
2309 #[test]
2310 fn test_extract_jwt_from_cookie() {
2311 assert_eq!(
2312 extract_jwt_from_cookie("varpulis_session=abc123"),
2313 Some("abc123".to_string())
2314 );
2315 assert_eq!(
2316 extract_jwt_from_cookie("other=foo; varpulis_session=abc123; more=bar"),
2317 Some("abc123".to_string())
2318 );
2319 assert_eq!(extract_jwt_from_cookie("other=foo"), None);
2320 assert_eq!(extract_jwt_from_cookie("varpulis_session="), None);
2321 }
2322
2323 #[test]
2324 fn test_local_jwt_roundtrip() {
2325 let config = OAuthConfig {
2326 github_client_id: "test".to_string(),
2327 github_client_secret: "test".to_string(),
2328 jwt_secret: "test-secret-key-32chars-minimum!!".to_string(),
2329 frontend_url: "http://localhost:5173".to_string(),
2330 server_url: "http://localhost:9000".to_string(),
2331 };
2332
2333 let token = create_jwt_for_local_user(
2334 &config,
2335 "user-123",
2336 "alice",
2337 "Alice Smith",
2338 "alice@example.com",
2339 "admin",
2340 "session-456",
2341 3600,
2342 "",
2343 )
2344 .unwrap();
2345
2346 let claims = verify_jwt(&config, &token).unwrap();
2347 assert_eq!(claims.sub, "user-123");
2348 assert_eq!(claims.login, "alice");
2349 assert_eq!(claims.name, "Alice Smith");
2350 assert_eq!(claims.role, "admin");
2351 assert_eq!(claims.session_id, "session-456");
2352 assert_eq!(claims.auth_method, "local");
2353 }
2354
2355 #[tokio::test]
2359 async fn test_me_endpoint_with_cookie() {
2360 let config = OAuthConfig {
2361 github_client_id: "test".to_string(),
2362 github_client_secret: "test".to_string(),
2363 jwt_secret: "test-secret".to_string(),
2364 frontend_url: "http://localhost:5173".to_string(),
2365 server_url: "http://localhost:9000".to_string(),
2366 };
2367
2368 let token = create_jwt_for_local_user(
2369 &config,
2370 "user-1",
2371 "alice",
2372 "Alice",
2373 "alice@test.com",
2374 "admin",
2375 "sess-1",
2376 3600,
2377 "",
2378 )
2379 .unwrap();
2380
2381 let state = Arc::new(OAuthState::new(config));
2382 let app = oauth_routes(Some(state));
2383
2384 let req: Request<Body> = Request::builder()
2385 .method("GET")
2386 .uri("/api/v1/me")
2387 .header("cookie", format!("varpulis_session={token}"))
2388 .body(Body::empty())
2389 .unwrap();
2390 let res = app.oneshot(req).await.unwrap();
2391
2392 assert_eq!(res.status(), 200);
2393 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
2394 .await
2395 .unwrap();
2396 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
2397 assert_eq!(body["login"], "alice");
2398 assert_eq!(body["role"], "admin");
2399 assert_eq!(body["auth_method"], "local");
2400 }
2401}