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 let cutoff = std::time::Instant::now()
276 .checked_sub(std::time::Duration::from_secs(86400))
277 .unwrap();
278 self.revoked.retain(|_, instant| *instant > cutoff);
279 }
280}
281
282pub type SharedOAuthState = Arc<OAuthState>;
287
288#[derive(Debug)]
289pub struct OAuthState {
290 pub config: OAuthConfig,
291 pub sessions: RwLock<SessionStore>,
292 pub http_client: reqwest::Client,
293 #[cfg(feature = "saas")]
294 pub db_pool: Option<varpulis_db::PgPool>,
295 pub audit_logger: Option<SharedAuditLogger>,
296 pub session_manager: Option<SharedSessionManager>,
297 #[cfg(feature = "saas")]
298 pub email_sender: Option<crate::email::SharedEmailSender>,
299}
300
301impl OAuthState {
302 pub fn new(config: OAuthConfig) -> Self {
303 Self {
304 config,
305 sessions: RwLock::new(SessionStore::new()),
306 http_client: reqwest::Client::new(),
307 #[cfg(feature = "saas")]
308 db_pool: None,
309 audit_logger: None,
310 session_manager: None,
311 #[cfg(feature = "saas")]
312 email_sender: None,
313 }
314 }
315
316 pub fn with_audit_logger(mut self, logger: Option<SharedAuditLogger>) -> Self {
317 self.audit_logger = logger;
318 self
319 }
320
321 pub fn with_session_manager(mut self, mgr: SharedSessionManager) -> Self {
322 self.session_manager = Some(mgr);
323 self
324 }
325
326 #[cfg(feature = "saas")]
327 pub fn with_db_pool(mut self, pool: varpulis_db::PgPool) -> Self {
328 self.db_pool = Some(pool);
329 self
330 }
331
332 #[cfg(feature = "saas")]
333 pub fn with_email_sender(mut self, sender: Option<crate::email::SharedEmailSender>) -> Self {
334 self.email_sender = sender;
335 self
336 }
337}
338
339fn create_jwt(
344 config: &OAuthConfig,
345 user: &GitHubUser,
346 user_id: &str,
347 org_id: &str,
348 org_role: &str,
349) -> Result<String, jsonwebtoken::errors::Error> {
350 use jsonwebtoken::{encode, EncodingKey, Header};
351
352 let now = chrono::Utc::now().timestamp() as usize;
353 let claims = Claims {
354 sub: user.id.to_string(),
355 name: user.name.clone().unwrap_or_else(|| user.login.clone()),
356 login: user.login.clone(),
357 avatar: user.avatar_url.clone(),
358 email: user.email.clone().unwrap_or_default(),
359 exp: now + 86400 * 7, iat: now,
361 user_id: user_id.to_string(),
362 org_id: org_id.to_string(),
363 role: String::new(),
364 session_id: String::new(),
365 auth_method: "github".to_string(),
366 org_role: org_role.to_string(),
367 };
368
369 encode(
370 &Header::default(),
371 &claims,
372 &EncodingKey::from_secret(config.jwt_secret.as_bytes()),
373 )
374}
375
376#[allow(clippy::too_many_arguments)]
378pub fn create_jwt_for_local_user(
379 config: &OAuthConfig,
380 user_id: &str,
381 username: &str,
382 display_name: &str,
383 email: &str,
384 role: &str,
385 session_id: &str,
386 ttl_secs: usize,
387 org_id: &str,
388) -> Result<String, jsonwebtoken::errors::Error> {
389 use jsonwebtoken::{encode, EncodingKey, Header};
390
391 let now = chrono::Utc::now().timestamp() as usize;
392 let claims = Claims {
393 sub: user_id.to_string(),
394 name: display_name.to_string(),
395 login: username.to_string(),
396 avatar: String::new(),
397 email: email.to_string(),
398 exp: now + ttl_secs,
399 iat: now,
400 user_id: user_id.to_string(),
401 org_id: org_id.to_string(),
402 role: role.to_string(),
403 session_id: session_id.to_string(),
404 auth_method: "local".to_string(),
405 org_role: String::new(),
406 };
407
408 encode(
409 &Header::default(),
410 &claims,
411 &EncodingKey::from_secret(config.jwt_secret.as_bytes()),
412 )
413}
414
415pub fn verify_jwt(
416 config: &OAuthConfig,
417 token: &str,
418) -> Result<Claims, jsonwebtoken::errors::Error> {
419 use jsonwebtoken::{decode, DecodingKey, Validation};
420
421 let token_data = decode::<Claims>(
422 token,
423 &DecodingKey::from_secret(config.jwt_secret.as_bytes()),
424 &Validation::default(),
425 )?;
426
427 Ok(token_data.claims)
428}
429
430pub fn token_hash(token: &str) -> String {
432 use sha2::Digest;
433 hex::encode(sha2::Sha256::digest(token.as_bytes()))
434}
435
436const COOKIE_NAME: &str = "varpulis_session";
441
442fn create_session_cookie(jwt: &str, max_age_secs: u64) -> String {
444 format!(
445 "{COOKIE_NAME}={jwt}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age={max_age_secs}"
446 )
447}
448
449fn clear_session_cookie() -> String {
451 format!("{COOKIE_NAME}=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0")
452}
453
454pub fn extract_jwt_from_cookie(cookie_header: &str) -> Option<String> {
456 for cookie in cookie_header.split(';') {
457 let cookie = cookie.trim();
458 if let Some(value) = cookie.strip_prefix("varpulis_session=") {
459 let value = value.trim();
460 if !value.is_empty() {
461 return Some(value.to_string());
462 }
463 }
464 }
465 None
466}
467
468async fn handle_github_redirect(State(state): State<Option<SharedOAuthState>>) -> Response {
474 let state = match state {
475 Some(s) => s,
476 None => {
477 return (
478 StatusCode::SERVICE_UNAVAILABLE,
479 Json(serde_json::json!({"error": "OAuth not configured"})),
480 )
481 .into_response();
482 }
483 };
484
485 let redirect_uri = format!("{}/auth/github/callback", state.config.server_url);
486 let url = format!(
487 "https://github.com/login/oauth/authorize?client_id={}&redirect_uri={}&scope=read:user%20user:email",
488 state.config.github_client_id,
489 urlencoding::encode(&redirect_uri),
490 );
491
492 Redirect::temporary(&url).into_response()
493}
494
495#[derive(Debug, Deserialize)]
497struct CallbackQuery {
498 code: String,
499}
500
501async fn handle_github_callback(
503 State(state): State<Option<SharedOAuthState>>,
504 Query(query): Query<CallbackQuery>,
505) -> Response {
506 let state = match state {
507 Some(s) => s,
508 None => {
509 return (
510 StatusCode::SERVICE_UNAVAILABLE,
511 Json(serde_json::json!({"error": "OAuth not configured"})),
512 )
513 .into_response();
514 }
515 };
516
517 let redirect_uri = format!("{}/auth/github/callback", state.config.server_url);
518
519 let token_resp = match state
521 .http_client
522 .post("https://github.com/login/oauth/access_token")
523 .header("Accept", "application/json")
524 .form(&[
525 ("client_id", state.config.github_client_id.as_str()),
526 ("client_secret", state.config.github_client_secret.as_str()),
527 ("code", query.code.as_str()),
528 ("redirect_uri", redirect_uri.as_str()),
529 ])
530 .send()
531 .await
532 {
533 Ok(resp) => resp,
534 Err(e) => {
535 tracing::error!("GitHub token exchange failed: {}", e);
536 return (
537 StatusCode::BAD_GATEWAY,
538 Json(serde_json::json!({"error": "GitHub token exchange failed"})),
539 )
540 .into_response();
541 }
542 };
543
544 let token_data: GitHubTokenResponse = match token_resp.json().await {
545 Ok(data) => data,
546 Err(e) => {
547 tracing::error!("Failed to parse GitHub token response: {}", e);
548 return (
549 StatusCode::BAD_GATEWAY,
550 Json(serde_json::json!({"error": "Failed to parse GitHub token response"})),
551 )
552 .into_response();
553 }
554 };
555
556 let user: GitHubUser = match state
558 .http_client
559 .get("https://api.github.com/user")
560 .header(
561 "Authorization",
562 format!("Bearer {}", token_data.access_token),
563 )
564 .header("User-Agent", "Varpulis")
565 .send()
566 .await
567 {
568 Ok(resp) => match resp.json().await {
569 Ok(user) => user,
570 Err(e) => {
571 tracing::error!("Failed to parse GitHub user: {}", e);
572 return (
573 StatusCode::BAD_GATEWAY,
574 Json(serde_json::json!({"error": "Failed to parse GitHub user"})),
575 )
576 .into_response();
577 }
578 },
579 Err(e) => {
580 tracing::error!("GitHub user fetch failed: {}", e);
581 return (
582 StatusCode::BAD_GATEWAY,
583 Json(serde_json::json!({"error": "GitHub user fetch failed"})),
584 )
585 .into_response();
586 }
587 };
588
589 let (db_user_id, db_org_id) = {
591 #[cfg(feature = "saas")]
592 {
593 if let Some(ref pool) = state.db_pool {
594 match upsert_user_and_org(pool, &user).await {
595 Ok((uid, oid)) => (uid, oid),
596 Err(e) => {
597 tracing::error!("DB user/org upsert failed: {}", e);
598 (String::new(), String::new())
599 }
600 }
601 } else {
602 (String::new(), String::new())
603 }
604 }
605 #[cfg(not(feature = "saas"))]
606 {
607 (String::new(), String::new())
608 }
609 };
610
611 let jwt = match create_jwt(&state.config, &user, &db_user_id, &db_org_id, "owner") {
613 Ok(token) => token,
614 Err(e) => {
615 tracing::error!("JWT creation failed: {}", e);
616 return (
617 StatusCode::INTERNAL_SERVER_ERROR,
618 Json(serde_json::json!({"error": "JWT creation failed"})),
619 )
620 .into_response();
621 }
622 };
623
624 tracing::info!("OAuth login: {} ({})", user.login, user.id);
625
626 if let Some(ref logger) = state.audit_logger {
628 logger
629 .log(
630 AuditEntry::new(&user.login, AuditAction::Login, "/auth/github/callback")
631 .with_detail(format!("GitHub user ID: {}", user.id)),
632 )
633 .await;
634 }
635
636 let redirect_url = format!("{}/?token={}", state.config.frontend_url, jwt);
638 Redirect::temporary(&redirect_url).into_response()
639}
640
641#[cfg(feature = "saas")]
643async fn upsert_user_and_org(
644 pool: &varpulis_db::PgPool,
645 github_user: &GitHubUser,
646) -> Result<(String, String), String> {
647 let db_user = varpulis_db::repo::create_or_update_user(
648 pool,
649 &github_user.id.to_string(),
650 github_user.email.as_deref().unwrap_or(""),
651 github_user.name.as_deref().unwrap_or(&github_user.login),
652 &github_user.avatar_url,
653 )
654 .await
655 .map_err(|e| e.to_string())?;
656
657 let orgs = varpulis_db::repo::get_user_organizations(pool, db_user.id)
658 .await
659 .map_err(|e| e.to_string())?;
660
661 let org = if orgs.is_empty() {
662 let org_name = format!("{}'s org", github_user.login);
663 varpulis_db::repo::create_organization(pool, db_user.id, &org_name)
664 .await
665 .map_err(|e| e.to_string())?
666 } else {
667 orgs.into_iter().next().unwrap()
668 };
669
670 tracing::info!(
671 "DB upsert: user={} org={} ({})",
672 db_user.id,
673 org.id,
674 org.name
675 );
676
677 Ok((db_user.id.to_string(), org.id.to_string()))
678}
679
680async fn handle_logout(
682 State(state): State<Option<SharedOAuthState>>,
683 headers: HeaderMap,
684) -> Response {
685 let state = match state {
686 Some(s) => s,
687 None => {
688 return (
689 StatusCode::SERVICE_UNAVAILABLE,
690 Json(serde_json::json!({"error": "OAuth not configured"})),
691 )
692 .into_response();
693 }
694 };
695
696 let auth_header = headers
697 .get("authorization")
698 .and_then(|v| v.to_str().ok())
699 .map(|s| s.to_string());
700 let cookie_header = headers
701 .get("cookie")
702 .and_then(|v| v.to_str().ok())
703 .map(|s| s.to_string());
704
705 let token = cookie_header
707 .as_deref()
708 .and_then(extract_jwt_from_cookie)
709 .or_else(|| {
710 auth_header
711 .as_ref()
712 .map(|h| h.strip_prefix("Bearer ").unwrap_or(h).trim().to_string())
713 });
714
715 if let Some(token) = token {
716 if !token.is_empty() {
717 if let Ok(claims) = verify_jwt(&state.config, &token) {
719 if claims.auth_method == "local" && !claims.session_id.is_empty() {
720 if let Some(ref session_mgr) = state.session_manager {
721 session_mgr.write().await.revoke_session(&claims.session_id);
722 }
723 }
724 }
725
726 let hash = token_hash(&token);
727 state.sessions.write().await.revoke(hash);
728
729 if let Some(ref logger) = state.audit_logger {
731 logger
732 .log(AuditEntry::new(
733 "session",
734 AuditAction::Logout,
735 "/auth/logout",
736 ))
737 .await;
738 }
739 }
740 }
741
742 (
743 StatusCode::OK,
744 [("set-cookie", clear_session_cookie())],
745 Json(serde_json::json!({ "ok": true })),
746 )
747 .into_response()
748}
749
750async fn handle_me(State(state): State<Option<SharedOAuthState>>, headers: HeaderMap) -> Response {
752 let state = match state {
753 Some(s) => s,
754 None => {
755 return (
756 StatusCode::SERVICE_UNAVAILABLE,
757 Json(serde_json::json!({"error": "OAuth not configured"})),
758 )
759 .into_response();
760 }
761 };
762
763 let auth_header = headers
764 .get("authorization")
765 .and_then(|v| v.to_str().ok())
766 .map(|s| s.to_string());
767 let cookie_header = headers
768 .get("cookie")
769 .and_then(|v| v.to_str().ok())
770 .map(|s| s.to_string());
771
772 let token = cookie_header
774 .as_deref()
775 .and_then(extract_jwt_from_cookie)
776 .or_else(|| {
777 auth_header
778 .as_ref()
779 .map(|h| h.strip_prefix("Bearer ").unwrap_or(h).trim().to_string())
780 });
781
782 let token = match token {
783 Some(t) if !t.is_empty() => t,
784 _ => {
785 return (
786 StatusCode::UNAUTHORIZED,
787 Json(serde_json::json!({ "error": "No token provided" })),
788 )
789 .into_response();
790 }
791 };
792
793 let hash = token_hash(&token);
795 if state.sessions.read().await.is_revoked(&hash) {
796 return (
797 StatusCode::UNAUTHORIZED,
798 Json(serde_json::json!({ "error": "Token revoked" })),
799 )
800 .into_response();
801 }
802
803 match verify_jwt(&state.config, &token) {
805 Ok(claims) => {
806 #[allow(unused_mut)]
807 let mut response = serde_json::json!({
808 "id": claims.sub,
809 "name": claims.name,
810 "login": claims.login,
811 "avatar": claims.avatar,
812 "email": claims.email,
813 "user_id": claims.user_id,
814 "org_id": claims.org_id,
815 "role": claims.role,
816 "auth_method": claims.auth_method,
817 });
818
819 #[cfg(feature = "saas")]
821 if let Some(ref pool) = state.db_pool {
822 if !claims.user_id.is_empty() {
823 if let Ok(user_uuid) = claims.user_id.parse::<uuid::Uuid>() {
824 if let Ok(orgs) =
825 varpulis_db::repo::get_user_organizations(pool, user_uuid).await
826 {
827 let orgs_json: Vec<serde_json::Value> = orgs
828 .iter()
829 .map(|o| {
830 serde_json::json!({
831 "id": o.id.to_string(),
832 "name": o.name,
833 "tier": o.tier,
834 })
835 })
836 .collect();
837 response["organizations"] = serde_json::json!(orgs_json);
838 }
839 }
840 }
841 }
842
843 (StatusCode::OK, Json(response)).into_response()
844 }
845 Err(e) => {
846 tracing::debug!("JWT verification failed: {}", e);
847 (
848 StatusCode::UNAUTHORIZED,
849 Json(serde_json::json!({ "error": "Invalid token" })),
850 )
851 .into_response()
852 }
853 }
854}
855
856#[derive(Debug, Deserialize)]
862#[allow(dead_code)]
863struct LoginRequest {
864 username: String,
865 password: String,
866}
867
868async fn handle_login(
870 State(state): State<Option<SharedOAuthState>>,
871 Json(body): Json<LoginRequest>,
872) -> Response {
873 let state = match state {
874 Some(s) => s,
875 None => {
876 return (
877 StatusCode::SERVICE_UNAVAILABLE,
878 Json(serde_json::json!({ "error": "OAuth not configured" })),
879 )
880 .into_response();
881 }
882 };
883
884 #[cfg(feature = "saas")]
886 let db_user = {
887 let pool = match &state.db_pool {
888 Some(p) => p,
889 None => {
890 return (
891 StatusCode::SERVICE_UNAVAILABLE,
892 Json(serde_json::json!({ "error": "Database not configured" })),
893 )
894 .into_response();
895 }
896 };
897 match varpulis_db::repo::get_user_by_username(pool, &body.username).await {
898 Ok(Some(u)) => u,
899 Ok(None) | Err(_) => {
900 if let Some(ref logger) = state.audit_logger {
901 logger
902 .log(
903 AuditEntry::new(&body.username, AuditAction::Login, "/auth/login")
904 .with_outcome(crate::audit::AuditOutcome::Failure)
905 .with_detail("Invalid username or password".to_string()),
906 )
907 .await;
908 }
909 return (
910 StatusCode::UNAUTHORIZED,
911 Json(serde_json::json!({ "error": "Invalid username or password" })),
912 )
913 .into_response();
914 }
915 }
916 };
917 #[cfg(not(feature = "saas"))]
918 {
919 let _ = (&body, &state);
920 (
921 StatusCode::SERVICE_UNAVAILABLE,
922 Json(serde_json::json!({ "error": "Local auth requires saas feature" })),
923 )
924 .into_response()
925 }
926
927 #[cfg(feature = "saas")]
928 {
929 if db_user.disabled {
931 return (
932 StatusCode::UNAUTHORIZED,
933 Json(serde_json::json!({ "error": "Account is disabled" })),
934 )
935 .into_response();
936 }
937
938 if !db_user.email_verified {
940 return (
941 StatusCode::FORBIDDEN,
942 Json(serde_json::json!({ "error": "Please verify your email before logging in" })),
943 )
944 .into_response();
945 }
946
947 let password_hash = match &db_user.password_hash {
949 Some(h) => h.clone(),
950 None => {
951 return (
952 StatusCode::UNAUTHORIZED,
953 Json(serde_json::json!({ "error": "Invalid username or password" })),
954 )
955 .into_response();
956 }
957 };
958 match crate::users::verify_password(&body.password, &password_hash) {
959 Ok(true) => {}
960 _ => {
961 if let Some(ref logger) = state.audit_logger {
962 logger
963 .log(
964 AuditEntry::new(&body.username, AuditAction::Login, "/auth/login")
965 .with_outcome(crate::audit::AuditOutcome::Failure)
966 .with_detail("Invalid username or password".to_string()),
967 )
968 .await;
969 }
970 return (
971 StatusCode::UNAUTHORIZED,
972 Json(serde_json::json!({ "error": "Invalid username or password" })),
973 )
974 .into_response();
975 }
976 }
977
978 let session_mgr = match &state.session_manager {
980 Some(m) => m.clone(),
981 None => {
982 return (
983 StatusCode::SERVICE_UNAVAILABLE,
984 Json(serde_json::json!({ "error": "Session manager not configured" })),
985 )
986 .into_response();
987 }
988 };
989
990 let mut mgr = session_mgr.write().await;
991 let user_id_str = db_user.id.to_string();
992 let username = db_user.username.as_deref().unwrap_or("");
993 let session = mgr.create_session(&user_id_str, username, &db_user.role);
994 let ttl_secs = mgr.session_config().absolute_timeout.as_secs() as usize;
995 drop(mgr);
996
997 let org_id = {
999 let pool = state.db_pool.as_ref().unwrap();
1000 match varpulis_db::repo::get_user_organizations(pool, db_user.id).await {
1001 Ok(orgs) if !orgs.is_empty() => orgs[0].id.to_string(),
1002 _ => String::new(),
1003 }
1004 };
1005
1006 let jwt = match create_jwt_for_local_user(
1007 &state.config,
1008 &user_id_str,
1009 username,
1010 &db_user.display_name,
1011 &db_user.email,
1012 &db_user.role,
1013 &session.session_id,
1014 ttl_secs,
1015 &org_id,
1016 ) {
1017 Ok(token) => token,
1018 Err(e) => {
1019 tracing::error!("JWT creation failed: {}", e);
1020 return (
1021 StatusCode::INTERNAL_SERVER_ERROR,
1022 Json(serde_json::json!({ "error": "Internal server error" })),
1023 )
1024 .into_response();
1025 }
1026 };
1027
1028 if let Some(ref logger) = state.audit_logger {
1030 logger
1031 .log(
1032 AuditEntry::new(username, AuditAction::Login, "/auth/login")
1033 .with_detail(format!("session: {}", session.session_id)),
1034 )
1035 .await;
1036 }
1037
1038 let cookie = create_session_cookie(&jwt, ttl_secs as u64);
1039 let response = serde_json::json!({
1040 "ok": true,
1041 "user": {
1042 "id": user_id_str,
1043 "username": username,
1044 "display_name": db_user.display_name,
1045 "email": db_user.email,
1046 "role": db_user.role,
1047 },
1048 "token": jwt,
1049 });
1050
1051 (StatusCode::OK, [("set-cookie", cookie)], Json(response)).into_response()
1052 }
1053}
1054
1055async fn handle_renew(
1057 State(state): State<Option<SharedOAuthState>>,
1058 headers: HeaderMap,
1059) -> Response {
1060 let state = match state {
1061 Some(s) => s,
1062 None => {
1063 return (
1064 StatusCode::SERVICE_UNAVAILABLE,
1065 Json(serde_json::json!({"error": "OAuth not configured"})),
1066 )
1067 .into_response();
1068 }
1069 };
1070
1071 let auth_header = headers
1072 .get("authorization")
1073 .and_then(|v| v.to_str().ok())
1074 .map(|s| s.to_string());
1075 let cookie_header = headers
1076 .get("cookie")
1077 .and_then(|v| v.to_str().ok())
1078 .map(|s| s.to_string());
1079
1080 let token = cookie_header
1082 .as_deref()
1083 .and_then(extract_jwt_from_cookie)
1084 .or_else(|| {
1085 auth_header
1086 .as_ref()
1087 .map(|h| h.strip_prefix("Bearer ").unwrap_or(h).trim().to_string())
1088 });
1089
1090 let token = match token {
1091 Some(t) if !t.is_empty() => t,
1092 _ => {
1093 return (
1094 StatusCode::UNAUTHORIZED,
1095 Json(serde_json::json!({ "error": "No session token" })),
1096 )
1097 .into_response();
1098 }
1099 };
1100
1101 let claims = match verify_jwt(&state.config, &token) {
1103 Ok(c) => c,
1104 Err(_) => {
1105 return (
1106 StatusCode::UNAUTHORIZED,
1107 Json(serde_json::json!({ "error": "Invalid or expired token" })),
1108 )
1109 .into_response();
1110 }
1111 };
1112
1113 if claims.auth_method != "local" || claims.session_id.is_empty() {
1115 return (
1116 StatusCode::BAD_REQUEST,
1117 Json(serde_json::json!({ "error": "Session renewal not applicable" })),
1118 )
1119 .into_response();
1120 }
1121
1122 let session_mgr = match &state.session_manager {
1123 Some(m) => m.clone(),
1124 None => {
1125 return (
1126 StatusCode::SERVICE_UNAVAILABLE,
1127 Json(serde_json::json!({ "error": "Session manager not configured" })),
1128 )
1129 .into_response();
1130 }
1131 };
1132
1133 let mut mgr = session_mgr.write().await;
1134
1135 if mgr.validate_session(&claims.session_id).is_none() {
1137 return (
1138 StatusCode::UNAUTHORIZED,
1139 Json(serde_json::json!({ "error": "Session expired or revoked" })),
1140 )
1141 .into_response();
1142 }
1143
1144 let ttl_secs = mgr.session_config().absolute_timeout.as_secs() as usize;
1145 drop(mgr);
1146
1147 let (username, display_name, email, role, org_id) = {
1149 #[cfg(feature = "saas")]
1150 {
1151 if let Some(ref pool) = state.db_pool {
1152 if let Ok(user_uuid) = claims.sub.parse::<uuid::Uuid>() {
1153 match varpulis_db::repo::get_user_by_id(pool, user_uuid).await {
1154 Ok(Some(u)) => {
1155 let oid =
1156 match varpulis_db::repo::get_user_organizations(pool, u.id).await {
1157 Ok(orgs) if !orgs.is_empty() => orgs[0].id.to_string(),
1158 _ => claims.org_id.clone(),
1159 };
1160 (
1161 u.username.unwrap_or_else(|| claims.login.clone()),
1162 u.display_name,
1163 u.email,
1164 u.role,
1165 oid,
1166 )
1167 }
1168 _ => (
1169 claims.login.clone(),
1170 claims.name.clone(),
1171 claims.email.clone(),
1172 claims.role.clone(),
1173 claims.org_id.clone(),
1174 ),
1175 }
1176 } else {
1177 (
1178 claims.login.clone(),
1179 claims.name.clone(),
1180 claims.email.clone(),
1181 claims.role.clone(),
1182 claims.org_id.clone(),
1183 )
1184 }
1185 } else {
1186 (
1187 claims.login.clone(),
1188 claims.name.clone(),
1189 claims.email.clone(),
1190 claims.role.clone(),
1191 claims.org_id.clone(),
1192 )
1193 }
1194 }
1195 #[cfg(not(feature = "saas"))]
1196 {
1197 (
1198 claims.login.clone(),
1199 claims.name.clone(),
1200 claims.email.clone(),
1201 claims.role.clone(),
1202 claims.org_id.clone(),
1203 )
1204 }
1205 };
1206
1207 let hash = token_hash(&token);
1209 state.sessions.write().await.revoke(hash);
1210
1211 let jwt = match create_jwt_for_local_user(
1212 &state.config,
1213 &claims.sub,
1214 &username,
1215 &display_name,
1216 &email,
1217 &role,
1218 &claims.session_id,
1219 ttl_secs,
1220 &org_id,
1221 ) {
1222 Ok(t) => t,
1223 Err(e) => {
1224 tracing::error!("JWT renewal failed: {}", e);
1225 return (
1226 StatusCode::INTERNAL_SERVER_ERROR,
1227 Json(serde_json::json!({ "error": "Internal server error" })),
1228 )
1229 .into_response();
1230 }
1231 };
1232
1233 if let Some(ref logger) = state.audit_logger {
1234 logger
1235 .log(AuditEntry::new(
1236 &username,
1237 AuditAction::SessionRenew,
1238 "/auth/renew",
1239 ))
1240 .await;
1241 }
1242
1243 let cookie = create_session_cookie(&jwt, ttl_secs as u64);
1244
1245 (
1246 StatusCode::OK,
1247 [("set-cookie", cookie)],
1248 Json(serde_json::json!({
1249 "ok": true,
1250 "token": jwt,
1251 })),
1252 )
1253 .into_response()
1254}
1255
1256#[derive(Debug, Deserialize)]
1258#[allow(dead_code)]
1259struct CreateUserRequest {
1260 username: String,
1261 password: String,
1262 display_name: String,
1263 #[serde(default)]
1264 email: String,
1265 #[serde(default = "default_role")]
1266 role: String,
1267}
1268
1269fn default_role() -> String {
1270 "viewer".to_string()
1271}
1272
1273async fn handle_create_user(
1275 State(state): State<Option<SharedOAuthState>>,
1276 headers: HeaderMap,
1277 Json(body): Json<CreateUserRequest>,
1278) -> Response {
1279 let state = match state {
1280 Some(s) => s,
1281 None => {
1282 return (
1283 StatusCode::SERVICE_UNAVAILABLE,
1284 Json(serde_json::json!({"error": "OAuth not configured"})),
1285 )
1286 .into_response();
1287 }
1288 };
1289
1290 let auth_header = headers.get("authorization").and_then(|v| v.to_str().ok());
1291 let cookie_header = headers.get("cookie").and_then(|v| v.to_str().ok());
1292
1293 let claims = match extract_and_verify_claims(&state, auth_header, cookie_header).await {
1295 Ok(c) => c,
1296 Err(resp) => return resp,
1297 };
1298
1299 if claims.role != "admin" {
1300 return (
1301 StatusCode::FORBIDDEN,
1302 Json(serde_json::json!({ "error": "Admin access required" })),
1303 )
1304 .into_response();
1305 }
1306
1307 if body.username.is_empty() || body.username.len() > 64 {
1309 return (
1310 StatusCode::BAD_REQUEST,
1311 Json(serde_json::json!({ "error": "Username must be 1-64 characters" })),
1312 )
1313 .into_response();
1314 }
1315 if body.password.len() < 8 {
1316 return (
1317 StatusCode::BAD_REQUEST,
1318 Json(serde_json::json!({ "error": "Password must be at least 8 characters" })),
1319 )
1320 .into_response();
1321 }
1322
1323 let password_hash = match crate::users::hash_password(&body.password) {
1325 Ok(h) => h,
1326 Err(e) => {
1327 tracing::error!("Password hashing failed: {}", e);
1328 return (
1329 StatusCode::INTERNAL_SERVER_ERROR,
1330 Json(serde_json::json!({ "error": "Internal server error" })),
1331 )
1332 .into_response();
1333 }
1334 };
1335
1336 #[cfg(feature = "saas")]
1337 {
1338 let pool = match &state.db_pool {
1339 Some(p) => p,
1340 None => {
1341 return (
1342 StatusCode::SERVICE_UNAVAILABLE,
1343 Json(serde_json::json!({ "error": "Database not configured" })),
1344 )
1345 .into_response();
1346 }
1347 };
1348
1349 match varpulis_db::repo::create_local_user(
1350 pool,
1351 &body.username,
1352 &password_hash,
1353 &body.display_name,
1354 &body.email,
1355 &body.role,
1356 )
1357 .await
1358 {
1359 Ok(user) => {
1360 if let Some(ref logger) = state.audit_logger {
1361 logger
1362 .log(
1363 AuditEntry::new(&claims.login, AuditAction::UserCreate, "/auth/users")
1364 .with_detail(format!(
1365 "Created user: {} ({})",
1366 body.username, body.role
1367 )),
1368 )
1369 .await;
1370 }
1371
1372 (
1373 StatusCode::CREATED,
1374 Json(serde_json::json!({
1375 "id": user.id.to_string(),
1376 "username": user.username,
1377 "display_name": user.display_name,
1378 "email": user.email,
1379 "role": user.role,
1380 })),
1381 )
1382 .into_response()
1383 }
1384 Err(e) => {
1385 let msg = e.to_string();
1386 let status = if msg.contains("duplicate") || msg.contains("unique") {
1387 StatusCode::CONFLICT
1388 } else {
1389 StatusCode::BAD_REQUEST
1390 };
1391 (status, Json(serde_json::json!({ "error": msg }))).into_response()
1392 }
1393 }
1394 }
1395 #[cfg(not(feature = "saas"))]
1396 {
1397 let _ = password_hash;
1398 (
1399 StatusCode::SERVICE_UNAVAILABLE,
1400 Json(serde_json::json!({ "error": "Requires saas feature" })),
1401 )
1402 .into_response()
1403 }
1404}
1405
1406async fn handle_list_users(
1408 State(state): State<Option<SharedOAuthState>>,
1409 headers: HeaderMap,
1410) -> Response {
1411 let state = match state {
1412 Some(s) => s,
1413 None => {
1414 return (
1415 StatusCode::SERVICE_UNAVAILABLE,
1416 Json(serde_json::json!({"error": "OAuth not configured"})),
1417 )
1418 .into_response();
1419 }
1420 };
1421
1422 let auth_header = headers.get("authorization").and_then(|v| v.to_str().ok());
1423 let cookie_header = headers.get("cookie").and_then(|v| v.to_str().ok());
1424
1425 let claims = match extract_and_verify_claims(&state, auth_header, cookie_header).await {
1426 Ok(c) => c,
1427 Err(resp) => return resp,
1428 };
1429
1430 if claims.role != "admin" {
1431 return (
1432 StatusCode::FORBIDDEN,
1433 Json(serde_json::json!({ "error": "Admin access required" })),
1434 )
1435 .into_response();
1436 }
1437
1438 #[cfg(feature = "saas")]
1439 {
1440 let pool = match &state.db_pool {
1441 Some(p) => p,
1442 None => {
1443 return (
1444 StatusCode::SERVICE_UNAVAILABLE,
1445 Json(serde_json::json!({ "error": "Database not configured" })),
1446 )
1447 .into_response();
1448 }
1449 };
1450
1451 match varpulis_db::repo::list_users(pool).await {
1452 Ok(db_users) => {
1453 let users: Vec<crate::users::UserSummary> = db_users
1454 .iter()
1455 .map(|u| crate::users::UserSummary {
1456 id: u.id.to_string(),
1457 username: u.username.clone().unwrap_or_default(),
1458 display_name: u.display_name.clone(),
1459 email: u.email.clone(),
1460 role: u.role.clone(),
1461 disabled: u.disabled,
1462 created_at: u.created_at,
1463 })
1464 .collect();
1465 (StatusCode::OK, Json(serde_json::json!({ "users": users }))).into_response()
1466 }
1467 Err(e) => {
1468 tracing::error!("Failed to list users: {}", e);
1469 (
1470 StatusCode::INTERNAL_SERVER_ERROR,
1471 Json(serde_json::json!({ "error": "Internal error" })),
1472 )
1473 .into_response()
1474 }
1475 }
1476 }
1477 #[cfg(not(feature = "saas"))]
1478 {
1479 (
1480 StatusCode::SERVICE_UNAVAILABLE,
1481 Json(serde_json::json!({ "error": "Requires saas feature" })),
1482 )
1483 .into_response()
1484 }
1485}
1486
1487async fn extract_and_verify_claims(
1489 state: &SharedOAuthState,
1490 auth_header: Option<&str>,
1491 cookie_header: Option<&str>,
1492) -> Result<Claims, Response> {
1493 let token = cookie_header
1494 .and_then(extract_jwt_from_cookie)
1495 .or_else(|| auth_header.map(|h| h.strip_prefix("Bearer ").unwrap_or(h).trim().to_string()));
1496
1497 let token = match token {
1498 Some(t) if !t.is_empty() => t,
1499 _ => {
1500 return Err((
1501 StatusCode::UNAUTHORIZED,
1502 Json(serde_json::json!({ "error": "Authentication required" })),
1503 )
1504 .into_response());
1505 }
1506 };
1507
1508 let hash = token_hash(&token);
1510 if state.sessions.read().await.is_revoked(&hash) {
1511 return Err((
1512 StatusCode::UNAUTHORIZED,
1513 Json(serde_json::json!({ "error": "Token revoked" })),
1514 )
1515 .into_response());
1516 }
1517
1518 verify_jwt(&state.config, &token).map_err(|_| {
1519 (
1520 StatusCode::UNAUTHORIZED,
1521 Json(serde_json::json!({ "error": "Invalid or expired token" })),
1522 )
1523 .into_response()
1524 })
1525}
1526
1527#[derive(Debug, Deserialize)]
1533#[allow(dead_code)]
1534struct RegisterRequest {
1535 username: String,
1536 email: String,
1537 password: String,
1538 org_name: String,
1539}
1540
1541#[allow(unused_variables)]
1543async fn handle_register(
1544 State(state): State<Option<SharedOAuthState>>,
1545 Json(body): Json<RegisterRequest>,
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 if body.username.is_empty() || body.username.len() > 64 {
1560 return (
1561 StatusCode::BAD_REQUEST,
1562 Json(serde_json::json!({ "error": "Username must be 1-64 characters" })),
1563 )
1564 .into_response();
1565 }
1566 if body.password.len() < 8 {
1567 return (
1568 StatusCode::BAD_REQUEST,
1569 Json(serde_json::json!({ "error": "Password must be at least 8 characters" })),
1570 )
1571 .into_response();
1572 }
1573 if !body.email.contains('@') || body.email.len() < 3 {
1574 return (
1575 StatusCode::BAD_REQUEST,
1576 Json(serde_json::json!({ "error": "Invalid email address" })),
1577 )
1578 .into_response();
1579 }
1580
1581 #[cfg(feature = "saas")]
1582 {
1583 let pool = match &state.db_pool {
1584 Some(p) => p,
1585 None => {
1586 return (
1587 StatusCode::SERVICE_UNAVAILABLE,
1588 Json(serde_json::json!({ "error": "Database not configured" })),
1589 )
1590 .into_response();
1591 }
1592 };
1593
1594 match varpulis_db::repo::get_user_by_email(pool, &body.email).await {
1596 Ok(Some(_)) => {
1597 return (
1598 StatusCode::CONFLICT,
1599 Json(serde_json::json!({ "error": "Email already registered" })),
1600 )
1601 .into_response();
1602 }
1603 Err(e) => {
1604 tracing::error!("DB error checking email: {}", e);
1605 return (
1606 StatusCode::INTERNAL_SERVER_ERROR,
1607 Json(serde_json::json!({ "error": "Internal server error" })),
1608 )
1609 .into_response();
1610 }
1611 Ok(None) => {}
1612 }
1613
1614 match varpulis_db::repo::get_user_by_username(pool, &body.username).await {
1616 Ok(Some(_)) => {
1617 return (
1618 StatusCode::CONFLICT,
1619 Json(serde_json::json!({ "error": "Username already taken" })),
1620 )
1621 .into_response();
1622 }
1623 Err(e) => {
1624 tracing::error!("DB error checking username: {}", e);
1625 return (
1626 StatusCode::INTERNAL_SERVER_ERROR,
1627 Json(serde_json::json!({ "error": "Internal server error" })),
1628 )
1629 .into_response();
1630 }
1631 Ok(None) => {}
1632 }
1633
1634 let password_hash = match crate::users::hash_password(&body.password) {
1636 Ok(h) => h,
1637 Err(e) => {
1638 tracing::error!("Password hashing failed: {}", e);
1639 return (
1640 StatusCode::INTERNAL_SERVER_ERROR,
1641 Json(serde_json::json!({ "error": "Internal server error" })),
1642 )
1643 .into_response();
1644 }
1645 };
1646
1647 let token = crate::email::generate_verification_token();
1649 let expires_at = chrono::Utc::now() + chrono::Duration::hours(24);
1650
1651 let user = match varpulis_db::repo::create_local_user_with_verification(
1653 pool,
1654 &body.username,
1655 &password_hash,
1656 &body.username,
1657 &body.email,
1658 "operator",
1659 &token,
1660 expires_at,
1661 )
1662 .await
1663 {
1664 Ok(u) => u,
1665 Err(e) => {
1666 let msg = e.to_string();
1667 let status = if msg.contains("duplicate") || msg.contains("unique") {
1668 StatusCode::CONFLICT
1669 } else {
1670 StatusCode::BAD_REQUEST
1671 };
1672 return (status, Json(serde_json::json!({ "error": msg }))).into_response();
1673 }
1674 };
1675
1676 let org_name = if body.org_name.is_empty() {
1678 format!("{}'s org", body.username)
1679 } else {
1680 body.org_name.clone()
1681 };
1682 let new_org = varpulis_db::repo::create_trial_organization(pool, user.id, &org_name).await;
1683 match &new_org {
1684 Ok(org) => {
1685 if let Ok(templates) = varpulis_db::repo::list_deployed_global_templates(pool).await
1687 {
1688 for t in &templates {
1689 if let Err(e) = varpulis_db::repo::create_global_pipeline_copy(
1690 pool,
1691 org.id,
1692 t.id,
1693 &t.name,
1694 &t.vpl_source,
1695 )
1696 .await
1697 {
1698 tracing::warn!(
1699 "Failed to copy global pipeline '{}' to new org {}: {}",
1700 t.name,
1701 org.id,
1702 e
1703 );
1704 }
1705 }
1706 }
1707 }
1708 Err(e) => {
1709 tracing::error!("Failed to create org for new user: {}", e);
1710 }
1711 }
1712
1713 match &state.email_sender {
1715 Some(sender) => {
1716 if let Err(e) = sender
1717 .send_verification_email(&body.email, &body.username, &token)
1718 .await
1719 {
1720 tracing::error!("Failed to send verification email: {}", e);
1721 }
1722 }
1723 None => {
1724 let frontend_url = &state.config.frontend_url;
1725 tracing::info!(
1726 "Verification URL (SMTP not configured): {}/verify-email?token={}",
1727 frontend_url,
1728 token
1729 );
1730 }
1731 }
1732
1733 if let Some(ref logger) = state.audit_logger {
1735 logger
1736 .log(
1737 crate::audit::AuditEntry::new(
1738 &body.username,
1739 crate::audit::AuditAction::UserCreate,
1740 "/auth/register",
1741 )
1742 .with_detail("Self-service signup".to_string()),
1743 )
1744 .await;
1745 }
1746
1747 (
1748 StatusCode::CREATED,
1749 Json(serde_json::json!({
1750 "ok": true,
1751 "message": "Check your email to verify your account",
1752 })),
1753 )
1754 .into_response()
1755 }
1756
1757 #[cfg(not(feature = "saas"))]
1758 {
1759 (
1760 StatusCode::SERVICE_UNAVAILABLE,
1761 Json(serde_json::json!({ "error": "Registration requires saas feature" })),
1762 )
1763 .into_response()
1764 }
1765}
1766
1767#[derive(Debug, Deserialize)]
1769#[allow(dead_code)]
1770struct VerifyQuery {
1771 token: String,
1772}
1773
1774#[allow(unused_variables)]
1776async fn handle_verify_email(
1777 State(state): State<Option<SharedOAuthState>>,
1778 Query(query): Query<VerifyQuery>,
1779) -> Response {
1780 let state = match state {
1781 Some(s) => s,
1782 None => {
1783 return (
1784 StatusCode::SERVICE_UNAVAILABLE,
1785 Json(serde_json::json!({ "error": "OAuth not configured" })),
1786 )
1787 .into_response();
1788 }
1789 };
1790
1791 #[cfg(feature = "saas")]
1792 {
1793 let pool = match &state.db_pool {
1794 Some(p) => p,
1795 None => {
1796 return (
1797 StatusCode::SERVICE_UNAVAILABLE,
1798 Json(serde_json::json!({ "error": "Database not configured" })),
1799 )
1800 .into_response();
1801 }
1802 };
1803
1804 let user = match varpulis_db::repo::get_user_by_verification_token(pool, &query.token).await
1805 {
1806 Ok(Some(u)) => u,
1807 Ok(None) => {
1808 return (
1809 StatusCode::BAD_REQUEST,
1810 Json(serde_json::json!({ "error": "Invalid or expired verification token" })),
1811 )
1812 .into_response();
1813 }
1814 Err(e) => {
1815 tracing::error!("DB error looking up verification token: {}", e);
1816 return (
1817 StatusCode::INTERNAL_SERVER_ERROR,
1818 Json(serde_json::json!({ "error": "Internal server error" })),
1819 )
1820 .into_response();
1821 }
1822 };
1823
1824 if let Some(expires_at) = user.verification_expires_at {
1826 if chrono::Utc::now() > expires_at {
1827 return (
1828 StatusCode::BAD_REQUEST,
1829 Json(serde_json::json!({ "error": "Verification token has expired" })),
1830 )
1831 .into_response();
1832 }
1833 }
1834
1835 if let Err(e) = varpulis_db::repo::verify_user_email(pool, user.id).await {
1837 tracing::error!("Failed to verify user email: {}", e);
1838 return (
1839 StatusCode::INTERNAL_SERVER_ERROR,
1840 Json(serde_json::json!({ "error": "Internal server error" })),
1841 )
1842 .into_response();
1843 }
1844
1845 tracing::info!(
1846 "Email verified for user: {} ({})",
1847 user.username.as_deref().unwrap_or("?"),
1848 user.email
1849 );
1850
1851 (
1852 StatusCode::OK,
1853 Json(serde_json::json!({
1854 "ok": true,
1855 "message": "Email verified. You can now log in.",
1856 })),
1857 )
1858 .into_response()
1859 }
1860
1861 #[cfg(not(feature = "saas"))]
1862 {
1863 let _ = query;
1864 (
1865 StatusCode::SERVICE_UNAVAILABLE,
1866 Json(serde_json::json!({ "error": "Requires saas feature" })),
1867 )
1868 .into_response()
1869 }
1870}
1871
1872pub fn oauth_routes(state: Option<SharedOAuthState>) -> Router {
1878 Router::new()
1879 .route("/auth/github", get(handle_github_redirect))
1881 .route("/auth/github/callback", get(handle_github_callback))
1883 .route("/auth/login", post(handle_login))
1885 .route("/auth/register", post(handle_register))
1887 .route("/auth/verify", get(handle_verify_email))
1889 .route("/auth/renew", post(handle_renew))
1891 .route("/auth/logout", post(handle_logout))
1893 .route("/api/v1/me", get(handle_me))
1895 .route(
1898 "/auth/users",
1899 post(handle_create_user).get(handle_list_users),
1900 )
1901 .with_state(state)
1902}
1903
1904pub fn spawn_session_cleanup(state: SharedOAuthState) {
1906 tokio::spawn(async move {
1907 let mut interval = tokio::time::interval(std::time::Duration::from_secs(3600));
1908 loop {
1909 interval.tick().await;
1910 state.sessions.write().await.cleanup();
1911 }
1912 });
1913}
1914
1915#[cfg(test)]
1920mod tests {
1921 use axum::body::Body;
1922 use axum::http::Request;
1923 use tower::ServiceExt;
1924
1925 use super::*;
1926
1927 fn get_req(uri: &str) -> Request<Body> {
1928 Request::builder()
1929 .method("GET")
1930 .uri(uri)
1931 .body(Body::empty())
1932 .unwrap()
1933 }
1934
1935 #[test]
1936 fn test_jwt_roundtrip() {
1937 let config = OAuthConfig {
1938 github_client_id: "test".to_string(),
1939 github_client_secret: "test".to_string(),
1940 jwt_secret: "super-secret-key-for-testing".to_string(),
1941 frontend_url: "http://localhost:5173".to_string(),
1942 server_url: "http://localhost:9000".to_string(),
1943 };
1944
1945 let user = GitHubUser {
1946 id: 12345,
1947 login: "testuser".to_string(),
1948 name: Some("Test User".to_string()),
1949 avatar_url: "https://example.com/avatar.png".to_string(),
1950 email: Some("test@example.com".to_string()),
1951 };
1952
1953 let token = create_jwt(&config, &user, "", "", "").expect("JWT creation should succeed");
1954 let claims = verify_jwt(&config, &token).expect("JWT verification should succeed");
1955
1956 assert_eq!(claims.sub, "12345");
1957 assert_eq!(claims.login, "testuser");
1958 assert_eq!(claims.name, "Test User");
1959 assert_eq!(claims.email, "test@example.com");
1960 }
1961
1962 #[test]
1963 fn test_jwt_invalid_secret() {
1964 let config = OAuthConfig {
1965 github_client_id: "test".to_string(),
1966 github_client_secret: "test".to_string(),
1967 jwt_secret: "secret-1".to_string(),
1968 frontend_url: "http://localhost:5173".to_string(),
1969 server_url: "http://localhost:9000".to_string(),
1970 };
1971
1972 let user = GitHubUser {
1973 id: 1,
1974 login: "u".to_string(),
1975 name: None,
1976 avatar_url: String::new(),
1977 email: None,
1978 };
1979
1980 let token = create_jwt(&config, &user, "", "", "").unwrap();
1981
1982 let config2 = OAuthConfig {
1984 jwt_secret: "secret-2".to_string(),
1985 ..config
1986 };
1987 assert!(verify_jwt(&config2, &token).is_err());
1988 }
1989
1990 #[test]
1991 fn test_session_store_revoke() {
1992 let mut store = SessionStore::new();
1993 let hash = "abc123".to_string();
1994
1995 assert!(!store.is_revoked(&hash));
1996 store.revoke(hash.clone());
1997 assert!(store.is_revoked(&hash));
1998 }
1999
2000 #[test]
2001 fn test_token_hash_deterministic() {
2002 let h1 = token_hash("my-token");
2003 let h2 = token_hash("my-token");
2004 assert_eq!(h1, h2);
2005 }
2006
2007 #[test]
2008 fn test_token_hash_different_for_different_tokens() {
2009 let h1 = token_hash("token-a");
2010 let h2 = token_hash("token-b");
2011 assert_ne!(h1, h2);
2012 }
2013
2014 #[tokio::test]
2015 async fn test_me_endpoint_no_token() {
2016 let config = OAuthConfig {
2017 github_client_id: "test".to_string(),
2018 github_client_secret: "test".to_string(),
2019 jwt_secret: "test-secret".to_string(),
2020 frontend_url: "http://localhost:5173".to_string(),
2021 server_url: "http://localhost:9000".to_string(),
2022 };
2023 let state = Arc::new(OAuthState::new(config));
2024 let app = oauth_routes(Some(state));
2025
2026 let res = app.oneshot(get_req("/api/v1/me")).await.unwrap();
2027
2028 assert_eq!(res.status(), 401);
2029 }
2030
2031 #[tokio::test]
2032 async fn test_me_endpoint_valid_token() {
2033 let config = OAuthConfig {
2034 github_client_id: "test".to_string(),
2035 github_client_secret: "test".to_string(),
2036 jwt_secret: "test-secret".to_string(),
2037 frontend_url: "http://localhost:5173".to_string(),
2038 server_url: "http://localhost:9000".to_string(),
2039 };
2040
2041 let user = GitHubUser {
2042 id: 42,
2043 login: "octocat".to_string(),
2044 name: Some("Octocat".to_string()),
2045 avatar_url: "https://github.com/octocat.png".to_string(),
2046 email: Some("octocat@github.com".to_string()),
2047 };
2048
2049 let token = create_jwt(&config, &user, "", "", "").unwrap();
2050 let state = Arc::new(OAuthState::new(config));
2051 let app = oauth_routes(Some(state));
2052
2053 let req: Request<Body> = Request::builder()
2054 .method("GET")
2055 .uri("/api/v1/me")
2056 .header("authorization", format!("Bearer {token}"))
2057 .body(Body::empty())
2058 .unwrap();
2059 let res = app.oneshot(req).await.unwrap();
2060
2061 assert_eq!(res.status(), 200);
2062 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
2063 .await
2064 .unwrap();
2065 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
2066 assert_eq!(body["login"], "octocat");
2067 assert_eq!(body["name"], "Octocat");
2068 }
2069
2070 #[tokio::test]
2071 async fn test_me_endpoint_revoked_token() {
2072 let config = OAuthConfig {
2073 github_client_id: "test".to_string(),
2074 github_client_secret: "test".to_string(),
2075 jwt_secret: "test-secret".to_string(),
2076 frontend_url: "http://localhost:5173".to_string(),
2077 server_url: "http://localhost:9000".to_string(),
2078 };
2079
2080 let user = GitHubUser {
2081 id: 42,
2082 login: "octocat".to_string(),
2083 name: Some("Octocat".to_string()),
2084 avatar_url: "https://github.com/octocat.png".to_string(),
2085 email: Some("octocat@github.com".to_string()),
2086 };
2087
2088 let token = create_jwt(&config, &user, "", "", "").unwrap();
2089 let state = Arc::new(OAuthState::new(config));
2090
2091 let hash = token_hash(&token);
2093 state.sessions.write().await.revoke(hash);
2094
2095 let app = oauth_routes(Some(state));
2096
2097 let req: Request<Body> = Request::builder()
2098 .method("GET")
2099 .uri("/api/v1/me")
2100 .header("authorization", format!("Bearer {token}"))
2101 .body(Body::empty())
2102 .unwrap();
2103 let res = app.oneshot(req).await.unwrap();
2104
2105 assert_eq!(res.status(), 401);
2106 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
2107 .await
2108 .unwrap();
2109 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
2110 assert_eq!(body["error"], "Token revoked");
2111 }
2112
2113 #[tokio::test]
2114 async fn test_logout_endpoint() {
2115 let config = OAuthConfig {
2116 github_client_id: "test".to_string(),
2117 github_client_secret: "test".to_string(),
2118 jwt_secret: "test-secret".to_string(),
2119 frontend_url: "http://localhost:5173".to_string(),
2120 server_url: "http://localhost:9000".to_string(),
2121 };
2122 let state = Arc::new(OAuthState::new(config));
2123 let app = oauth_routes(Some(state));
2124
2125 let req: Request<Body> = Request::builder()
2126 .method("POST")
2127 .uri("/auth/logout")
2128 .header("authorization", "Bearer some-token")
2129 .body(Body::empty())
2130 .unwrap();
2131 let res = app.oneshot(req).await.unwrap();
2132
2133 assert_eq!(res.status(), 200);
2134 let set_cookie = res.headers().get("set-cookie").unwrap().to_str().unwrap();
2135 assert!(set_cookie.contains("Max-Age=0"));
2136 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
2137 .await
2138 .unwrap();
2139 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
2140 assert_eq!(body["ok"], true);
2141 }
2142
2143 #[test]
2144 fn test_extract_jwt_from_cookie() {
2145 assert_eq!(
2146 extract_jwt_from_cookie("varpulis_session=abc123"),
2147 Some("abc123".to_string())
2148 );
2149 assert_eq!(
2150 extract_jwt_from_cookie("other=foo; varpulis_session=abc123; more=bar"),
2151 Some("abc123".to_string())
2152 );
2153 assert_eq!(extract_jwt_from_cookie("other=foo"), None);
2154 assert_eq!(extract_jwt_from_cookie("varpulis_session="), None);
2155 }
2156
2157 #[test]
2158 fn test_local_jwt_roundtrip() {
2159 let config = OAuthConfig {
2160 github_client_id: "test".to_string(),
2161 github_client_secret: "test".to_string(),
2162 jwt_secret: "test-secret-key-32chars-minimum!!".to_string(),
2163 frontend_url: "http://localhost:5173".to_string(),
2164 server_url: "http://localhost:9000".to_string(),
2165 };
2166
2167 let token = create_jwt_for_local_user(
2168 &config,
2169 "user-123",
2170 "alice",
2171 "Alice Smith",
2172 "alice@example.com",
2173 "admin",
2174 "session-456",
2175 3600,
2176 "",
2177 )
2178 .unwrap();
2179
2180 let claims = verify_jwt(&config, &token).unwrap();
2181 assert_eq!(claims.sub, "user-123");
2182 assert_eq!(claims.login, "alice");
2183 assert_eq!(claims.name, "Alice Smith");
2184 assert_eq!(claims.role, "admin");
2185 assert_eq!(claims.session_id, "session-456");
2186 assert_eq!(claims.auth_method, "local");
2187 }
2188
2189 #[tokio::test]
2193 async fn test_me_endpoint_with_cookie() {
2194 let config = OAuthConfig {
2195 github_client_id: "test".to_string(),
2196 github_client_secret: "test".to_string(),
2197 jwt_secret: "test-secret".to_string(),
2198 frontend_url: "http://localhost:5173".to_string(),
2199 server_url: "http://localhost:9000".to_string(),
2200 };
2201
2202 let token = create_jwt_for_local_user(
2203 &config,
2204 "user-1",
2205 "alice",
2206 "Alice",
2207 "alice@test.com",
2208 "admin",
2209 "sess-1",
2210 3600,
2211 "",
2212 )
2213 .unwrap();
2214
2215 let state = Arc::new(OAuthState::new(config));
2216 let app = oauth_routes(Some(state));
2217
2218 let req: Request<Body> = Request::builder()
2219 .method("GET")
2220 .uri("/api/v1/me")
2221 .header("cookie", format!("varpulis_session={token}"))
2222 .body(Body::empty())
2223 .unwrap();
2224 let res = app.oneshot(req).await.unwrap();
2225
2226 assert_eq!(res.status(), 200);
2227 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
2228 .await
2229 .unwrap();
2230 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
2231 assert_eq!(body["login"], "alice");
2232 assert_eq!(body["role"], "admin");
2233 assert_eq!(body["auth_method"], "local");
2234 }
2235}