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