fix: secret() error propagation, exp as u64, bcrypt cost 4 in tests, skip empty auth header

This commit is contained in:
2026-04-28 01:36:22 +02:00
parent 83b25b1693
commit a351c442d3
3 changed files with 34 additions and 22 deletions

View File

@@ -4,24 +4,30 @@ use serde::{Deserialize, Serialize};
use crate::error::AppError;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TutorClaims { pub sub: i64, pub email: String, pub exp: usize }
pub struct TutorClaims { pub sub: i64, pub email: String, pub exp: u64 }
fn secret() -> String {
std::env::var("JWT_SECRET").expect("JWT_SECRET not set")
fn secret() -> Result<String, AppError> {
std::env::var("JWT_SECRET").map_err(|_| {
tracing::error!("JWT_SECRET environment variable is not set");
AppError::Unauthorized
})
}
pub fn encode_jwt(id: i64, email: &str) -> Result<String, AppError> {
let exp = chrono::Utc::now().timestamp() as usize + 86400 * 7;
let exp = (chrono::Utc::now() + chrono::Duration::days(7)).timestamp() as u64;
let claims = TutorClaims { sub: id, email: email.into(), exp };
encode(&Header::default(), &claims, &EncodingKey::from_secret(secret().as_bytes()))
encode(&Header::default(), &claims, &EncodingKey::from_secret(secret()?.as_bytes()))
.map_err(|_| AppError::Unauthorized)
}
pub fn decode_jwt(token: &str) -> Result<TutorClaims, AppError> {
decode::<TutorClaims>(token, &DecodingKey::from_secret(secret().as_bytes()),
decode::<TutorClaims>(token, &DecodingKey::from_secret(secret()?.as_bytes()),
&Validation::default())
.map(|d| d.claims)
.map_err(|_| AppError::Unauthorized)
.map_err(|e| {
tracing::debug!(error = %e, "JWT decode failed");
AppError::Unauthorized
})
}
// Axum extractor: pulls JWT from Authorization: Bearer header
@@ -41,16 +47,16 @@ mod tests {
use super::*;
#[test]
fn roundtrip_jwt() {
unsafe { std::env::set_var("JWT_SECRET", "testsecret"); }
fn jwt_roundtrip_and_rejection() {
// Set var inside the test; still unsafe in edition 2024
unsafe { std::env::set_var("JWT_SECRET", "testsecret_auth"); }
// roundtrip
let token = encode_jwt(1, "test@example.com").unwrap();
let claims = decode_jwt(&token).unwrap();
assert_eq!(claims.sub, 1);
}
#[test]
fn invalid_jwt_rejected() {
unsafe { std::env::set_var("JWT_SECRET", "testsecret"); }
// rejection
assert!(decode_jwt("not.a.token").is_err());
}
}

View File

@@ -35,7 +35,7 @@ mod tests {
#[sqlx::test(migrations = "./migrations")]
async fn login_returns_token(pool: SqlitePool) {
let hash = bcrypt::hash("secret", bcrypt::DEFAULT_COST).unwrap();
let hash = bcrypt::hash("secret", 4).unwrap();
sqlx::query("INSERT INTO tutors (name,email,password_hash) VALUES (?,?,?)")
.bind("Test").bind("t@test.com").bind(&hash)
.execute(&pool).await.unwrap();
@@ -49,7 +49,7 @@ mod tests {
#[sqlx::test(migrations = "./migrations")]
async fn login_wrong_password(pool: SqlitePool) {
let hash = bcrypt::hash("correct", bcrypt::DEFAULT_COST).unwrap();
let hash = bcrypt::hash("correct", 4).unwrap();
sqlx::query("INSERT INTO tutors (name,email,password_hash) VALUES (?,?,?)")
.bind("Test").bind("t@test.com").bind(&hash)
.execute(&pool).await.unwrap();

View File

@@ -7,7 +7,7 @@ use http_body_util::BodyExt;
/// Insert a test tutor (if not exists), return a valid JWT for that tutor.
pub async fn make_token(pool: &SqlitePool, email: &str) -> String {
let hash = bcrypt::hash("testpass", bcrypt::DEFAULT_COST).unwrap();
let hash = bcrypt::hash("testpass", 4).unwrap();
sqlx::query("INSERT OR IGNORE INTO tutors (name,email,password_hash) VALUES (?,?,?)")
.bind("Test Tutor").bind(email).bind(&hash)
.execute(pool).await.unwrap();
@@ -29,10 +29,13 @@ pub async fn build_test_app(pool: SqlitePool) -> (Router, String) {
pub async fn post_json(app: Router, path: &str, auth: &str, body: serde_json::Value)
-> (StatusCode, bytes::Bytes)
{
let req = Request::builder()
let mut builder = Request::builder()
.method("POST").uri(path)
.header("Content-Type", "application/json")
.header("Authorization", auth)
.header("Content-Type", "application/json");
if !auth.is_empty() {
builder = builder.header("Authorization", auth);
}
let req = builder
.body(axum::body::Body::from(body.to_string()))
.unwrap();
let res = app.oneshot(req).await.unwrap();
@@ -43,9 +46,12 @@ pub async fn post_json(app: Router, path: &str, auth: &str, body: serde_json::Va
/// GET from the app (one-shot), returns (StatusCode, response body bytes).
pub async fn get(app: Router, path: &str, auth: &str) -> (StatusCode, bytes::Bytes) {
let req = Request::builder()
.method("GET").uri(path)
.header("Authorization", auth)
let mut builder = Request::builder()
.method("GET").uri(path);
if !auth.is_empty() {
builder = builder.header("Authorization", auth);
}
let req = builder
.body(axum::body::Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();