feat(auth): refresh tokens + remember me
Backend: add refresh JWT (30d, token_type claim), POST /auth/refresh endpoint (rotates token pair), remember_me on login, JWT_REFRESH_EXPIRY_DAYS env var. Extractors now reject refresh tokens on protected routes. Frontend: sessionStorage for non-remembered sessions, localStorage + refresh token for remembered sessions. Transparent 401 recovery in api.ts (retry once after refresh). Remember me checkbox on login page with security note when checked.
This commit is contained in:
@@ -36,6 +36,7 @@ pub struct Config {
|
||||
pub jwt_issuer: Option<String>,
|
||||
pub jwt_audience: Option<String>,
|
||||
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,
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// Per-provider info returned by `GET /config`.
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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<impl IntoRespon
|
||||
}))
|
||||
}
|
||||
|
||||
/// Exchange a valid refresh token for a new access + refresh token pair
|
||||
#[cfg(feature = "auth-jwt")]
|
||||
pub(super) async fn refresh_token(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<RefreshRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
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,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -18,7 +18,9 @@ pub fn router() -> Router<AppState> {
|
||||
.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<AppState> {
|
||||
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<String, ApiError> {
|
||||
let validator = state
|
||||
@@ -45,3 +47,21 @@ pub(super) fn create_jwt(user: &domain::User, state: &AppState) -> Result<String
|
||||
pub(super) fn create_jwt(_user: &domain::User, _state: &AppState) -> Result<String, ApiError> {
|
||||
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<String, ApiError> {
|
||||
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<String, ApiError> {
|
||||
Err(ApiError::Internal("JWT feature not enabled".to_string()))
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -20,8 +20,10 @@ pub struct JwtConfig {
|
||||
pub issuer: Option<String>,
|
||||
/// Expected audience (for validation)
|
||||
pub audience: Option<String>,
|
||||
/// 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<String>,
|
||||
audience: Option<String>,
|
||||
expiry_hours: Option<u64>,
|
||||
refresh_expiry_days: Option<u64>,
|
||||
is_production: bool,
|
||||
) -> Result<Self, JwtError> {
|
||||
// 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<String>,
|
||||
/// 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<String, JwtError> {
|
||||
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<String, JwtError> {
|
||||
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<JwtClaims, JwtError> {
|
||||
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<JwtClaims, JwtError> {
|
||||
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<JwtClaims, JwtError> {
|
||||
|
||||
Reference in New Issue
Block a user