You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
synctv/synctv-core/tests/jwt_security_tests.rs

778 lines
24 KiB
Rust

//! JWT token generation/validation integration tests //!
//! These tests verify JWT security properties including tampering detection,
//! expiry enforcement, and type confusion prevention.
use std::sync::Arc;
use base64::{engine::general_purpose, Engine as _};
use chrono::Utc;
use jsonwebtoken::{Algorithm, EncodingKey, Header};
use serde::{Deserialize, Serialize};
use synctv_core::{
models::UserId,
service::{Claims, JwtService, JwtValidator, TokenCredentialBinding},
};
use synctv_core_testing::{create_test_jwt_service, err, ok};
#[derive(Debug, Serialize, Deserialize)]
struct ExpiredClaims {
sub: String,
typ: String,
jti: String,
iat: i64,
exp: i64,
}
fn sign_test_refresh_token(jwt_service: &JwtService, user_id: &UserId) -> String {
ok(
jwt_service.sign_refresh_token_with_session(
user_id,
0,
None,
"jwt-security-refresh-session",
&TokenCredentialBinding::Password { version: 0 },
),
"refresh token should be signed",
)
}
fn sign_access_token(jwt_service: &JwtService, user_id: &UserId, pv: i32) -> String {
ok(
jwt_service.sign_access_token(user_id, pv),
"access token should be signed",
)
}
fn verify_access_token(jwt_service: &JwtService, token: &str) -> Claims {
ok(
jwt_service.verify_access_token(token),
"access token should verify",
)
}
fn verify_refresh_token(jwt_service: &JwtService, token: &str) -> Claims {
ok(
jwt_service.verify_refresh_token(token),
"refresh token should verify",
)
}
fn jwt_parts(token: &str) -> Vec<&str> {
let parts: Vec<&str> = token.split('.').collect();
assert_eq!(parts.len(), 3);
parts
}
#[tokio::test]
async fn test_jwt_tampering_detection_signature() {
let jwt_service = create_test_jwt_service();
let user_id = UserId::new();
// Generate valid token
let token = sign_access_token(&jwt_service, &user_id, 0);
// Tamper with signature
let parts = jwt_parts(&token);
let tampered_token = format!("{}.{}.TAMPERED_SIGNATURE", parts[0], parts[1]);
// Verification should fail
let result = jwt_service.verify_access_token(&tampered_token);
assert!(result.is_err(), "Tampered signature should be rejected");
}
#[tokio::test]
async fn test_jwt_tampering_detection_payload() {
let jwt_service = create_test_jwt_service();
let user_id = UserId::new();
let token = sign_access_token(&jwt_service, &user_id, 0);
// Extract and modify payload
let parts = jwt_parts(&token);
let mut payload_bytes = ok(
general_purpose::URL_SAFE_NO_PAD.decode(parts[1]),
"payload should decode",
);
// Tamper with payload (change user ID)
if let Some(byte) = payload_bytes.get_mut(10) {
*byte = byte.wrapping_add(1);
}
let tampered_payload = general_purpose::URL_SAFE_NO_PAD.encode(&payload_bytes);
let tampered_token = format!("{}.{}.{}", parts[0], tampered_payload, parts[2]);
// Verification should fail due to signature mismatch
let result = jwt_service.verify_access_token(&tampered_token);
assert!(result.is_err(), "Tampered payload should be rejected");
}
#[tokio::test]
async fn test_jwt_expiry_enforcement() {
use chrono::Utc;
use jsonwebtoken::{encode, EncodingKey, Header};
#[derive(Debug, Serialize, Deserialize)]
struct ExpiredClaims {
sub: String,
typ: String,
jti: String,
pv: i32,
iat: i64,
exp: i64,
}
let jwt_service = create_test_jwt_service();
let user_id = UserId::new();
let now = Utc::now().timestamp();
let claims = ExpiredClaims {
sub: user_id.to_string(),
typ: "access".to_string(),
jti: synctv_common::snanoid!(),
pv: 0,
iat: now - 7200, // 2 hours ago
exp: now - 3600, // expired 1 hour ago
};
let secret = "test-secret-key-for-integration-tests-minimum-length-32-chars";
let encoding_key = EncodingKey::from_secret(secret.as_bytes());
let expired_token = ok(
encode(&Header::new(Algorithm::HS256), &claims, &encoding_key),
"expired token should encode",
);
// Verification should fail due to expiry
let result = jwt_service.verify_access_token(&expired_token);
assert!(result.is_err(), "Expired token should be rejected");
let err_msg = format!("{:?}", err(result, "expired token should be rejected"));
assert!(
err_msg.contains("expired") || err_msg.contains("ExpiredSignature"),
"Error should indicate token expiry: {err_msg}"
);
}
#[tokio::test]
async fn test_jwt_type_confusion_access_vs_refresh() {
let jwt_service = create_test_jwt_service();
let user_id = UserId::new();
// Generate access token
let access_token = sign_access_token(&jwt_service, &user_id, 0);
// Generate refresh token
let refresh_token = sign_test_refresh_token(&jwt_service, &user_id);
// Try to use access token as refresh token
let result = jwt_service.verify_refresh_token(&access_token);
assert!(
result.is_err(),
"Access token should not validate as refresh token"
);
// Try to use refresh token as access token
let result = jwt_service.verify_access_token(&refresh_token);
assert!(
result.is_err(),
"Refresh token should not validate as access token"
);
// Verify correct usage works
assert!(jwt_service.verify_access_token(&access_token).is_ok());
assert!(jwt_service.verify_refresh_token(&refresh_token).is_ok());
}
#[tokio::test]
async fn test_jwt_type_confusion_guest_token() {
use synctv_core::models::RoomId;
let jwt_service = create_test_jwt_service();
let user_id = UserId::new();
let room_id = RoomId::new();
// Generate guest token (session_id is generated internally by sign_guest_token)
let guest_token = ok(
jwt_service.sign_guest_token(&room_id),
"guest token should be signed",
);
// Try to use guest token as access token
let result = jwt_service.verify_access_token(&guest_token);
assert!(
result.is_err(),
"Guest token should not validate as access token"
);
// Generate regular access token
let access_token = sign_access_token(&jwt_service, &user_id, 0);
// Try to use access token as guest token
let result = jwt_service.verify_guest_token(&access_token);
assert!(
result.is_err(),
"Access token should not validate as guest token"
);
}
#[tokio::test]
async fn test_jwt_malformed_token_rejection() {
let jwt_service = create_test_jwt_service();
// Various malformed tokens
let malformed_tokens = vec![
"", // Empty
"invalid", // No dots
"invalid.token", // Only 2 parts
"a.b.c.d", // Too many parts
"!!!.@@@.###", // Invalid base64
"eyJ.eyJ.abc", // Partial base64
];
for token in malformed_tokens {
let result = jwt_service.verify_token(token);
assert!(
result.is_err(),
"Malformed token '{token}' should be rejected"
);
}
}
#[tokio::test]
async fn test_jwt_wrong_algorithm_rejection() {
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
#[derive(Debug, Serialize, Deserialize)]
struct TestClaims {
sub: String,
typ: String,
jti: String,
iat: i64,
exp: i64,
}
let jwt_service = create_test_jwt_service();
let user_id = UserId::new();
let now = chrono::Utc::now().timestamp();
let claims = TestClaims {
sub: user_id.to_string(),
typ: "access".to_string(),
jti: synctv_common::snanoid!(),
iat: now,
exp: now + 3600,
};
let secret = "test-secret-key-for-integration-tests-minimum-length-32-chars";
let encoding_key = EncodingKey::from_secret(secret.as_bytes());
let wrong_alg_token = ok(
encode(&Header::new(Algorithm::HS512), &claims, &encoding_key),
"wrong-algorithm token should encode",
);
// Should fail because service expects HS256
let result = jwt_service.verify_access_token(&wrong_alg_token);
assert!(
result.is_err(),
"Token with wrong algorithm should be rejected"
);
}
#[tokio::test]
async fn test_jwt_future_issued_at_rejection() {
use jsonwebtoken::{encode, EncodingKey, Header};
#[derive(Debug, Serialize, Deserialize)]
struct FutureClaims {
sub: String,
typ: String,
jti: String,
iat: i64,
exp: i64,
}
let jwt_service = create_test_jwt_service();
let user_id = UserId::new();
let now = chrono::Utc::now().timestamp();
let claims = FutureClaims {
sub: user_id.to_string(),
typ: "access".to_string(),
jti: synctv_common::snanoid!(),
iat: now + 3600, // Future timestamp
exp: now + 7200,
};
let secret = "test-secret-key-for-integration-tests-minimum-length-32-chars";
let encoding_key = EncodingKey::from_secret(secret.as_bytes());
let future_token = ok(
encode(&Header::new(Algorithm::HS256), &claims, &encoding_key),
"future-issued token should encode",
);
// Verification may succeed or fail depending on clock skew tolerance
// But the token should not be usable for authentication
let result = jwt_service.verify_access_token(&future_token);
// Some implementations reject future iat, others allow with clock skew
// Either behavior is acceptable as long as it's consistent
if let Ok(claims) = result {
// If accepted, verify the timestamp is actually in the future
assert!(claims.iat > now, "Future iat should be preserved");
}
}
#[tokio::test]
async fn test_jwt_jti_uniqueness() {
let jwt_service = create_test_jwt_service();
let user_id = UserId::new();
// Generate multiple tokens
let token1 = sign_access_token(&jwt_service, &user_id, 0);
let token2 = sign_access_token(&jwt_service, &user_id, 0);
let claims1 = verify_access_token(&jwt_service, &token1);
let claims2 = verify_access_token(&jwt_service, &token2);
// JTI should be different for each token
assert_ne!(
claims1.token_id(),
claims2.token_id(),
"JTI should be unique per token"
);
assert!(!claims1.token_id().is_empty(), "JTI should not be empty");
assert!(!claims2.token_id().is_empty(), "JTI should not be empty");
}
#[tokio::test]
async fn test_jwt_different_secrets_incompatible() {
let jwt_service1 = ok(
JwtService::new("test-secret-key-1-with-sufficient-length-32chars"),
"first JWT service should be created",
);
let jwt_service2 = ok(
JwtService::new("test-secret-key-2-with-sufficient-length-32chars"),
"second JWT service should be created",
);
let user_id = UserId::new();
// Sign with service 1
let token = sign_access_token(&jwt_service1, &user_id, 0);
// Verify with service 2 should fail
let result = jwt_service2.verify_access_token(&token);
assert!(
result.is_err(),
"Token from different secret should be rejected"
);
}
#[tokio::test]
async fn test_jwt_token_expiration_boundary() {
let jwt_service = create_test_jwt_service();
let user_id = UserId::new();
// Generate token
let token = sign_access_token(&jwt_service, &user_id, 0);
let claims = verify_access_token(&jwt_service, &token);
// Verify expiration is correctly set (1 hour for access token)
let expected_exp = claims.iat + 3600;
assert_eq!(
claims.exp, expected_exp,
"Access token should expire in 1 hour"
);
// Generate refresh token
let refresh_token = sign_test_refresh_token(&jwt_service, &user_id);
let refresh_claims = verify_refresh_token(&jwt_service, &refresh_token);
// Verify refresh token expiration (30 days)
let expected_refresh_exp = refresh_claims.iat + (30 * 24 * 3600);
assert_eq!(
refresh_claims.exp, expected_refresh_exp,
"Refresh token should expire in 30 days"
);
}
#[tokio::test]
async fn test_jwt_concurrent_token_generation() {
use std::collections::HashSet;
use std::sync::Arc;
let jwt_service = Arc::new(create_test_jwt_service());
let mut handles = vec![];
// Generate 50 tokens concurrently
for _ in 0..50 {
let service = jwt_service.clone();
let handle = tokio::spawn(async move {
let user_id = UserId::new();
let token = sign_access_token(&service, &user_id, 0);
let claims = verify_access_token(&service, &token);
claims.token_id().to_string()
});
handles.push(handle);
}
let jtis: Vec<String> = futures::future::join_all(handles)
.await
.into_iter()
.map(|result| ok(result, "JWT generation task should complete"))
.collect();
// All JTIs should be unique
let unique_jtis: HashSet<_> = jtis.iter().collect();
assert_eq!(jtis.len(), unique_jtis.len(), "All JTIs should be unique");
}
// SEC8: JwtValidator edge cases
#[tokio::test]
async fn test_jwt_validator_empty_bearer_token_after_prefix() {
let jwt_service = Arc::new(create_test_jwt_service());
let validator = JwtValidator::new(jwt_service);
// "Bearer " with nothing after it -- the extracted token is empty string
let result = JwtValidator::extract_bearer_token("Bearer ");
// The implementation checks len() <= 7 and "Bearer " is 7 chars,
// so "Bearer " (with trailing space) has len = 7, which is <= 7.
// Actually "Bearer " has 7 chars, so len <= 7 means it fails.
assert!(
result.is_err(),
"Bearer with empty token after prefix should be rejected"
);
// Slightly different: "Bearer" without the trailing space
let result2 = JwtValidator::extract_bearer_token("Bearer");
assert!(
result2.is_err(),
"'Bearer' without space should be rejected"
);
// "Bearer " (with extra space) -- extracts " " which is invalid JWT
let result3 = JwtValidator::extract_bearer_token("Bearer ");
// This extracts " " as the token, which is technically 8 chars > 7 so passes extraction
// but the token " " will fail JWT verification
if let Ok(token) = result3 {
let verify_result = validator.validate_token(&token);
assert!(
verify_result.is_err(),
"Whitespace-only token should fail verification"
);
}
// HTTP: "Bearer " + empty should fail
let result4 = validator.validate_authorization_header("Bearer ");
assert!(
result4.is_err(),
"HTTP validation with empty bearer should fail"
);
}
#[tokio::test]
async fn test_jwt_validator_authorization_header_returns_authentication_error() {
let jwt_service = Arc::new(create_test_jwt_service());
let validator = JwtValidator::new(jwt_service);
let result = JwtValidator::extract_bearer_token("Basic invalid.token");
assert!(matches!(
err(result, "invalid authorization header should fail"),
synctv_core::Error::Authentication(_)
));
let result2 = validator.validate_authorization_header("Bearer invalid.token");
assert!(matches!(
err(result2, "invalid bearer token should fail"),
synctv_core::Error::Authentication(_)
));
}
// SEC9: JwtService::verify_custom skips issuer/audience
#[tokio::test]
async fn test_verify_custom_skips_issuer_validation() {
#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct CustomClaims {
sub: String,
custom_field: String,
exp: i64,
}
let jwt_service = ok(
JwtService::with_durations_and_claims(
"test-secret-key-for-integration-tests-minimum-length-32-chars",
1,
30,
4,
60,
Some("synctv".to_string()),
Some("synctv-api".to_string()),
),
"JWT service with claims should be created",
);
let custom_claims = CustomClaims {
sub: "custom_subject".to_string(),
custom_field: "custom_value".to_string(),
exp: chrono::Utc::now().timestamp() + 3600,
};
let token = ok(
jwt_service.sign_custom(&custom_claims),
"custom token should be signed",
);
// verify_custom should succeed (it skips issuer/audience checks)
let result = jwt_service.verify_custom::<CustomClaims>(&token);
assert!(
result.is_ok(),
"verify_custom should skip issuer/audience validation: {:?}",
result.err()
);
let verified = ok(result, "custom token should verify");
assert_eq!(verified.sub, "custom_subject");
assert_eq!(verified.custom_field, "custom_value");
// In contrast, verify_token should FAIL for a custom token without proper issuer
// (because verify_token validates issuer/audience when configured)
// We can't easily test this since sign_custom doesn't include iss/aud in claims,
// but verify_token requires them when configured. Let's verify that by trying
// to parse it as standard Claims with verify_token.
// First, sign a regular token (which includes iss/aud)
let user_id = UserId::new();
let regular_token = sign_access_token(&jwt_service, &user_id, 0);
// Regular token should pass verify_token (has correct issuer/audience)
assert!(jwt_service.verify_token(&regular_token).is_ok());
// Regular token (which contains aud/iss claims) will FAIL verify_custom
// because jsonwebtoken's default Validation expects `aud` to be validated
// when present in the token, but verify_custom doesn't set expected audiences.
// This documents that verify_custom is intended for custom tokens only, NOT
// for regular tokens that carry standard iss/aud claims.
let custom_on_regular = jwt_service.verify_custom::<CustomClaims>(&regular_token);
assert!(
custom_on_regular.is_err(),
"verify_custom should fail on tokens with aud claim (no expected aud configured)"
);
}
#[tokio::test]
async fn test_verify_custom_still_validates_expiry() {
#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct ExpiringClaims {
sub: String,
exp: i64,
iat: i64,
}
let jwt_service = create_test_jwt_service();
let secret = "test-secret-key-for-integration-tests-minimum-length-32-chars";
let now = chrono::Utc::now().timestamp();
let expired_claims = ExpiringClaims {
sub: "expired_custom".to_string(),
exp: now - 3600,
iat: now - 7200,
};
// Sign it manually (can't use sign_custom since it adds a valid exp)
let encoding_key = EncodingKey::from_secret(secret.as_bytes());
let token = ok(
jsonwebtoken::encode(
&Header::new(Algorithm::HS256),
&expired_claims,
&encoding_key,
),
"expired custom token should encode",
);
// verify_custom should still reject expired tokens
let result = jwt_service.verify_custom::<ExpiringClaims>(&token);
assert!(
result.is_err(),
"verify_custom should still validate expiry"
);
}
#[tokio::test]
async fn test_verify_custom_validates_signature() {
#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct SignedClaims {
sub: String,
data: i64,
exp: i64,
}
let jwt_service1 = create_test_jwt_service();
let jwt_service2 = ok(
JwtService::new("DIFFERENT-secret-key-for-custom-token-tests-1234567890!@#"),
"second JWT service should be created",
);
let claims = SignedClaims {
sub: "test".to_string(),
data: 42,
exp: chrono::Utc::now().timestamp() + 3600,
};
let token = ok(
jwt_service1.sign_custom(&claims),
"custom token should be signed",
);
// verify_custom with different secret should fail
let result = jwt_service2.verify_custom::<SignedClaims>(&token);
assert!(
result.is_err(),
"verify_custom should reject tokens signed with different secret"
);
}
#[tokio::test]
async fn test_token_includes_password_version() {
let jwt_service = create_test_jwt_service();
let user_id = UserId::new();
// Token with password version 0
let token_v0 = sign_access_token(&jwt_service, &user_id, 0);
let claims_v0 = verify_access_token(&jwt_service, &token_v0);
assert_eq!(claims_v0.pv, 0, "Token should include password version");
// Token with password version 5
let token_v5 = sign_access_token(&jwt_service, &user_id, 5);
let claims_v5 = verify_access_token(&jwt_service, &token_v5);
assert_eq!(
claims_v5.pv, 5,
"Token should include updated password version"
);
}
#[tokio::test]
async fn test_refresh_token_rotation_produces_new_jti() {
let jwt_service = create_test_jwt_service();
let user_id = UserId::new();
// Simulate token rotation: old token -> new token
let old_refresh = sign_test_refresh_token(&jwt_service, &user_id);
let old_claims = verify_refresh_token(&jwt_service, &old_refresh);
// New token (as would be produced by rotation)
let new_refresh = sign_test_refresh_token(&jwt_service, &user_id);
let new_claims = verify_refresh_token(&jwt_service, &new_refresh);
// New token should have different JTI
assert_ne!(
old_claims.token_id(),
new_claims.token_id(),
"Rotated token should have new JTI"
);
// Both should have same subject
assert_eq!(old_claims.user_id(), new_claims.user_id());
}
#[tokio::test]
async fn test_access_and_refresh_tokens_have_different_expirations() {
let jwt_service = create_test_jwt_service();
let user_id = UserId::new();
let access_token = sign_access_token(&jwt_service, &user_id, 0);
let refresh_token = sign_test_refresh_token(&jwt_service, &user_id);
let access_claims = verify_access_token(&jwt_service, &access_token);
let refresh_claims = verify_refresh_token(&jwt_service, &refresh_token);
// Access token should expire before refresh token
assert!(
access_claims.exp < refresh_claims.exp,
"Access token should have shorter expiration than refresh token"
);
// Access token typically expires in 1 hour
let expected_access_exp = access_claims.iat + 3600;
assert_eq!(
access_claims.exp, expected_access_exp,
"Access token should expire in 1 hour"
);
// Refresh token typically expires in 30 days
let expected_refresh_exp = refresh_claims.iat + (30 * 24 * 3600);
assert_eq!(
refresh_claims.exp, expected_refresh_exp,
"Refresh token should expire in 30 days"
);
}
#[tokio::test]
async fn test_tampered_subject_rejected() {
let jwt_service = create_test_jwt_service();
let user_id = UserId::new();
let token = sign_access_token(&jwt_service, &user_id, 0);
let parts = jwt_parts(&token);
// Decode, modify subject, re-encode (but with wrong signature)
let payload_bytes = ok(
base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(parts[1]),
"payload should decode",
);
let mut payload: serde_json::Value = ok(
serde_json::from_slice(&payload_bytes),
"payload should parse",
);
// Change subject to different user
payload["sub"] = serde_json::json!("attacker_user_id");
let tampered_payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(ok(serde_json::to_vec(&payload), "payload should encode"));
let tampered_token = format!("{}.{}.{}", parts[0], tampered_payload, parts[2]);
let result = jwt_service.verify_access_token(&tampered_token);
assert!(
result.is_err(),
"Token with modified subject should be rejected"
);
}
#[tokio::test]
async fn test_expired_refresh_token_rejected() {
let jwt_service = create_test_jwt_service();
let user_id = UserId::new();
let now = Utc::now().timestamp();
let claims = ExpiredClaims {
sub: user_id.to_string(),
typ: "refresh".to_string(),
jti: synctv_common::snanoid!(),
iat: now - 2_592_000 - 3600, // 30 days + 1 hour ago
exp: now - 3600, // expired 1 hour ago
};
let encoding_key =
EncodingKey::from_secret(b"test-secret-key-for-integration-tests-minimum-length-32-chars");
let expired_token = ok(
jsonwebtoken::encode(&Header::new(Algorithm::HS256), &claims, &encoding_key),
"expired refresh token should encode",
);
let result = jwt_service.verify_refresh_token(&expired_token);
assert!(result.is_err(), "Expired refresh token should be rejected");
}