fix: secret() error propagation, exp as u64, bcrypt cost 4 in tests, skip empty auth header
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user