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