//! 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 { 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 { 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 { 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 { 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 { 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() ); }