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]
|
[features]
|
||||||
# Public features
|
# Public features
|
||||||
default = ["storage-mem", "storage-rocksdb", "scripting", "http"]
|
default = ["storage-mem", "storage-rocksdb", "scripting", "http", "jwks"]
|
||||||
storage-mem = ["surrealdb/kv-mem"]
|
storage-mem = ["surrealdb/kv-mem"]
|
||||||
storage-rocksdb = ["surrealdb/kv-rocksdb"]
|
storage-rocksdb = ["surrealdb/kv-rocksdb"]
|
||||||
storage-speedb = ["surrealdb/kv-speedb"]
|
storage-speedb = ["surrealdb/kv-speedb"]
|
||||||
|
@ -18,6 +18,7 @@ scripting = ["surrealdb/scripting"]
|
||||||
http = ["surrealdb/http"]
|
http = ["surrealdb/http"]
|
||||||
http-compression = []
|
http-compression = []
|
||||||
ml = ["surrealdb/ml", "surrealml-core"]
|
ml = ["surrealdb/ml", "surrealml-core"]
|
||||||
|
jwks = ["surrealdb/jwks"]
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
members = ["lib", "lib/examples/actix", "lib/examples/axum"]
|
members = ["lib", "lib/examples/actix", "lib/examples/axum"]
|
||||||
|
|
|
@ -10,12 +10,12 @@ args = ["check", "--locked", "--workspace"]
|
||||||
[tasks.ci-check-wasm]
|
[tasks.ci-check-wasm]
|
||||||
category = "CI - CHECK"
|
category = "CI - CHECK"
|
||||||
command = "cargo"
|
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]
|
[tasks.ci-clippy]
|
||||||
category = "CI - CHECK"
|
category = "CI - CHECK"
|
||||||
command = "cargo"
|
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
|
# Integration Tests
|
||||||
|
@ -25,13 +25,13 @@ args = ["clippy", "--all-targets", "--features", "storage-mem,storage-rocksdb,st
|
||||||
category = "CI - INTEGRATION TESTS"
|
category = "CI - INTEGRATION TESTS"
|
||||||
command = "cargo"
|
command = "cargo"
|
||||||
env = { RUST_LOG={ value = "cli_integration=debug", condition = { env_not_set = ["RUST_LOG"] } } }
|
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]
|
[tasks.ci-http-integration]
|
||||||
category = "CI - INTEGRATION TESTS"
|
category = "CI - INTEGRATION TESTS"
|
||||||
command = "cargo"
|
command = "cargo"
|
||||||
env = { RUST_LOG={ value = "http_integration=debug", condition = { env_not_set = ["RUST_LOG"] } } }
|
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]
|
[tasks.ci-ws-integration]
|
||||||
category = "WS - INTEGRATION TESTS"
|
category = "WS - INTEGRATION TESTS"
|
||||||
|
@ -49,7 +49,7 @@ args = ["test", "--locked", "--features", "storage-mem,ml", "--workspace", "--te
|
||||||
category = "CI - INTEGRATION TESTS"
|
category = "CI - INTEGRATION TESTS"
|
||||||
command = "cargo"
|
command = "cargo"
|
||||||
args = [
|
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", "api_integration",
|
||||||
"--skip", "cli_integration",
|
"--skip", "cli_integration",
|
||||||
"--skip", "http_integration",
|
"--skip", "http_integration",
|
||||||
|
@ -228,7 +228,7 @@ args = ["build", "--locked", "--no-default-features", "--features", "storage-mem
|
||||||
[tasks.ci-bench]
|
[tasks.ci-bench]
|
||||||
category = "CI - BENCHMARK"
|
category = "CI - BENCHMARK"
|
||||||
command = "cargo"
|
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
|
# Benchmarks - SDB - Per Target
|
||||||
|
|
|
@ -17,7 +17,7 @@ dependencies = ["cargo-upgrade", "cargo-update"]
|
||||||
[tasks.docs]
|
[tasks.docs]
|
||||||
category = "LOCAL USAGE"
|
category = "LOCAL USAGE"
|
||||||
command = "cargo"
|
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
|
# Test
|
||||||
[tasks.test]
|
[tasks.test]
|
||||||
|
@ -68,7 +68,7 @@ args = ["clean"]
|
||||||
[tasks.bench]
|
[tasks.bench]
|
||||||
category = "LOCAL USAGE"
|
category = "LOCAL USAGE"
|
||||||
command = "cargo"
|
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
|
# Run
|
||||||
[tasks.run]
|
[tasks.run]
|
||||||
|
|
|
@ -10,7 +10,7 @@ reduce_output = true
|
||||||
default_to_workspace = false
|
default_to_workspace = false
|
||||||
|
|
||||||
[env]
|
[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_LOG={ value = "trace", condition = { env_not_set = ["SURREAL_LOG"] } }
|
||||||
SURREAL_USER={ value = "root", condition = { env_not_set = ["SURREAL_USER"] } }
|
SURREAL_USER={ value = "root", condition = { env_not_set = ["SURREAL_USER"] } }
|
||||||
SURREAL_PASS={ value = "root", condition = { env_not_set = ["SURREAL_PASS"] } }
|
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"]
|
native-tls = ["dep:native-tls", "reqwest?/native-tls", "tokio-tungstenite?/native-tls"]
|
||||||
rustls = ["dep:rustls", "reqwest?/rustls-tls", "tokio-tungstenite?/rustls-tls-webpki-roots"]
|
rustls = ["dep:rustls", "reqwest?/rustls-tls", "tokio-tungstenite?/rustls-tls-webpki-roots"]
|
||||||
ml = ["surrealml-core", "ndarray"]
|
ml = ["surrealml-core", "ndarray"]
|
||||||
|
jwks = ["dep:reqwest"]
|
||||||
arbitrary = ["dep:arbitrary", "dep:regex-syntax", "rust_decimal/rust-fuzz", "geo-types/arbitrary", "uuid/arbitrary"]
|
arbitrary = ["dep:arbitrary", "dep:regex-syntax", "rust_decimal/rust-fuzz", "geo-types/arbitrary", "uuid/arbitrary"]
|
||||||
# Private features
|
# Private features
|
||||||
kv-fdb = ["foundationdb", "tokio/time"]
|
kv-fdb = ["foundationdb", "tokio/time"]
|
||||||
|
@ -48,7 +49,7 @@ features = [
|
||||||
"protocol-ws", "protocol-http",
|
"protocol-ws", "protocol-http",
|
||||||
"kv-mem", "kv-indxdb", "kv-rocksdb",
|
"kv-mem", "kv-indxdb", "kv-rocksdb",
|
||||||
"rustls", "native-tls",
|
"rustls", "native-tls",
|
||||||
"http", "scripting"
|
"http", "scripting", "jwks"
|
||||||
]
|
]
|
||||||
targets = []
|
targets = []
|
||||||
|
|
||||||
|
|
|
@ -755,6 +755,14 @@ pub enum Error {
|
||||||
#[error("Auth was expected to be set but was unknown")]
|
#[error("Auth was expected to be set but was unknown")]
|
||||||
UnknownAuth,
|
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
|
/// The key being inserted in the transaction already exists
|
||||||
#[error("The key being inserted already exists: {0}")]
|
#[error("The key being inserted already exists: {0}")]
|
||||||
TxKeyAlreadyExistsCategory(KeyCategory),
|
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 {
|
impl From<reqwest::Error> for Error {
|
||||||
fn from(e: reqwest::Error) -> Error {
|
fn from(e: reqwest::Error) -> Error {
|
||||||
Error::Http(e.to_string())
|
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 check;
|
||||||
pub mod clear;
|
pub mod clear;
|
||||||
pub mod entities;
|
pub mod entities;
|
||||||
|
#[cfg(feature = "jwks")]
|
||||||
|
pub mod jwks;
|
||||||
pub mod policies;
|
pub mod policies;
|
||||||
pub mod signin;
|
pub mod signin;
|
||||||
pub mod signup;
|
pub mod signup;
|
||||||
|
|
|
@ -1,17 +1,43 @@
|
||||||
use crate::dbs::Session;
|
use crate::dbs::Session;
|
||||||
use crate::err::Error;
|
use crate::err::Error;
|
||||||
|
#[cfg(feature = "jwks")]
|
||||||
|
use crate::iam::jwks;
|
||||||
use crate::iam::{token::Claims, Actor, Auth, Level, Role};
|
use crate::iam::{token::Claims, Actor, Auth, Level, Role};
|
||||||
use crate::kvs::{Datastore, LockType::*, TransactionType::*};
|
use crate::kvs::{Datastore, LockType::*, TransactionType::*};
|
||||||
use crate::sql::{statements::DefineUserStatement, Algorithm, Value};
|
use crate::sql::{statements::DefineUserStatement, Algorithm, Value};
|
||||||
use crate::syn;
|
use crate::syn;
|
||||||
use argon2::{Argon2, PasswordHash, PasswordVerifier};
|
use argon2::{Argon2, PasswordHash, PasswordVerifier};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use jsonwebtoken::{decode, DecodingKey, Validation};
|
use jsonwebtoken::{decode, DecodingKey, Header, Validation};
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use std::str::{self, FromStr};
|
use std::str::{self, FromStr};
|
||||||
use std::sync::Arc;
|
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 {
|
match algo {
|
||||||
Algorithm::Hs256 => Ok((
|
Algorithm::Hs256 => Ok((
|
||||||
DecodingKey::from_secret(code.as_ref()),
|
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())?,
|
DecodingKey::from_rsa_pem(code.as_ref())?,
|
||||||
Validation::new(jsonwebtoken::Algorithm::RS512),
|
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
|
// Get the scope token
|
||||||
let de = tx.get_sc_token(&ns, &db, &sc, &tk).await?;
|
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
|
// Verify the token
|
||||||
decode::<Claims>(token, &cf.0, &cf.1)?;
|
decode::<Claims>(token, &cf.0, &cf.1)?;
|
||||||
// Log the success
|
// Log the success
|
||||||
|
@ -230,7 +258,7 @@ pub async fn token(kvs: &Datastore, session: &mut Session, token: &str) -> Resul
|
||||||
let id = syn::thing(&id)?;
|
let id = syn::thing(&id)?;
|
||||||
// Get the scope
|
// Get the scope
|
||||||
let de = tx.get_sc(&ns, &db, &sc).await?;
|
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
|
// Verify the token
|
||||||
decode::<Claims>(token, &cf.0, &cf.1)?;
|
decode::<Claims>(token, &cf.0, &cf.1)?;
|
||||||
// Log the success
|
// 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?;
|
let mut tx = kvs.transaction(Read, Optimistic).await?;
|
||||||
// Get the database token
|
// Get the database token
|
||||||
let de = tx.get_db_token(&ns, &db, &tk).await?;
|
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
|
// Verify the token
|
||||||
decode::<Claims>(token, &cf.0, &cf.1)?;
|
decode::<Claims>(token, &cf.0, &cf.1)?;
|
||||||
// Parse the roles
|
// 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}");
|
trace!("Error while authenticating to database `{db}`: {e}");
|
||||||
Error::InvalidAuth
|
Error::InvalidAuth
|
||||||
})?;
|
})?;
|
||||||
let cf = config(Algorithm::Hs512, de.code)?;
|
let cf = config_alg(Algorithm::Hs512, de.code)?;
|
||||||
// Verify the token
|
// Verify the token
|
||||||
decode::<Claims>(token, &cf.0, &cf.1)?;
|
decode::<Claims>(token, &cf.0, &cf.1)?;
|
||||||
// Log the success
|
// 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?;
|
let mut tx = kvs.transaction(Read, Optimistic).await?;
|
||||||
// Get the namespace token
|
// Get the namespace token
|
||||||
let de = tx.get_ns_token(&ns, &tk).await?;
|
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
|
// Verify the token
|
||||||
decode::<Claims>(token, &cf.0, &cf.1)?;
|
decode::<Claims>(token, &cf.0, &cf.1)?;
|
||||||
// Parse the roles
|
// 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}");
|
trace!("Error while authenticating to namespace `{ns}`: {e}");
|
||||||
Error::InvalidAuth
|
Error::InvalidAuth
|
||||||
})?;
|
})?;
|
||||||
let cf = config(Algorithm::Hs512, de.code)?;
|
let cf = config_alg(Algorithm::Hs512, de.code)?;
|
||||||
// Verify the token
|
// Verify the token
|
||||||
decode::<Claims>(token, &cf.0, &cf.1)?;
|
decode::<Claims>(token, &cf.0, &cf.1)?;
|
||||||
// Log the success
|
// 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}");
|
trace!("Error while authenticating to root: {e}");
|
||||||
Error::InvalidAuth
|
Error::InvalidAuth
|
||||||
})?;
|
})?;
|
||||||
let cf = config(Algorithm::Hs512, de.code)?;
|
let cf = config_alg(Algorithm::Hs512, de.code)?;
|
||||||
// Verify the token
|
// Verify the token
|
||||||
decode::<Claims>(token, &cf.0, &cf.1)?;
|
decode::<Claims>(token, &cf.0, &cf.1)?;
|
||||||
// Log the success
|
// Log the success
|
||||||
|
@ -532,7 +562,7 @@ pub async fn verify_creds_legacy(
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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 argon2::password_hash::{PasswordHasher, SaltString};
|
||||||
use chrono::Duration;
|
use chrono::Duration;
|
||||||
use jsonwebtoken::{encode, EncodingKey};
|
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]
|
#[test]
|
||||||
fn test_verify_pass() {
|
fn test_verify_pass() {
|
||||||
let salt = SaltString::generate(&mut rand::thread_rng());
|
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::clock::SystemClock;
|
||||||
use crate::kvs::{LockType, LockType::*, TransactionType, TransactionType::*, NO_LIMIT};
|
use crate::kvs::{LockType, LockType::*, TransactionType, TransactionType::*, NO_LIMIT};
|
||||||
use crate::opt::auth::Root;
|
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::sql::{self, statements::DefineUserStatement, Base, Query, Uuid, Value};
|
||||||
use crate::syn;
|
use crate::syn;
|
||||||
use crate::vs::Oracle;
|
use crate::vs::Oracle;
|
||||||
|
@ -422,6 +424,12 @@ impl Datastore {
|
||||||
self.auth_level_enabled
|
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
|
/// Setup the initial credentials
|
||||||
/// Trigger the `unreachable definition` compilation error, probably due to this issue:
|
/// Trigger the `unreachable definition` compilation error, probably due to this issue:
|
||||||
/// https://github.com/rust-lang/rust/issues/111370
|
/// 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_favicon_url = "https://surrealdb.s3.amazonaws.com/favicon.png")]
|
||||||
#![doc(html_logo_url = "https://surrealdb.s3.amazonaws.com/icon.png")]
|
#![doc(html_logo_url = "https://surrealdb.s3.amazonaws.com/icon.png")]
|
||||||
#![cfg_attr(docsrs, feature(doc_cfg))]
|
#![cfg_attr(docsrs, feature(doc_cfg))]
|
||||||
#![cfg_attr(test, deny(warnings))]
|
|
||||||
|
|
||||||
#[macro_use]
|
#[macro_use]
|
||||||
extern crate tracing;
|
extern crate tracing;
|
||||||
|
@ -134,8 +133,7 @@ pub mod idx;
|
||||||
pub mod key;
|
pub mod key;
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
pub mod kvs;
|
pub mod kvs;
|
||||||
|
#[cfg(any(feature = "ml", feature = "jwks"))]
|
||||||
#[cfg(feature = "ml")]
|
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
pub mod obs;
|
pub mod obs;
|
||||||
#[doc(hidden)]
|
#[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/)
|
//! 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 crate::err::Error;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use futures::stream::BoxStream;
|
use futures::stream::BoxStream;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use object_store::local::LocalFileSystem;
|
use object_store::local::LocalFileSystem;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use object_store::memory::InMemory;
|
||||||
use object_store::parse_url;
|
use object_store::parse_url;
|
||||||
use object_store::path::Path;
|
use object_store::path::Path;
|
||||||
use object_store::ObjectStore;
|
use object_store::ObjectStore;
|
||||||
|
@ -28,8 +31,15 @@ static STORE: Lazy<Arc<dyn ObjectStore>> =
|
||||||
fs::create_dir_all(&path)
|
fs::create_dir_all(&path)
|
||||||
.expect("Unable to create directory structure for SURREAL_OBJECT_STORE");
|
.expect("Unable to create directory structure for SURREAL_OBJECT_STORE");
|
||||||
}
|
}
|
||||||
// As long as the provided path is correct, the following should never panic
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
Arc::new(LocalFileSystem::new_with_prefix(path).unwrap())
|
{
|
||||||
|
// 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)
|
fs::create_dir_all(&path)
|
||||||
.expect("Unable to create directory structure for SURREAL_OBJECT_CACHE");
|
.expect("Unable to create directory structure for SURREAL_OBJECT_CACHE");
|
||||||
}
|
}
|
||||||
// As long as the provided path is correct, the following should never panic
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
Arc::new(LocalFileSystem::new_with_prefix(path).unwrap())
|
{
|
||||||
|
// 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(
|
pub async fn stream(
|
||||||
file: String,
|
file: String,
|
||||||
) -> Result<BoxStream<'static, Result<Bytes, object_store::Error>>, Error> {
|
) -> 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> {
|
pub async fn get(file: &str) -> Result<Vec<u8>, Error> {
|
||||||
match CACHE.get(&Path::from(file)).await {
|
match CACHE.get(&Path::from(file)).await {
|
||||||
Ok(data) => Ok(data.bytes().await?.to_vec()),
|
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> {
|
pub async fn put(file: &str, data: Vec<u8>) -> Result<(), Error> {
|
||||||
let _ = STORE.put(&Path::from(file), Bytes::from(data)).await?;
|
let _ = STORE.put(&Path::from(file), Bytes::from(data)).await?;
|
||||||
Ok(())
|
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> {
|
pub async fn del(file: &str) -> Result<(), Error> {
|
||||||
Ok(STORE.delete(&Path::from(file)).await?)
|
Ok(STORE.delete(&Path::from(file)).await?)
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,6 +19,7 @@ pub enum Algorithm {
|
||||||
Rs256,
|
Rs256,
|
||||||
Rs384,
|
Rs384,
|
||||||
Rs512,
|
Rs512,
|
||||||
|
Jwks, // Not an argorithm.
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Algorithm {
|
impl Default for Algorithm {
|
||||||
|
@ -43,6 +44,7 @@ impl fmt::Display for Algorithm {
|
||||||
Self::Rs256 => "RS256",
|
Self::Rs256 => "RS256",
|
||||||
Self::Rs384 => "RS384",
|
Self::Rs384 => "RS384",
|
||||||
Self::Rs512 => "RS512",
|
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::Rs256, tag("RS256")),
|
||||||
value(Algorithm::Rs384, tag("RS384")),
|
value(Algorithm::Rs384, tag("RS384")),
|
||||||
value(Algorithm::Rs512, tag("RS512")),
|
value(Algorithm::Rs512, tag("RS512")),
|
||||||
|
value(Algorithm::Jwks, tag("JWKS")), // Not an algorithm.
|
||||||
))(i)
|
))(i)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
#[cfg(not(feature = "jwks"))]
|
||||||
|
use super::super::super::error::ParseError::Expected;
|
||||||
use super::super::super::{
|
use super::super::super::{
|
||||||
comment::shouldbespace,
|
comment::shouldbespace,
|
||||||
ending,
|
ending,
|
||||||
|
@ -7,6 +9,8 @@ use super::super::super::{
|
||||||
IResult,
|
IResult,
|
||||||
};
|
};
|
||||||
use crate::sql::{statements::DefineTokenStatement, Algorithm, Strand};
|
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};
|
use nom::{branch::alt, bytes::complete::tag_no_case, combinator::cut, multi::many0};
|
||||||
|
|
||||||
pub fn token(i: &str) -> IResult<&str, DefineTokenStatement> {
|
pub fn token(i: &str) -> IResult<&str, DefineTokenStatement> {
|
||||||
|
@ -32,6 +36,13 @@ pub fn token(i: &str) -> IResult<&str, DefineTokenStatement> {
|
||||||
for opt in opts {
|
for opt in opts {
|
||||||
match opt {
|
match opt {
|
||||||
DefineTokenOption::Type(v) => {
|
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;
|
res.kind = v;
|
||||||
}
|
}
|
||||||
DefineTokenOption::Value(v) => {
|
DefineTokenOption::Value(v) => {
|
||||||
|
|
Loading…
Reference in a new issue