1use axum::extract::{Json, Query, State};
7use axum::http::{HeaderMap, StatusCode};
8use axum::response::{IntoResponse, Redirect, Response};
9use axum::routing::{get, post};
10use axum::Router;
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use std::sync::Arc;
14use tokio::sync::RwLock;
15
16use crate::audit::{AuditAction, AuditEntry, SharedAuditLogger};
17use crate::users::SharedUserStore;
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct UserInfo {
26 pub provider_id: String,
28 pub name: String,
30 pub login: String,
32 pub email: String,
34 pub avatar: String,
36}
37
38#[derive(Debug)]
42pub struct OAuthError(pub String);
43
44impl std::fmt::Display for OAuthError {
45 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46 write!(f, "OAuth error: {}", self.0)
47 }
48}
49
50impl std::error::Error for OAuthError {}
51
52#[async_trait::async_trait]
58pub trait AuthProvider: Send + Sync {
59 fn name(&self) -> &str;
61
62 fn authorize_url(&self, redirect_uri: &str) -> String;
64
65 async fn exchange_code(&self, code: &str, redirect_uri: &str) -> Result<UserInfo, OAuthError>;
67}
68
69#[derive(Debug, Clone)]
75pub struct OAuthConfig {
76 pub github_client_id: String,
77 pub github_client_secret: String,
78 pub jwt_secret: String,
79 pub frontend_url: String,
81 pub server_url: String,
83}
84
85impl OAuthConfig {
86 pub fn from_env() -> Option<Self> {
89 let client_id = std::env::var("GITHUB_CLIENT_ID").ok()?;
90 let client_secret = std::env::var("GITHUB_CLIENT_SECRET").ok()?;
91 let jwt_secret =
92 std::env::var("JWT_SECRET").unwrap_or_else(|_| crate::auth::generate_api_key());
93 let frontend_url =
94 std::env::var("FRONTEND_URL").unwrap_or_else(|_| "http://localhost:5173".to_string());
95 let server_url =
96 std::env::var("SERVER_URL").unwrap_or_else(|_| "http://localhost:9000".to_string());
97
98 Some(Self {
99 github_client_id: client_id,
100 github_client_secret: client_secret,
101 jwt_secret,
102 frontend_url,
103 server_url,
104 })
105 }
106}
107
108#[derive(Debug, Serialize, Deserialize)]
113pub struct Claims {
114 pub sub: String, pub name: String, pub login: String, pub avatar: String, pub email: String, pub exp: usize, pub iat: usize, #[serde(default)]
122 pub user_id: String, #[serde(default)]
124 pub org_id: String, #[serde(default)]
126 pub role: String, #[serde(default)]
128 pub session_id: String, #[serde(default)]
130 pub auth_method: String, }
132
133#[derive(Debug)]
139pub struct GitHubOAuth {
140 pub client_id: String,
141 pub client_secret: String,
142 http_client: reqwest::Client,
143}
144
145impl GitHubOAuth {
146 pub fn new(client_id: String, client_secret: String) -> Self {
147 Self {
148 client_id,
149 client_secret,
150 http_client: reqwest::Client::new(),
151 }
152 }
153}
154
155#[async_trait::async_trait]
156impl AuthProvider for GitHubOAuth {
157 fn name(&self) -> &'static str {
158 "github"
159 }
160
161 fn authorize_url(&self, redirect_uri: &str) -> String {
162 format!(
163 "https://github.com/login/oauth/authorize?client_id={}&redirect_uri={}&scope=read:user%20user:email",
164 self.client_id,
165 urlencoding::encode(redirect_uri),
166 )
167 }
168
169 async fn exchange_code(&self, code: &str, redirect_uri: &str) -> Result<UserInfo, OAuthError> {
170 let token_resp = self
172 .http_client
173 .post("https://github.com/login/oauth/access_token")
174 .header("Accept", "application/json")
175 .form(&[
176 ("client_id", self.client_id.as_str()),
177 ("client_secret", self.client_secret.as_str()),
178 ("code", code),
179 ("redirect_uri", redirect_uri),
180 ])
181 .send()
182 .await
183 .map_err(|e| OAuthError(format!("GitHub token exchange failed: {e}")))?;
184
185 let token_data: GitHubTokenResponse = token_resp
186 .json()
187 .await
188 .map_err(|e| OAuthError(format!("Failed to parse GitHub token response: {e}")))?;
189
190 let user: GitHubUser = self
192 .http_client
193 .get("https://api.github.com/user")
194 .header(
195 "Authorization",
196 format!("Bearer {}", token_data.access_token),
197 )
198 .header("User-Agent", "Varpulis")
199 .send()
200 .await
201 .map_err(|e| OAuthError(format!("GitHub user fetch failed: {e}")))?
202 .json()
203 .await
204 .map_err(|e| OAuthError(format!("Failed to parse GitHub user: {e}")))?;
205
206 Ok(UserInfo {
207 provider_id: user.id.to_string(),
208 name: user.name.clone().unwrap_or_else(|| user.login.clone()),
209 login: user.login,
210 email: user.email.unwrap_or_default(),
211 avatar: user.avatar_url,
212 })
213 }
214}
215
216#[derive(Debug, Deserialize)]
221struct GitHubTokenResponse {
222 access_token: String,
223 #[allow(dead_code)]
224 token_type: String,
225}
226
227#[derive(Debug, Deserialize)]
228struct GitHubUser {
229 id: u64,
230 login: String,
231 name: Option<String>,
232 avatar_url: String,
233 email: Option<String>,
234}
235
236#[derive(Debug)]
244pub struct SessionStore {
245 revoked: HashMap<String, std::time::Instant>,
247}
248
249impl Default for SessionStore {
250 fn default() -> Self {
251 Self::new()
252 }
253}
254
255impl SessionStore {
256 pub fn new() -> Self {
257 Self {
258 revoked: HashMap::new(),
259 }
260 }
261
262 pub fn revoke(&mut self, token_hash: String) {
263 self.revoked.insert(token_hash, std::time::Instant::now());
264 }
265
266 pub fn is_revoked(&self, token_hash: &str) -> bool {
267 self.revoked.contains_key(token_hash)
268 }
269
270 pub fn cleanup(&mut self) {
272 let cutoff = std::time::Instant::now()
273 .checked_sub(std::time::Duration::from_secs(86400))
274 .unwrap();
275 self.revoked.retain(|_, instant| *instant > cutoff);
276 }
277}
278
279pub type SharedOAuthState = Arc<OAuthState>;
284
285#[derive(Debug)]
286pub struct OAuthState {
287 pub config: OAuthConfig,
288 pub sessions: RwLock<SessionStore>,
289 pub http_client: reqwest::Client,
290 #[cfg(feature = "saas")]
291 pub db_pool: Option<varpulis_db::PgPool>,
292 pub audit_logger: Option<SharedAuditLogger>,
293 pub user_store: Option<SharedUserStore>,
294}
295
296impl OAuthState {
297 pub fn new(config: OAuthConfig) -> Self {
298 Self {
299 config,
300 sessions: RwLock::new(SessionStore::new()),
301 http_client: reqwest::Client::new(),
302 #[cfg(feature = "saas")]
303 db_pool: None,
304 audit_logger: None,
305 user_store: None,
306 }
307 }
308
309 pub fn with_audit_logger(mut self, logger: Option<SharedAuditLogger>) -> Self {
310 self.audit_logger = logger;
311 self
312 }
313
314 pub fn with_user_store(mut self, store: SharedUserStore) -> Self {
315 self.user_store = Some(store);
316 self
317 }
318
319 #[cfg(feature = "saas")]
320 pub fn with_db_pool(mut self, pool: varpulis_db::PgPool) -> Self {
321 self.db_pool = Some(pool);
322 self
323 }
324}
325
326fn create_jwt(
331 config: &OAuthConfig,
332 user: &GitHubUser,
333 user_id: &str,
334 org_id: &str,
335) -> Result<String, jsonwebtoken::errors::Error> {
336 use jsonwebtoken::{encode, EncodingKey, Header};
337
338 let now = chrono::Utc::now().timestamp() as usize;
339 let claims = Claims {
340 sub: user.id.to_string(),
341 name: user.name.clone().unwrap_or_else(|| user.login.clone()),
342 login: user.login.clone(),
343 avatar: user.avatar_url.clone(),
344 email: user.email.clone().unwrap_or_default(),
345 exp: now + 86400 * 7, iat: now,
347 user_id: user_id.to_string(),
348 org_id: org_id.to_string(),
349 role: String::new(),
350 session_id: String::new(),
351 auth_method: "github".to_string(),
352 };
353
354 encode(
355 &Header::default(),
356 &claims,
357 &EncodingKey::from_secret(config.jwt_secret.as_bytes()),
358 )
359}
360
361#[allow(clippy::too_many_arguments)]
363pub fn create_jwt_for_local_user(
364 config: &OAuthConfig,
365 user_id: &str,
366 username: &str,
367 display_name: &str,
368 email: &str,
369 role: &str,
370 session_id: &str,
371 ttl_secs: usize,
372) -> Result<String, jsonwebtoken::errors::Error> {
373 use jsonwebtoken::{encode, EncodingKey, Header};
374
375 let now = chrono::Utc::now().timestamp() as usize;
376 let claims = Claims {
377 sub: user_id.to_string(),
378 name: display_name.to_string(),
379 login: username.to_string(),
380 avatar: String::new(),
381 email: email.to_string(),
382 exp: now + ttl_secs,
383 iat: now,
384 user_id: user_id.to_string(),
385 org_id: String::new(),
386 role: role.to_string(),
387 session_id: session_id.to_string(),
388 auth_method: "local".to_string(),
389 };
390
391 encode(
392 &Header::default(),
393 &claims,
394 &EncodingKey::from_secret(config.jwt_secret.as_bytes()),
395 )
396}
397
398pub fn verify_jwt(
399 config: &OAuthConfig,
400 token: &str,
401) -> Result<Claims, jsonwebtoken::errors::Error> {
402 use jsonwebtoken::{decode, DecodingKey, Validation};
403
404 let token_data = decode::<Claims>(
405 token,
406 &DecodingKey::from_secret(config.jwt_secret.as_bytes()),
407 &Validation::default(),
408 )?;
409
410 Ok(token_data.claims)
411}
412
413pub fn token_hash(token: &str) -> String {
415 use std::collections::hash_map::DefaultHasher;
416 use std::hash::{Hash, Hasher};
417 let mut hasher = DefaultHasher::new();
418 token.hash(&mut hasher);
419 format!("{:016x}", hasher.finish())
420}
421
422const COOKIE_NAME: &str = "varpulis_session";
427
428fn create_session_cookie(jwt: &str, max_age_secs: u64) -> String {
430 format!(
431 "{COOKIE_NAME}={jwt}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age={max_age_secs}"
432 )
433}
434
435fn clear_session_cookie() -> String {
437 format!("{COOKIE_NAME}=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0")
438}
439
440pub fn extract_jwt_from_cookie(cookie_header: &str) -> Option<String> {
442 for cookie in cookie_header.split(';') {
443 let cookie = cookie.trim();
444 if let Some(value) = cookie.strip_prefix("varpulis_session=") {
445 let value = value.trim();
446 if !value.is_empty() {
447 return Some(value.to_string());
448 }
449 }
450 }
451 None
452}
453
454async fn handle_github_redirect(State(state): State<Option<SharedOAuthState>>) -> Response {
460 let state = match state {
461 Some(s) => s,
462 None => {
463 return (
464 StatusCode::SERVICE_UNAVAILABLE,
465 Json(serde_json::json!({"error": "OAuth not configured"})),
466 )
467 .into_response();
468 }
469 };
470
471 let redirect_uri = format!("{}/auth/github/callback", state.config.server_url);
472 let url = format!(
473 "https://github.com/login/oauth/authorize?client_id={}&redirect_uri={}&scope=read:user%20user:email",
474 state.config.github_client_id,
475 urlencoding::encode(&redirect_uri),
476 );
477
478 Redirect::temporary(&url).into_response()
479}
480
481#[derive(Debug, Deserialize)]
483struct CallbackQuery {
484 code: String,
485}
486
487async fn handle_github_callback(
489 State(state): State<Option<SharedOAuthState>>,
490 Query(query): Query<CallbackQuery>,
491) -> Response {
492 let state = match state {
493 Some(s) => s,
494 None => {
495 return (
496 StatusCode::SERVICE_UNAVAILABLE,
497 Json(serde_json::json!({"error": "OAuth not configured"})),
498 )
499 .into_response();
500 }
501 };
502
503 let redirect_uri = format!("{}/auth/github/callback", state.config.server_url);
504
505 let token_resp = match state
507 .http_client
508 .post("https://github.com/login/oauth/access_token")
509 .header("Accept", "application/json")
510 .form(&[
511 ("client_id", state.config.github_client_id.as_str()),
512 ("client_secret", state.config.github_client_secret.as_str()),
513 ("code", query.code.as_str()),
514 ("redirect_uri", redirect_uri.as_str()),
515 ])
516 .send()
517 .await
518 {
519 Ok(resp) => resp,
520 Err(e) => {
521 tracing::error!("GitHub token exchange failed: {}", e);
522 return (
523 StatusCode::BAD_GATEWAY,
524 Json(serde_json::json!({"error": "GitHub token exchange failed"})),
525 )
526 .into_response();
527 }
528 };
529
530 let token_data: GitHubTokenResponse = match token_resp.json().await {
531 Ok(data) => data,
532 Err(e) => {
533 tracing::error!("Failed to parse GitHub token response: {}", e);
534 return (
535 StatusCode::BAD_GATEWAY,
536 Json(serde_json::json!({"error": "Failed to parse GitHub token response"})),
537 )
538 .into_response();
539 }
540 };
541
542 let user: GitHubUser = match state
544 .http_client
545 .get("https://api.github.com/user")
546 .header(
547 "Authorization",
548 format!("Bearer {}", token_data.access_token),
549 )
550 .header("User-Agent", "Varpulis")
551 .send()
552 .await
553 {
554 Ok(resp) => match resp.json().await {
555 Ok(user) => user,
556 Err(e) => {
557 tracing::error!("Failed to parse GitHub user: {}", e);
558 return (
559 StatusCode::BAD_GATEWAY,
560 Json(serde_json::json!({"error": "Failed to parse GitHub user"})),
561 )
562 .into_response();
563 }
564 },
565 Err(e) => {
566 tracing::error!("GitHub user fetch failed: {}", e);
567 return (
568 StatusCode::BAD_GATEWAY,
569 Json(serde_json::json!({"error": "GitHub user fetch failed"})),
570 )
571 .into_response();
572 }
573 };
574
575 let (db_user_id, db_org_id) = {
577 #[cfg(feature = "saas")]
578 {
579 if let Some(ref pool) = state.db_pool {
580 match upsert_user_and_org(pool, &user).await {
581 Ok((uid, oid)) => (uid, oid),
582 Err(e) => {
583 tracing::error!("DB user/org upsert failed: {}", e);
584 (String::new(), String::new())
585 }
586 }
587 } else {
588 (String::new(), String::new())
589 }
590 }
591 #[cfg(not(feature = "saas"))]
592 {
593 (String::new(), String::new())
594 }
595 };
596
597 let jwt = match create_jwt(&state.config, &user, &db_user_id, &db_org_id) {
599 Ok(token) => token,
600 Err(e) => {
601 tracing::error!("JWT creation failed: {}", e);
602 return (
603 StatusCode::INTERNAL_SERVER_ERROR,
604 Json(serde_json::json!({"error": "JWT creation failed"})),
605 )
606 .into_response();
607 }
608 };
609
610 tracing::info!("OAuth login: {} ({})", user.login, user.id);
611
612 if let Some(ref logger) = state.audit_logger {
614 logger
615 .log(
616 AuditEntry::new(&user.login, AuditAction::Login, "/auth/github/callback")
617 .with_detail(format!("GitHub user ID: {}", user.id)),
618 )
619 .await;
620 }
621
622 let redirect_url = format!("{}/?token={}", state.config.frontend_url, jwt);
624 Redirect::temporary(&redirect_url).into_response()
625}
626
627#[cfg(feature = "saas")]
629async fn upsert_user_and_org(
630 pool: &varpulis_db::PgPool,
631 github_user: &GitHubUser,
632) -> Result<(String, String), String> {
633 let db_user = varpulis_db::repo::create_or_update_user(
634 pool,
635 &github_user.id.to_string(),
636 github_user.email.as_deref().unwrap_or(""),
637 github_user.name.as_deref().unwrap_or(&github_user.login),
638 &github_user.avatar_url,
639 )
640 .await
641 .map_err(|e| e.to_string())?;
642
643 let orgs = varpulis_db::repo::get_user_organizations(pool, db_user.id)
644 .await
645 .map_err(|e| e.to_string())?;
646
647 let org = if orgs.is_empty() {
648 let org_name = format!("{}'s org", github_user.login);
649 varpulis_db::repo::create_organization(pool, db_user.id, &org_name)
650 .await
651 .map_err(|e| e.to_string())?
652 } else {
653 orgs.into_iter().next().unwrap()
654 };
655
656 tracing::info!(
657 "DB upsert: user={} org={} ({})",
658 db_user.id,
659 org.id,
660 org.name
661 );
662
663 Ok((db_user.id.to_string(), org.id.to_string()))
664}
665
666async fn handle_logout(
668 State(state): State<Option<SharedOAuthState>>,
669 headers: HeaderMap,
670) -> Response {
671 let state = match state {
672 Some(s) => s,
673 None => {
674 return (
675 StatusCode::SERVICE_UNAVAILABLE,
676 Json(serde_json::json!({"error": "OAuth not configured"})),
677 )
678 .into_response();
679 }
680 };
681
682 let auth_header = headers
683 .get("authorization")
684 .and_then(|v| v.to_str().ok())
685 .map(|s| s.to_string());
686 let cookie_header = headers
687 .get("cookie")
688 .and_then(|v| v.to_str().ok())
689 .map(|s| s.to_string());
690
691 let token = cookie_header
693 .as_deref()
694 .and_then(extract_jwt_from_cookie)
695 .or_else(|| {
696 auth_header
697 .as_ref()
698 .map(|h| h.strip_prefix("Bearer ").unwrap_or(h).trim().to_string())
699 });
700
701 if let Some(token) = token {
702 if !token.is_empty() {
703 if let Ok(claims) = verify_jwt(&state.config, &token) {
705 if claims.auth_method == "local" && !claims.session_id.is_empty() {
706 if let Some(ref user_store) = state.user_store {
707 user_store.write().await.revoke_session(&claims.session_id);
708 }
709 }
710 }
711
712 let hash = token_hash(&token);
713 state.sessions.write().await.revoke(hash);
714
715 if let Some(ref logger) = state.audit_logger {
717 logger
718 .log(AuditEntry::new(
719 "session",
720 AuditAction::Logout,
721 "/auth/logout",
722 ))
723 .await;
724 }
725 }
726 }
727
728 (
729 StatusCode::OK,
730 [("set-cookie", clear_session_cookie())],
731 Json(serde_json::json!({ "ok": true })),
732 )
733 .into_response()
734}
735
736async fn handle_me(State(state): State<Option<SharedOAuthState>>, headers: HeaderMap) -> Response {
738 let state = match state {
739 Some(s) => s,
740 None => {
741 return (
742 StatusCode::SERVICE_UNAVAILABLE,
743 Json(serde_json::json!({"error": "OAuth not configured"})),
744 )
745 .into_response();
746 }
747 };
748
749 let auth_header = headers
750 .get("authorization")
751 .and_then(|v| v.to_str().ok())
752 .map(|s| s.to_string());
753 let cookie_header = headers
754 .get("cookie")
755 .and_then(|v| v.to_str().ok())
756 .map(|s| s.to_string());
757
758 let token = cookie_header
760 .as_deref()
761 .and_then(extract_jwt_from_cookie)
762 .or_else(|| {
763 auth_header
764 .as_ref()
765 .map(|h| h.strip_prefix("Bearer ").unwrap_or(h).trim().to_string())
766 });
767
768 let token = match token {
769 Some(t) if !t.is_empty() => t,
770 _ => {
771 return (
772 StatusCode::UNAUTHORIZED,
773 Json(serde_json::json!({ "error": "No token provided" })),
774 )
775 .into_response();
776 }
777 };
778
779 let hash = token_hash(&token);
781 if state.sessions.read().await.is_revoked(&hash) {
782 return (
783 StatusCode::UNAUTHORIZED,
784 Json(serde_json::json!({ "error": "Token revoked" })),
785 )
786 .into_response();
787 }
788
789 match verify_jwt(&state.config, &token) {
791 Ok(claims) => {
792 #[allow(unused_mut)]
793 let mut response = serde_json::json!({
794 "id": claims.sub,
795 "name": claims.name,
796 "login": claims.login,
797 "avatar": claims.avatar,
798 "email": claims.email,
799 "user_id": claims.user_id,
800 "org_id": claims.org_id,
801 "role": claims.role,
802 "auth_method": claims.auth_method,
803 });
804
805 #[cfg(feature = "saas")]
807 if let Some(ref pool) = state.db_pool {
808 if !claims.user_id.is_empty() {
809 if let Ok(user_uuid) = claims.user_id.parse::<uuid::Uuid>() {
810 if let Ok(orgs) =
811 varpulis_db::repo::get_user_organizations(pool, user_uuid).await
812 {
813 let orgs_json: Vec<serde_json::Value> = orgs
814 .iter()
815 .map(|o| {
816 serde_json::json!({
817 "id": o.id.to_string(),
818 "name": o.name,
819 "tier": o.tier,
820 })
821 })
822 .collect();
823 response["organizations"] = serde_json::json!(orgs_json);
824 }
825 }
826 }
827 }
828
829 (StatusCode::OK, Json(response)).into_response()
830 }
831 Err(e) => {
832 tracing::debug!("JWT verification failed: {}", e);
833 (
834 StatusCode::UNAUTHORIZED,
835 Json(serde_json::json!({ "error": "Invalid token" })),
836 )
837 .into_response()
838 }
839 }
840}
841
842#[derive(Debug, Deserialize)]
848struct LoginRequest {
849 username: String,
850 password: String,
851}
852
853async fn handle_login(
855 State(state): State<Option<SharedOAuthState>>,
856 Json(body): Json<LoginRequest>,
857) -> Response {
858 let state = match state {
859 Some(s) => s,
860 None => {
861 return (
862 StatusCode::SERVICE_UNAVAILABLE,
863 Json(serde_json::json!({ "error": "OAuth not configured" })),
864 )
865 .into_response();
866 }
867 };
868
869 let user_store = match &state.user_store {
870 Some(store) => store.clone(),
871 None => {
872 return (
873 StatusCode::SERVICE_UNAVAILABLE,
874 Json(serde_json::json!({ "error": "Local auth not configured" })),
875 )
876 .into_response();
877 }
878 };
879
880 let mut store = user_store.write().await;
881
882 let user = match store.verify_password(&body.username, &body.password) {
883 Ok(u) => u,
884 Err(e) => {
885 if let Some(ref logger) = state.audit_logger {
887 logger
888 .log(
889 AuditEntry::new(&body.username, AuditAction::Login, "/auth/login")
890 .with_outcome(crate::audit::AuditOutcome::Failure)
891 .with_detail(e.clone()),
892 )
893 .await;
894 }
895 return (
896 StatusCode::UNAUTHORIZED,
897 Json(serde_json::json!({ "error": "Invalid username or password" })),
898 )
899 .into_response();
900 }
901 };
902
903 let session = store.create_session(&user);
904 let ttl_secs = store.session_config().absolute_timeout.as_secs() as usize;
905 drop(store);
906
907 let jwt = match create_jwt_for_local_user(
908 &state.config,
909 &user.id,
910 &user.username,
911 &user.display_name,
912 &user.email,
913 &user.role,
914 &session.session_id,
915 ttl_secs,
916 ) {
917 Ok(token) => token,
918 Err(e) => {
919 tracing::error!("JWT creation failed: {}", e);
920 return (
921 StatusCode::INTERNAL_SERVER_ERROR,
922 Json(serde_json::json!({ "error": "Internal server error" })),
923 )
924 .into_response();
925 }
926 };
927
928 if let Some(ref logger) = state.audit_logger {
930 logger
931 .log(
932 AuditEntry::new(&user.username, AuditAction::Login, "/auth/login")
933 .with_detail(format!("session: {}", session.session_id)),
934 )
935 .await;
936 }
937
938 let cookie = create_session_cookie(&jwt, ttl_secs as u64);
939 let response = serde_json::json!({
940 "ok": true,
941 "user": {
942 "id": user.id,
943 "username": user.username,
944 "display_name": user.display_name,
945 "email": user.email,
946 "role": user.role,
947 },
948 "token": jwt,
949 });
950
951 (StatusCode::OK, [("set-cookie", cookie)], Json(response)).into_response()
952}
953
954async fn handle_renew(
956 State(state): State<Option<SharedOAuthState>>,
957 headers: HeaderMap,
958) -> Response {
959 let state = match state {
960 Some(s) => s,
961 None => {
962 return (
963 StatusCode::SERVICE_UNAVAILABLE,
964 Json(serde_json::json!({"error": "OAuth not configured"})),
965 )
966 .into_response();
967 }
968 };
969
970 let auth_header = headers
971 .get("authorization")
972 .and_then(|v| v.to_str().ok())
973 .map(|s| s.to_string());
974 let cookie_header = headers
975 .get("cookie")
976 .and_then(|v| v.to_str().ok())
977 .map(|s| s.to_string());
978
979 let token = cookie_header
981 .as_deref()
982 .and_then(extract_jwt_from_cookie)
983 .or_else(|| {
984 auth_header
985 .as_ref()
986 .map(|h| h.strip_prefix("Bearer ").unwrap_or(h).trim().to_string())
987 });
988
989 let token = match token {
990 Some(t) if !t.is_empty() => t,
991 _ => {
992 return (
993 StatusCode::UNAUTHORIZED,
994 Json(serde_json::json!({ "error": "No session token" })),
995 )
996 .into_response();
997 }
998 };
999
1000 let claims = match verify_jwt(&state.config, &token) {
1002 Ok(c) => c,
1003 Err(_) => {
1004 return (
1005 StatusCode::UNAUTHORIZED,
1006 Json(serde_json::json!({ "error": "Invalid or expired token" })),
1007 )
1008 .into_response();
1009 }
1010 };
1011
1012 if claims.auth_method != "local" || claims.session_id.is_empty() {
1014 return (
1015 StatusCode::BAD_REQUEST,
1016 Json(serde_json::json!({ "error": "Session renewal not applicable" })),
1017 )
1018 .into_response();
1019 }
1020
1021 let user_store = match &state.user_store {
1022 Some(store) => store.clone(),
1023 None => {
1024 return (
1025 StatusCode::SERVICE_UNAVAILABLE,
1026 Json(serde_json::json!({ "error": "Local auth not configured" })),
1027 )
1028 .into_response();
1029 }
1030 };
1031
1032 let mut store = user_store.write().await;
1033
1034 if store.validate_session(&claims.session_id).is_none() {
1036 return (
1037 StatusCode::UNAUTHORIZED,
1038 Json(serde_json::json!({ "error": "Session expired or revoked" })),
1039 )
1040 .into_response();
1041 }
1042
1043 let user = match store.get_user_by_id(&claims.sub) {
1045 Some(u) => u.clone(),
1046 None => {
1047 return (
1048 StatusCode::UNAUTHORIZED,
1049 Json(serde_json::json!({ "error": "User not found" })),
1050 )
1051 .into_response();
1052 }
1053 };
1054
1055 let ttl_secs = store.session_config().absolute_timeout.as_secs() as usize;
1056 drop(store);
1057
1058 let hash = token_hash(&token);
1060 state.sessions.write().await.revoke(hash);
1061
1062 let jwt = match create_jwt_for_local_user(
1063 &state.config,
1064 &user.id,
1065 &user.username,
1066 &user.display_name,
1067 &user.email,
1068 &user.role,
1069 &claims.session_id,
1070 ttl_secs,
1071 ) {
1072 Ok(t) => t,
1073 Err(e) => {
1074 tracing::error!("JWT renewal failed: {}", e);
1075 return (
1076 StatusCode::INTERNAL_SERVER_ERROR,
1077 Json(serde_json::json!({ "error": "Internal server error" })),
1078 )
1079 .into_response();
1080 }
1081 };
1082
1083 if let Some(ref logger) = state.audit_logger {
1084 logger
1085 .log(AuditEntry::new(
1086 &user.username,
1087 AuditAction::SessionRenew,
1088 "/auth/renew",
1089 ))
1090 .await;
1091 }
1092
1093 let cookie = create_session_cookie(&jwt, ttl_secs as u64);
1094
1095 (
1096 StatusCode::OK,
1097 [("set-cookie", cookie)],
1098 Json(serde_json::json!({
1099 "ok": true,
1100 "token": jwt,
1101 })),
1102 )
1103 .into_response()
1104}
1105
1106#[derive(Debug, Deserialize)]
1108struct CreateUserRequest {
1109 username: String,
1110 password: String,
1111 display_name: String,
1112 #[serde(default)]
1113 email: String,
1114 #[serde(default = "default_role")]
1115 role: String,
1116}
1117
1118fn default_role() -> String {
1119 "viewer".to_string()
1120}
1121
1122async fn handle_create_user(
1124 State(state): State<Option<SharedOAuthState>>,
1125 headers: HeaderMap,
1126 Json(body): Json<CreateUserRequest>,
1127) -> Response {
1128 let state = match state {
1129 Some(s) => s,
1130 None => {
1131 return (
1132 StatusCode::SERVICE_UNAVAILABLE,
1133 Json(serde_json::json!({"error": "OAuth not configured"})),
1134 )
1135 .into_response();
1136 }
1137 };
1138
1139 let auth_header = headers.get("authorization").and_then(|v| v.to_str().ok());
1140 let cookie_header = headers.get("cookie").and_then(|v| v.to_str().ok());
1141
1142 let claims = match extract_and_verify_claims(&state, auth_header, cookie_header).await {
1144 Ok(c) => c,
1145 Err(resp) => return resp,
1146 };
1147
1148 if claims.role != "admin" {
1149 return (
1150 StatusCode::FORBIDDEN,
1151 Json(serde_json::json!({ "error": "Admin access required" })),
1152 )
1153 .into_response();
1154 }
1155
1156 let user_store = match &state.user_store {
1157 Some(s) => s.clone(),
1158 None => {
1159 return (
1160 StatusCode::SERVICE_UNAVAILABLE,
1161 Json(serde_json::json!({ "error": "Local auth not configured" })),
1162 )
1163 .into_response();
1164 }
1165 };
1166
1167 let mut store = user_store.write().await;
1168 match store.create_user(
1169 &body.username,
1170 &body.password,
1171 &body.display_name,
1172 &body.email,
1173 &body.role,
1174 ) {
1175 Ok(user) => {
1176 if let Some(ref logger) = state.audit_logger {
1177 logger
1178 .log(
1179 AuditEntry::new(&claims.login, AuditAction::UserCreate, "/auth/users")
1180 .with_detail(format!(
1181 "Created user: {} ({})",
1182 user.username, user.role
1183 )),
1184 )
1185 .await;
1186 }
1187
1188 (
1189 StatusCode::CREATED,
1190 Json(serde_json::json!({
1191 "id": user.id,
1192 "username": user.username,
1193 "display_name": user.display_name,
1194 "email": user.email,
1195 "role": user.role,
1196 })),
1197 )
1198 .into_response()
1199 }
1200 Err(e) => (
1201 StatusCode::BAD_REQUEST,
1202 Json(serde_json::json!({ "error": e })),
1203 )
1204 .into_response(),
1205 }
1206}
1207
1208async fn handle_list_users(
1210 State(state): State<Option<SharedOAuthState>>,
1211 headers: HeaderMap,
1212) -> Response {
1213 let state = match state {
1214 Some(s) => s,
1215 None => {
1216 return (
1217 StatusCode::SERVICE_UNAVAILABLE,
1218 Json(serde_json::json!({"error": "OAuth not configured"})),
1219 )
1220 .into_response();
1221 }
1222 };
1223
1224 let auth_header = headers.get("authorization").and_then(|v| v.to_str().ok());
1225 let cookie_header = headers.get("cookie").and_then(|v| v.to_str().ok());
1226
1227 let claims = match extract_and_verify_claims(&state, auth_header, cookie_header).await {
1228 Ok(c) => c,
1229 Err(resp) => return resp,
1230 };
1231
1232 if claims.role != "admin" {
1233 return (
1234 StatusCode::FORBIDDEN,
1235 Json(serde_json::json!({ "error": "Admin access required" })),
1236 )
1237 .into_response();
1238 }
1239
1240 let user_store = match &state.user_store {
1241 Some(s) => s.clone(),
1242 None => {
1243 return (
1244 StatusCode::SERVICE_UNAVAILABLE,
1245 Json(serde_json::json!({ "error": "Local auth not configured" })),
1246 )
1247 .into_response();
1248 }
1249 };
1250
1251 let store = user_store.read().await;
1252 let users = store.list_users();
1253
1254 (StatusCode::OK, Json(serde_json::json!({ "users": users }))).into_response()
1255}
1256
1257async fn extract_and_verify_claims(
1259 state: &SharedOAuthState,
1260 auth_header: Option<&str>,
1261 cookie_header: Option<&str>,
1262) -> Result<Claims, Response> {
1263 let token = cookie_header
1264 .and_then(extract_jwt_from_cookie)
1265 .or_else(|| auth_header.map(|h| h.strip_prefix("Bearer ").unwrap_or(h).trim().to_string()));
1266
1267 let token = match token {
1268 Some(t) if !t.is_empty() => t,
1269 _ => {
1270 return Err((
1271 StatusCode::UNAUTHORIZED,
1272 Json(serde_json::json!({ "error": "Authentication required" })),
1273 )
1274 .into_response());
1275 }
1276 };
1277
1278 let hash = token_hash(&token);
1280 if state.sessions.read().await.is_revoked(&hash) {
1281 return Err((
1282 StatusCode::UNAUTHORIZED,
1283 Json(serde_json::json!({ "error": "Token revoked" })),
1284 )
1285 .into_response());
1286 }
1287
1288 verify_jwt(&state.config, &token).map_err(|_| {
1289 (
1290 StatusCode::UNAUTHORIZED,
1291 Json(serde_json::json!({ "error": "Invalid or expired token" })),
1292 )
1293 .into_response()
1294 })
1295}
1296
1297pub fn oauth_routes(state: Option<SharedOAuthState>) -> Router {
1303 Router::new()
1304 .route("/auth/github", get(handle_github_redirect))
1306 .route("/auth/github/callback", get(handle_github_callback))
1308 .route("/auth/login", post(handle_login))
1310 .route("/auth/renew", post(handle_renew))
1312 .route("/auth/logout", post(handle_logout))
1314 .route("/api/v1/me", get(handle_me))
1316 .route(
1319 "/auth/users",
1320 post(handle_create_user).get(handle_list_users),
1321 )
1322 .with_state(state)
1323}
1324
1325pub fn spawn_session_cleanup(state: SharedOAuthState) {
1327 tokio::spawn(async move {
1328 let mut interval = tokio::time::interval(std::time::Duration::from_secs(3600));
1329 loop {
1330 interval.tick().await;
1331 state.sessions.write().await.cleanup();
1332 }
1333 });
1334}
1335
1336#[cfg(test)]
1341mod tests {
1342 use super::*;
1343 use axum::body::Body;
1344 use axum::http::Request;
1345 use tower::ServiceExt;
1346
1347 fn get_req(uri: &str) -> Request<Body> {
1348 Request::builder()
1349 .method("GET")
1350 .uri(uri)
1351 .body(Body::empty())
1352 .unwrap()
1353 }
1354
1355 #[test]
1356 fn test_jwt_roundtrip() {
1357 let config = OAuthConfig {
1358 github_client_id: "test".to_string(),
1359 github_client_secret: "test".to_string(),
1360 jwt_secret: "super-secret-key-for-testing".to_string(),
1361 frontend_url: "http://localhost:5173".to_string(),
1362 server_url: "http://localhost:9000".to_string(),
1363 };
1364
1365 let user = GitHubUser {
1366 id: 12345,
1367 login: "testuser".to_string(),
1368 name: Some("Test User".to_string()),
1369 avatar_url: "https://example.com/avatar.png".to_string(),
1370 email: Some("test@example.com".to_string()),
1371 };
1372
1373 let token = create_jwt(&config, &user, "", "").expect("JWT creation should succeed");
1374 let claims = verify_jwt(&config, &token).expect("JWT verification should succeed");
1375
1376 assert_eq!(claims.sub, "12345");
1377 assert_eq!(claims.login, "testuser");
1378 assert_eq!(claims.name, "Test User");
1379 assert_eq!(claims.email, "test@example.com");
1380 }
1381
1382 #[test]
1383 fn test_jwt_invalid_secret() {
1384 let config = OAuthConfig {
1385 github_client_id: "test".to_string(),
1386 github_client_secret: "test".to_string(),
1387 jwt_secret: "secret-1".to_string(),
1388 frontend_url: "http://localhost:5173".to_string(),
1389 server_url: "http://localhost:9000".to_string(),
1390 };
1391
1392 let user = GitHubUser {
1393 id: 1,
1394 login: "u".to_string(),
1395 name: None,
1396 avatar_url: String::new(),
1397 email: None,
1398 };
1399
1400 let token = create_jwt(&config, &user, "", "").unwrap();
1401
1402 let config2 = OAuthConfig {
1404 jwt_secret: "secret-2".to_string(),
1405 ..config
1406 };
1407 assert!(verify_jwt(&config2, &token).is_err());
1408 }
1409
1410 #[test]
1411 fn test_session_store_revoke() {
1412 let mut store = SessionStore::new();
1413 let hash = "abc123".to_string();
1414
1415 assert!(!store.is_revoked(&hash));
1416 store.revoke(hash.clone());
1417 assert!(store.is_revoked(&hash));
1418 }
1419
1420 #[test]
1421 fn test_token_hash_deterministic() {
1422 let h1 = token_hash("my-token");
1423 let h2 = token_hash("my-token");
1424 assert_eq!(h1, h2);
1425 }
1426
1427 #[test]
1428 fn test_token_hash_different_for_different_tokens() {
1429 let h1 = token_hash("token-a");
1430 let h2 = token_hash("token-b");
1431 assert_ne!(h1, h2);
1432 }
1433
1434 #[tokio::test]
1435 async fn test_me_endpoint_no_token() {
1436 let config = OAuthConfig {
1437 github_client_id: "test".to_string(),
1438 github_client_secret: "test".to_string(),
1439 jwt_secret: "test-secret".to_string(),
1440 frontend_url: "http://localhost:5173".to_string(),
1441 server_url: "http://localhost:9000".to_string(),
1442 };
1443 let state = Arc::new(OAuthState::new(config));
1444 let app = oauth_routes(Some(state));
1445
1446 let res = app.oneshot(get_req("/api/v1/me")).await.unwrap();
1447
1448 assert_eq!(res.status(), 401);
1449 }
1450
1451 #[tokio::test]
1452 async fn test_me_endpoint_valid_token() {
1453 let config = OAuthConfig {
1454 github_client_id: "test".to_string(),
1455 github_client_secret: "test".to_string(),
1456 jwt_secret: "test-secret".to_string(),
1457 frontend_url: "http://localhost:5173".to_string(),
1458 server_url: "http://localhost:9000".to_string(),
1459 };
1460
1461 let user = GitHubUser {
1462 id: 42,
1463 login: "octocat".to_string(),
1464 name: Some("Octocat".to_string()),
1465 avatar_url: "https://github.com/octocat.png".to_string(),
1466 email: Some("octocat@github.com".to_string()),
1467 };
1468
1469 let token = create_jwt(&config, &user, "", "").unwrap();
1470 let state = Arc::new(OAuthState::new(config));
1471 let app = oauth_routes(Some(state));
1472
1473 let req: Request<Body> = Request::builder()
1474 .method("GET")
1475 .uri("/api/v1/me")
1476 .header("authorization", format!("Bearer {token}"))
1477 .body(Body::empty())
1478 .unwrap();
1479 let res = app.oneshot(req).await.unwrap();
1480
1481 assert_eq!(res.status(), 200);
1482 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
1483 .await
1484 .unwrap();
1485 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
1486 assert_eq!(body["login"], "octocat");
1487 assert_eq!(body["name"], "Octocat");
1488 }
1489
1490 #[tokio::test]
1491 async fn test_me_endpoint_revoked_token() {
1492 let config = OAuthConfig {
1493 github_client_id: "test".to_string(),
1494 github_client_secret: "test".to_string(),
1495 jwt_secret: "test-secret".to_string(),
1496 frontend_url: "http://localhost:5173".to_string(),
1497 server_url: "http://localhost:9000".to_string(),
1498 };
1499
1500 let user = GitHubUser {
1501 id: 42,
1502 login: "octocat".to_string(),
1503 name: Some("Octocat".to_string()),
1504 avatar_url: "https://github.com/octocat.png".to_string(),
1505 email: Some("octocat@github.com".to_string()),
1506 };
1507
1508 let token = create_jwt(&config, &user, "", "").unwrap();
1509 let state = Arc::new(OAuthState::new(config));
1510
1511 let hash = token_hash(&token);
1513 state.sessions.write().await.revoke(hash);
1514
1515 let app = oauth_routes(Some(state));
1516
1517 let req: Request<Body> = Request::builder()
1518 .method("GET")
1519 .uri("/api/v1/me")
1520 .header("authorization", format!("Bearer {token}"))
1521 .body(Body::empty())
1522 .unwrap();
1523 let res = app.oneshot(req).await.unwrap();
1524
1525 assert_eq!(res.status(), 401);
1526 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
1527 .await
1528 .unwrap();
1529 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
1530 assert_eq!(body["error"], "Token revoked");
1531 }
1532
1533 #[tokio::test]
1534 async fn test_logout_endpoint() {
1535 let config = OAuthConfig {
1536 github_client_id: "test".to_string(),
1537 github_client_secret: "test".to_string(),
1538 jwt_secret: "test-secret".to_string(),
1539 frontend_url: "http://localhost:5173".to_string(),
1540 server_url: "http://localhost:9000".to_string(),
1541 };
1542 let state = Arc::new(OAuthState::new(config));
1543 let app = oauth_routes(Some(state));
1544
1545 let req: Request<Body> = Request::builder()
1546 .method("POST")
1547 .uri("/auth/logout")
1548 .header("authorization", "Bearer some-token")
1549 .body(Body::empty())
1550 .unwrap();
1551 let res = app.oneshot(req).await.unwrap();
1552
1553 assert_eq!(res.status(), 200);
1554 let set_cookie = res.headers().get("set-cookie").unwrap().to_str().unwrap();
1555 assert!(set_cookie.contains("Max-Age=0"));
1556 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
1557 .await
1558 .unwrap();
1559 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
1560 assert_eq!(body["ok"], true);
1561 }
1562
1563 #[test]
1564 fn test_extract_jwt_from_cookie() {
1565 assert_eq!(
1566 extract_jwt_from_cookie("varpulis_session=abc123"),
1567 Some("abc123".to_string())
1568 );
1569 assert_eq!(
1570 extract_jwt_from_cookie("other=foo; varpulis_session=abc123; more=bar"),
1571 Some("abc123".to_string())
1572 );
1573 assert_eq!(extract_jwt_from_cookie("other=foo"), None);
1574 assert_eq!(extract_jwt_from_cookie("varpulis_session="), None);
1575 }
1576
1577 #[test]
1578 fn test_local_jwt_roundtrip() {
1579 let config = OAuthConfig {
1580 github_client_id: "test".to_string(),
1581 github_client_secret: "test".to_string(),
1582 jwt_secret: "test-secret-key-32chars-minimum!!".to_string(),
1583 frontend_url: "http://localhost:5173".to_string(),
1584 server_url: "http://localhost:9000".to_string(),
1585 };
1586
1587 let token = create_jwt_for_local_user(
1588 &config,
1589 "user-123",
1590 "alice",
1591 "Alice Smith",
1592 "alice@example.com",
1593 "admin",
1594 "session-456",
1595 3600,
1596 )
1597 .unwrap();
1598
1599 let claims = verify_jwt(&config, &token).unwrap();
1600 assert_eq!(claims.sub, "user-123");
1601 assert_eq!(claims.login, "alice");
1602 assert_eq!(claims.name, "Alice Smith");
1603 assert_eq!(claims.role, "admin");
1604 assert_eq!(claims.session_id, "session-456");
1605 assert_eq!(claims.auth_method, "local");
1606 }
1607
1608 #[tokio::test]
1609 async fn test_login_endpoint() {
1610 let config = OAuthConfig {
1611 github_client_id: "test".to_string(),
1612 github_client_secret: "test".to_string(),
1613 jwt_secret: "test-secret".to_string(),
1614 frontend_url: "http://localhost:5173".to_string(),
1615 server_url: "http://localhost:9000".to_string(),
1616 };
1617
1618 let dir = tempfile::tempdir().unwrap();
1619 let user_store = Arc::new(RwLock::new(crate::users::UserStore::new(
1620 dir.path().join("users.json"),
1621 crate::users::SessionConfig::default(),
1622 )));
1623
1624 user_store
1626 .write()
1627 .await
1628 .create_user(
1629 "testuser",
1630 "testpass123",
1631 "Test User",
1632 "test@test.com",
1633 "admin",
1634 )
1635 .unwrap();
1636
1637 let state = Arc::new(OAuthState::new(config).with_user_store(user_store));
1638 let app = oauth_routes(Some(state));
1639
1640 let req: Request<Body> = Request::builder()
1642 .method("POST")
1643 .uri("/auth/login")
1644 .header("content-type", "application/json")
1645 .body(Body::from(
1646 r#"{"username":"testuser","password":"testpass123"}"#,
1647 ))
1648 .unwrap();
1649 let res = app.clone().oneshot(req).await.unwrap();
1650
1651 assert_eq!(res.status(), 200);
1652 let set_cookie = res.headers().get("set-cookie").unwrap().to_str().unwrap();
1653 assert!(set_cookie.contains("varpulis_session="));
1654 assert!(set_cookie.contains("HttpOnly"));
1655 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
1656 .await
1657 .unwrap();
1658 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
1659 assert_eq!(body["ok"], true);
1660 assert_eq!(body["user"]["username"], "testuser");
1661 assert!(body["token"].is_string());
1662
1663 let req: Request<Body> = Request::builder()
1665 .method("POST")
1666 .uri("/auth/login")
1667 .header("content-type", "application/json")
1668 .body(Body::from(
1669 r#"{"username":"testuser","password":"wrongpass"}"#,
1670 ))
1671 .unwrap();
1672 let res = app.oneshot(req).await.unwrap();
1673
1674 assert_eq!(res.status(), 401);
1675 }
1676
1677 #[tokio::test]
1678 async fn test_me_endpoint_with_cookie() {
1679 let config = OAuthConfig {
1680 github_client_id: "test".to_string(),
1681 github_client_secret: "test".to_string(),
1682 jwt_secret: "test-secret".to_string(),
1683 frontend_url: "http://localhost:5173".to_string(),
1684 server_url: "http://localhost:9000".to_string(),
1685 };
1686
1687 let token = create_jwt_for_local_user(
1688 &config,
1689 "user-1",
1690 "alice",
1691 "Alice",
1692 "alice@test.com",
1693 "admin",
1694 "sess-1",
1695 3600,
1696 )
1697 .unwrap();
1698
1699 let state = Arc::new(OAuthState::new(config));
1700 let app = oauth_routes(Some(state));
1701
1702 let req: Request<Body> = Request::builder()
1703 .method("GET")
1704 .uri("/api/v1/me")
1705 .header("cookie", format!("varpulis_session={token}"))
1706 .body(Body::empty())
1707 .unwrap();
1708 let res = app.oneshot(req).await.unwrap();
1709
1710 assert_eq!(res.status(), 200);
1711 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
1712 .await
1713 .unwrap();
1714 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
1715 assert_eq!(body["login"], "alice");
1716 assert_eq!(body["role"], "admin");
1717 assert_eq!(body["auth_method"], "local");
1718 }
1719}