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// Self-service registration
1529// ---------------------------------------------------------------------------
1530
1531/// Registration request body.
1532#[derive(Debug, Deserialize)]
1533#[allow(dead_code)]
1534struct RegisterRequest {
1535    username: String,
1536    email: String,
1537    password: String,
1538    org_name: String,
1539}
1540
1541/// POST /auth/register — self-service signup with email verification.
1542#[allow(unused_variables)]
1543async fn handle_register(
1544    State(state): State<Option<SharedOAuthState>>,
1545    Json(body): Json<RegisterRequest>,
1546) -> Response {
1547    let state = match state {
1548        Some(s) => s,
1549        None => {
1550            return (
1551                StatusCode::SERVICE_UNAVAILABLE,
1552                Json(serde_json::json!({ "error": "OAuth not configured" })),
1553            )
1554                .into_response();
1555        }
1556    };
1557
1558    // Validate input
1559    if body.username.is_empty() || body.username.len() > 64 {
1560        return (
1561            StatusCode::BAD_REQUEST,
1562            Json(serde_json::json!({ "error": "Username must be 1-64 characters" })),
1563        )
1564            .into_response();
1565    }
1566    if body.password.len() < 8 {
1567        return (
1568            StatusCode::BAD_REQUEST,
1569            Json(serde_json::json!({ "error": "Password must be at least 8 characters" })),
1570        )
1571            .into_response();
1572    }
1573    if !body.email.contains('@') || body.email.len() < 3 {
1574        return (
1575            StatusCode::BAD_REQUEST,
1576            Json(serde_json::json!({ "error": "Invalid email address" })),
1577        )
1578            .into_response();
1579    }
1580
1581    #[cfg(feature = "saas")]
1582    {
1583        let pool = match &state.db_pool {
1584            Some(p) => p,
1585            None => {
1586                return (
1587                    StatusCode::SERVICE_UNAVAILABLE,
1588                    Json(serde_json::json!({ "error": "Database not configured" })),
1589                )
1590                    .into_response();
1591            }
1592        };
1593
1594        // Check duplicate email
1595        match varpulis_db::repo::get_user_by_email(pool, &body.email).await {
1596            Ok(Some(_)) => {
1597                return (
1598                    StatusCode::CONFLICT,
1599                    Json(serde_json::json!({ "error": "Email already registered" })),
1600                )
1601                    .into_response();
1602            }
1603            Err(e) => {
1604                tracing::error!("DB error checking email: {}", e);
1605                return (
1606                    StatusCode::INTERNAL_SERVER_ERROR,
1607                    Json(serde_json::json!({ "error": "Internal server error" })),
1608                )
1609                    .into_response();
1610            }
1611            Ok(None) => {}
1612        }
1613
1614        // Check duplicate username
1615        match varpulis_db::repo::get_user_by_username(pool, &body.username).await {
1616            Ok(Some(_)) => {
1617                return (
1618                    StatusCode::CONFLICT,
1619                    Json(serde_json::json!({ "error": "Username already taken" })),
1620                )
1621                    .into_response();
1622            }
1623            Err(e) => {
1624                tracing::error!("DB error checking username: {}", e);
1625                return (
1626                    StatusCode::INTERNAL_SERVER_ERROR,
1627                    Json(serde_json::json!({ "error": "Internal server error" })),
1628                )
1629                    .into_response();
1630            }
1631            Ok(None) => {}
1632        }
1633
1634        // Hash password
1635        let password_hash = match crate::users::hash_password(&body.password) {
1636            Ok(h) => h,
1637            Err(e) => {
1638                tracing::error!("Password hashing failed: {}", e);
1639                return (
1640                    StatusCode::INTERNAL_SERVER_ERROR,
1641                    Json(serde_json::json!({ "error": "Internal server error" })),
1642                )
1643                    .into_response();
1644            }
1645        };
1646
1647        // Generate verification token
1648        let token = crate::email::generate_verification_token();
1649        let expires_at = chrono::Utc::now() + chrono::Duration::hours(24);
1650
1651        // Create user with verification pending
1652        let user = match varpulis_db::repo::create_local_user_with_verification(
1653            pool,
1654            &body.username,
1655            &password_hash,
1656            &body.username,
1657            &body.email,
1658            "operator",
1659            &token,
1660            expires_at,
1661        )
1662        .await
1663        {
1664            Ok(u) => u,
1665            Err(e) => {
1666                let msg = e.to_string();
1667                let status = if msg.contains("duplicate") || msg.contains("unique") {
1668                    StatusCode::CONFLICT
1669                } else {
1670                    StatusCode::BAD_REQUEST
1671                };
1672                return (status, Json(serde_json::json!({ "error": msg }))).into_response();
1673            }
1674        };
1675
1676        // Create trial organization
1677        let org_name = if body.org_name.is_empty() {
1678            format!("{}'s org", body.username)
1679        } else {
1680            body.org_name.clone()
1681        };
1682        let new_org = varpulis_db::repo::create_trial_organization(pool, user.id, &org_name).await;
1683        match &new_org {
1684            Ok(org) => {
1685                // Auto-copy deployed global pipeline templates to the new org
1686                if let Ok(templates) = varpulis_db::repo::list_deployed_global_templates(pool).await
1687                {
1688                    for t in &templates {
1689                        if let Err(e) = varpulis_db::repo::create_global_pipeline_copy(
1690                            pool,
1691                            org.id,
1692                            t.id,
1693                            &t.name,
1694                            &t.vpl_source,
1695                        )
1696                        .await
1697                        {
1698                            tracing::warn!(
1699                                "Failed to copy global pipeline '{}' to new org {}: {}",
1700                                t.name,
1701                                org.id,
1702                                e
1703                            );
1704                        }
1705                    }
1706                }
1707            }
1708            Err(e) => {
1709                tracing::error!("Failed to create org for new user: {}", e);
1710            }
1711        }
1712
1713        // Send verification email (or log if SMTP not configured)
1714        match &state.email_sender {
1715            Some(sender) => {
1716                if let Err(e) = sender
1717                    .send_verification_email(&body.email, &body.username, &token)
1718                    .await
1719                {
1720                    tracing::error!("Failed to send verification email: {}", e);
1721                }
1722            }
1723            None => {
1724                let frontend_url = &state.config.frontend_url;
1725                tracing::info!(
1726                    "Verification URL (SMTP not configured): {}/verify-email?token={}",
1727                    frontend_url,
1728                    token
1729                );
1730            }
1731        }
1732
1733        // Audit log
1734        if let Some(ref logger) = state.audit_logger {
1735            logger
1736                .log(
1737                    crate::audit::AuditEntry::new(
1738                        &body.username,
1739                        crate::audit::AuditAction::UserCreate,
1740                        "/auth/register",
1741                    )
1742                    .with_detail("Self-service signup".to_string()),
1743                )
1744                .await;
1745        }
1746
1747        (
1748            StatusCode::CREATED,
1749            Json(serde_json::json!({
1750                "ok": true,
1751                "message": "Check your email to verify your account",
1752            })),
1753        )
1754            .into_response()
1755    }
1756
1757    #[cfg(not(feature = "saas"))]
1758    {
1759        (
1760            StatusCode::SERVICE_UNAVAILABLE,
1761            Json(serde_json::json!({ "error": "Registration requires saas feature" })),
1762        )
1763            .into_response()
1764    }
1765}
1766
1767/// Query params for email verification.
1768#[derive(Debug, Deserialize)]
1769#[allow(dead_code)]
1770struct VerifyQuery {
1771    token: String,
1772}
1773
1774/// GET /auth/verify?token=... — verify email address.
1775#[allow(unused_variables)]
1776async fn handle_verify_email(
1777    State(state): State<Option<SharedOAuthState>>,
1778    Query(query): Query<VerifyQuery>,
1779) -> Response {
1780    let state = match state {
1781        Some(s) => s,
1782        None => {
1783            return (
1784                StatusCode::SERVICE_UNAVAILABLE,
1785                Json(serde_json::json!({ "error": "OAuth not configured" })),
1786            )
1787                .into_response();
1788        }
1789    };
1790
1791    #[cfg(feature = "saas")]
1792    {
1793        let pool = match &state.db_pool {
1794            Some(p) => p,
1795            None => {
1796                return (
1797                    StatusCode::SERVICE_UNAVAILABLE,
1798                    Json(serde_json::json!({ "error": "Database not configured" })),
1799                )
1800                    .into_response();
1801            }
1802        };
1803
1804        let user = match varpulis_db::repo::get_user_by_verification_token(pool, &query.token).await
1805        {
1806            Ok(Some(u)) => u,
1807            Ok(None) => {
1808                return (
1809                    StatusCode::BAD_REQUEST,
1810                    Json(serde_json::json!({ "error": "Invalid or expired verification token" })),
1811                )
1812                    .into_response();
1813            }
1814            Err(e) => {
1815                tracing::error!("DB error looking up verification token: {}", e);
1816                return (
1817                    StatusCode::INTERNAL_SERVER_ERROR,
1818                    Json(serde_json::json!({ "error": "Internal server error" })),
1819                )
1820                    .into_response();
1821            }
1822        };
1823
1824        // Check expiration
1825        if let Some(expires_at) = user.verification_expires_at {
1826            if chrono::Utc::now() > expires_at {
1827                return (
1828                    StatusCode::BAD_REQUEST,
1829                    Json(serde_json::json!({ "error": "Verification token has expired" })),
1830                )
1831                    .into_response();
1832            }
1833        }
1834
1835        // Mark as verified
1836        if let Err(e) = varpulis_db::repo::verify_user_email(pool, user.id).await {
1837            tracing::error!("Failed to verify user email: {}", e);
1838            return (
1839                StatusCode::INTERNAL_SERVER_ERROR,
1840                Json(serde_json::json!({ "error": "Internal server error" })),
1841            )
1842                .into_response();
1843        }
1844
1845        tracing::info!(
1846            "Email verified for user: {} ({})",
1847            user.username.as_deref().unwrap_or("?"),
1848            user.email
1849        );
1850
1851        (
1852            StatusCode::OK,
1853            Json(serde_json::json!({
1854                "ok": true,
1855                "message": "Email verified. You can now log in.",
1856            })),
1857        )
1858            .into_response()
1859    }
1860
1861    #[cfg(not(feature = "saas"))]
1862    {
1863        let _ = query;
1864        (
1865            StatusCode::SERVICE_UNAVAILABLE,
1866            Json(serde_json::json!({ "error": "Requires saas feature" })),
1867        )
1868            .into_response()
1869    }
1870}
1871
1872// ---------------------------------------------------------------------------
1873// Route assembly
1874// ---------------------------------------------------------------------------
1875
1876/// Build OAuth/auth routes. When `state` is None, endpoints return 503.
1877pub fn oauth_routes(state: Option<SharedOAuthState>) -> Router {
1878    Router::new()
1879        // GET /auth/github
1880        .route("/auth/github", get(handle_github_redirect))
1881        // GET /auth/github/callback?code=...
1882        .route("/auth/github/callback", get(handle_github_callback))
1883        // POST /auth/login
1884        .route("/auth/login", post(handle_login))
1885        // POST /auth/register (self-service signup)
1886        .route("/auth/register", post(handle_register))
1887        // GET /auth/verify?token=... (email verification)
1888        .route("/auth/verify", get(handle_verify_email))
1889        // POST /auth/renew
1890        .route("/auth/renew", post(handle_renew))
1891        // POST /auth/logout
1892        .route("/auth/logout", post(handle_logout))
1893        // GET /api/v1/me
1894        .route("/api/v1/me", get(handle_me))
1895        // POST /auth/users (admin only)
1896        // GET /auth/users (admin only)
1897        .route(
1898            "/auth/users",
1899            post(handle_create_user).get(handle_list_users),
1900        )
1901        .with_state(state)
1902}
1903
1904/// Spawn a background task to periodically clean up revoked tokens.
1905pub fn spawn_session_cleanup(state: SharedOAuthState) {
1906    tokio::spawn(async move {
1907        let mut interval = tokio::time::interval(std::time::Duration::from_secs(3600));
1908        loop {
1909            interval.tick().await;
1910            state.sessions.write().await.cleanup();
1911        }
1912    });
1913}
1914
1915// ---------------------------------------------------------------------------
1916// Tests
1917// ---------------------------------------------------------------------------
1918
1919#[cfg(test)]
1920mod tests {
1921    use axum::body::Body;
1922    use axum::http::Request;
1923    use tower::ServiceExt;
1924
1925    use super::*;
1926
1927    fn get_req(uri: &str) -> Request<Body> {
1928        Request::builder()
1929            .method("GET")
1930            .uri(uri)
1931            .body(Body::empty())
1932            .unwrap()
1933    }
1934
1935    #[test]
1936    fn test_jwt_roundtrip() {
1937        let config = OAuthConfig {
1938            github_client_id: "test".to_string(),
1939            github_client_secret: "test".to_string(),
1940            jwt_secret: "super-secret-key-for-testing".to_string(),
1941            frontend_url: "http://localhost:5173".to_string(),
1942            server_url: "http://localhost:9000".to_string(),
1943        };
1944
1945        let user = GitHubUser {
1946            id: 12345,
1947            login: "testuser".to_string(),
1948            name: Some("Test User".to_string()),
1949            avatar_url: "https://example.com/avatar.png".to_string(),
1950            email: Some("test@example.com".to_string()),
1951        };
1952
1953        let token = create_jwt(&config, &user, "", "", "").expect("JWT creation should succeed");
1954        let claims = verify_jwt(&config, &token).expect("JWT verification should succeed");
1955
1956        assert_eq!(claims.sub, "12345");
1957        assert_eq!(claims.login, "testuser");
1958        assert_eq!(claims.name, "Test User");
1959        assert_eq!(claims.email, "test@example.com");
1960    }
1961
1962    #[test]
1963    fn test_jwt_invalid_secret() {
1964        let config = OAuthConfig {
1965            github_client_id: "test".to_string(),
1966            github_client_secret: "test".to_string(),
1967            jwt_secret: "secret-1".to_string(),
1968            frontend_url: "http://localhost:5173".to_string(),
1969            server_url: "http://localhost:9000".to_string(),
1970        };
1971
1972        let user = GitHubUser {
1973            id: 1,
1974            login: "u".to_string(),
1975            name: None,
1976            avatar_url: String::new(),
1977            email: None,
1978        };
1979
1980        let token = create_jwt(&config, &user, "", "", "").unwrap();
1981
1982        // Verify with different secret should fail
1983        let config2 = OAuthConfig {
1984            jwt_secret: "secret-2".to_string(),
1985            ..config
1986        };
1987        assert!(verify_jwt(&config2, &token).is_err());
1988    }
1989
1990    #[test]
1991    fn test_session_store_revoke() {
1992        let mut store = SessionStore::new();
1993        let hash = "abc123".to_string();
1994
1995        assert!(!store.is_revoked(&hash));
1996        store.revoke(hash.clone());
1997        assert!(store.is_revoked(&hash));
1998    }
1999
2000    #[test]
2001    fn test_token_hash_deterministic() {
2002        let h1 = token_hash("my-token");
2003        let h2 = token_hash("my-token");
2004        assert_eq!(h1, h2);
2005    }
2006
2007    #[test]
2008    fn test_token_hash_different_for_different_tokens() {
2009        let h1 = token_hash("token-a");
2010        let h2 = token_hash("token-b");
2011        assert_ne!(h1, h2);
2012    }
2013
2014    #[tokio::test]
2015    async fn test_me_endpoint_no_token() {
2016        let config = OAuthConfig {
2017            github_client_id: "test".to_string(),
2018            github_client_secret: "test".to_string(),
2019            jwt_secret: "test-secret".to_string(),
2020            frontend_url: "http://localhost:5173".to_string(),
2021            server_url: "http://localhost:9000".to_string(),
2022        };
2023        let state = Arc::new(OAuthState::new(config));
2024        let app = oauth_routes(Some(state));
2025
2026        let res = app.oneshot(get_req("/api/v1/me")).await.unwrap();
2027
2028        assert_eq!(res.status(), 401);
2029    }
2030
2031    #[tokio::test]
2032    async fn test_me_endpoint_valid_token() {
2033        let config = OAuthConfig {
2034            github_client_id: "test".to_string(),
2035            github_client_secret: "test".to_string(),
2036            jwt_secret: "test-secret".to_string(),
2037            frontend_url: "http://localhost:5173".to_string(),
2038            server_url: "http://localhost:9000".to_string(),
2039        };
2040
2041        let user = GitHubUser {
2042            id: 42,
2043            login: "octocat".to_string(),
2044            name: Some("Octocat".to_string()),
2045            avatar_url: "https://github.com/octocat.png".to_string(),
2046            email: Some("octocat@github.com".to_string()),
2047        };
2048
2049        let token = create_jwt(&config, &user, "", "", "").unwrap();
2050        let state = Arc::new(OAuthState::new(config));
2051        let app = oauth_routes(Some(state));
2052
2053        let req: Request<Body> = Request::builder()
2054            .method("GET")
2055            .uri("/api/v1/me")
2056            .header("authorization", format!("Bearer {token}"))
2057            .body(Body::empty())
2058            .unwrap();
2059        let res = app.oneshot(req).await.unwrap();
2060
2061        assert_eq!(res.status(), 200);
2062        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
2063            .await
2064            .unwrap();
2065        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
2066        assert_eq!(body["login"], "octocat");
2067        assert_eq!(body["name"], "Octocat");
2068    }
2069
2070    #[tokio::test]
2071    async fn test_me_endpoint_revoked_token() {
2072        let config = OAuthConfig {
2073            github_client_id: "test".to_string(),
2074            github_client_secret: "test".to_string(),
2075            jwt_secret: "test-secret".to_string(),
2076            frontend_url: "http://localhost:5173".to_string(),
2077            server_url: "http://localhost:9000".to_string(),
2078        };
2079
2080        let user = GitHubUser {
2081            id: 42,
2082            login: "octocat".to_string(),
2083            name: Some("Octocat".to_string()),
2084            avatar_url: "https://github.com/octocat.png".to_string(),
2085            email: Some("octocat@github.com".to_string()),
2086        };
2087
2088        let token = create_jwt(&config, &user, "", "", "").unwrap();
2089        let state = Arc::new(OAuthState::new(config));
2090
2091        // Revoke the token
2092        let hash = token_hash(&token);
2093        state.sessions.write().await.revoke(hash);
2094
2095        let app = oauth_routes(Some(state));
2096
2097        let req: Request<Body> = Request::builder()
2098            .method("GET")
2099            .uri("/api/v1/me")
2100            .header("authorization", format!("Bearer {token}"))
2101            .body(Body::empty())
2102            .unwrap();
2103        let res = app.oneshot(req).await.unwrap();
2104
2105        assert_eq!(res.status(), 401);
2106        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
2107            .await
2108            .unwrap();
2109        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
2110        assert_eq!(body["error"], "Token revoked");
2111    }
2112
2113    #[tokio::test]
2114    async fn test_logout_endpoint() {
2115        let config = OAuthConfig {
2116            github_client_id: "test".to_string(),
2117            github_client_secret: "test".to_string(),
2118            jwt_secret: "test-secret".to_string(),
2119            frontend_url: "http://localhost:5173".to_string(),
2120            server_url: "http://localhost:9000".to_string(),
2121        };
2122        let state = Arc::new(OAuthState::new(config));
2123        let app = oauth_routes(Some(state));
2124
2125        let req: Request<Body> = Request::builder()
2126            .method("POST")
2127            .uri("/auth/logout")
2128            .header("authorization", "Bearer some-token")
2129            .body(Body::empty())
2130            .unwrap();
2131        let res = app.oneshot(req).await.unwrap();
2132
2133        assert_eq!(res.status(), 200);
2134        let set_cookie = res.headers().get("set-cookie").unwrap().to_str().unwrap();
2135        assert!(set_cookie.contains("Max-Age=0"));
2136        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
2137            .await
2138            .unwrap();
2139        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
2140        assert_eq!(body["ok"], true);
2141    }
2142
2143    #[test]
2144    fn test_extract_jwt_from_cookie() {
2145        assert_eq!(
2146            extract_jwt_from_cookie("varpulis_session=abc123"),
2147            Some("abc123".to_string())
2148        );
2149        assert_eq!(
2150            extract_jwt_from_cookie("other=foo; varpulis_session=abc123; more=bar"),
2151            Some("abc123".to_string())
2152        );
2153        assert_eq!(extract_jwt_from_cookie("other=foo"), None);
2154        assert_eq!(extract_jwt_from_cookie("varpulis_session="), None);
2155    }
2156
2157    #[test]
2158    fn test_local_jwt_roundtrip() {
2159        let config = OAuthConfig {
2160            github_client_id: "test".to_string(),
2161            github_client_secret: "test".to_string(),
2162            jwt_secret: "test-secret-key-32chars-minimum!!".to_string(),
2163            frontend_url: "http://localhost:5173".to_string(),
2164            server_url: "http://localhost:9000".to_string(),
2165        };
2166
2167        let token = create_jwt_for_local_user(
2168            &config,
2169            "user-123",
2170            "alice",
2171            "Alice Smith",
2172            "alice@example.com",
2173            "admin",
2174            "session-456",
2175            3600,
2176            "",
2177        )
2178        .unwrap();
2179
2180        let claims = verify_jwt(&config, &token).unwrap();
2181        assert_eq!(claims.sub, "user-123");
2182        assert_eq!(claims.login, "alice");
2183        assert_eq!(claims.name, "Alice Smith");
2184        assert_eq!(claims.role, "admin");
2185        assert_eq!(claims.session_id, "session-456");
2186        assert_eq!(claims.auth_method, "local");
2187    }
2188
2189    // Note: test_login_endpoint requires a real DB (saas feature) and is tested
2190    // via integration tests. Unit tests cover JWT creation/verification only.
2191
2192    #[tokio::test]
2193    async fn test_me_endpoint_with_cookie() {
2194        let config = OAuthConfig {
2195            github_client_id: "test".to_string(),
2196            github_client_secret: "test".to_string(),
2197            jwt_secret: "test-secret".to_string(),
2198            frontend_url: "http://localhost:5173".to_string(),
2199            server_url: "http://localhost:9000".to_string(),
2200        };
2201
2202        let token = create_jwt_for_local_user(
2203            &config,
2204            "user-1",
2205            "alice",
2206            "Alice",
2207            "alice@test.com",
2208            "admin",
2209            "sess-1",
2210            3600,
2211            "",
2212        )
2213        .unwrap();
2214
2215        let state = Arc::new(OAuthState::new(config));
2216        let app = oauth_routes(Some(state));
2217
2218        let req: Request<Body> = Request::builder()
2219            .method("GET")
2220            .uri("/api/v1/me")
2221            .header("cookie", format!("varpulis_session={token}"))
2222            .body(Body::empty())
2223            .unwrap();
2224        let res = app.oneshot(req).await.unwrap();
2225
2226        assert_eq!(res.status(), 200);
2227        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
2228            .await
2229            .unwrap();
2230        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
2231        assert_eq!(body["login"], "alice");
2232        assert_eq!(body["role"], "admin");
2233        assert_eq!(body["auth_method"], "local");
2234    }
2235}