Implement support for remote JSON Web Key Sets (#3198)

This commit is contained in:
Gerard Guillemas Martos 2024-01-09 18:17:48 +01:00 committed by GitHub
parent 65a5867b28
commit ccb4813886
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 934 additions and 34 deletions

View file

@ -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"]

View file

@ -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

View file

@ -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]

View file

@ -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"] } }

View file

@ -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 = []

View file

@ -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
View 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"
);
}
}

View file

@ -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;

View file

@ -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());

View file

@ -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

View file

@ -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)]

View file

@ -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?)
} }

View file

@ -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.
}) })
} }
} }

View file

@ -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)
} }

View file

@ -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) => {