Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
232 changes: 232 additions & 0 deletions src/middleware/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,235 @@ pub fn decode_jwt(token: &str, secret: &str) -> Result<JwtClaims, AppError> {

Ok(data.claims)
}

#[cfg(test)]
mod tests {
use super::*;
use axum::http::Request;
use jsonwebtoken::{encode, EncodingKey, Header};
use sqlx::PgPool;

const TEST_SECRET: &str = "super-secret-jwt-key-must-be-at-least-32-chars-long";
const OTHER_SECRET: &str = "another-secret-jwt-key-must-be-32-chars-or-more!!";

fn test_app_state(secret: &str) -> AppState {
let config = crate::Config {
port: 8080,
host: "0.0.0.0".into(),
database_url: "postgres://user:pass@localhost/db".into(),
database_max_connections: 5,
database_min_connections: 1,
database_connect_timeout_secs: 10,
jwt_secret: secret.into(),
jwt_expiry_hours: 24,
horizon_url: "https://horizon-testnet.stellar.org".into(),
stellar_network_passphrase: "Test SDF Network ; September 2015".into(),
soroban_rpc_url: "https://soroban-testnet.stellar.org".into(),
keeper_secret_key: None,
subscription_contract_id: None,
escrow_contract_id: None,
keeper_poll_interval_secs: 60,
keeper_enabled: false,
reconciliation_poll_interval_secs: 30,
reconciliation_stale_after_secs: 60,
rate_cache_ttl_secs: 60,
allowed_origins: vec!["*".into()],
app_env: crate::config::AppEnv::Development,
};
Arc::new(crate::AppState {
pool: PgPool::connect_lazy("postgres://user:pass@localhost/db").unwrap(),
config,
loop_health: crate::BackgroundLoopHealth::default(),
})
}

#[test]
fn round_trip_issue_and_decode_recovers_claims() {
let user_id = Uuid::new_v4();
let email = "alice@example.com";
let token = issue_jwt(user_id, email, TEST_SECRET, 24).expect("issue_jwt should succeed");

let claims = decode_jwt(&token, TEST_SECRET).expect("decode_jwt should succeed");
assert_eq!(claims.sub, user_id.to_string());
assert_eq!(claims.email, email);
assert!(claims.exp > claims.iat);
assert_eq!(claims.exp - claims.iat, 24 * 3600);
}

#[test]
fn decode_fails_with_invalid_token_when_secret_mismatches() {
let user_id = Uuid::new_v4();
let email = "bob@example.com";
let token = issue_jwt(user_id, email, TEST_SECRET, 24).expect("issue_jwt should succeed");

let err = decode_jwt(&token, OTHER_SECRET).unwrap_err();
assert!(
matches!(err, AppError::InvalidToken),
"expected InvalidToken on secret mismatch, got {err:?}"
);
}

#[test]
fn decode_specifically_fails_with_token_expired_when_past_exp() {
let user_id = Uuid::new_v4();
let email = "charlie@example.com";
// Issue token with negative expiry hours to produce an expired token
let token = issue_jwt(user_id, email, TEST_SECRET, -1).expect("issue_jwt should succeed");

let err = decode_jwt(&token, TEST_SECRET).unwrap_err();
assert!(
matches!(err, AppError::TokenExpired),
"expected TokenExpired on expired token, got {err:?}"
);
}

#[test]
fn decode_fails_when_signature_is_tampered() {
let user_id = Uuid::new_v4();
let email = "dave@example.com";
let token = issue_jwt(user_id, email, TEST_SECRET, 24).expect("issue_jwt should succeed");

let mut parts: Vec<&str> = token.split('.').collect();
assert_eq!(parts.len(), 3);
// Tamper with the signature portion
let mut tampered_sig = parts[2].to_string();
if tampered_sig.ends_with('A') {
tampered_sig.replace_range(tampered_sig.len() - 1.., "B");
} else {
tampered_sig.replace_range(tampered_sig.len() - 1.., "A");
}
parts[2] = &tampered_sig;
let tampered_token = parts.join(".");

let err = decode_jwt(&tampered_token, TEST_SECRET).unwrap_err();
assert!(
matches!(err, AppError::InvalidToken),
"expected InvalidToken on tampered signature, got {err:?}"
);
}

#[test]
fn decode_fails_when_algorithm_is_different() {
let user_id = Uuid::new_v4();
let email = "eve@example.com";
let now = chrono::Utc::now();
let exp = now + chrono::Duration::hours(1);

let claims = JwtClaims {
sub: user_id.to_string(),
email: email.to_string(),
iat: now.timestamp(),
exp: exp.timestamp(),
};

// Create token explicitly signed with HS384 instead of HS256
let header = Header::new(Algorithm::HS384);
let token = encode(
&header,
&claims,
&EncodingKey::from_secret(TEST_SECRET.as_bytes()),
)
.expect("encode with HS384 should succeed");

let err = decode_jwt(&token, TEST_SECRET).unwrap_err();
assert!(
matches!(err, AppError::InvalidToken),
"expected InvalidToken when token algorithm is HS384 instead of HS256, got {err:?}"
);
}

#[tokio::test]
async fn extractor_rejects_missing_authorization_header() {
let state = test_app_state(TEST_SECRET);
let req = Request::builder()
.uri("/api/protected")
.body(())
.unwrap();
let (mut parts, _) = req.into_parts();

let err = AuthUser::from_request_parts(&mut parts, &state)
.await
.unwrap_err();
assert!(
matches!(err, AppError::Unauthorized),
"expected Unauthorized for missing Authorization header, got {err:?}"
);
}

#[tokio::test]
async fn extractor_rejects_malformed_auth_scheme() {
let state = test_app_state(TEST_SECRET);
let req = Request::builder()
.uri("/api/protected")
.header(header::AUTHORIZATION, "Basic dXNlcjpwYXNz")
.body(())
.unwrap();
let (mut parts, _) = req.into_parts();

let err = AuthUser::from_request_parts(&mut parts, &state)
.await
.unwrap_err();
assert!(
matches!(err, AppError::Unauthorized),
"expected Unauthorized for non-Bearer Authorization scheme, got {err:?}"
);
}

#[tokio::test]
async fn extractor_rejects_non_uuid_sub() {
let state = test_app_state(TEST_SECRET);
let now = chrono::Utc::now();
let exp = now + chrono::Duration::hours(1);

let claims = JwtClaims {
sub: "not-a-valid-uuid".into(),
email: "frank@example.com".into(),
iat: now.timestamp(),
exp: exp.timestamp(),
};

let token = encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(TEST_SECRET.as_bytes()),
)
.expect("encode should succeed");

let req = Request::builder()
.uri("/api/protected")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(())
.unwrap();
let (mut parts, _) = req.into_parts();

let err = AuthUser::from_request_parts(&mut parts, &state)
.await
.unwrap_err();
assert!(
matches!(err, AppError::InvalidToken),
"expected InvalidToken for non-UUID sub claim, got {err:?}"
);
}

#[tokio::test]
async fn extractor_succeeds_with_valid_bearer_token() {
let state = test_app_state(TEST_SECRET);
let user_id = Uuid::new_v4();
let email = "grace@example.com";
let token = issue_jwt(user_id, email, TEST_SECRET, 24).expect("issue_jwt should succeed");

let req = Request::builder()
.uri("/api/protected")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(())
.unwrap();
let (mut parts, _) = req.into_parts();

let auth_user = AuthUser::from_request_parts(&mut parts, &state)
.await
.expect("AuthUser extraction should succeed");
assert_eq!(auth_user.user_id, user_id);
assert_eq!(auth_user.email, email);
assert_eq!(auth_user.claims.sub, user_id.to_string());
}
}