Skip to main content

varpulis_cli/
oauth.rs

1//! OAuth/OIDC authentication module for Varpulis Cloud.
2//!
3//! Provides OAuth 2.0 flow with GitHub as the identity provider,
4//! optional generic OIDC support, JWT session management, and axum route handlers.
5
6use 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// ---------------------------------------------------------------------------
20// Auth Provider trait
21// ---------------------------------------------------------------------------
22
23/// Standardized user info returned by any auth provider.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct UserInfo {
26    /// Unique provider-side user identifier
27    pub provider_id: String,
28    /// Display name
29    pub name: String,
30    /// Login/username (provider-specific)
31    pub login: String,
32    /// Email address (may be empty)
33    pub email: String,
34    /// Avatar URL (may be empty)
35    pub avatar: String,
36}
37
38/// Error type for OAuth provider operations.
39///
40/// Distinct from [`crate::auth::AuthError`] which covers API key/header authentication.
41#[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/// Trait for pluggable authentication providers.
53///
54/// Implementations handle the OAuth/OIDC flow for a specific identity provider.
55/// The engine uses this to abstract over GitHub OAuth, generic OIDC (Okta, Auth0,
56/// Azure AD, Keycloak, etc.), and future providers.
57#[async_trait::async_trait]
58pub trait AuthProvider: Send + Sync {
59    /// Provider name (e.g., "github", "oidc")
60    fn name(&self) -> &str;
61
62    /// Generate the authorization URL to redirect the user to.
63    fn authorize_url(&self, redirect_uri: &str) -> String;
64
65    /// Exchange an authorization code for user info.
66    async fn exchange_code(&self, code: &str, redirect_uri: &str) -> Result<UserInfo, OAuthError>;
67}
68
69// ---------------------------------------------------------------------------
70// Configuration
71// ---------------------------------------------------------------------------
72
73/// OAuth configuration loaded from environment variables.
74#[derive(Debug, Clone)]
75pub struct OAuthConfig {
76    pub github_client_id: String,
77    pub github_client_secret: String,
78    pub jwt_secret: String,
79    /// Where to redirect after successful OAuth callback (e.g. "http://localhost:5173")
80    pub frontend_url: String,
81    /// The base URL of this server for the callback (e.g. "http://localhost:9000")
82    pub server_url: String,
83}
84
85impl OAuthConfig {
86    /// Build config from environment variables.
87    /// Returns None if required vars are not set (OAuth disabled).
88    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// ---------------------------------------------------------------------------
109// JWT Claims
110// ---------------------------------------------------------------------------
111
112#[derive(Debug, Serialize, Deserialize)]
113pub struct Claims {
114    pub sub: String,    // GitHub user ID or local user ID
115    pub name: String,   // Display name
116    pub login: String,  // GitHub username or local username
117    pub avatar: String, // Avatar URL
118    pub email: String,  // Email (may be empty)
119    pub exp: usize,     // Expiration (Unix timestamp)
120    pub iat: usize,     // Issued at
121    #[serde(default)]
122    pub user_id: String, // DB user UUID (empty when saas not enabled)
123    #[serde(default)]
124    pub org_id: String, // DB organization UUID (empty when saas not enabled)
125    #[serde(default)]
126    pub role: String, // "admin" | "operator" | "viewer"
127    #[serde(default)]
128    pub session_id: String, // For session revocation
129    #[serde(default)]
130    pub auth_method: String, // "local" | "github" | "oidc" | "apikey"
131}
132
133// ---------------------------------------------------------------------------
134// GitHub OAuth Provider
135// ---------------------------------------------------------------------------
136
137/// GitHub OAuth 2.0 auth provider.
138#[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        // Exchange authorization code for access token
171        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        // Fetch user profile
191        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// ---------------------------------------------------------------------------
217// GitHub API response types
218// ---------------------------------------------------------------------------
219
220#[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// ---------------------------------------------------------------------------
237// Session store (invalidated tokens)
238// ---------------------------------------------------------------------------
239
240/// Tracks invalidated JWT tokens (logout).
241/// In production this would be backed by Redis/DB, but for MVP an in-memory
242/// set is sufficient.
243#[derive(Debug)]
244pub struct SessionStore {
245    /// Set of invalidated JTIs (JWT IDs) or raw token hashes.
246    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    /// Remove entries older than 24 hours (tokens expire anyway).
271    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
279// ---------------------------------------------------------------------------
280// State
281// ---------------------------------------------------------------------------
282
283pub 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
326// ---------------------------------------------------------------------------
327// JWT helpers
328// ---------------------------------------------------------------------------
329
330fn 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, // 7 days
346        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/// Create a JWT for a local (username/password) user with session tracking.
362#[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
413/// Simple hash for token revocation tracking (not cryptographic, just for lookup).
414pub 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
422// ---------------------------------------------------------------------------
423// Cookie helpers
424// ---------------------------------------------------------------------------
425
426const COOKIE_NAME: &str = "varpulis_session";
427
428/// Create a Set-Cookie header value for the session JWT.
429fn 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
435/// Create a Set-Cookie header value that clears the session cookie.
436fn clear_session_cookie() -> String {
437    format!("{COOKIE_NAME}=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0")
438}
439
440/// Extract the session JWT from a Cookie header value.
441pub 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
454// ---------------------------------------------------------------------------
455// Route handlers
456// ---------------------------------------------------------------------------
457
458/// GET /auth/github — redirect user to GitHub OAuth authorization page.
459async 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/// Query params for the OAuth callback.
482#[derive(Debug, Deserialize)]
483struct CallbackQuery {
484    code: String,
485}
486
487/// GET /auth/github/callback?code=... — exchange code for token, fetch user, issue JWT.
488async 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    // Exchange authorization code for access token
506    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    // Fetch user profile
543    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    // DB integration: upsert user and auto-create org
576    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    // Create JWT
598    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    // Audit log: successful login
613    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    // Redirect to frontend with JWT as query parameter
623    let redirect_url = format!("{}/?token={}", state.config.frontend_url, jwt);
624    Redirect::temporary(&redirect_url).into_response()
625}
626
627/// Upsert user in DB and auto-create a default org if none exist.
628#[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
666/// POST /auth/logout — invalidate JWT and clear session cookie.
667async 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    // Extract token from cookie or Authorization header
692    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            // Revoke session in user store if it's a local auth session
704            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            // Audit log: logout
716            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
736/// GET /api/v1/me — return current user from JWT (cookie or Bearer header).
737async 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    // Extract token from cookie or Authorization header
759    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    // Check revocation
780    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    // Verify JWT
790    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            // Enrich with DB data when saas is enabled
806            #[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// ---------------------------------------------------------------------------
843// Local auth route handlers
844// ---------------------------------------------------------------------------
845
846/// Login request body.
847#[derive(Debug, Deserialize)]
848struct LoginRequest {
849    username: String,
850    password: String,
851}
852
853/// POST /auth/login — authenticate with username/password, return JWT in cookie.
854async 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            // Audit: failed login
886            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    // Audit: successful login
929    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
954/// POST /auth/renew — renew session, issue new JWT in cookie.
955async 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    // Extract JWT from cookie or Authorization header
980    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    // Verify existing JWT
1001    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    // Only renew local auth sessions
1013    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    // Validate existing session
1035    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    // Look up user to get current role (may have been updated)
1044    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    // Revoke old token and issue new one with same session
1059    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/// Request body for creating a user.
1107#[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
1122/// POST /auth/users — create a new user (admin only).
1123async 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    // Verify admin access
1143    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
1208/// GET /auth/users — list all users (admin only).
1209async 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
1257/// Helper: extract JWT from cookie or Authorization header, verify it, check revocation.
1258async 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    // Check revocation
1279    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
1297// ---------------------------------------------------------------------------
1298// Route assembly
1299// ---------------------------------------------------------------------------
1300
1301/// Build OAuth/auth routes. When `state` is None, endpoints return 503.
1302pub fn oauth_routes(state: Option<SharedOAuthState>) -> Router {
1303    Router::new()
1304        // GET /auth/github
1305        .route("/auth/github", get(handle_github_redirect))
1306        // GET /auth/github/callback?code=...
1307        .route("/auth/github/callback", get(handle_github_callback))
1308        // POST /auth/login
1309        .route("/auth/login", post(handle_login))
1310        // POST /auth/renew
1311        .route("/auth/renew", post(handle_renew))
1312        // POST /auth/logout
1313        .route("/auth/logout", post(handle_logout))
1314        // GET /api/v1/me
1315        .route("/api/v1/me", get(handle_me))
1316        // POST /auth/users (admin only)
1317        // GET /auth/users (admin only)
1318        .route(
1319            "/auth/users",
1320            post(handle_create_user).get(handle_list_users),
1321        )
1322        .with_state(state)
1323}
1324
1325/// Spawn a background task to periodically clean up revoked tokens.
1326pub 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// ---------------------------------------------------------------------------
1337// Tests
1338// ---------------------------------------------------------------------------
1339
1340#[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        // Verify with different secret should fail
1403        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        // Revoke the token
1512        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        // Create a test user
1625        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        // Test successful login
1641        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        // Test failed login
1664        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}