Files
tutortool/backend/src/auth.rs
s0wlz (Matthias Puchstein) ff5ad26cfc feat: harden security with httpOnly cookies and modernize frontend with Svelte 5 runes
- Switched to secure httpOnly, SameSite=Strict cookies for JWT authentication.
- Refactored backend to use AppState for shared secrets and database pool caching.
- Modernized frontend with Svelte 5 runes ($state) and removed localStorage reliance.
- Gated destructive test endpoints behind debug_assertions and fixed unsafe test patterns.
- Enhanced CI pipeline with cargo clippy, cargo fmt, and pinned pnpm version.
- Updated documentation and implementation plans to match the hardened architecture.
2026-05-02 03:16:33 +02:00

102 lines
2.9 KiB
Rust

use crate::{AppState, error::AppError};
use axum::{RequestPartsExt, extract::FromRef, extract::FromRequestParts, http::request::Parts};
use axum_extra::extract::CookieJar;
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TutorClaims {
pub sub: i64,
pub email: String,
pub is_superadmin: bool,
pub exp: u64,
}
pub fn encode_jwt(
id: i64,
email: &str,
is_superadmin: bool,
secret: &str,
) -> Result<String, AppError> {
let exp = (chrono::Utc::now() + chrono::Duration::days(7)).timestamp() as u64;
let claims = TutorClaims {
sub: id,
email: email.into(),
is_superadmin,
exp,
};
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(secret.as_bytes()),
)
.map_err(|_| AppError::Unauthorized)
}
pub fn decode_jwt(token: &str, secret: &str) -> Result<TutorClaims, AppError> {
decode::<TutorClaims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
&Validation::default(),
)
.map(|d| d.claims)
.map_err(|e| {
tracing::debug!(error = %e, "JWT decode failed");
AppError::Unauthorized
})
}
// Axum extractor: pulls JWT from httpOnly cookie or Authorization: Bearer header
impl<S> FromRequestParts<S> for TutorClaims
where
S: Send + Sync,
AppState: axum::extract::FromRef<S>,
{
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let app_state = AppState::from_ref(state);
// Try cookie first
let jar = parts
.extract::<CookieJar>()
.await
.map_err(|_| AppError::Unauthorized)?;
let token = if let Some(cookie) = jar.get("token") {
cookie.value().to_string()
} else {
// Fallback to header for compatibility/testing
parts
.headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or(AppError::Unauthorized)?
.to_string()
};
decode_jwt(&token, &app_state.jwt_secret)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
#[test]
#[serial]
fn jwt_roundtrip_and_rejection() {
temp_env::with_var("JWT_SECRET", Some("testsecret_auth"), || {
let secret = "testsecret_auth";
// roundtrip
let token = encode_jwt(1, "test@example.com", true, secret).unwrap();
let claims = decode_jwt(&token, secret).unwrap();
assert_eq!(claims.sub, 1);
assert!(claims.is_superadmin);
// rejection
assert!(decode_jwt("not.a.token", secret).is_err());
});
}
}