Implement support for remote JSON Web Key Sets (#3198)
This commit is contained in:
parent
65a5867b28
commit
ccb4813886
15 changed files with 934 additions and 34 deletions
|
@ -8,7 +8,7 @@ authors = ["Tobie Morgan Hitchcock <tobie@surrealdb.com>"]
|
|||
|
||||
[features]
|
||||
# Public features
|
||||
default = ["storage-mem", "storage-rocksdb", "scripting", "http"]
|
||||
default = ["storage-mem", "storage-rocksdb", "scripting", "http", "jwks"]
|
||||
storage-mem = ["surrealdb/kv-mem"]
|
||||
storage-rocksdb = ["surrealdb/kv-rocksdb"]
|
||||
storage-speedb = ["surrealdb/kv-speedb"]
|
||||
|
@ -18,6 +18,7 @@ scripting = ["surrealdb/scripting"]
|
|||
http = ["surrealdb/http"]
|
||||
http-compression = []
|
||||
ml = ["surrealdb/ml", "surrealml-core"]
|
||||
jwks = ["surrealdb/jwks"]
|
||||
|
||||
[workspace]
|
||||
members = ["lib", "lib/examples/actix", "lib/examples/axum"]
|
||||
|
|
|
@ -10,12 +10,12 @@ args = ["check", "--locked", "--workspace"]
|
|||
[tasks.ci-check-wasm]
|
||||
category = "CI - CHECK"
|
||||
command = "cargo"
|
||||
args = ["check", "--locked", "--package", "surrealdb", "--features", "protocol-ws,protocol-http,kv-mem,kv-indxdb,http", "--target", "wasm32-unknown-unknown"]
|
||||
args = ["check", "--locked", "--package", "surrealdb", "--features", "protocol-ws,protocol-http,kv-mem,kv-indxdb,http,jwks", "--target", "wasm32-unknown-unknown"]
|
||||
|
||||
[tasks.ci-clippy]
|
||||
category = "CI - CHECK"
|
||||
command = "cargo"
|
||||
args = ["clippy", "--all-targets", "--features", "storage-mem,storage-rocksdb,storage-speedb,storage-tikv,storage-fdb,scripting,http", "--tests", "--benches", "--examples","--bins", "--", "-D", "warnings"]
|
||||
args = ["clippy", "--all-targets", "--features", "storage-mem,storage-rocksdb,storage-speedb,storage-tikv,storage-fdb,scripting,http,jwks", "--tests", "--benches", "--examples","--bins", "--", "-D", "warnings"]
|
||||
|
||||
#
|
||||
# Integration Tests
|
||||
|
@ -25,13 +25,13 @@ args = ["clippy", "--all-targets", "--features", "storage-mem,storage-rocksdb,st
|
|||
category = "CI - INTEGRATION TESTS"
|
||||
command = "cargo"
|
||||
env = { RUST_LOG={ value = "cli_integration=debug", condition = { env_not_set = ["RUST_LOG"] } } }
|
||||
args = ["test", "--locked", "--no-default-features", "--features", "storage-mem,http,scripting", "--workspace", "--test", "cli_integration", "--", "cli_integration", "--nocapture"]
|
||||
args = ["test", "--locked", "--no-default-features", "--features", "storage-mem,http,scripting,jwks", "--workspace", "--test", "cli_integration", "--", "cli_integration", "--nocapture"]
|
||||
|
||||
[tasks.ci-http-integration]
|
||||
category = "CI - INTEGRATION TESTS"
|
||||
command = "cargo"
|
||||
env = { RUST_LOG={ value = "http_integration=debug", condition = { env_not_set = ["RUST_LOG"] } } }
|
||||
args = ["test", "--locked", "--no-default-features", "--features", "storage-mem,http-compression", "--workspace", "--test", "http_integration", "--", "http_integration", "--nocapture"]
|
||||
args = ["test", "--locked", "--no-default-features", "--features", "storage-mem,http-compression,jwks", "--workspace", "--test", "http_integration", "--", "http_integration", "--nocapture"]
|
||||
|
||||
[tasks.ci-ws-integration]
|
||||
category = "WS - INTEGRATION TESTS"
|
||||
|
@ -49,7 +49,7 @@ args = ["test", "--locked", "--features", "storage-mem,ml", "--workspace", "--te
|
|||
category = "CI - INTEGRATION TESTS"
|
||||
command = "cargo"
|
||||
args = [
|
||||
"llvm-cov", "--html", "--locked", "--no-default-features", "--features", "storage-mem,scripting,http", "--workspace", "--",
|
||||
"llvm-cov", "--html", "--locked", "--no-default-features", "--features", "storage-mem,scripting,http,jwks", "--workspace", "--",
|
||||
"--skip", "api_integration",
|
||||
"--skip", "cli_integration",
|
||||
"--skip", "http_integration",
|
||||
|
@ -228,7 +228,7 @@ args = ["build", "--locked", "--no-default-features", "--features", "storage-mem
|
|||
[tasks.ci-bench]
|
||||
category = "CI - BENCHMARK"
|
||||
command = "cargo"
|
||||
args = ["bench", "--quiet", "--package", "surrealdb", "--no-default-features", "--features", "kv-mem,scripting,http", "${@}"]
|
||||
args = ["bench", "--quiet", "--package", "surrealdb", "--no-default-features", "--features", "kv-mem,scripting,http,jwks", "${@}"]
|
||||
|
||||
#
|
||||
# Benchmarks - SDB - Per Target
|
||||
|
|
|
@ -17,7 +17,7 @@ dependencies = ["cargo-upgrade", "cargo-update"]
|
|||
[tasks.docs]
|
||||
category = "LOCAL USAGE"
|
||||
command = "cargo"
|
||||
args = ["doc", "--open", "--no-deps", "--package", "surrealdb", "--features", "rustls,native-tls,protocol-ws,protocol-http,kv-mem,kv-indxdb,kv-speedb,kv-rocksdb,kv-tikv,http,scripting"]
|
||||
args = ["doc", "--open", "--no-deps", "--package", "surrealdb", "--features", "rustls,native-tls,protocol-ws,protocol-http,kv-mem,kv-indxdb,kv-speedb,kv-rocksdb,kv-tikv,http,scripting,jwks"]
|
||||
|
||||
# Test
|
||||
[tasks.test]
|
||||
|
@ -68,7 +68,7 @@ args = ["clean"]
|
|||
[tasks.bench]
|
||||
category = "LOCAL USAGE"
|
||||
command = "cargo"
|
||||
args = ["bench", "--package", "surrealdb", "--no-default-features", "--features", "kv-mem,http,scripting", "--", "${@}"]
|
||||
args = ["bench", "--package", "surrealdb", "--no-default-features", "--features", "kv-mem,http,scripting,jwks", "--", "${@}"]
|
||||
|
||||
# Run
|
||||
[tasks.run]
|
||||
|
|
|
@ -10,7 +10,7 @@ reduce_output = true
|
|||
default_to_workspace = false
|
||||
|
||||
[env]
|
||||
DEV_FEATURES={ value = "storage-mem,scripting,http,ml", condition = { env_not_set = ["DEV_FEATURES"] } }
|
||||
DEV_FEATURES={ value = "storage-mem,scripting,http,ml,jwks", condition = { env_not_set = ["DEV_FEATURES"] } }
|
||||
SURREAL_LOG={ value = "trace", condition = { env_not_set = ["SURREAL_LOG"] } }
|
||||
SURREAL_USER={ value = "root", condition = { env_not_set = ["SURREAL_USER"] } }
|
||||
SURREAL_PASS={ value = "root", condition = { env_not_set = ["SURREAL_PASS"] } }
|
||||
|
|
|
@ -38,6 +38,7 @@ http = ["dep:reqwest"]
|
|||
native-tls = ["dep:native-tls", "reqwest?/native-tls", "tokio-tungstenite?/native-tls"]
|
||||
rustls = ["dep:rustls", "reqwest?/rustls-tls", "tokio-tungstenite?/rustls-tls-webpki-roots"]
|
||||
ml = ["surrealml-core", "ndarray"]
|
||||
jwks = ["dep:reqwest"]
|
||||
arbitrary = ["dep:arbitrary", "dep:regex-syntax", "rust_decimal/rust-fuzz", "geo-types/arbitrary", "uuid/arbitrary"]
|
||||
# Private features
|
||||
kv-fdb = ["foundationdb", "tokio/time"]
|
||||
|
@ -48,7 +49,7 @@ features = [
|
|||
"protocol-ws", "protocol-http",
|
||||
"kv-mem", "kv-indxdb", "kv-rocksdb",
|
||||
"rustls", "native-tls",
|
||||
"http", "scripting"
|
||||
"http", "scripting", "jwks"
|
||||
]
|
||||
targets = []
|
||||
|
||||
|
|
|
@ -755,6 +755,14 @@ pub enum Error {
|
|||
#[error("Auth was expected to be set but was unknown")]
|
||||
UnknownAuth,
|
||||
|
||||
/// Auth requires a token header which is missing
|
||||
#[error("Auth token is missing the '{0}' header")]
|
||||
MissingTokenHeader(String),
|
||||
|
||||
/// Auth requires a token claim which is missing
|
||||
#[error("Auth token is missing the '{0}' claim")]
|
||||
MissingTokenClaim(String),
|
||||
|
||||
/// The key being inserted in the transaction already exists
|
||||
#[error("The key being inserted already exists: {0}")]
|
||||
TxKeyAlreadyExistsCategory(KeyCategory),
|
||||
|
@ -850,7 +858,7 @@ impl<T> From<channel::SendError<T>> for Error {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "http")]
|
||||
#[cfg(any(feature = "http", feature = "jwks"))]
|
||||
impl From<reqwest::Error> for Error {
|
||||
fn from(e: reqwest::Error) -> Error {
|
||||
Error::Http(e.to_string())
|
||||
|
|
686
lib/src/iam/jwks.rs
Normal file
686
lib/src/iam/jwks.rs
Normal file
|
@ -0,0 +1,686 @@
|
|||
use crate::err::Error;
|
||||
use crate::kvs::Datastore;
|
||||
use crate::opt::capabilities::NetTarget;
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use jsonwebtoken::jwk::{Jwk, JwkSet, KeyOperations, PublicKeyUse};
|
||||
use jsonwebtoken::{DecodingKey, Validation};
|
||||
use once_cell::sync::Lazy;
|
||||
use reqwest::{Client, Url};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::str::FromStr;
|
||||
|
||||
#[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
|
||||
}
|
||||
});
|
||||
|
||||
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,
|
||||
) -> Result<(DecodingKey, Validation), Error> {
|
||||
// Attempt to fetch relevant JWK object either from local cache or remote location
|
||||
let jwk = match fetch_jwks_from_cache(url).await {
|
||||
Ok(jwks_cache) => {
|
||||
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_cache.time) < *CACHE_EXPIRATION {
|
||||
// Attempt to find JWK in JWKS object from local cache
|
||||
match jwks_cache.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_cache.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?
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
trace!("Could not fetch JWKS object from local cache");
|
||||
find_jwk_from_url(kvs, url, kid).await?
|
||||
}
|
||||
};
|
||||
|
||||
// Check if algorithm specified is supported
|
||||
let alg = match jwk.common.algorithm {
|
||||
Some(alg) => alg,
|
||||
_ => {
|
||||
warn!("Invalid value for parameter 'alg' in JWK object: '{:?}'", jwk.common.algorithm);
|
||||
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
|
||||
}
|
||||
|
||||
// Attempt to fetch JWKS object from remote location
|
||||
match fetch_jwks_from_url(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(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(jwks.clone(), url).await {
|
||||
Ok(_) => trace!("Successfully stored JWKS object in local cache"),
|
||||
Err(err) => {
|
||||
warn!("Failed to store JWKS object in local cache: '{}'", err);
|
||||
}
|
||||
};
|
||||
|
||||
Ok(jwks)
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("Failed to parse malformed JWKS object: '{}'", err);
|
||||
Err(Error::InvalidAuth) // Return opaque error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct JwksCache {
|
||||
jwks: JwkSet,
|
||||
time: DateTime<Utc>,
|
||||
}
|
||||
|
||||
// Attempts to fetch a JWKS object from the local cache
|
||||
async fn fetch_jwks_from_cache(url: &str) -> Result<JwksCache, Error> {
|
||||
let path = cache_path_from_url(url);
|
||||
let bytes = crate::obs::get(&path).await?;
|
||||
|
||||
match serde_json::from_slice::<JwksCache>(&bytes) {
|
||||
Ok(jwks_cache) => Ok(jwks_cache),
|
||||
Err(err) => {
|
||||
warn!("Failed to parse malformed JWKS object: '{}'", err);
|
||||
Err(Error::InvalidAuth) // Return opaque error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Attempts to store a JWKS object in the local cache
|
||||
async fn store_jwks_in_cache(jwks: JwkSet, url: &str) -> Result<(), Error> {
|
||||
let jwks_cache = JwksCache {
|
||||
jwks,
|
||||
time: Utc::now(),
|
||||
};
|
||||
let path = cache_path_from_url(url);
|
||||
|
||||
match serde_json::to_vec(&jwks_cache) {
|
||||
Ok(data) => crate::obs::put(&path, data).await,
|
||||
Err(err) => {
|
||||
warn!("Failed to cache malformed JWKS object: '{}'", err);
|
||||
Err(Error::InvalidAuth) // Return opaque error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generates a unique cache path for a given URL string
|
||||
fn cache_path_from_url(url: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(url);
|
||||
let result = hasher.finalize();
|
||||
|
||||
format!("jwks/{:x}.json", result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::opt::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)).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)).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)).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)).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)).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)).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)).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)).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)).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)).await;
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"Unexpected success validating token with an expired cache and remote down"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_unsupported_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)).await;
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"Unexpected success validating token with key using unsupported algorithm"
|
||||
);
|
||||
}
|
||||
|
||||
#[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)).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)).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)).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)).await;
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"Unexpected success validating token configuration with unavailable remote location"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
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)).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"
|
||||
);
|
||||
}
|
||||
}
|
|
@ -7,6 +7,8 @@ pub mod base;
|
|||
pub mod check;
|
||||
pub mod clear;
|
||||
pub mod entities;
|
||||
#[cfg(feature = "jwks")]
|
||||
pub mod jwks;
|
||||
pub mod policies;
|
||||
pub mod signin;
|
||||
pub mod signup;
|
||||
|
|
|
@ -1,17 +1,43 @@
|
|||
use crate::dbs::Session;
|
||||
use crate::err::Error;
|
||||
#[cfg(feature = "jwks")]
|
||||
use crate::iam::jwks;
|
||||
use crate::iam::{token::Claims, Actor, Auth, Level, Role};
|
||||
use crate::kvs::{Datastore, LockType::*, TransactionType::*};
|
||||
use crate::sql::{statements::DefineUserStatement, Algorithm, Value};
|
||||
use crate::syn;
|
||||
use argon2::{Argon2, PasswordHash, PasswordVerifier};
|
||||
use chrono::Utc;
|
||||
use jsonwebtoken::{decode, DecodingKey, Validation};
|
||||
use jsonwebtoken::{decode, DecodingKey, Header, Validation};
|
||||
use once_cell::sync::Lazy;
|
||||
use std::str::{self, FromStr};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn config(algo: Algorithm, code: String) -> Result<(DecodingKey, Validation), Error> {
|
||||
async fn config(
|
||||
kvs: &Datastore,
|
||||
de_kind: Algorithm,
|
||||
de_code: String,
|
||||
token_header: Header,
|
||||
) -> Result<(DecodingKey, Validation), Error> {
|
||||
if de_kind == Algorithm::Jwks {
|
||||
#[cfg(not(feature = "jwks"))]
|
||||
{
|
||||
warn!("Failed to verify a token defined as JWKS when the feature is not enabled");
|
||||
Err(Error::InvalidAuth)
|
||||
}
|
||||
#[cfg(feature = "jwks")]
|
||||
// The key identifier header must be present
|
||||
if let Some(kid) = token_header.kid {
|
||||
jwks::config(kvs, &kid, &de_code).await
|
||||
} else {
|
||||
Err(Error::MissingTokenHeader("kid".to_string()))
|
||||
}
|
||||
} else {
|
||||
config_alg(de_kind, de_code)
|
||||
}
|
||||
}
|
||||
|
||||
fn config_alg(algo: Algorithm, code: String) -> Result<(DecodingKey, Validation), Error> {
|
||||
match algo {
|
||||
Algorithm::Hs256 => Ok((
|
||||
DecodingKey::from_secret(code.as_ref()),
|
||||
|
@ -65,6 +91,7 @@ fn config(algo: Algorithm, code: String) -> Result<(DecodingKey, Validation), Er
|
|||
DecodingKey::from_rsa_pem(code.as_ref())?,
|
||||
Validation::new(jsonwebtoken::Algorithm::RS512),
|
||||
)),
|
||||
Algorithm::Jwks => Err(Error::InvalidAuth), // We should never get here
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -196,7 +223,8 @@ pub async fn token(kvs: &Datastore, session: &mut Session, token: &str) -> Resul
|
|||
};
|
||||
// Get the scope token
|
||||
let de = tx.get_sc_token(&ns, &db, &sc, &tk).await?;
|
||||
let cf = config(de.kind, de.code)?;
|
||||
// Obtain the configuration with which to verify the token
|
||||
let cf = config(kvs, de.kind, de.code, token_data.header).await?;
|
||||
// Verify the token
|
||||
decode::<Claims>(token, &cf.0, &cf.1)?;
|
||||
// Log the success
|
||||
|
@ -230,7 +258,7 @@ pub async fn token(kvs: &Datastore, session: &mut Session, token: &str) -> Resul
|
|||
let id = syn::thing(&id)?;
|
||||
// Get the scope
|
||||
let de = tx.get_sc(&ns, &db, &sc).await?;
|
||||
let cf = config(Algorithm::Hs512, de.code)?;
|
||||
let cf = config_alg(Algorithm::Hs512, de.code)?;
|
||||
// Verify the token
|
||||
decode::<Claims>(token, &cf.0, &cf.1)?;
|
||||
// Log the success
|
||||
|
@ -261,7 +289,8 @@ pub async fn token(kvs: &Datastore, session: &mut Session, token: &str) -> Resul
|
|||
let mut tx = kvs.transaction(Read, Optimistic).await?;
|
||||
// Get the database token
|
||||
let de = tx.get_db_token(&ns, &db, &tk).await?;
|
||||
let cf = config(de.kind, de.code)?;
|
||||
// Obtain the configuration with which to verify the token
|
||||
let cf = config(kvs, de.kind, de.code, token_data.header).await?;
|
||||
// Verify the token
|
||||
decode::<Claims>(token, &cf.0, &cf.1)?;
|
||||
// Parse the roles
|
||||
|
@ -305,7 +334,7 @@ pub async fn token(kvs: &Datastore, session: &mut Session, token: &str) -> Resul
|
|||
trace!("Error while authenticating to database `{db}`: {e}");
|
||||
Error::InvalidAuth
|
||||
})?;
|
||||
let cf = config(Algorithm::Hs512, de.code)?;
|
||||
let cf = config_alg(Algorithm::Hs512, de.code)?;
|
||||
// Verify the token
|
||||
decode::<Claims>(token, &cf.0, &cf.1)?;
|
||||
// Log the success
|
||||
|
@ -333,7 +362,8 @@ pub async fn token(kvs: &Datastore, session: &mut Session, token: &str) -> Resul
|
|||
let mut tx = kvs.transaction(Read, Optimistic).await?;
|
||||
// Get the namespace token
|
||||
let de = tx.get_ns_token(&ns, &tk).await?;
|
||||
let cf = config(de.kind, de.code)?;
|
||||
// Obtain the configuration with which to verify the token
|
||||
let cf = config(kvs, de.kind, de.code, token_data.header).await?;
|
||||
// Verify the token
|
||||
decode::<Claims>(token, &cf.0, &cf.1)?;
|
||||
// Parse the roles
|
||||
|
@ -372,7 +402,7 @@ pub async fn token(kvs: &Datastore, session: &mut Session, token: &str) -> Resul
|
|||
trace!("Error while authenticating to namespace `{ns}`: {e}");
|
||||
Error::InvalidAuth
|
||||
})?;
|
||||
let cf = config(Algorithm::Hs512, de.code)?;
|
||||
let cf = config_alg(Algorithm::Hs512, de.code)?;
|
||||
// Verify the token
|
||||
decode::<Claims>(token, &cf.0, &cf.1)?;
|
||||
// Log the success
|
||||
|
@ -401,7 +431,7 @@ pub async fn token(kvs: &Datastore, session: &mut Session, token: &str) -> Resul
|
|||
trace!("Error while authenticating to root: {e}");
|
||||
Error::InvalidAuth
|
||||
})?;
|
||||
let cf = config(Algorithm::Hs512, de.code)?;
|
||||
let cf = config_alg(Algorithm::Hs512, de.code)?;
|
||||
// Verify the token
|
||||
decode::<Claims>(token, &cf.0, &cf.1)?;
|
||||
// Log the success
|
||||
|
@ -532,7 +562,7 @@ pub async fn verify_creds_legacy(
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{iam::token::HEADER, kvs::Datastore};
|
||||
use crate::{iam::token::Claims, iam::token::HEADER, iam::verify::token, kvs::Datastore};
|
||||
use argon2::password_hash::{PasswordHasher, SaltString};
|
||||
use chrono::Duration;
|
||||
use jsonwebtoken::{encode, EncodingKey};
|
||||
|
@ -1224,6 +1254,141 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_token_scope_jwks() {
|
||||
use crate::opt::capabilities::{Capabilities, NetTarget, Targets};
|
||||
use base64_lib::{engine::general_purpose::STANDARD_NO_PAD, Engine};
|
||||
use jsonwebtoken::jwk::{Jwk, JwkSet};
|
||||
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()
|
||||
}
|
||||
|
||||
// Key identifier used in both JWT and JWT
|
||||
let kid = "test_kid";
|
||||
// Secret used to both sign and verify with HMAC
|
||||
let secret = "jwt_secret";
|
||||
|
||||
// JWKS object with single JWK object providing the HS512 secret used to verify
|
||||
let jwks = JwkSet {
|
||||
keys: vec![Jwk {
|
||||
common: jsonwebtoken::jwk::CommonParameters {
|
||||
public_key_use: None,
|
||||
key_operations: None,
|
||||
algorithm: Some(jsonwebtoken::Algorithm::HS512),
|
||||
key_id: Some(kid.to_string()),
|
||||
x509_url: None,
|
||||
x509_chain: None,
|
||||
x509_sha1_fingerprint: None,
|
||||
x509_sha256_fingerprint: None,
|
||||
},
|
||||
algorithm: jsonwebtoken::jwk::AlgorithmParameters::OctetKey(
|
||||
jsonwebtoken::jwk::OctetKeyParameters {
|
||||
key_type: jsonwebtoken::jwk::OctetKeyType::Octet,
|
||||
value: STANDARD_NO_PAD.encode(&secret),
|
||||
},
|
||||
),
|
||||
}],
|
||||
};
|
||||
|
||||
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 server_url = mock_server.uri();
|
||||
|
||||
// We allow requests to the local server serving the JWKS object
|
||||
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 sess = Session::owner().with_ns("test").with_db("test");
|
||||
ds.execute(
|
||||
format!("DEFINE TOKEN token ON SCOPE test TYPE JWKS VALUE '{server_url}/{jwks_path}';")
|
||||
.as_str(),
|
||||
&sess,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Use custom JWT header that includes the key identifier
|
||||
let header_with_kid = jsonwebtoken::Header {
|
||||
kid: Some(kid.to_string()),
|
||||
alg: jsonwebtoken::Algorithm::HS512,
|
||||
..jsonwebtoken::Header::default()
|
||||
};
|
||||
|
||||
// Sign the JWT with the same secret specified in the JWK
|
||||
let key = EncodingKey::from_secret(secret.as_ref());
|
||||
let claims = Claims {
|
||||
iss: Some("surrealdb-test".to_string()),
|
||||
iat: Some(Utc::now().timestamp()),
|
||||
nbf: Some(Utc::now().timestamp()),
|
||||
exp: Some((Utc::now() + Duration::hours(1)).timestamp()),
|
||||
tk: Some("token".to_string()),
|
||||
ns: Some("test".to_string()),
|
||||
db: Some("test".to_string()),
|
||||
sc: Some("test".to_string()),
|
||||
..Claims::default()
|
||||
};
|
||||
|
||||
//
|
||||
// Test without roles defined
|
||||
// Roles should be ignored in scope authentication
|
||||
//
|
||||
{
|
||||
// Prepare the claims object
|
||||
let mut claims = claims.clone();
|
||||
claims.roles = None;
|
||||
// Create the token
|
||||
let enc = encode(&header_with_kid, &claims, &key).unwrap();
|
||||
// Signin with the token
|
||||
let mut sess = Session::default();
|
||||
let res = token(&ds, &mut sess, &enc).await;
|
||||
|
||||
assert!(res.is_ok(), "Failed to signin with token: {:?}", res);
|
||||
assert_eq!(sess.ns, Some("test".to_string()));
|
||||
assert_eq!(sess.db, Some("test".to_string()));
|
||||
assert_eq!(sess.sc, Some("test".to_string()));
|
||||
assert_eq!(sess.au.id(), "token");
|
||||
assert!(sess.au.is_scope());
|
||||
assert_eq!(sess.au.level().ns(), Some("test"));
|
||||
assert_eq!(sess.au.level().db(), Some("test"));
|
||||
assert!(!sess.au.has_role(&Role::Viewer), "Auth user expected to not have Viewer role");
|
||||
assert!(!sess.au.has_role(&Role::Editor), "Auth user expected to not have Editor role");
|
||||
assert!(!sess.au.has_role(&Role::Owner), "Auth user expected to not have Owner role");
|
||||
}
|
||||
|
||||
//
|
||||
// Test with invalid signature
|
||||
//
|
||||
{
|
||||
// Prepare the claims object
|
||||
let claims = claims.clone();
|
||||
// Create the token
|
||||
let key = EncodingKey::from_secret("invalid".as_ref());
|
||||
let enc = encode(&header_with_kid, &claims, &key).unwrap();
|
||||
// Signin with the token
|
||||
let mut sess = Session::default();
|
||||
let res = token(&ds, &mut sess, &enc).await;
|
||||
|
||||
assert!(res.is_err(), "Unexpected success signing in with token: {:?}", res);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_pass() {
|
||||
let salt = SaltString::generate(&mut rand::thread_rng());
|
||||
|
|
|
@ -14,6 +14,8 @@ use crate::kvs::clock::SizedClock;
|
|||
use crate::kvs::clock::SystemClock;
|
||||
use crate::kvs::{LockType, LockType::*, TransactionType, TransactionType::*, NO_LIMIT};
|
||||
use crate::opt::auth::Root;
|
||||
#[cfg(feature = "jwks")]
|
||||
use crate::opt::capabilities::NetTarget;
|
||||
use crate::sql::{self, statements::DefineUserStatement, Base, Query, Uuid, Value};
|
||||
use crate::syn;
|
||||
use crate::vs::Oracle;
|
||||
|
@ -422,6 +424,12 @@ impl Datastore {
|
|||
self.auth_level_enabled
|
||||
}
|
||||
|
||||
/// Does the datastore allow connections to a network target?
|
||||
#[cfg(feature = "jwks")]
|
||||
pub(crate) fn allows_network_target(&self, net_target: &NetTarget) -> bool {
|
||||
self.capabilities.allows_network_target(net_target)
|
||||
}
|
||||
|
||||
/// Setup the initial credentials
|
||||
/// Trigger the `unreachable definition` compilation error, probably due to this issue:
|
||||
/// https://github.com/rust-lang/rust/issues/111370
|
||||
|
|
|
@ -98,7 +98,6 @@
|
|||
#![doc(html_favicon_url = "https://surrealdb.s3.amazonaws.com/favicon.png")]
|
||||
#![doc(html_logo_url = "https://surrealdb.s3.amazonaws.com/icon.png")]
|
||||
#![cfg_attr(docsrs, feature(doc_cfg))]
|
||||
#![cfg_attr(test, deny(warnings))]
|
||||
|
||||
#[macro_use]
|
||||
extern crate tracing;
|
||||
|
@ -134,8 +133,7 @@ pub mod idx;
|
|||
pub mod key;
|
||||
#[doc(hidden)]
|
||||
pub mod kvs;
|
||||
|
||||
#[cfg(feature = "ml")]
|
||||
#[cfg(any(feature = "ml", feature = "jwks"))]
|
||||
#[doc(hidden)]
|
||||
pub mod obs;
|
||||
#[doc(hidden)]
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
//! This module defines the operations for object storage using the [object_store](https://docs.rs/object_store/latest/object_store/)
|
||||
//! crate. This will enable the user to store objects using local file storage, or cloud storage such as S3 or GCS.
|
||||
//! crate. This will enable the user to store objects using local file storage, memory, or cloud storage such as S3 or GCS.
|
||||
use crate::err::Error;
|
||||
use bytes::Bytes;
|
||||
use futures::stream::BoxStream;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use object_store::local::LocalFileSystem;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use object_store::memory::InMemory;
|
||||
use object_store::parse_url;
|
||||
use object_store::path::Path;
|
||||
use object_store::ObjectStore;
|
||||
|
@ -28,8 +31,15 @@ static STORE: Lazy<Arc<dyn ObjectStore>> =
|
|||
fs::create_dir_all(&path)
|
||||
.expect("Unable to create directory structure for SURREAL_OBJECT_STORE");
|
||||
}
|
||||
// As long as the provided path is correct, the following should never panic
|
||||
Arc::new(LocalFileSystem::new_with_prefix(path).unwrap())
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
// As long as the provided path is correct, the following should never panic
|
||||
Arc::new(LocalFileSystem::new_with_prefix(path).unwrap())
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
Arc::new(InMemory::new())
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -47,12 +57,19 @@ static CACHE: Lazy<Arc<dyn ObjectStore>> =
|
|||
fs::create_dir_all(&path)
|
||||
.expect("Unable to create directory structure for SURREAL_OBJECT_CACHE");
|
||||
}
|
||||
// As long as the provided path is correct, the following should never panic
|
||||
Arc::new(LocalFileSystem::new_with_prefix(path).unwrap())
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
// As long as the provided path is correct, the following should never panic
|
||||
Arc::new(LocalFileSystem::new_with_prefix(path).unwrap())
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
Arc::new(InMemory::new())
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/// Gets the file from the local file system object storage.
|
||||
/// Streams the file from the local system or memory object storage.
|
||||
pub async fn stream(
|
||||
file: String,
|
||||
) -> Result<BoxStream<'static, Result<Bytes, object_store::Error>>, Error> {
|
||||
|
@ -62,7 +79,7 @@ pub async fn stream(
|
|||
}
|
||||
}
|
||||
|
||||
/// Gets the file from the local file system object storage.
|
||||
/// Gets the file from the local file system or memory object storage.
|
||||
pub async fn get(file: &str) -> Result<Vec<u8>, Error> {
|
||||
match CACHE.get(&Path::from(file)).await {
|
||||
Ok(data) => Ok(data.bytes().await?.to_vec()),
|
||||
|
@ -74,13 +91,13 @@ pub async fn get(file: &str) -> Result<Vec<u8>, Error> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Gets the file from the local file system object storage.
|
||||
/// Puts the file into the local file system or memory object storage.
|
||||
pub async fn put(file: &str, data: Vec<u8>) -> Result<(), Error> {
|
||||
let _ = STORE.put(&Path::from(file), Bytes::from(data)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gets the file from the local file system object storage.
|
||||
/// Deletes the file from the local file system or memory object storage.
|
||||
pub async fn del(file: &str) -> Result<(), Error> {
|
||||
Ok(STORE.delete(&Path::from(file)).await?)
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ pub enum Algorithm {
|
|||
Rs256,
|
||||
Rs384,
|
||||
Rs512,
|
||||
Jwks, // Not an argorithm.
|
||||
}
|
||||
|
||||
impl Default for Algorithm {
|
||||
|
@ -43,6 +44,7 @@ impl fmt::Display for Algorithm {
|
|||
Self::Rs256 => "RS256",
|
||||
Self::Rs384 => "RS384",
|
||||
Self::Rs512 => "RS512",
|
||||
Self::Jwks => "JWKS", // Not an algorithm.
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,5 +17,6 @@ pub fn algorithm(i: &str) -> IResult<&str, Algorithm> {
|
|||
value(Algorithm::Rs256, tag("RS256")),
|
||||
value(Algorithm::Rs384, tag("RS384")),
|
||||
value(Algorithm::Rs512, tag("RS512")),
|
||||
value(Algorithm::Jwks, tag("JWKS")), // Not an algorithm.
|
||||
))(i)
|
||||
}
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
#[cfg(not(feature = "jwks"))]
|
||||
use super::super::super::error::ParseError::Expected;
|
||||
use super::super::super::{
|
||||
comment::shouldbespace,
|
||||
ending,
|
||||
|
@ -7,6 +9,8 @@ use super::super::super::{
|
|||
IResult,
|
||||
};
|
||||
use crate::sql::{statements::DefineTokenStatement, Algorithm, Strand};
|
||||
#[cfg(not(feature = "jwks"))]
|
||||
use nom::Err;
|
||||
use nom::{branch::alt, bytes::complete::tag_no_case, combinator::cut, multi::many0};
|
||||
|
||||
pub fn token(i: &str) -> IResult<&str, DefineTokenStatement> {
|
||||
|
@ -32,6 +36,13 @@ pub fn token(i: &str) -> IResult<&str, DefineTokenStatement> {
|
|||
for opt in opts {
|
||||
match opt {
|
||||
DefineTokenOption::Type(v) => {
|
||||
#[cfg(not(feature = "jwks"))]
|
||||
if matches!(v, Algorithm::Jwks) {
|
||||
return Err(Err::Error(Expected {
|
||||
tried: i,
|
||||
expected: "the 'jwks' feature to be enabled",
|
||||
}));
|
||||
}
|
||||
res.kind = v;
|
||||
}
|
||||
DefineTokenOption::Value(v) => {
|
||||
|
|
Loading…
Reference in a new issue