#![allow(clippy::unwrap_used, clippy::panic)] use std::{
sync::Arc,
time::{Duration, Instant},
};
use parking_lot::RwLock;
use crate::security::{
errors::SecurityError,
oidc::{
jwks::{CachedJwks, Jwk, Jwks},
providers::OidcConfig,
token::OidcValidator,
},
};
#[test]
fn test_oidc_config_default() {
let config = OidcConfig::default();
assert!(config.issuer.is_empty());
assert!(config.audience.is_none());
assert_eq!(config.jwks_cache_ttl_secs, 300);
assert_eq!(config.allowed_algorithms, vec!["RS256"]);
assert_eq!(config.clock_skew_secs, 60);
assert!(config.required);
}
#[test]
fn test_oidc_config_auth0() {
let config = OidcConfig::auth0("my-tenant.auth0.com", "my-api");
assert_eq!(config.issuer, "https://my-tenant.auth0.com/");
assert_eq!(config.audience, Some("my-api".to_string()));
}
#[test]
fn test_oidc_config_keycloak() {
let config = OidcConfig::keycloak("https://keycloak.example.com", "myrealm", "myclient");
assert_eq!(config.issuer, "https://keycloak.example.com/realms/myrealm");
assert_eq!(config.audience, Some("myclient".to_string()));
}
#[test]
fn test_oidc_config_okta() {
let config = OidcConfig::okta("myorg.okta.com", "api://default");
assert_eq!(config.issuer, "https://myorg.okta.com");
assert_eq!(config.audience, Some("api://default".to_string()));
}
#[test]
fn test_oidc_config_cognito() {
let config = OidcConfig::cognito("us-east-1", "us-east-1_abc123", "client123");
assert_eq!(config.issuer, "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_abc123");
assert_eq!(config.audience, Some("client123".to_string()));
}
#[test]
fn test_oidc_config_azure_ad() {
let config = OidcConfig::azure_ad("tenant-id-123", "client-id-456");
assert_eq!(config.issuer, "https://login.microsoftonline.com/tenant-id-123/v2.0");
assert_eq!(config.audience, Some("client-id-456".to_string()));
}
#[test]
fn test_oidc_config_google() {
let config = OidcConfig::google("123456.apps.googleusercontent.com");
assert_eq!(config.issuer, "https://accounts.google.com");
assert_eq!(config.audience, Some("123456.apps.googleusercontent.com".to_string()));
}
#[test]
fn test_oidc_config_validate_empty_issuer() {
let config = OidcConfig::default();
let result = config.validate();
assert!(
matches!(result, Err(SecurityError::SecurityConfigError(_))),
"expected SecurityConfigError for empty issuer, got: {result:?}"
);
}
#[test]
fn test_oidc_config_validate_http_issuer() {
let config = OidcConfig {
issuer: "http://insecure.example.com".to_string(),
..Default::default()
};
let result = config.validate();
assert!(result.is_err(), "expected http:// issuer to be rejected, got: {result:?}");
}
#[test]
fn test_oidc_config_validate_localhost_allowed() {
let config = OidcConfig {
issuer: "http://localhost:8080".to_string(),
audience: Some("my-api".to_string()),
..Default::default()
};
config
.validate()
.unwrap_or_else(|e| panic!("expected localhost to be allowed: {e}"));
}
#[test]
fn test_oidc_config_validate_https_required() {
let config = OidcConfig {
issuer: "https://secure.example.com".to_string(),
audience: Some("https://api.example.com".to_string()),
..Default::default()
};
config
.validate()
.unwrap_or_else(|e| panic!("expected https:// issuer to be valid: {e}"));
}
#[test]
fn test_oidc_config_with_custom_cache_ttl() {
let config = OidcConfig {
issuer: "http://localhost:8080".to_string(),
jwks_cache_ttl_secs: 600, ..Default::default()
};
assert_eq!(config.jwks_cache_ttl_secs, 600);
}
#[test]
fn test_oidc_config_default_cache_ttl_is_short() {
let config = OidcConfig::default();
assert!(
config.jwks_cache_ttl_secs <= 300,
"Default cache TTL should be short (≤ 300 seconds) to prevent token poisoning"
);
}
fn make_validator(issuer: &str) -> OidcValidator {
OidcValidator {
config: OidcConfig {
issuer: issuer.to_string(),
..Default::default()
},
http_client: reqwest::Client::new(),
jwks_uri: format!("{issuer}/.well-known/jwks.json"),
jwks_cache: Arc::new(RwLock::new(None)),
replay_cache: None,
}
}
fn make_jwk(kid: &str) -> Jwk {
Jwk {
kty: "RSA".to_string(),
kid: Some(kid.to_string()),
alg: None,
key_use: None,
n: None,
e: None,
x5c: vec![],
}
}
#[test]
fn test_detect_key_rotation_when_no_cache() {
let validator = make_validator("http://localhost:8080");
let new_jwks = Jwks {
keys: vec![make_jwk("key1")],
};
assert!(!validator.detect_key_rotation(&new_jwks));
}
#[test]
fn test_detect_key_rotation_when_keys_removed() {
let validator = make_validator("http://localhost:8080");
let old_jwks = Jwks {
keys: vec![make_jwk("old_key_1"), make_jwk("old_key_2")],
};
{
let mut cache = validator.jwks_cache.write();
*cache = Some(CachedJwks {
jwks: old_jwks,
fetched_at: Instant::now(),
ttl: Duration::from_secs(300),
});
}
let new_jwks = Jwks {
keys: vec![make_jwk("old_key_1"), make_jwk("new_key_1")],
};
assert!(validator.detect_key_rotation(&new_jwks));
}
#[test]
fn test_detect_key_rotation_when_no_keys_removed() {
let validator = make_validator("http://localhost:8080");
let old_jwks = Jwks {
keys: vec![make_jwk("key_1"), make_jwk("key_2")],
};
{
let mut cache = validator.jwks_cache.write();
*cache = Some(CachedJwks {
jwks: old_jwks,
fetched_at: Instant::now(),
ttl: Duration::from_secs(300),
});
}
let new_jwks = Jwks {
keys: vec![make_jwk("key_1"), make_jwk("key_2"), make_jwk("new_key")],
};
assert!(!validator.detect_key_rotation(&new_jwks));
}
#[test]
fn test_find_key_by_kid() {
let validator = make_validator("http://localhost:8080");
let jwks = Jwks {
keys: vec![make_jwk("key1"), make_jwk("key2")],
};
assert!(validator.find_key(&jwks, "key1").is_some());
assert!(validator.find_key(&jwks, "key2").is_some());
assert!(validator.find_key(&jwks, "key3").is_none());
}
#[test]
fn test_find_key_without_kid() {
let validator = make_validator("http://localhost:8080");
let jwks = Jwks {
keys: vec![Jwk {
kty: "RSA".to_string(),
kid: None, alg: None,
key_use: None,
n: None,
e: None,
x5c: vec![],
}],
};
assert!(validator.find_key(&jwks, "any_kid").is_none());
}
#[tokio::test]
async fn oidc_discovery_oversized_response_is_rejected() {
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{method, path},
};
use crate::security::oidc::token::MAX_DISCOVERY_RESPONSE_BYTES;
let mock = MockServer::start().await;
let oversized_body = vec![b'x'; MAX_DISCOVERY_RESPONSE_BYTES + 1];
Mock::given(method("GET"))
.and(path("/.well-known/openid-configuration"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(oversized_body))
.mount(&mock)
.await;
let config = OidcConfig {
issuer: mock.uri(),
audience: Some("test-audience".to_string()),
..Default::default()
};
let result = OidcValidator::new(config).await;
assert!(result.is_err(), "oversized discovery response must be rejected");
let msg = result.err().unwrap().to_string();
assert!(msg.contains("too large"), "error must mention size limit: {msg}");
}
#[tokio::test]
async fn oidc_discovery_within_size_limit_proceeds_to_parse() {
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{method, path},
};
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/.well-known/openid-configuration"))
.respond_with(ResponseTemplate::new(200).set_body_string("not json"))
.mount(&mock)
.await;
let config = OidcConfig {
issuer: mock.uri(),
audience: Some("test-audience".to_string()),
..Default::default()
};
let result = OidcValidator::new(config).await;
assert!(result.is_err(), "expected JSON parse failure for 'not json' body");
let msg = result.err().unwrap().to_string();
assert!(
!msg.contains("too large"),
"small body must not trigger size limit error: {msg}"
);
}
#[test]
fn with_jwks_uri_creates_validator_without_panicking() {
let config = OidcConfig {
issuer: "https://example.com".to_string(),
jwks_uri: Some("https://example.com/.well-known/jwks.json".to_string()),
..Default::default()
};
let validator = OidcValidator::with_jwks_uri(config, "https://example.com/jwks".to_string());
assert_eq!(validator.issuer(), "https://example.com");
}
const TEST_RSA_PRIVATE_KEY_PEM: &str = "\
-----BEGIN PRIVATE KEY-----\n\
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCpf3bisHt/omOk\n\
VFHz/xb4p14mkeOerg4balAN0NznbieVbmnKwPjaaUfS9ZspwwCn9bLbIAaMIa3G\n\
oKqsSyfIITWNikiLp8ZnzaQH8JbgPLGaSfvy4w6dTp0cm9kL4te6KRk2J7owbVdp\n\
wW6nfFKyYwtNAJLSDg6aX7HCJ9QAoWT9rWC8lKvCodTwYvrf2T5PkLje8UDZYHx1\n\
WC+T9bal+0uKl+hP7j5NIM84Kh2W0KfMEOkMlGdQ6r8Y7aSuum2qYnH5K8gSoWlB\n\
Tn7393F+dbkTGmfGlo9k36flmIhu1eFWUgrYhG5HdKwofOI4HQEH9cuW+RHplrpt\n\
S6D9BJnRAgMBAAECggEAOz6FVGjxUcR15YtfddR0uAbwHrUhhWY7IhP/1URq4i2b\n\
glysd6UJlnX0F+WnDWrOgOadVIAWKcbf0ax4224NgqMw778k6kODUucK7YeHhOtR\n\
/KbdfKEmi49d1REYRVJNqxEQceBi8OhXBG0K+1m2IgoCejC4INmu+wB1xnJbZLhz\n\
B4uGLljKaqBssFIfsV4n+zcZcqesCTpsCcqcWURjcCWbWVBpqG7EkTFRtq35T/+c\n\
eQQTeH/UR/Bv9IHALJeTXQ41GYskZ4UPV0OMQ/bQtpojB6KZfTyz+CNn2iUeLNN6\n\
HXE8oAg3h4Unhajq8jT4XrWxY69HZhb/8zSeXRxEfQKBgQDdwvNmcG2GH1quysD+\n\
9qvO+w19lRun4AC886nQoaalrhaXAeK/GjS1D6vnUiJoN/rhkfI8mzhWdjHVgtql\n\
yJjKWb6C2bwTGsF1eDn1JTOZx+O4E/ToU/h1OzyAjrRTTPIiabSLNsCugwF45KHM\n\
ACEctgfUKVel/KAPeN0dkpPjEwKBgQDDqs/RZWj/FqJtj+SGBl6xcOL6F7L9d2hW\n\
0nZj/8/bgmRyvncO8A0YooqcJnMsYUWuhxdkkOH5f/q6FEuDrxJn9EdUxJNp4g4H\n\
65pcTJynQEF0QN/cc/1zR2H0h2TblS5mTW/Ya1GbLmu5KYshLjqDfKGDUgqpV73+\n\
6juxARHICwKBgQC3YgaLmL9JYVZJIwu0C+IJ2JvQVOS4z0ls94ZfK742Vh8CIyIR\n\
7CbX76y1LrubOWey71DFA4r0HOua54nN/HM1Kj+bz1hy5/ZBIPm0ml3wdlb+myo0\n\
kXPt5d1jZh8Cn6fAA2+0i8OMzHMEOPT/UMAREQqqTMHZVm46PTWExfiblwKBgQCH\n\
EYqTyaVJMZ6+cu4VdqA3bO3CJknwnlTwWihPr28U4FXmv4QAU8U2lD2KvSAUKrGn\n\
YKnNShYz3Rx/BzN5m4jhKcdzxJ7eIKX+4ayUum4JJloInh/qVkdHJKeB3VTKH5kA\n\
FcR3aN3UeZ7zGrJoHTlXOtljhWbGr0MAjUDXVx2nMQKBgAOyNRVrUMJiwamU2Gc1\n\
BapbzTBbUEbvWImcNE0hk7GyENBNhse6z/nIdp/DPMEJH8N45qpePcHGsgCBjS2M\n\
uwC0NSetnZwbndQWR409pzWQL9oeQL1vo0w+lHGhX7Ll7onkWgzJg7rPMc7swmoC\n\
AKX8L9QxXylh0eeeaWhGmS8M\n\
-----END PRIVATE KEY-----\n";
const TEST_RSA_N: &str = "qX924rB7f6JjpFRR8_8W-KdeJpHjnq4OG2pQDdDc524nlW5pysD42mlH0vWbKcMAp_Wy2yAGjCGtxqCqrEsnyCE1jYpIi6fGZ82kB_CW4Dyxmkn78uMOnU6dHJvZC-LXuikZNie6MG1XacFup3xSsmMLTQCS0g4Oml-xwifUAKFk_a1gvJSrwqHU8GL639k-T5C43vFA2WB8dVgvk_W2pftLipfoT-4-TSDPOCodltCnzBDpDJRnUOq_GO2krrptqmJx-SvIEqFpQU5-9_dxfnW5ExpnxpaPZN-n5ZiIbtXhVlIK2IRuR3SsKHziOB0BB_XLlvkR6Za6bUug_QSZ0Q";
const TEST_RSA_E: &str = "AQAB";
#[tokio::test]
async fn validate_token_with_real_rsa_keypair_and_wiremock_jwks() {
use jsonwebtoken::{Algorithm, EncodingKey, Header};
use serde_json::json;
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{method, path},
};
let mock = MockServer::start().await;
let port = mock.uri().rsplit(':').next().unwrap().to_string();
let issuer = format!("http://localhost:{port}");
let jwks_path = "/.well-known/jwks.json";
let jwks_body = json!({
"keys": [{
"kty": "RSA",
"kid": "test-key-c14",
"alg": "RS256",
"use": "sig",
"n": TEST_RSA_N,
"e": TEST_RSA_E,
}]
});
Mock::given(method("GET"))
.and(path(jwks_path))
.respond_with(ResponseTemplate::new(200).set_body_json(&jwks_body))
.expect(1..)
.mount(&mock)
.await;
let now = chrono::Utc::now().timestamp();
let claims = json!({
"sub": "user-42",
"iss": issuer,
"aud": "fraiseql-test-api",
"exp": now + 3600,
"iat": now,
"scope": "read write admin",
});
let mut header = Header::new(Algorithm::RS256);
header.kid = Some("test-key-c14".to_string());
let encoding_key = EncodingKey::from_rsa_pem(TEST_RSA_PRIVATE_KEY_PEM.as_bytes())
.expect("test RSA private key PEM should be valid");
let token =
jsonwebtoken::encode(&header, &claims, &encoding_key).expect("JWT encoding should succeed");
let config = OidcConfig {
issuer: issuer.clone(),
audience: Some("fraiseql-test-api".to_string()),
allowed_algorithms: vec!["RS256".to_string()],
..Default::default()
};
let validator = OidcValidator::with_jwks_uri(config, format!("{issuer}{jwks_path}"));
let user = validator.validate_token(&token).await.expect("token validation should succeed");
assert_eq!(user.user_id.as_str(), "user-42");
assert_eq!(user.scopes, vec!["read", "write", "admin"]);
assert!(user.expires_at > chrono::Utc::now(), "token should not be expired yet");
}
#[tokio::test]
async fn validate_token_rejects_wrong_signing_key() {
use jsonwebtoken::{Algorithm, EncodingKey, Header};
use serde_json::json;
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{method, path},
};
let mock = MockServer::start().await;
let port = mock.uri().rsplit(':').next().unwrap().to_string();
let issuer = format!("http://localhost:{port}");
let jwks_path = "/.well-known/jwks.json";
let jwks_body = json!({
"keys": [{
"kty": "RSA",
"kid": "test-key-c14",
"alg": "RS256",
"use": "sig",
"n": TEST_RSA_N,
"e": TEST_RSA_E,
}]
});
Mock::given(method("GET"))
.and(path(jwks_path))
.respond_with(ResponseTemplate::new(200).set_body_json(&jwks_body))
.expect(1..)
.mount(&mock)
.await;
let now = chrono::Utc::now().timestamp();
let claims = json!({
"sub": "user-42",
"iss": issuer,
"aud": "fraiseql-test-api",
"exp": now + 3600,
"iat": now,
});
let mut header = Header::new(Algorithm::RS256);
header.kid = Some("test-key-c14".to_string());
let encoding_key = EncodingKey::from_rsa_pem(TEST_RSA_PRIVATE_KEY_PEM.as_bytes()).unwrap();
let token = jsonwebtoken::encode(&header, &claims, &encoding_key).unwrap();
let last_dot = token.rfind('.').unwrap();
let mut chars: Vec<char> = token.chars().collect();
chars[last_dot + 1] = if chars[last_dot + 1] == 'A' { 'B' } else { 'A' };
let token: String = chars.into_iter().collect();
let config = OidcConfig {
issuer: issuer.clone(),
audience: Some("fraiseql-test-api".to_string()),
allowed_algorithms: vec!["RS256".to_string()],
..Default::default()
};
let validator = OidcValidator::with_jwks_uri(config, format!("{issuer}{jwks_path}"));
let result = validator.validate_token(&token).await;
assert!(result.is_err(), "corrupted signature must be rejected");
}
#[tokio::test]
async fn validate_token_rejects_expired_jwt() {
use jsonwebtoken::{Algorithm, EncodingKey, Header};
use serde_json::json;
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{method, path},
};
let mock = MockServer::start().await;
let port = mock.uri().rsplit(':').next().unwrap().to_string();
let issuer = format!("http://localhost:{port}");
let jwks_path = "/.well-known/jwks.json";
let jwks_body = json!({
"keys": [{
"kty": "RSA",
"kid": "test-key-c14",
"alg": "RS256",
"use": "sig",
"n": TEST_RSA_N,
"e": TEST_RSA_E,
}]
});
Mock::given(method("GET"))
.and(path(jwks_path))
.respond_with(ResponseTemplate::new(200).set_body_json(&jwks_body))
.expect(1..)
.mount(&mock)
.await;
let now = chrono::Utc::now().timestamp();
let claims = json!({
"sub": "user-42",
"iss": issuer,
"aud": "fraiseql-test-api",
"exp": now - 600, "iat": now - 4200,
});
let mut header = Header::new(Algorithm::RS256);
header.kid = Some("test-key-c14".to_string());
let encoding_key = EncodingKey::from_rsa_pem(TEST_RSA_PRIVATE_KEY_PEM.as_bytes()).unwrap();
let token = jsonwebtoken::encode(&header, &claims, &encoding_key).unwrap();
let config = OidcConfig {
issuer: issuer.clone(),
audience: Some("fraiseql-test-api".to_string()),
allowed_algorithms: vec!["RS256".to_string()],
..Default::default()
};
let validator = OidcValidator::with_jwks_uri(config, format!("{issuer}{jwks_path}"));
let result = validator.validate_token(&token).await;
assert!(result.is_err(), "expired token must be rejected");
assert!(
matches!(result, Err(SecurityError::TokenExpired { .. })),
"error should be TokenExpired, got: {result:?}"
);
}
#[tokio::test]
async fn validate_token_rejects_wrong_audience() {
use jsonwebtoken::{Algorithm, EncodingKey, Header};
use serde_json::json;
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{method, path},
};
let mock = MockServer::start().await;
let port = mock.uri().rsplit(':').next().unwrap().to_string();
let issuer = format!("http://localhost:{port}");
let jwks_path = "/.well-known/jwks.json";
let jwks_body = json!({
"keys": [{
"kty": "RSA",
"kid": "test-key-c14",
"alg": "RS256",
"use": "sig",
"n": TEST_RSA_N,
"e": TEST_RSA_E,
}]
});
Mock::given(method("GET"))
.and(path(jwks_path))
.respond_with(ResponseTemplate::new(200).set_body_json(&jwks_body))
.expect(1..)
.mount(&mock)
.await;
let now = chrono::Utc::now().timestamp();
let claims = json!({
"sub": "user-42",
"iss": issuer,
"aud": "wrong-audience",
"exp": now + 3600,
"iat": now,
});
let mut header = Header::new(Algorithm::RS256);
header.kid = Some("test-key-c14".to_string());
let encoding_key = EncodingKey::from_rsa_pem(TEST_RSA_PRIVATE_KEY_PEM.as_bytes()).unwrap();
let token = jsonwebtoken::encode(&header, &claims, &encoding_key).unwrap();
let config = OidcConfig {
issuer: issuer.clone(),
audience: Some("fraiseql-test-api".to_string()),
allowed_algorithms: vec!["RS256".to_string()],
..Default::default()
};
let validator = OidcValidator::with_jwks_uri(config, format!("{issuer}{jwks_path}"));
let result = validator.validate_token(&token).await;
assert!(result.is_err(), "wrong audience must be rejected");
}
mod audience_tests {
#![allow(clippy::unwrap_used)]
use crate::security::oidc::*;
#[test]
fn test_audience_none() {
let aud = Audience::None;
assert!(!aud.contains("test"));
assert!(aud.to_vec().is_empty());
}
#[test]
fn test_audience_single() {
let aud = Audience::Single("my-api".to_string());
assert!(aud.contains("my-api"));
assert!(!aud.contains("other"));
assert_eq!(aud.to_vec(), vec!["my-api"]);
}
#[test]
fn test_audience_multiple() {
let aud = Audience::Multiple(vec!["api1".to_string(), "api2".to_string()]);
assert!(aud.contains("api1"));
assert!(aud.contains("api2"));
assert!(!aud.contains("api3"));
assert_eq!(aud.to_vec(), vec!["api1", "api2"]);
}
#[test]
fn test_extra_claims_captures_namespaced_claim() {
let claims_json = r#"{
"sub": "user123",
"exp": 1735689600,
"https://myapp.com/role": "admin",
"tenant_id": "acme-corp"
}"#;
let claims: JwtClaims = serde_json::from_str(claims_json).unwrap();
assert_eq!(claims.extra.get("https://myapp.com/role"), Some(&serde_json::json!("admin")));
assert_eq!(claims.extra.get("tenant_id"), Some(&serde_json::json!("acme-corp")));
}
#[test]
fn test_named_claim_not_duplicated_in_extra() {
let claims_json = r#"{
"sub": "user123",
"exp": 1735689600,
"email": "user@example.com",
"name": "Alice"
}"#;
let claims: JwtClaims = serde_json::from_str(claims_json).unwrap();
assert_eq!(claims.email, Some(serde_json::json!("user@example.com")));
assert!(!claims.extra.contains_key("email"), "named claim must not appear in extra");
assert!(!claims.extra.contains_key("name"), "named claim must not appear in extra");
}
#[test]
fn test_extra_claims_empty_when_no_unknowns() {
let claims_json = r#"{"sub": "user123", "exp": 1735689600}"#;
let claims: JwtClaims = serde_json::from_str(claims_json).unwrap();
assert!(claims.extra.is_empty());
}
#[test]
fn test_jwt_claims_deserialization() {
let claims_json = r#"{
"sub": "user123",
"iss": "https://issuer.example.com",
"aud": "my-api",
"exp": 1735689600,
"iat": 1735686000,
"scope": "read write",
"email": "user@example.com"
}"#;
let claims: JwtClaims = serde_json::from_str(claims_json).unwrap();
assert_eq!(claims.sub, Some("user123".to_string()));
assert_eq!(claims.iss, Some("https://issuer.example.com".to_string()));
assert!(claims.aud.contains("my-api"));
assert_eq!(claims.exp, Some(1_735_689_600));
assert_eq!(claims.scope, Some("read write".to_string()));
}
#[test]
fn test_jwt_claims_array_audience() {
let claims_json = r#"{
"sub": "user123",
"aud": ["api1", "api2"],
"exp": 1735689600
}"#;
let claims: JwtClaims = serde_json::from_str(claims_json).unwrap();
assert!(claims.aud.contains("api1"));
assert!(claims.aud.contains("api2"));
}
}
mod jwks_tests {
#![allow(clippy::unwrap_used)]
use std::time::{Duration, Instant};
use crate::security::oidc::{
jwks::{CachedJwks, MAX_JWKS_RESPONSE_BYTES},
*,
};
#[test]
fn test_jwk_deserialization() {
let jwk_json = r#"{
"kty": "RSA",
"kid": "test-key-id",
"alg": "RS256",
"use": "sig",
"n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw",
"e": "AQAB"
}"#;
let jwk: Jwk = serde_json::from_str(jwk_json).unwrap();
assert_eq!(jwk.kty, "RSA");
assert_eq!(jwk.kid, Some("test-key-id".to_string()));
assert_eq!(jwk.alg, Some("RS256".to_string()));
assert!(jwk.n.is_some());
assert!(jwk.e.is_some());
}
#[test]
fn test_jwks_deserialization() {
let jwks_json = r#"{
"keys": [
{
"kty": "RSA",
"kid": "key1",
"n": "test_n",
"e": "AQAB"
},
{
"kty": "RSA",
"kid": "key2",
"n": "test_n2",
"e": "AQAB"
}
]
}"#;
let jwks: Jwks = serde_json::from_str(jwks_json).unwrap();
assert_eq!(jwks.keys.len(), 2);
assert_eq!(jwks.keys[0].kid, Some("key1".to_string()));
assert_eq!(jwks.keys[1].kid, Some("key2".to_string()));
}
#[test]
fn test_cached_jwks_expiration() {
let jwks = Jwks { keys: vec![] };
let cached = CachedJwks {
jwks,
fetched_at: Instant::now(),
ttl: Duration::from_secs(1),
};
assert!(!cached.is_expired());
std::thread::sleep(Duration::from_millis(1100));
assert!(cached.is_expired());
}
#[test]
fn test_oidc_discovery_document_deserialization() {
let doc_json = r#"{
"issuer": "https://issuer.example.com",
"jwks_uri": "https://issuer.example.com/.well-known/jwks.json",
"authorization_endpoint": "https://issuer.example.com/authorize",
"token_endpoint": "https://issuer.example.com/oauth/token",
"id_token_signing_alg_values_supported": ["RS256", "RS384", "RS512"]
}"#;
let doc: OidcDiscoveryDocument = serde_json::from_str(doc_json).unwrap();
assert_eq!(doc.issuer, "https://issuer.example.com");
assert_eq!(doc.jwks_uri, "https://issuer.example.com/.well-known/jwks.json");
assert_eq!(doc.id_token_signing_alg_values_supported.len(), 3);
}
#[test]
fn test_jwks_cache_ttl_reduced_for_security() {
const EXPECTED_DEFAULT_TTL: u64 = 300;
assert_eq!(EXPECTED_DEFAULT_TTL, 300, "Cache TTL should be 5 minutes (300 seconds)");
}
#[test]
fn test_max_jwks_response_bytes_is_one_mib() {
assert_eq!(MAX_JWKS_RESPONSE_BYTES, 1024 * 1024, "JWKS size cap must be exactly 1 MiB");
}
#[test]
fn test_jwks_size_check_accepts_payload_at_limit() {
let len = MAX_JWKS_RESPONSE_BYTES;
let rejected = len > MAX_JWKS_RESPONSE_BYTES;
assert!(!rejected, "payload at exactly {len} bytes must be accepted (> not >=)");
}
#[test]
fn test_jwks_size_check_rejects_payload_over_limit() {
let len = MAX_JWKS_RESPONSE_BYTES + 1;
let rejected = len > MAX_JWKS_RESPONSE_BYTES;
assert!(rejected, "payload of {len} bytes must be rejected (exceeds 1 MiB cap)");
}
}
mod replay_cache_tests {
#![allow(clippy::unwrap_used)]
use std::time::Duration;
use async_trait::async_trait;
use crate::security::oidc::*;
#[tokio::test]
async fn test_first_use_accepted() {
let cache = ReplayCache::new(MemoryReplayCache::new());
let result = cache.check_and_record("jti-abc", Duration::from_secs(900)).await;
assert!(result.is_ok(), "first use should be accepted");
}
#[tokio::test]
async fn test_replay_rejected() {
let cache = ReplayCache::new(MemoryReplayCache::new());
cache.check_and_record("jti-abc", Duration::from_secs(900)).await.unwrap();
let result = cache.check_and_record("jti-abc", Duration::from_secs(900)).await;
assert!(
matches!(result, Err(ReplayCacheError::Replayed)),
"second use of same jti should be rejected"
);
}
#[tokio::test]
async fn test_different_jtis_accepted_independently() {
let cache = ReplayCache::new(MemoryReplayCache::new());
cache.check_and_record("jti-1", Duration::from_secs(900)).await.unwrap();
let result = cache.check_and_record("jti-2", Duration::from_secs(900)).await;
assert!(result.is_ok(), "different jti should be accepted");
}
#[tokio::test]
async fn test_fail_open_policy_on_backend_error() {
struct AlwaysErrorBackend;
#[async_trait]
impl ReplayCacheBackend for AlwaysErrorBackend {
async fn check_and_record(
&self,
_jti: &str,
_ttl: Duration,
) -> Result<(), ReplayCacheError> {
Err(ReplayCacheError::Backend("simulated error".to_string()))
}
}
let cache = ReplayCache::new(AlwaysErrorBackend).with_policy(FailurePolicy::FailOpen);
let result = cache.check_and_record("jti-xyz", Duration::from_secs(900)).await;
assert!(result.is_ok(), "fail-open should accept on backend error");
}
#[tokio::test]
async fn test_fail_closed_policy_on_backend_error() {
struct AlwaysErrorBackend;
#[async_trait]
impl ReplayCacheBackend for AlwaysErrorBackend {
async fn check_and_record(
&self,
_jti: &str,
_ttl: Duration,
) -> Result<(), ReplayCacheError> {
Err(ReplayCacheError::Backend("simulated error".to_string()))
}
}
let cache = ReplayCache::new(AlwaysErrorBackend).with_policy(FailurePolicy::FailClosed);
let result = cache.check_and_record("jti-xyz", Duration::from_secs(900)).await;
assert!(result.is_err(), "fail-closed should reject on backend error");
}
#[tokio::test]
async fn test_replay_counter_increments() {
let before = jwt_replay_rejected_total();
let cache = ReplayCache::new(MemoryReplayCache::new());
cache.check_and_record("jti-counter", Duration::from_secs(900)).await.unwrap();
let _ = cache.check_and_record("jti-counter", Duration::from_secs(900)).await;
let after = jwt_replay_rejected_total();
assert!(after > before, "replay counter should have incremented");
}
}