surrealpatch/core/src/iam/jwks.rs
2024-04-17 14:41:17 +00:00

869 lines
30 KiB
Rust

use crate::dbs::capabilities::NetTarget;
use crate::err::Error;
use crate::kvs::Datastore;
use chrono::{DateTime, Duration, Utc};
use jsonwebtoken::jwk::{AlgorithmParameters::*, Jwk, JwkSet, KeyOperations, PublicKeyUse};
use jsonwebtoken::{Algorithm::*, DecodingKey, Validation};
use once_cell::sync::Lazy;
use reqwest::{Client, Url};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::str::FromStr;
use std::sync::Arc;
use tokio::sync::RwLock;
pub(crate) type JwksCache = HashMap<String, JwksCacheEntry>;
#[derive(Clone, Serialize, Deserialize)]
pub(crate) struct JwksCacheEntry {
jwks: JwkSet,
time: DateTime<Utc>,
}
#[cfg(test)]
static CACHE_EXPIRATION: Lazy<chrono::Duration> = Lazy::new(|| Duration::seconds(1));
#[cfg(not(test))]
static CACHE_EXPIRATION: Lazy<chrono::Duration> =
Lazy::new(|| match std::env::var("SURREAL_JWKS_CACHE_EXPIRATION_SECONDS") {
Ok(seconds_str) => {
let seconds = seconds_str.parse::<u64>().expect(
"Expected a valid number of seconds for SURREAL_JWKS_CACHE_EXPIRATION_SECONDS",
);
Duration::seconds(seconds as i64)
}
Err(_) => {
Duration::seconds(43200) // Set default cache expiration of 12 hours
}
});
#[cfg(test)]
static CACHE_COOLDOWN: Lazy<chrono::Duration> = Lazy::new(|| Duration::seconds(300));
#[cfg(not(test))]
static CACHE_COOLDOWN: Lazy<chrono::Duration> =
Lazy::new(|| match std::env::var("SURREAL_JWKS_CACHE_COOLDOWN_SECONDS") {
Ok(seconds_str) => {
let seconds = seconds_str.parse::<u64>().expect(
"Expected a valid number of seconds for SURREAL_JWKS_CACHE_COOLDOWN_SECONDS",
);
Duration::seconds(seconds as i64)
}
Err(_) => {
Duration::seconds(300) // Set default cache refresh cooldown of 5 minutes
}
});
#[cfg(not(target_arch = "wasm32"))]
static REMOTE_TIMEOUT: Lazy<chrono::Duration> =
Lazy::new(|| match std::env::var("SURREAL_JWKS_REMOTE_TIMEOUT_MILLISECONDS") {
Ok(milliseconds_str) => {
let milliseconds = milliseconds_str
.parse::<u64>()
.expect("Expected a valid number of milliseconds for SURREAL_JWKS_REMOTE_TIMEOUT_MILLISECONDS");
Duration::milliseconds(milliseconds as i64)
}
Err(_) => {
Duration::milliseconds(1000) // Set default remote timeout to 1 second
}
});
// Generates a verification configuration from a JWKS object hosted in a remote location
// Performs local caching of all JWKS objects to prevent unnecessary network requests
// Implements checks to prevent denial of service and unauthorized network requests
// Validates the JWK objects found in the JWKS object according to RFC 7517
// Source: https://datatracker.ietf.org/doc/html/rfc7517
pub(super) async fn config(
kvs: &Datastore,
kid: &str,
url: &str,
token_alg: jsonwebtoken::Algorithm,
) -> Result<(DecodingKey, Validation), Error> {
// Retrieve JWKS cache
let cache = kvs.jwks_cache();
// Attempt to fetch relevant JWK object either from local cache or remote location
let jwk = match fetch_jwks_from_cache(cache, url).await {
Some(jwks) => {
trace!("Successfully fetched JWKS object from local cache");
// Check that the cached JWKS object has not expired yet
if Utc::now().signed_duration_since(jwks.time) < *CACHE_EXPIRATION {
// Attempt to find JWK in JWKS object from local cache
match jwks.jwks.find(kid) {
Some(jwk) => jwk.to_owned(),
_ => {
trace!("Could not find valid JWK object with key identifier '{kid}' in cached JWKS object");
// Check that the cached JWKS object has not been recently updated
if Utc::now().signed_duration_since(jwks.time) < *CACHE_COOLDOWN {
debug!("Refused to refresh cache before cooldown period is over");
return Err(Error::InvalidAuth); // Return opaque error
}
find_jwk_from_url(kvs, url, kid).await?
}
}
} else {
trace!("Fetched JWKS object from local cache has expired");
find_jwk_from_url(kvs, url, kid).await?
}
}
None => {
trace!("Could not fetch JWKS object from local cache");
find_jwk_from_url(kvs, url, kid).await?
}
};
// Use algorithm provided, if specified
// This parameter is not required to be present, although is usually expected
// When missing, tokens must be validated using only the required key type parameter
// This is discouraged, as it requires relying on the algorithm specified in the token
// Source: https://datatracker.ietf.org/doc/html/rfc7517#section-4.4
let alg = match jwk.common.algorithm {
Some(alg) => alg,
// If not specified, use the algorithm provided in the token header
// It is critical that the JWT library prevents the "none" algorithm from being used
// Reference: https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/#Meet-the--None--Algorithm
// In the case of "jsonwebtoken", the "none" algorithm is not part of the enumeration
// Source: https://docs.rs/jsonwebtoken/latest/jsonwebtoken/enum.Algorithm.html
// Confirmation: https://github.com/Keats/jsonwebtoken/issues/381
_ => {
// Ensure that the algorithm specified in the token matches the key type defined in the JWK
match (&jwk.algorithm, token_alg) {
(RSA(_), RS256 | RS384 | RS512 | PS256 | PS384 | PS512) => token_alg,
(RSA(key), _) => {
warn!(
"Algorithm from token '{:?}' does not match JWK key type '{:?}'",
token_alg, key.key_type
);
return Err(Error::InvalidAuth); // Return opaque error
}
(EllipticCurve(_), ES256 | ES384) => token_alg,
(EllipticCurve(key), _) => {
warn!(
"Algorithm from token '{:?}' does not match JWK key type '{:?}'",
token_alg, key.key_type
);
return Err(Error::InvalidAuth); // Return opaque error
}
(OctetKey(_), HS256 | HS384 | HS512) => token_alg,
(OctetKey(key), _) => {
warn!(
"Algorithm from token '{:?}' does not match JWK key type '{:?}'",
token_alg, key.key_type
);
return Err(Error::InvalidAuth); // Return opaque error
}
(OctetKeyPair(_), EdDSA) => token_alg,
(OctetKeyPair(key), _) => {
warn!(
"Algorithm from token '{:?}' does not match JWK key type '{:?}'",
token_alg, key.key_type
);
return Err(Error::InvalidAuth); // Return opaque error
}
}
}
};
// Check if the key use (if specified) is intended to be used for signing
// Source: https://datatracker.ietf.org/doc/html/rfc7517#section-4.2
match &jwk.common.public_key_use {
Some(PublicKeyUse::Signature) => (),
Some(key_use) => {
warn!("Invalid value for parameter 'use' in JWK object: '{:?}'", key_use);
return Err(Error::InvalidAuth); // Return opaque error
}
None => (),
}
// Check if the key operations (if specified) include verification
// Source: https://datatracker.ietf.org/doc/html/rfc7517#section-4.3
if let Some(ops) = &jwk.common.key_operations {
if !ops.iter().any(|op| *op == KeyOperations::Verify) {
warn!(
"Invalid values for parameter 'key_ops' in JWK object: '{:?}'",
jwk.common.key_operations
);
return Err(Error::InvalidAuth); // Return opaque error
}
}
// Return verification configuration if a decoding key can be retrieved from the JWK object
match DecodingKey::from_jwk(&jwk) {
Ok(dec) => Ok((dec, Validation::new(alg))),
Err(err) => {
warn!("Failed to retrieve decoding key from JWK object: '{}'", err);
Err(Error::InvalidAuth) // Return opaque error
}
}
}
// Checks if network access to a remote location is allowed by the datastore capabilities
// Attempts to find a relevant JWK object inside a JWKS object fetched from the remote location
async fn find_jwk_from_url(kvs: &Datastore, url: &str, kid: &str) -> Result<Jwk, Error> {
// Check that the datastore capabilities allow connections to the URL host
if let Err(err) = check_capabilities_url(kvs, url) {
warn!("Network access to JWKS location is not allowed: '{}'", err);
return Err(Error::InvalidAuth); // Return opaque error
}
// Retrieve JWKS cache
let cache = kvs.jwks_cache();
// Attempt to fetch JWKS object from remote location
match fetch_jwks_from_url(cache, url).await {
Ok(jwks) => {
trace!("Successfully fetched JWKS object from remote location");
// Attempt to find JWK in JWKS by the key identifier
match jwks.find(kid) {
Some(jwk) => Ok(jwk.to_owned()),
_ => {
debug!("Failed to find JWK object with key identifier '{kid}' in remote JWKS object");
Err(Error::InvalidAuth) // Return opaque error
}
}
}
Err(err) => {
warn!("Failed to fetch JWKS object from remote location: '{}'", err);
Err(Error::InvalidAuth) // Return opaque error
}
}
}
// Returns an error if network access to the address from a given URL string is not allowed
fn check_capabilities_url(kvs: &Datastore, url: &str) -> Result<(), Error> {
let url_parsed = match Url::parse(url) {
Ok(url) => url,
Err(_) => {
return Err(Error::InvalidUrl(url.to_string()));
}
};
let addr = match url_parsed.host_str() {
Some(host) => {
if let Some(port) = url_parsed.port() {
format!("{host}:{port}")
} else {
host.to_string()
}
}
None => {
return Err(Error::InvalidUrl(url.to_string()));
}
};
let target = match NetTarget::from_str(&addr) {
Ok(host) => host,
Err(_) => {
return Err(Error::InvalidUrl(url.to_string()));
}
};
if !kvs.allows_network_target(&target) {
return Err(Error::InvalidUrl(url.to_string()));
}
Ok(())
}
// Attempts to fetch a JWKS object from a remote location and stores it in the cache if successful
async fn fetch_jwks_from_url(cache: &Arc<RwLock<JwksCache>>, url: &str) -> Result<JwkSet, Error> {
let client = Client::new();
#[cfg(not(target_arch = "wasm32"))]
let res = client.get(url).timeout((*REMOTE_TIMEOUT).to_std().unwrap()).send().await?;
#[cfg(target_arch = "wasm32")]
let res = client.get(url).send().await?;
if !res.status().is_success() {
warn!("Unsuccessful HTTP status code received when fetching JWKS object from remote location: '{:?}'", res.status());
return Err(Error::InvalidAuth); // Return opaque error
}
let jwks = res.bytes().await?;
match serde_json::from_slice::<JwkSet>(&jwks) {
Ok(jwks) => {
// If successful, cache the JWKS object by its URL
match store_jwks_in_cache(cache, jwks.clone(), url).await {
None => trace!("Successfully added JWKS object to local cache"),
Some(_) => trace!("Successfully updated JWKS object in local cache"),
};
Ok(jwks)
}
Err(err) => {
warn!("Failed to parse malformed JWKS object: '{}'", err);
Err(Error::InvalidAuth) // Return opaque error
}
}
}
// Attempts to fetch a JWKS object from the local cache
async fn fetch_jwks_from_cache(
cache: &Arc<RwLock<JwksCache>>,
url: &str,
) -> Option<JwksCacheEntry> {
let path = cache_key_from_url(url);
let cache = cache.read().await;
cache.get(&path).cloned()
}
// Attempts to store a JWKS object in the local cache
async fn store_jwks_in_cache(
cache: &Arc<RwLock<JwksCache>>,
jwks: JwkSet,
url: &str,
) -> Option<JwksCacheEntry> {
let entry = JwksCacheEntry {
jwks,
time: Utc::now(),
};
let path = cache_key_from_url(url);
let mut cache = cache.write().await;
cache.insert(path, entry)
}
// Generates a unique cache key for a given URL string
fn cache_key_from_url(url: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(url);
let result = hasher.finalize();
format!("{:x}", result)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dbs::capabilities::{Capabilities, NetTarget, Targets};
use rand::{distributions::Alphanumeric, Rng};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
// Use unique path to prevent accidental cache reuse
fn random_path() -> String {
let rng = rand::thread_rng();
rng.sample_iter(&Alphanumeric).take(8).map(char::from).collect()
}
static DEFAULT_JWKS: Lazy<JwkSet> = Lazy::new(|| {
JwkSet{
keys: vec![Jwk{
common: jsonwebtoken::jwk::CommonParameters {
public_key_use: Some(jsonwebtoken::jwk::PublicKeyUse::Signature),
key_operations: None,
algorithm: Some(jsonwebtoken::Algorithm::RS256),
key_id: Some("test_1".to_string()),
x509_url: None,
x509_chain: Some(vec![
"MIIDBTCCAe2gAwIBAgIJdeyDfUXHLwX+MA0GCSqGSIb3DQEBCwUAMCAxHjAcBgNVBAMTFWdlcmF1c2VyLmV1LmF1dGgwLmNvbTAeFw0yMzEwMzExNzI5MDBaFw0zNzA3MDkxNzI5MDBaMCAxHjAcBgNVBAMTFWdlcmF1c2VyLmV1LmF1dGgwLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANp7Er60Z7sOjiyQqpbZIkQBldO/t+YrHT8Mk661kNz8MRGXPpQ26rkO2fRZMeWPpXVcwW0xxLG5oQ2Iwbm1JUuHYbLb6WA/d/et0sOkOoEI6MP0+MVqrGnro+D6XGoz4yP8m2w8C2u2yFxAc+wAt1AIMWNJIYhEX6tqrliGnitDCye2wXKchhe4WctUlHoUNfO/sgazPQ7ItqisUF/fNSRbHLRJyS2mm76FlDELDLnEyVwUaeV/2xie9F44AfOQzVk1asO18BH3v6YjOQ3L41XEfOm2DMPkLOOmtyM7yA7OeF/fvn6zN+SByza6cFW37IOKoJsmvxkzxeDUlWm9MWkCAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUOeOmT9I3/MJ/zI/lS74gPQmAQfEwDgYDVR0PAQH/BAQDAgKEMA0GCSqGSIb3DQEBCwUAA4IBAQBDue8iM90XJcLORvr6e+h15f5tlvVjZ/cAzv09487QSHJtUd6qwTlunEABS5818TgMEMFDRafQ7CDX3KaAAXFpo2bnyWY9c+Ozp0PWtp8ChunOs94ayaG+viO0AiTrIY28cc26ehNBZ/4gC4/1k0IlXEk8rd1e03hQuhNiy7SQaxS2f1xkJfR4vCeF8HTN5omjKvIMcWRqkkwFZm4gdgkMfi2lNsV8V6+HXyTc3XUIdcwOUcC+Ms/m+vKxnyxw0UGh0RliB1qBc0ADg83hOsXEqZjneHh1ZhqqVF4IkKSJTfK5ofcc14GqvpLjjTR3s2eX6zxdujzwf4gnHdxjVvdJ".to_string(),
]),
x509_sha1_fingerprint: None,
x509_sha256_fingerprint: None,
},
algorithm: jsonwebtoken::jwk::AlgorithmParameters::RSA(
jsonwebtoken::jwk::RSAKeyParameters{
key_type: jsonwebtoken::jwk::RSAKeyType::RSA,
n: "2nsSvrRnuw6OLJCqltkiRAGV07-35isdPwyTrrWQ3PwxEZc-lDbquQ7Z9Fkx5Y-ldVzBbTHEsbmhDYjBubUlS4dhstvpYD93963Sw6Q6gQjow_T4xWqsaeuj4PpcajPjI_ybbDwLa7bIXEBz7AC3UAgxY0khiERfq2quWIaeK0MLJ7bBcpyGF7hZy1SUehQ187-yBrM9Dsi2qKxQX981JFsctEnJLaabvoWUMQsMucTJXBRp5X_bGJ70XjgB85DNWTVqw7XwEfe_piM5DcvjVcR86bYMw-Qs46a3IzvIDs54X9--frM35IHLNrpwVbfsg4qgmya_GTPF4NSVab0xaQ".to_string(),
e: "AQAB".to_string(),
}
),
},
Jwk{
common: jsonwebtoken::jwk::CommonParameters {
public_key_use: Some(jsonwebtoken::jwk::PublicKeyUse::Signature),
key_operations: None,
algorithm: Some(jsonwebtoken::Algorithm::RS256),
key_id: Some("test_2".to_string()),
x509_url: None,
x509_chain: Some(vec![
"MIIDBTCCAe2gAwIBAgIJUzJ062XCgOVQMA0GCSqGSIb3DQEBCwUAMCAxHjAcBgNVBAMTFWdlcmF1c2VyLmV1LmF1dGgwLmNvbTAeFw0yMzEwMzExNzI5MDBaFw0zNzA3MDkxNzI5MDBaMCAxHjAcBgNVBAMTFWdlcmF1c2VyLmV1LmF1dGgwLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL7TgjrojPySPO5kPBOiAgiS0guSpYkfb0AtLNVyVeANe5vgjJoBQDe7vAGm68SHft841GQBPp5KWpxDTO+liECVAnbdR9YHwuZWOGuPRVVwNqtVmS8A75YG/mTWGV4tr2h+dLjjV3jvV0hvXRJwVFShlUS9+BqgevFBoF6zxi5AHIx/k1tCg1y2fhSlzYUHxEiFRgx0RhtJfizyv9QHoLSY3RFI4QOAkPtYwN5C1X69nEHPK0Q+W+POkeV7wuMQZWTRRT+xZuYn+JIYQCQviZ52FoJsrTzOEO5jlmrUa9PMEJpn0Aw68OdyLHjQPsip8B2JSegoVP1LTc0tDoqVGqUCAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUYp67WM42b2pqF7ES0LsFvAI/Qy8wDgYDVR0PAQH/BAQDAgKEMA0GCSqGSIb3DQEBCwUAA4IBAQANOhmYz0jxNJG6pZ0klNtH00E6SoEsM/MNYH+atraTVZNeqPLAZH9514gMcqdu7+rBfQ/pRjpQG1YbkdZGQBaq5cZNlE6hNCT4BSgKddBYsN0WbfTGxstDVdoXLySgGCYpyKO6Hek4ULxwAf1LmMyOYpn4JrECy4mYShsCcfe504qfzUTd7pz1VaZ4minclOhz0dZbgYa+slUepe0C2+w+T3US138x0lPB9C266SLDakb6n/JTum+Czn2xlFBf4K4w6eWuSknvTlRrqTGE8RX3vzOiKTM3hpDdjU7Tu7eNsZpLkDR1e+w33m5NMi9iYgJcyTGsIeeHr0xjrRPD9Dwh".to_string(),
]),
x509_sha1_fingerprint: None,
x509_sha256_fingerprint: None,
},
algorithm: jsonwebtoken::jwk::AlgorithmParameters::RSA(
jsonwebtoken::jwk::RSAKeyParameters{
key_type: jsonwebtoken::jwk::RSAKeyType::RSA,
n: "vtOCOuiM_JI87mQ8E6ICCJLSC5KliR9vQC0s1XJV4A17m-CMmgFAN7u8AabrxId-3zjUZAE-nkpanENM76WIQJUCdt1H1gfC5lY4a49FVXA2q1WZLwDvlgb-ZNYZXi2vaH50uONXeO9XSG9dEnBUVKGVRL34GqB68UGgXrPGLkAcjH-TW0KDXLZ-FKXNhQfESIVGDHRGG0l-LPK_1AegtJjdEUjhA4CQ-1jA3kLVfr2cQc8rRD5b486R5XvC4xBlZNFFP7Fm5if4khhAJC-JnnYWgmytPM4Q7mOWatRr08wQmmfQDDrw53IseNA-yKnwHYlJ6ChU_UtNzS0OipUapQ".to_string(),
e: "AQAB".to_string(),
}
),
}
],
}
});
#[tokio::test]
async fn test_golden_path() {
let ds = Datastore::new("memory").await.unwrap().with_capabilities(
Capabilities::default().with_network_targets(Targets::<NetTarget>::Some(
[NetTarget::from_str("127.0.0.1").unwrap()].into(),
)),
);
let jwks = DEFAULT_JWKS.clone();
let jwks_path = format!("{}/jwks.json", random_path());
let mock_server = MockServer::start().await;
let response = ResponseTemplate::new(200).set_body_json(jwks);
Mock::given(method("GET"))
.and(path(&jwks_path))
.respond_with(response)
.mount(&mock_server)
.await;
let url = mock_server.uri();
// Get first token configuration from remote location
let res = config(
&ds,
"test_1",
&format!("{}/{}", &url, &jwks_path),
jsonwebtoken::Algorithm::RS256,
)
.await;
assert!(res.is_ok(), "Failed to validate token the first time: {:?}", res.err());
// Drop server to force usage of the local cache
drop(mock_server);
// Get second token configuration from local cache
let res = config(
&ds,
"test_2",
&format!("{}/{}", &url, &jwks_path),
jsonwebtoken::Algorithm::RS256,
)
.await;
assert!(res.is_ok(), "Failed to validate token the second time: {:?}", res.err());
}
#[tokio::test]
async fn test_capabilities_default() {
let ds = Datastore::new("memory").await.unwrap().with_capabilities(Capabilities::default());
let jwks = DEFAULT_JWKS.clone();
let jwks_path = format!("{}/jwks.json", random_path());
let mock_server = MockServer::start().await;
let response = ResponseTemplate::new(200).set_body_json(jwks);
Mock::given(method("GET"))
.and(path(&jwks_path))
.respond_with(response)
.expect(0)
.mount(&mock_server)
.await;
let url = mock_server.uri();
// Get token configuration from unallowed remote location
let res = config(
&ds,
"test_1",
&format!("{}/{}", &url, &jwks_path),
jsonwebtoken::Algorithm::RS256,
)
.await;
assert!(res.is_err(), "Unexpected success validating token from unallowed remote location");
}
#[tokio::test]
async fn test_capabilities_specific_port() {
let ds = Datastore::new("memory").await.unwrap().with_capabilities(
Capabilities::default().with_network_targets(Targets::<NetTarget>::Some(
[NetTarget::from_str("127.0.0.1:443").unwrap()].into(), // Different port from server
)),
);
let jwks = DEFAULT_JWKS.clone();
let jwks_path = format!("{}/jwks.json", random_path());
let mock_server = MockServer::start().await;
let response = ResponseTemplate::new(200).set_body_json(jwks);
Mock::given(method("GET"))
.and(path(&jwks_path))
.respond_with(response)
.expect(0)
.mount(&mock_server)
.await;
let url = mock_server.uri();
// Get token configuration from unallowed remote location
let res = config(
&ds,
"test_1",
&format!("{}/{}", &url, &jwks_path),
jsonwebtoken::Algorithm::RS256,
)
.await;
assert!(res.is_err(), "Unexpected success validating token from unallowed remote location");
}
#[tokio::test]
async fn test_cache_expiration() {
let ds = Datastore::new("memory").await.unwrap().with_capabilities(
Capabilities::default().with_network_targets(Targets::<NetTarget>::Some(
[NetTarget::from_str("127.0.0.1").unwrap()].into(),
)),
);
let jwks = DEFAULT_JWKS.clone();
let jwks_path = format!("{}/jwks.json", random_path());
let mock_server = MockServer::start().await;
let response = ResponseTemplate::new(200).set_body_json(jwks);
Mock::given(method("GET"))
.and(path(&jwks_path))
.respond_with(response)
.expect(2)
.mount(&mock_server)
.await;
let url = mock_server.uri();
// Get token configuration from remote location
let res = config(
&ds,
"test_1",
&format!("{}/{}", &url, &jwks_path),
jsonwebtoken::Algorithm::RS256,
)
.await;
assert!(res.is_ok(), "Failed to validate token the first time: {:?}", res.err());
// Wait for cache to expire
std::thread::sleep((*CACHE_EXPIRATION + Duration::seconds(1)).to_std().unwrap());
// Get same token configuration again after cache has expired
let res = config(
&ds,
"test_1",
&format!("{}/{}", &url, &jwks_path),
jsonwebtoken::Algorithm::RS256,
)
.await;
assert!(res.is_ok(), "Failed to validate token the second time: {:?}", res.err());
// The server will panic if it does not receive exactly two expected requests
}
#[tokio::test]
async fn test_cache_cooldown() {
let ds = Datastore::new("memory").await.unwrap().with_capabilities(
Capabilities::default().with_network_targets(Targets::<NetTarget>::Some(
[NetTarget::from_str("127.0.0.1").unwrap()].into(),
)),
);
let jwks = DEFAULT_JWKS.clone();
let jwks_path = format!("{}/jwks.json", random_path());
let mock_server = MockServer::start().await;
let response = ResponseTemplate::new(200).set_body_json(jwks);
Mock::given(method("GET"))
.and(path(&jwks_path))
.respond_with(response)
.expect(1)
.mount(&mock_server)
.await;
let url = mock_server.uri();
// Use token with invalid key identifier claim to force cache refresh
let res = config(
&ds,
"invalid",
&format!("{}/{}", &url, &jwks_path),
jsonwebtoken::Algorithm::RS256,
)
.await;
assert!(res.is_err(), "Unexpected success validating token with invalid key identifier");
// Use token with invalid key identifier claim to force cache refresh again before cooldown
let res = config(
&ds,
"invalid",
&format!("{}/{}", &url, &jwks_path),
jsonwebtoken::Algorithm::RS256,
)
.await;
assert!(res.is_err(), "Unexpected success validating token with invalid key identifier");
// The server will panic if it receives more than the single expected request
}
#[tokio::test]
async fn test_cache_expiration_remote_down() {
let ds = Datastore::new("memory").await.unwrap().with_capabilities(
Capabilities::default().with_network_targets(Targets::<NetTarget>::Some(
[NetTarget::from_str("127.0.0.1").unwrap()].into(),
)),
);
let jwks = DEFAULT_JWKS.clone();
let jwks_path = format!("{}/jwks.json", random_path());
let mock_server = MockServer::start().await;
let response = ResponseTemplate::new(200).set_body_json(jwks);
Mock::given(method("GET"))
.and(path(&jwks_path))
.respond_with(response)
.up_to_n_times(1) // Only respond the first time
.mount(&mock_server)
.await;
let url = mock_server.uri();
// Get token configuration from remote location
let res = config(
&ds,
"test_1",
&format!("{}/{}", &url, &jwks_path),
jsonwebtoken::Algorithm::RS256,
)
.await;
assert!(res.is_ok(), "Failed to validate token the first time: {:?}", res.err());
// Wait for cache to expire
std::thread::sleep((*CACHE_EXPIRATION + Duration::seconds(1)).to_std().unwrap());
// Get same token configuration again after cache has expired
let res = config(
&ds,
"test_1",
&format!("{}/{}", &url, &jwks_path),
jsonwebtoken::Algorithm::RS256,
)
.await;
assert!(
res.is_err(),
"Unexpected success validating token with an expired cache and remote down"
);
}
#[tokio::test]
async fn test_no_algorithm() {
let ds = Datastore::new("memory").await.unwrap().with_capabilities(
Capabilities::default().with_network_targets(Targets::<NetTarget>::Some(
[NetTarget::from_str("127.0.0.1").unwrap()].into(),
)),
);
let mut jwks = DEFAULT_JWKS.clone();
jwks.keys[0].common.algorithm = None;
let jwks_path = format!("{}/jwks.json", random_path());
let mock_server = MockServer::start().await;
let response = ResponseTemplate::new(200).set_body_json(jwks);
Mock::given(method("GET"))
.and(path(&jwks_path))
.respond_with(response)
.mount(&mock_server)
.await;
let url = mock_server.uri();
let res = config(
&ds,
"test_1",
&format!("{}/{}", &url, &jwks_path),
jsonwebtoken::Algorithm::RS256,
)
.await;
assert!(
res.is_ok(),
"Failed to validate token with key that does not specify algorithm: {:?}",
res.err()
);
}
#[tokio::test]
// An attacker can issue token indicating that it has been signed with an HMAC algorithm
// If the original issuer was trusted using RSA, this may allow the attacker to sign the token with the public key
// This test verifies that SurrealDB will not trust a token specifying an algorithm that does not match the key type
// Reference: https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/#RSA-or-HMAC
async fn test_no_algorithm_invalid() {
let ds = Datastore::new("memory").await.unwrap().with_capabilities(
Capabilities::default().with_network_targets(Targets::<NetTarget>::Some(
[NetTarget::from_str("127.0.0.1").unwrap()].into(),
)),
);
let mut jwks = DEFAULT_JWKS.clone();
jwks.keys[0].common.algorithm = None;
let jwks_path = format!("{}/jwks.json", random_path());
let mock_server = MockServer::start().await;
let response = ResponseTemplate::new(200).set_body_json(jwks);
Mock::given(method("GET"))
.and(path(&jwks_path))
.respond_with(response)
.mount(&mock_server)
.await;
let url = mock_server.uri();
let res = config(
&ds,
"test_1",
&format!("{}/{}", &url, &jwks_path),
// The token is signed using HMAC
jsonwebtoken::Algorithm::HS256,
)
.await;
assert!(
res.is_err(),
"Unexpected success validating token signed with algorithm that does not match the defined key type"
);
}
#[tokio::test]
async fn test_no_key_use() {
let ds = Datastore::new("memory").await.unwrap().with_capabilities(
Capabilities::default().with_network_targets(Targets::<NetTarget>::Some(
[NetTarget::from_str("127.0.0.1").unwrap()].into(),
)),
);
let mut jwks = DEFAULT_JWKS.clone();
jwks.keys[0].common.public_key_use = None;
let jwks_path = format!("{}/jwks.json", random_path());
let mock_server = MockServer::start().await;
let response = ResponseTemplate::new(200).set_body_json(jwks);
Mock::given(method("GET"))
.and(path(&jwks_path))
.respond_with(response)
.mount(&mock_server)
.await;
let url = mock_server.uri();
let res = config(
&ds,
"test_1",
&format!("{}/{}", &url, &jwks_path),
jsonwebtoken::Algorithm::RS256,
)
.await;
assert!(
res.is_ok(),
"Failed to validate token with key that does not specify use: {:?}",
res.err()
);
}
#[tokio::test]
async fn test_key_use_enc() {
let ds = Datastore::new("memory").await.unwrap().with_capabilities(
Capabilities::default().with_network_targets(Targets::<NetTarget>::Some(
[NetTarget::from_str("127.0.0.1").unwrap()].into(),
)),
);
let mut jwks = DEFAULT_JWKS.clone();
jwks.keys[0].common.public_key_use = Some(jsonwebtoken::jwk::PublicKeyUse::Encryption);
let jwks_path = format!("{}/jwks.json", random_path());
let mock_server = MockServer::start().await;
let response = ResponseTemplate::new(200).set_body_json(jwks);
Mock::given(method("GET"))
.and(path(&jwks_path))
.respond_with(response)
.mount(&mock_server)
.await;
let url = mock_server.uri();
let res = config(
&ds,
"test_1",
&format!("{}/{}", &url, &jwks_path),
jsonwebtoken::Algorithm::RS256,
)
.await;
assert!(
res.is_err(),
"Unexpected success validating token with key that only supports encryption"
);
}
#[tokio::test]
async fn test_key_ops_encrypt_only() {
let ds = Datastore::new("memory").await.unwrap().with_capabilities(
Capabilities::default().with_network_targets(Targets::<NetTarget>::Some(
[NetTarget::from_str("127.0.0.1").unwrap()].into(),
)),
);
let mut jwks = DEFAULT_JWKS.clone();
jwks.keys[0].common.key_operations = Some(vec![jsonwebtoken::jwk::KeyOperations::Encrypt]);
let jwks_path = format!("{}/jwks.json", random_path());
let mock_server = MockServer::start().await;
let response = ResponseTemplate::new(200).set_body_json(jwks);
Mock::given(method("GET"))
.and(path(&jwks_path))
.respond_with(response)
.mount(&mock_server)
.await;
let url = mock_server.uri();
let res = config(
&ds,
"test_1",
&format!("{}/{}", &url, &jwks_path),
jsonwebtoken::Algorithm::RS256,
)
.await;
assert!(
res.is_err(),
"Unexpected success validating token with key that only supports encryption"
);
}
#[tokio::test]
async fn test_remote_down() {
let ds = Datastore::new("memory").await.unwrap().with_capabilities(
Capabilities::default().with_network_targets(Targets::<NetTarget>::Some(
[NetTarget::from_str("127.0.0.1").unwrap()].into(),
)),
);
let jwks_path = format!("{}/jwks.json", random_path());
let mock_server = MockServer::start().await;
let response = ResponseTemplate::new(500);
Mock::given(method("GET"))
.and(path(&jwks_path))
.respond_with(response)
.mount(&mock_server)
.await;
let url = mock_server.uri();
// Get token configuration from remote location responding with Internal Server Error
let res = config(
&ds,
"test_1",
&format!("{}/{}", &url, &jwks_path),
jsonwebtoken::Algorithm::RS256,
)
.await;
assert!(
res.is_err(),
"Unexpected success validating token configuration with unavailable remote location"
);
}
#[tokio::test]
#[cfg(not(target_arch = "wasm32"))]
async fn test_remote_timeout() {
let ds = Datastore::new("memory").await.unwrap().with_capabilities(
Capabilities::default().with_network_targets(Targets::<NetTarget>::Some(
[NetTarget::from_str("127.0.0.1").unwrap()].into(),
)),
);
let jwks = DEFAULT_JWKS.clone();
let jwks_path = format!("{}/jwks.json", random_path());
let mock_server = MockServer::start().await;
let response = ResponseTemplate::new(200)
.set_body_json(jwks)
.set_delay((*REMOTE_TIMEOUT + Duration::seconds(10)).to_std().unwrap());
Mock::given(method("GET"))
.and(path(&jwks_path))
.respond_with(response)
.expect(1)
.mount(&mock_server)
.await;
let url = mock_server.uri();
let start_time = Utc::now();
// Get token configuration from remote location responding very slowly
let res = config(
&ds,
"test_1",
&format!("{}/{}", &url, &jwks_path),
jsonwebtoken::Algorithm::RS256,
)
.await;
assert!(
res.is_err(),
"Unexpected success validating token configuration with unavailable remote location"
);
assert!(
Utc::now() - start_time < *REMOTE_TIMEOUT + Duration::seconds(1),
"Remote request was not aborted immediately after timeout"
);
}
}