Files
sausage/host/tests/cred_oauth2.rs
T

300 lines
10 KiB
Rust

//! OAuth endpoint policy, single-hop exchanges, cache ownership, and safe injection fields.
use sausage::cred::{items::Item, oauth2::*};
use std::{
collections::BTreeMap,
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
},
time::{Duration, Instant},
};
use zeroize::Zeroizing;
fn item(version: i64) -> Item {
serde_json::from_value(serde_json::json!({
"id":"id", "name":"OAuth", "version":version,
"rules":[{"scheme":"https","hosts":["tokens.example.test"],"path_prefixes":["/token"],"methods":["POST"]}],
"transform_js":"", "fields":[{"field_id":"refresh","name":"refresh_token","public":false,"value":null,"field_type":"oauth2_refresh_token","key_type":null}]
})).unwrap()
}
fn deadline() -> Instant {
Instant::now() + Duration::from_secs(1)
}
fn ok(bytes: &[u8]) -> Response {
Response {
status: http::StatusCode::OK,
body: Zeroizing::new(bytes.to_vec()),
}
}
struct Fake(AtomicUsize);
impl Http for Fake {
async fn send_once(&self, request: &Request, _: Instant) -> Result<Response, Error> {
assert_eq!(request.method, "POST");
self.0.fetch_add(1, Ordering::SeqCst);
Ok(ok(br#"{"access_token":"SYNTHETIC","token_type":"Bearer","expires_in":3600,"scope":"one two"}"#))
}
}
#[tokio::test]
async fn encoding_and_cache() {
let req = request(
"https://tokens.example.test/token",
"REFRESH&=+",
"CLIENT",
"SECRET&=+",
)
.unwrap();
assert!(
req.body.as_str()
== "grant_type=refresh_token&refresh_token=REFRESH%26%3D%2B&client_id=CLIENT&client_secret=SECRET%26%3D%2B"
);
assert_eq!(req.headers[1].1, req.body.len().to_string());
let fake = Fake(AtomicUsize::new(0));
let cache = Cache::default();
let t = cache
.refresh(&item(1), &req, &fake, deadline())
.await
.unwrap();
assert_eq!(t.scopes.unwrap(), vec!["one", "two"]);
cache
.refresh(&item(1), &req, &fake, deadline())
.await
.unwrap();
assert_eq!(fake.0.load(Ordering::SeqCst), 1);
cache
.refresh(&item(2), &req, &fake, deadline())
.await
.unwrap();
assert_eq!(fake.0.load(Ordering::SeqCst), 2);
cache.clear().unwrap();
cache
.refresh(&item(2), &req, &fake, deadline())
.await
.unwrap();
assert_eq!(fake.0.load(Ordering::SeqCst), 3);
for url in [
"http://tokens.example.test/token",
"https://user@tokens.example.test/token",
"https://tokens.example.test/token#x",
] {
assert!(request(url, "r", "c", "s").is_err());
}
}
#[tokio::test]
async fn expiry_and_failure() {
struct Expired(AtomicUsize);
impl Http for Expired {
async fn send_once(&self, _: &Request, _: Instant) -> Result<Response, Error> {
self.0.fetch_add(1, Ordering::SeqCst);
Ok(ok(
br#"{"access_token":"SYNTHETIC","token_type":"Bearer","expires_in":1}"#,
))
}
}
struct Failed;
impl Http for Failed {
async fn send_once(&self, _: &Request, _: Instant) -> Result<Response, Error> {
Err(Error)
}
}
let req = request("https://tokens.example.test/token", "r", "c", "s").unwrap();
let cache = Cache::default();
let http = Expired(AtomicUsize::new(0));
cache
.refresh(&item(1), &req, &http, deadline())
.await
.unwrap();
cache
.refresh(&item(1), &req, &http, deadline())
.await
.unwrap();
assert_eq!(http.0.load(Ordering::SeqCst), 2);
assert!(
cache
.refresh(&item(2), &req, &Failed, deadline())
.await
.is_err()
);
cache
.refresh(&item(2), &req, &http, deadline())
.await
.unwrap();
assert_eq!(http.0.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn endpoint_policy_applies_before_http_and_cache_hits() {
let cache = Cache::default();
let fake = Fake(AtomicUsize::new(0));
let req = request("https://tokens.example.test/token", "r", "c", "s").unwrap();
cache
.refresh(&item(1), &req, &fake, deadline())
.await
.unwrap();
for rule in [
serde_json::json!({"scheme":"https","hosts":["wrong.example.test"],"path_prefixes":["/token"],"methods":["POST"]}),
serde_json::json!({"scheme":"http","hosts":["tokens.example.test"],"path_prefixes":["/token"],"methods":["POST"]}),
serde_json::json!({"scheme":"https","hosts":["tokens.example.test"],"path_prefixes":["/elsewhere"],"methods":["POST"]}),
serde_json::json!({"scheme":"https","hosts":["tokens.example.test"],"path_prefixes":["/token"],"methods":["GET"]}),
] {
let mut denied = item(1);
denied.rules = vec![serde_json::from_value(rule).unwrap()];
assert!(
cache
.refresh(&denied, &req, &fake, deadline())
.await
.is_err()
);
denied.version = 2;
assert!(
cache
.refresh(&denied, &req, &fake, deadline())
.await
.is_err()
);
}
for url in [
"http://tokens.example.test/token",
"https://user@tokens.example.test/token",
"https://tokens.example.test/token#x",
"https://wrong.example.test/token",
] {
let mut altered = request("https://tokens.example.test/token", "r", "c", "s").unwrap();
altered.url = url.into();
assert!(
cache
.refresh(&item(1), &altered, &fake, deadline())
.await
.is_err()
);
}
let mut altered = req;
altered.method = "GET";
assert!(
cache
.refresh(&item(1), &altered, &fake, deadline())
.await
.is_err()
);
assert_eq!(fake.0.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn rejects_redirects_without_a_second_exchange() {
struct Redirect(AtomicUsize);
impl Http for Redirect {
async fn send_once(&self, _: &Request, _: Instant) -> Result<Response, Error> {
self.0.fetch_add(1, Ordering::SeqCst);
let mut response =
ok(br#"{"access_token":"SYNTHETIC","token_type":"Bearer","expires_in":3600}"#);
response.status = http::StatusCode::FOUND;
Ok(response)
}
}
let http = Redirect(AtomicUsize::new(0));
let cache = Cache::default();
let req = request("https://tokens.example.test/token", "r", "c", "s").unwrap();
assert!(
cache
.refresh(&item(1), &req, &http, deadline())
.await
.is_err()
);
assert_eq!(http.0.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn injection_replaces_the_refresh_field() {
let fields: BTreeMap<_, _> = [
("token_url", "https://tokens.example.test/token"),
("refresh_token", "REFRESH"),
("client_id", "CLIENT"),
("client_secret", "SECRET"),
]
.into_iter()
.map(|(k, v)| (k.into(), Zeroizing::new(v.into())))
.collect();
let fake = Fake(AtomicUsize::new(0));
let result = Cache::default()
.fields_for_injection(&item(1), fields, &fake, deadline())
.await
.unwrap();
assert!(result["refresh_token"].as_str() == "SYNTHETIC");
assert!(result["client_secret"].as_str() == "SECRET");
}
#[tokio::test]
async fn inflight_releases_the_lock_and_cleans_up() {
struct Blocking {
started: tokio::sync::Notify,
resume: tokio::sync::Notify,
}
impl Http for Blocking {
async fn send_once(&self, _: &Request, _: Instant) -> Result<Response, Error> {
self.started.notify_one();
self.resume.notified().await;
Ok(ok(
br#"{"access_token":"SYNTHETIC","token_type":"Bearer","expires_in":3600}"#,
))
}
}
for clear in [false, true] {
let cache = Arc::new(Cache::default());
let http = Arc::new(Blocking {
started: tokio::sync::Notify::new(),
resume: tokio::sync::Notify::new(),
});
let task = tokio::spawn({
let cache = cache.clone();
let http = http.clone();
async move {
let req = request("https://tokens.example.test/token", "r", "c", "s").unwrap();
cache
.refresh(&item(1), &req, http.as_ref(), deadline())
.await
}
});
tokio::time::timeout(Duration::from_secs(1), http.started.notified())
.await
.unwrap();
let req = request("https://tokens.example.test/token", "r", "c", "s").unwrap();
let fake = Fake(AtomicUsize::new(0));
assert!(
cache
.refresh(&item(1), &req, &fake, deadline())
.await
.is_err()
);
let mut other = item(1);
other.id = "other".into();
cache
.refresh(&other, &req, &fake, deadline())
.await
.unwrap();
if clear {
cache.clear().unwrap();
http.resume.notify_one();
assert!(task.await.unwrap().is_err());
} else {
task.abort();
assert!(matches!(task.await, Err(error) if error.is_cancelled()));
}
cache
.refresh(&item(1), &req, &fake, deadline())
.await
.unwrap();
assert_eq!(fake.0.load(Ordering::SeqCst), 2);
}
}
#[test]
fn validation() {
for data in [
br#"{"error":"invalid_grant","secret":"DO NOT ECHO"}"#.as_slice(),
br#"{"access_token":"","token_type":"Bearer","expires_in":3600}"#,
br#"{"access_token":"x","token_type":"MAC","expires_in":3600}"#,
br#"{"access_token":"x","token_type":"Bearer","expires_in":0}"#,
br#"{"access_token":"x\r\ny","token_type":"Bearer","expires_in":3600}"#,
] {
let e = decode(data).err().unwrap();
assert!(!e.to_string().contains("DO NOT ECHO"));
}
assert!(
decode(br#"{"access_token":"x","token_type":"Bearer","expires_in":18446744073709551615}"#)
.is_ok()
);
}