#![cfg(feature = "http")]
use std::sync::Arc;
#[cfg(all(feature = "dpop", feature = "token-exchange"))]
use std::sync::Mutex;
use oauth_as::client::{Client, ClientAuth, ClientId, DynamicRegistration, SecretHash};
use oauth_as::grant::GrantType;
use oauth_as::http::{ApprovalDecision, Body, ServiceBuilder};
use oauth_as::registration::RegistrationConfig;
use oauth_as::scope::ScopeSet;
use oauth_as::server::{AuthorizationServer, ServerConfig, SystemClock};
use oauth_as::store::MemoryStorage;
use serde_json::Value;
const REDIRECT_URI: &str = "https://app.example/cb";
const CHALLENGE: &str = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
type Service = oauth_as::http::AuthorizationService<MemoryStorage, SystemClock>;
fn public_client() -> Client {
Client {
client_id: ClientId::new("app"),
auth: ClientAuth::Public,
grant_types: vec![GrantType::AuthorizationCode],
redirect_uris: vec![REDIRECT_URI.to_string()],
allowed_scopes: ScopeSet::parse("read").unwrap(),
default_scopes: ScopeSet::parse("read").unwrap(),
name: None,
registration: None,
}
}
async fn server_with(
cfg: ServerConfig,
clients: Vec<Client>,
) -> AuthorizationServer<MemoryStorage> {
let srv = AuthorizationServer::new(cfg, MemoryStorage::new());
for client in clients {
srv.register_client(client).await.expect("registered");
}
srv
}
fn authorize_request() -> http::Request<Body> {
http::Request::builder()
.method("GET")
.uri(format!(
"/authorize?response_type=code&client_id=app&redirect_uri={REDIRECT_URI}\
&code_challenge={CHALLENGE}&code_challenge_method=S256"
))
.body(Body::from(String::new()))
.expect("a well-formed request")
}
async fn body_of(response: http::Response<Body>) -> Value {
let bytes = response.into_body().into_bytes();
serde_json::from_slice(&bytes).unwrap_or_else(|e| {
panic!(
"body is not JSON ({e}): {:?}",
String::from_utf8_lossy(&bytes)
)
})
}
fn description(body: &Value) -> String {
body.get("error_description")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string()
}
#[tokio::test]
async fn an_unwired_host_is_told_to_supply_a_subject_resolver() {
let srv = server_with(
ServerConfig::new("https://as.example", "https://as.example/device"),
vec![public_client()],
)
.await;
let service = ServiceBuilder::new(Arc::new(srv)).build().expect("service");
let response = service.handle(authorize_request()).await;
assert_eq!(response.status(), http::StatusCode::FORBIDDEN);
let why = description(&body_of(response).await);
assert!(
why.contains("subject resolver"),
"a host with no resolver installed is the case that sentence was written for: {why}"
);
}
#[tokio::test]
async fn a_signed_out_visitor_is_not_told_the_host_forgot_to_wire_a_resolver() {
let srv = server_with(
ServerConfig::new("https://as.example", "https://as.example/device"),
vec![public_client()],
)
.await;
let service = ServiceBuilder::new(Arc::new(srv))
.with_subject_resolver(|_headers| None)
.with_approval_resolver(|_request| ApprovalDecision::Approve)
.build()
.expect("service");
let response = service.handle(authorize_request()).await;
assert_eq!(
response.status(),
http::StatusCode::FORBIDDEN,
"no code may be minted for a request no resource owner stands behind"
);
let why = description(&body_of(response).await);
assert!(
!why.contains("must supply a subject resolver"),
"the host DID supply one and it answered None; telling an operator to install what they \
installed sends them to the wrong file: {why}"
);
assert!(
why.contains("signed in") || why.contains("no authenticated resource owner"),
"the refusal must still say what is missing: {why}"
);
}
fn management_config() -> ServerConfig {
let mut cfg = ServerConfig::new("https://as.example", "https://as.example/device");
let mut registration = RegistrationConfig::new();
registration.allowed_scopes = ScopeSet::parse("read").unwrap();
cfg.registration = Some(Box::new(registration));
cfg
}
fn managed_client(client_id: &str) -> Client {
Client {
client_id: ClientId::new(client_id),
auth: ClientAuth::Public,
grant_types: vec![GrantType::AuthorizationCode],
redirect_uris: vec![REDIRECT_URI.to_string()],
allowed_scopes: ScopeSet::parse("read").unwrap(),
default_scopes: ScopeSet::parse("read").unwrap(),
name: None,
registration: Some(Box::new(DynamicRegistration {
registration_access_token_hash: SecretHash::sha256("the-registration-access-token"),
client_id_issued_at: Some(0),
client_secret_expires_at: None,
token_endpoint_auth_method: "none".to_string(),
})),
}
}
async fn management_service() -> Service {
let srv = server_with(management_config(), vec![managed_client("app")]).await;
ServiceBuilder::new(Arc::new(srv)).build().expect("service")
}
#[tokio::test]
async fn an_unauthenticated_management_put_is_refused_before_its_body_is_parsed() {
let service = management_service().await;
let request = http::Request::builder()
.method("PUT")
.uri("/register/app")
.header("content-type", "application/json")
.body(Body::from("{ this is not json".to_string()))
.expect("a well-formed request");
let response = service.handle(request).await;
assert_eq!(
response.status(),
http::StatusCode::UNAUTHORIZED,
"the missing credential is the refusal; the body is a stranger's bytes and must not be \
parsed to reach it"
);
}
#[tokio::test]
async fn an_authenticated_management_put_still_reports_a_body_that_is_not_metadata() {
let service = management_service().await;
let request = http::Request::builder()
.method("PUT")
.uri("/register/app")
.header("content-type", "application/json")
.header("authorization", "Bearer the-registration-access-token")
.body(Body::from("{ this is not json".to_string()))
.expect("a well-formed request");
let response = service.handle(request).await;
assert_eq!(
response.status(),
http::StatusCode::BAD_REQUEST,
"an authenticated caller is entitled to be told its document could not be read"
);
assert_eq!(
body_of(response)
.await
.get("error")
.and_then(|v| v.as_str()),
Some("invalid_client_metadata"),
);
}
struct RegistrationThrottle {
deny: bool,
asked: std::sync::atomic::AtomicUsize,
}
impl oauth_as::RateLimiter for RegistrationThrottle {
fn check(&self, attempt: oauth_as::Attempt<'_>) -> oauth_as::RateLimitDecision {
match attempt {
oauth_as::Attempt::ClientRegistration => {
self.asked
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if self.deny {
oauth_as::RateLimitDecision::Deny
} else {
oauth_as::RateLimitDecision::Allow
}
}
_ => oauth_as::RateLimitDecision::Allow,
}
}
fn record(&self, _attempt: oauth_as::Attempt<'_>, _outcome: oauth_as::AttemptOutcome) {}
}
struct ThrottleHandle(Arc<RegistrationThrottle>);
impl oauth_as::RateLimiter for ThrottleHandle {
fn check(&self, attempt: oauth_as::Attempt<'_>) -> oauth_as::RateLimitDecision {
self.0.check(attempt)
}
fn record(&self, attempt: oauth_as::Attempt<'_>, outcome: oauth_as::AttemptOutcome) {
self.0.record(attempt, outcome)
}
}
async fn registration_service(throttle: Arc<RegistrationThrottle>) -> Service {
let srv = AuthorizationServer::new(management_config(), MemoryStorage::new())
.with_rate_limiter(Box::new(ThrottleHandle(throttle)));
ServiceBuilder::new(Arc::new(srv)).build().expect("service")
}
fn register_request(body: &str) -> http::Request<Body> {
http::Request::builder()
.method("POST")
.uri("/register")
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.expect("a well-formed request")
}
#[tokio::test]
async fn a_throttled_registration_is_refused_before_its_body_is_parsed() {
let throttle = Arc::new(RegistrationThrottle {
deny: true,
asked: std::sync::atomic::AtomicUsize::new(0),
});
let service = registration_service(Arc::clone(&throttle)).await;
let response = service.handle(register_request("{ this is not json")).await;
assert_eq!(
response.status(),
http::StatusCode::UNAUTHORIZED,
"the throttle is the refusal; the body is a stranger's bytes and must not be parsed to \
reach it"
);
}
#[tokio::test]
async fn a_throttled_registration_answers_the_same_for_a_body_that_is_metadata() {
let throttle = Arc::new(RegistrationThrottle {
deny: true,
asked: std::sync::atomic::AtomicUsize::new(0),
});
let service = registration_service(Arc::clone(&throttle)).await;
let response = service
.handle(register_request(
r#"{"redirect_uris":["https://app.example/cb"]}"#,
))
.await;
assert_eq!(
response.status(),
http::StatusCode::UNAUTHORIZED,
"a throttled registration says the same thing whatever the body was"
);
assert!(
response.headers().get("www-authenticate").is_some(),
"and carries the same header the other refusal does"
);
}
#[tokio::test]
async fn an_unthrottled_registration_still_reports_a_body_that_is_not_metadata() {
let throttle = Arc::new(RegistrationThrottle {
deny: false,
asked: std::sync::atomic::AtomicUsize::new(0),
});
let service = registration_service(Arc::clone(&throttle)).await;
let response = service.handle(register_request("{ this is not json")).await;
assert_eq!(
response.status(),
http::StatusCode::BAD_REQUEST,
"a caller the throttle admitted is entitled to be told its document could not be read"
);
assert_eq!(
body_of(response)
.await
.get("error")
.and_then(|v| v.as_str()),
Some("invalid_client_metadata"),
);
}
#[tokio::test]
async fn an_http_registration_asks_the_throttle_exactly_once() {
let throttle = Arc::new(RegistrationThrottle {
deny: false,
asked: std::sync::atomic::AtomicUsize::new(0),
});
let service = registration_service(Arc::clone(&throttle)).await;
let response = service
.handle(register_request(
r#"{"redirect_uris":["https://app.example/cb"]}"#,
))
.await;
assert_eq!(response.status(), http::StatusCode::UNAUTHORIZED);
assert_eq!(
throttle.asked.load(std::sync::atomic::Ordering::Relaxed),
1,
"one registration request is one attempt"
);
}
#[tokio::test]
async fn a_minted_management_url_escapes_the_client_id_it_carries() {
let srv = server_with(management_config(), vec![managed_client("client one")]).await;
let info = srv
.read_registration(
&ClientId::new("client one"),
"the-registration-access-token",
)
.await
.expect("read");
assert_eq!(
info.registration_client_uri.as_deref(),
Some("https://as.example/register/client%20one"),
"the segment this server writes into a URL has to be a legal path segment"
);
}
#[cfg(all(feature = "dpop", feature = "token-exchange"))]
#[tokio::test]
async fn a_proof_refused_for_want_of_a_capability_is_not_reported_as_malformed() {
use oauth_as::events::{Event, EventSink};
struct Recorder(Arc<Mutex<Vec<String>>>);
impl EventSink for Recorder {
fn on_event(&self, event: Event<'_>) {
self.0
.lock()
.unwrap_or_else(|e| e.into_inner())
.push(format!("{event:?}"));
}
}
let seen = Arc::new(Mutex::new(Vec::new()));
let srv = AuthorizationServer::new(
ServerConfig::new("https://as.example", "https://as.example/device"),
MemoryStorage::new(),
)
.with_event_sink(Box::new(Recorder(seen.clone())));
srv.register_client(Client {
client_id: ClientId::new("gateway"),
auth: ClientAuth::ConfidentialSecret {
secret: "a-high-entropy-registered-client-secret".to_string(),
},
grant_types: vec![GrantType::TokenExchange],
redirect_uris: vec![],
allowed_scopes: ScopeSet::parse("read").unwrap(),
default_scopes: ScopeSet::parse("read").unwrap(),
name: None,
registration: None,
})
.await
.expect("registered");
let service = ServiceBuilder::new(Arc::new(srv)).build().expect("service");
let request = http::Request::builder()
.method("POST")
.uri("/token")
.header("content-type", "application/x-www-form-urlencoded")
.header("DPoP", "a.proof.here")
.body(Body::from(
"grant_type=urn:ietf:params:oauth:grant-type:token-exchange&client_id=gateway\
&client_secret=a-high-entropy-registered-client-secret&subject_token=x\
&subject_token_type=urn:ietf:params:oauth:token-type:access_token"
.to_string(),
))
.expect("a well-formed request");
assert_eq!(
service.handle(request).await.status(),
http::StatusCode::BAD_REQUEST,
"the wire answer is unchanged: RFC 9449 s5 invalid_dpop_proof"
);
let events = seen.lock().unwrap_or_else(|e| e.into_inner()).clone();
let refusals: Vec<&String> = events
.iter()
.filter(|e| e.contains("DpopProofRefused"))
.collect();
assert_eq!(
refusals.len(),
1,
"the refusal must still be reported: {events:?}"
);
assert!(
!refusals[0].contains("Malformed"),
"this proof was never read, so reporting it as malformed sends the operator to the \
client's author about a JWS that is probably fine: {:?}",
refusals[0]
);
}