300 lines
10 KiB
Rust
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()
|
|
);
|
|
}
|