diff --git a/k-tv-backend/api/src/config.rs b/k-tv-backend/api/src/config.rs index 66b1352..fabc5fc 100644 --- a/k-tv-backend/api/src/config.rs +++ b/k-tv-backend/api/src/config.rs @@ -36,6 +36,7 @@ pub struct Config { pub jwt_issuer: Option, pub jwt_audience: Option, pub jwt_expiry_hours: u64, + pub jwt_refresh_expiry_days: u64, /// Whether the application is running in production mode pub is_production: bool, @@ -117,6 +118,11 @@ impl Config { .and_then(|s| s.parse().ok()) .unwrap_or(24); + let jwt_refresh_expiry_days = env::var("JWT_REFRESH_EXPIRY_DAYS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(30); + let is_production = env::var("PRODUCTION") .or_else(|_| env::var("RUST_ENV")) .map(|v| v.to_lowercase() == "production" || v == "1" || v == "true") @@ -165,6 +171,7 @@ impl Config { jwt_issuer, jwt_audience, jwt_expiry_hours, + jwt_refresh_expiry_days, is_production, allow_registration, jellyfin_base_url, diff --git a/k-tv-backend/api/src/dto.rs b/k-tv-backend/api/src/dto.rs index 8b35429..ae62597 100644 --- a/k-tv-backend/api/src/dto.rs +++ b/k-tv-backend/api/src/dto.rs @@ -15,6 +15,15 @@ pub struct LoginRequest { pub email: Email, /// Password is validated on deserialization (min 8 chars) pub password: Password, + /// When true, a refresh token is also issued for persistent sessions + #[serde(default)] + pub remember_me: bool, +} + +/// Refresh token request +#[derive(Debug, Deserialize)] +pub struct RefreshRequest { + pub refresh_token: String, } /// Register request with validated email and password newtypes @@ -41,6 +50,9 @@ pub struct TokenResponse { pub access_token: String, pub token_type: String, pub expires_in: u64, + /// Only present when remember_me was true at login, or on token refresh + #[serde(skip_serializing_if = "Option::is_none")] + pub refresh_token: Option, } /// Per-provider info returned by `GET /config`. diff --git a/k-tv-backend/api/src/extractors.rs b/k-tv-backend/api/src/extractors.rs index e068759..0703ca6 100644 --- a/k-tv-backend/api/src/extractors.rs +++ b/k-tv-backend/api/src/extractors.rs @@ -122,7 +122,7 @@ pub(crate) async fn validate_jwt_token(token: &str, state: &AppState) -> Result< .as_ref() .ok_or_else(|| ApiError::Internal("JWT validator not configured".to_string()))?; - let claims = validator.validate_token(token).map_err(|e| { + let claims = validator.validate_access_token(token).map_err(|e| { tracing::debug!("JWT validation failed: {:?}", e); match e { infra::auth::jwt::JwtError::Expired => { diff --git a/k-tv-backend/api/src/routes/auth/local.rs b/k-tv-backend/api/src/routes/auth/local.rs index c429928..64cae50 100644 --- a/k-tv-backend/api/src/routes/auth/local.rs +++ b/k-tv-backend/api/src/routes/auth/local.rs @@ -6,13 +6,13 @@ use axum::{ }; use crate::{ - dto::{LoginRequest, RegisterRequest, TokenResponse, UserResponse}, + dto::{LoginRequest, RefreshRequest, RegisterRequest, TokenResponse, UserResponse}, error::ApiError, extractors::CurrentUser, state::AppState, }; -use super::create_jwt; +use super::{create_jwt, create_refresh_jwt}; /// Login with email + password → JWT token pub(super) async fn login( @@ -35,6 +35,11 @@ pub(super) async fn login( } let token = create_jwt(&user, &state)?; + let refresh_token = if payload.remember_me { + Some(create_refresh_jwt(&user, &state)?) + } else { + None + }; let _ = state.activity_log_repo.log("user_login", user.email.as_ref(), None).await; Ok(( @@ -43,6 +48,7 @@ pub(super) async fn login( access_token: token, token_type: "Bearer".to_string(), expires_in: state.config.jwt_expiry_hours * 3600, + refresh_token, }), )) } @@ -71,6 +77,7 @@ pub(super) async fn register( access_token: token, token_type: "Bearer".to_string(), expires_in: state.config.jwt_expiry_hours * 3600, + refresh_token: None, }), )) } @@ -90,6 +97,46 @@ pub(super) async fn me(CurrentUser(user): CurrentUser) -> Result, + Json(payload): Json, +) -> Result { + let validator = state + .jwt_validator + .as_ref() + .ok_or_else(|| ApiError::Internal("JWT not configured".to_string()))?; + + let claims = validator + .validate_refresh_token(&payload.refresh_token) + .map_err(|e| { + tracing::debug!("Refresh token validation failed: {:?}", e); + ApiError::Unauthorized("Invalid or expired refresh token".to_string()) + })?; + + let user_id: uuid::Uuid = claims + .sub + .parse() + .map_err(|_| ApiError::Unauthorized("Invalid user ID in token".to_string()))?; + + let user = state + .user_service + .find_by_id(user_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch user: {}", e)))?; + + let access_token = create_jwt(&user, &state)?; + let new_refresh_token = create_refresh_jwt(&user, &state)?; + + Ok(Json(TokenResponse { + access_token, + token_type: "Bearer".to_string(), + expires_in: state.config.jwt_expiry_hours * 3600, + refresh_token: Some(new_refresh_token), + })) +} + /// Issue a new JWT for the currently authenticated user (OIDC→JWT exchange or token refresh) #[cfg(feature = "auth-jwt")] pub(super) async fn get_token( @@ -102,5 +149,6 @@ pub(super) async fn get_token( access_token: token, token_type: "Bearer".to_string(), expires_in: state.config.jwt_expiry_hours * 3600, + refresh_token: None, })) } diff --git a/k-tv-backend/api/src/routes/auth/mod.rs b/k-tv-backend/api/src/routes/auth/mod.rs index b9384f3..0de4e76 100644 --- a/k-tv-backend/api/src/routes/auth/mod.rs +++ b/k-tv-backend/api/src/routes/auth/mod.rs @@ -18,7 +18,9 @@ pub fn router() -> Router { .route("/me", get(local::me)); #[cfg(feature = "auth-jwt")] - let r = r.route("/token", post(local::get_token)); + let r = r + .route("/token", post(local::get_token)) + .route("/refresh", post(local::refresh_token)); #[cfg(feature = "auth-oidc")] let r = r @@ -28,7 +30,7 @@ pub fn router() -> Router { r } -/// Helper: create JWT for a user +/// Helper: create access JWT for a user #[cfg(feature = "auth-jwt")] pub(super) fn create_jwt(user: &domain::User, state: &AppState) -> Result { let validator = state @@ -45,3 +47,21 @@ pub(super) fn create_jwt(user: &domain::User, state: &AppState) -> Result Result { Err(ApiError::Internal("JWT feature not enabled".to_string())) } + +/// Helper: create refresh JWT for a user +#[cfg(feature = "auth-jwt")] +pub(super) fn create_refresh_jwt(user: &domain::User, state: &AppState) -> Result { + let validator = state + .jwt_validator + .as_ref() + .ok_or_else(|| ApiError::Internal("JWT not configured".to_string()))?; + + validator + .create_refresh_token(user) + .map_err(|e| ApiError::Internal(format!("Failed to create refresh token: {}", e))) +} + +#[cfg(not(feature = "auth-jwt"))] +pub(super) fn create_refresh_jwt(_user: &domain::User, _state: &AppState) -> Result { + Err(ApiError::Internal("JWT feature not enabled".to_string())) +} diff --git a/k-tv-backend/api/src/state.rs b/k-tv-backend/api/src/state.rs index eb489c8..a5a543a 100644 --- a/k-tv-backend/api/src/state.rs +++ b/k-tv-backend/api/src/state.rs @@ -124,6 +124,7 @@ impl AppState { config.jwt_issuer.clone(), config.jwt_audience.clone(), Some(config.jwt_expiry_hours), + Some(config.jwt_refresh_expiry_days), config.is_production, )?; Some(Arc::new(JwtValidator::new(jwt_config))) diff --git a/k-tv-backend/infra/src/auth/jwt.rs b/k-tv-backend/infra/src/auth/jwt.rs index 3fcfc9e..bf040a5 100644 --- a/k-tv-backend/infra/src/auth/jwt.rs +++ b/k-tv-backend/infra/src/auth/jwt.rs @@ -20,8 +20,10 @@ pub struct JwtConfig { pub issuer: Option, /// Expected audience (for validation) pub audience: Option, - /// Token expiry in hours (default: 24) + /// Access token expiry in hours (default: 24) pub expiry_hours: u64, + /// Refresh token expiry in days (default: 30) + pub refresh_expiry_days: u64, } impl JwtConfig { @@ -33,6 +35,7 @@ impl JwtConfig { issuer: Option, audience: Option, expiry_hours: Option, + refresh_expiry_days: Option, is_production: bool, ) -> Result { // Validate secret strength in production @@ -48,6 +51,7 @@ impl JwtConfig { issuer, audience, expiry_hours: expiry_hours.unwrap_or(24), + refresh_expiry_days: refresh_expiry_days.unwrap_or(30), }) } @@ -58,10 +62,15 @@ impl JwtConfig { issuer: None, audience: None, expiry_hours: 24, + refresh_expiry_days: 30, } } } +fn default_token_type() -> String { + "access".to_string() +} + /// JWT claims structure #[derive(Debug, Serialize, Deserialize, Clone)] pub struct JwtClaims { @@ -79,6 +88,9 @@ pub struct JwtClaims { /// Audience #[serde(skip_serializing_if = "Option::is_none")] pub aud: Option, + /// Token type: "access" or "refresh". Defaults to "access" for backward compat. + #[serde(default = "default_token_type")] + pub token_type: String, } /// JWT-related errors @@ -141,7 +153,7 @@ impl JwtValidator { } } - /// Create a JWT token for the given user + /// Create an access JWT token for the given user pub fn create_token(&self, user: &User) -> Result { let now = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -157,6 +169,30 @@ impl JwtValidator { iat: now, iss: self.config.issuer.clone(), aud: self.config.audience.clone(), + token_type: "access".to_string(), + }; + + let header = Header::new(Algorithm::HS256); + encode(&header, &claims, &self.encoding_key).map_err(JwtError::CreationFailed) + } + + /// Create a refresh JWT token for the given user (longer-lived) + pub fn create_refresh_token(&self, user: &User) -> Result { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs() as usize; + + let expiry = now + (self.config.refresh_expiry_days as usize * 86400); + + let claims = JwtClaims { + sub: user.id.to_string(), + email: user.email.as_ref().to_string(), + exp: expiry, + iat: now, + iss: self.config.issuer.clone(), + aud: self.config.audience.clone(), + token_type: "refresh".to_string(), }; let header = Header::new(Algorithm::HS256); @@ -176,6 +212,24 @@ impl JwtValidator { Ok(token_data.claims) } + /// Validate an access token — rejects refresh tokens + pub fn validate_access_token(&self, token: &str) -> Result { + let claims = self.validate_token(token)?; + if claims.token_type != "access" { + return Err(JwtError::ValidationFailed("Not an access token".to_string())); + } + Ok(claims) + } + + /// Validate a refresh token — rejects access tokens + pub fn validate_refresh_token(&self, token: &str) -> Result { + let claims = self.validate_token(token)?; + if claims.token_type != "refresh" { + return Err(JwtError::ValidationFailed("Not a refresh token".to_string())); + } + Ok(claims) + } + /// Get the user ID (subject) from a token without full validation /// Useful for logging/debugging, but should not be trusted for auth pub fn decode_unverified(&self, token: &str) -> Result { diff --git a/k-tv-frontend/app/(auth)/login/page.tsx b/k-tv-frontend/app/(auth)/login/page.tsx index e069227..36378d8 100644 --- a/k-tv-frontend/app/(auth)/login/page.tsx +++ b/k-tv-frontend/app/(auth)/login/page.tsx @@ -8,12 +8,13 @@ import { useConfig } from "@/hooks/use-channels"; export default function LoginPage() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); + const [rememberMe, setRememberMe] = useState(false); const { mutate: login, isPending, error } = useLogin(); const { data: config } = useConfig(); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - login({ email, password }); + login({ email, password, rememberMe }); }; return ( @@ -54,6 +55,23 @@ export default function LoginPage() { /> +
+ + {rememberMe && ( +

+ A refresh token will be stored locally — don't share it. +

+ )} +
+ {error &&

{error.message}

}