#![cfg(feature = "http")]
use std::sync::{Arc, Mutex};
#[cfg(any(
feature = "client-assertion",
feature = "consent",
feature = "token-exchange"
))]
use oauth_as::client::{Client, ClientAuth, ClientId};
#[cfg(any(
feature = "client-assertion",
feature = "consent",
feature = "token-exchange"
))]
use oauth_as::grant::GrantType;
use oauth_as::http::{ApprovalDecision, Body};
#[cfg(any(
feature = "client-assertion",
feature = "consent",
feature = "token-exchange"
))]
use oauth_as::scope::ScopeSet;
use oauth_as::server::{AuthorizationServer, ServerConfig, SystemClock};
use oauth_as::store::MemoryStorage;
#[cfg(any(
feature = "client-assertion",
feature = "consent",
feature = "token-exchange"
))]
const SECRET: &str = "a-high-entropy-registered-client-secret";
#[cfg(any(
feature = "client-assertion",
feature = "consent",
feature = "token-exchange"
))]
const REDIRECT: &str = "https://app.example/cb";
#[cfg(feature = "consent")]
const VERIFIER: &str = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
#[cfg(any(
feature = "client-assertion",
feature = "consent",
feature = "token-exchange"
))]
fn client() -> Client {
Client {
client_id: ClientId::new("app"),
auth: ClientAuth::ConfidentialSecret {
secret: SECRET.to_string(),
},
grant_types: vec![
GrantType::ClientCredentials,
GrantType::AuthorizationCode,
#[cfg(feature = "token-exchange")]
GrantType::TokenExchange,
],
redirect_uris: vec![REDIRECT.to_string()],
allowed_scopes: ScopeSet::parse("read write").unwrap(),
default_scopes: ScopeSet::parse("read").unwrap(),
name: None,
registration: None,
}
}
#[cfg(any(feature = "client-assertion", feature = "token-exchange"))]
fn post(uri: &str, body: String) -> http::Request<Body> {
http::Request::builder()
.method("POST")
.uri(uri)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(body))
.expect("a well-formed request")
}
#[cfg(any(feature = "client-assertion", feature = "token-exchange"))]
async fn body_of(response: http::Response<Body>) -> serde_json::Value {
let bytes = response.into_body().into_bytes();
serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null)
}
#[tokio::test]
async fn an_authorization_header_too_short_to_hold_the_scheme_is_refused_not_a_panic() {
let service = registration_service(None).await;
for header in ["Basic", "B", "Bearer", "bearer"] {
let request = http::Request::builder()
.method("POST")
.uri("/register")
.header("content-type", "application/json")
.header("authorization", header)
.body(Body::from(
r#"{"redirect_uris":["https://app.example/cb"],"grant_types":["authorization_code"],"response_types":["code"]}"#,
))
.expect("a well-formed request");
let status = service.handle(request).await.status();
assert!(
status.is_client_error() || status.is_success(),
"an Authorization header of {} bytes must be answered, not crashed on, got {status}",
header.len()
);
}
}
#[tokio::test]
async fn a_credential_in_another_scheme_is_not_read_as_a_bearer_token() {
let seen: Arc<Mutex<Vec<Option<String>>>> = Arc::new(Mutex::new(Vec::new()));
let service = registration_service(Some(Arc::clone(&seen))).await;
let request = http::Request::builder()
.method("POST")
.uri("/register")
.header("content-type", "application/json")
.header("authorization", "Basic YWxpY2U6aHVudGVyMg==")
.body(Body::from(
r#"{"redirect_uris":["https://app.example/cb"],"grant_types":["authorization_code"],"response_types":["code"]}"#,
))
.expect("a well-formed request");
let _ = service.handle(request).await;
let seen = seen.lock().unwrap_or_else(|e| e.into_inner()).clone();
assert_eq!(
seen,
vec![None],
"the policy must be told that NO bearer token was presented, not handed a slice of \
somebody's Basic credential"
);
}
async fn registration_service(
seen: Option<Arc<Mutex<Vec<Option<String>>>>>,
) -> oauth_as::http::AuthorizationService<MemoryStorage, SystemClock> {
use oauth_as::{
RegistrationAttempt, RegistrationConfig, RegistrationDecision, RegistrationPolicy,
};
struct Recording(Option<Arc<Mutex<Vec<Option<String>>>>>);
impl RegistrationPolicy for Recording {
fn authorize(&self, attempt: &RegistrationAttempt<'_>) -> RegistrationDecision {
if let Some(seen) = &self.0 {
seen.lock()
.unwrap_or_else(|e| e.into_inner())
.push(attempt.initial_access_token.map(str::to_string));
}
RegistrationDecision::Allow
}
}
let mut cfg = ServerConfig::new("https://as.example", "https://as.example/device");
cfg.registration = Some(Box::new(RegistrationConfig::new()));
let srv = AuthorizationServer::new(cfg, MemoryStorage::new())
.with_registration_policy(Box::new(Recording(seen)));
oauth_as::http::ServiceBuilder::new(Arc::new(srv))
.with_subject_resolver(|_headers| Some("user-1".to_string()))
.with_approval_resolver(|_request| ApprovalDecision::Approve)
.build()
.expect("service")
}
#[cfg(feature = "client-assertion")]
#[tokio::test]
async fn an_assertion_presented_alongside_basic_credentials_is_refused() {
use base64::engine::general_purpose::STANDARD;
use base64::Engine as _;
use oauth_as::client_assertion::{AssertionKeys, ClientSecretKey, CLIENT_ASSERTION_TYPE};
use oauth_as::jwt::{compact_jws, hmac_sha256};
let mut asserting = client();
asserting.client_id = ClientId::new("assert-app");
asserting.auth = ClientAuth::ConfidentialAssertion {
keys: AssertionKeys::ClientSecret {
secret: ClientSecretKey::new(SECRET.to_string()).expect("a long enough secret"),
},
};
let srv = AuthorizationServer::new(
ServerConfig::new("https://as.example", "https://as.example/device"),
MemoryStorage::new(),
);
srv.register_client(client()).await.unwrap();
srv.register_client(asserting).await.unwrap();
let service = oauth_as::http::ServiceBuilder::new(Arc::new(srv))
.with_subject_resolver(|_headers| Some("user-1".to_string()))
.with_approval_resolver(|_request| ApprovalDecision::Approve)
.build()
.expect("service");
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let claims = serde_json::json!({
"iss": "assert-app",
"sub": "assert-app",
"aud": "https://as.example/token",
"exp": now + 120,
"iat": now,
"jti": "assertion-with-basic",
});
let assertion = compact_jws(
br#"{"alg":"HS256","typ":"JWT"}"#,
&serde_json::to_vec(&claims).unwrap(),
|input| hmac_sha256(SECRET.as_bytes(), input.as_bytes()).to_vec(),
);
let body = format!(
"grant_type=client_credentials&client_assertion_type={}&client_assertion={assertion}",
urlencode(CLIENT_ASSERTION_TYPE)
);
let mut request = post("/token", body);
request.headers_mut().insert(
http::header::AUTHORIZATION,
format!("Basic {}", STANDARD.encode(format!("assert-app:{SECRET}")))
.parse()
.expect("a header value"),
);
let response = service.handle(request).await;
assert_eq!(
response.status(),
http::StatusCode::BAD_REQUEST,
"RFC 6749 s2.3: two client authentication methods in one request is invalid_request"
);
assert_eq!(
body_of(response)
.await
.get("error")
.and_then(|v| v.as_str()),
Some("invalid_request"),
"and it must be refused rather than resolved by precedence"
);
}
#[cfg(feature = "client-assertion")]
fn urlencode(raw: &str) -> String {
raw.chars()
.map(|c| match c {
':' => "%3A".to_string(),
'/' => "%2F".to_string(),
other => other.to_string(),
})
.collect()
}
#[cfg(feature = "consent")]
#[tokio::test]
async fn an_approval_that_was_not_remembered_is_not_recorded() {
let srv = AuthorizationServer::new(
ServerConfig::new("https://as.example", "https://as.example/device"),
MemoryStorage::new(),
);
srv.register_client(client()).await.unwrap();
let srv = Arc::new(srv);
let service = oauth_as::http::ServiceBuilder::new(Arc::clone(&srv))
.with_subject_resolver(|_headers| Some("user-1".to_string()))
.with_approval_resolver(|_request| ApprovalDecision::Approve)
.build()
.expect("service");
let challenge = oauth_as::pkce::code_challenge_s256(VERIFIER);
let query = format!(
"response_type=code&client_id=app&redirect_uri={}&scope=read\
&code_challenge={challenge}&code_challenge_method=S256",
urlencode_uri(REDIRECT)
);
let request = http::Request::builder()
.method("GET")
.uri(format!("/authorize?{query}"))
.body(Body::empty())
.expect("a well-formed request");
let response = service.handle(request).await;
assert_eq!(
response.status(),
http::StatusCode::FOUND,
"the approval must still mint a code: this test is about what was REMEMBERED"
);
let remembered = srv
.remembered_consent(&ClientId::new("app"), "user-1")
.await
.expect("the store answers");
assert!(
remembered.is_none(),
"an Approve is not an ApproveAndRemember: nothing asked for this consent to be kept"
);
}
#[cfg(feature = "consent")]
fn urlencode_uri(raw: &str) -> String {
raw.chars()
.map(|c| match c {
':' => "%3A".to_string(),
'/' => "%2F".to_string(),
other => other.to_string(),
})
.collect()
}
#[cfg(feature = "token-exchange")]
#[tokio::test]
async fn a_token_exchange_posted_as_a_form_reaches_the_grant() {
let srv = AuthorizationServer::new(
ServerConfig::new("https://as.example", "https://as.example/device"),
MemoryStorage::new(),
);
srv.register_client(client()).await.unwrap();
let srv = Arc::new(srv);
let service = oauth_as::http::ServiceBuilder::new(Arc::clone(&srv))
.with_subject_resolver(|_headers| Some("user-1".to_string()))
.with_approval_resolver(|_request| ApprovalDecision::Approve)
.build()
.expect("service");
let subject = body_of(
service
.handle(post(
"/token",
format!("grant_type=client_credentials&client_id=app&client_secret={SECRET}"),
))
.await,
)
.await;
let subject_token = subject
.get("access_token")
.and_then(|v| v.as_str())
.expect("a subject token")
.to_string();
let body = format!(
"grant_type={}&subject_token={subject_token}&subject_token_type={}\
&client_id=app&client_secret={SECRET}",
urlencode_urn("urn:ietf:params:oauth:grant-type:token-exchange"),
urlencode_urn("urn:ietf:params:oauth:token-type:access_token"),
);
let response = service.handle(post("/token", body)).await;
let status = response.status();
let json = body_of(response).await;
assert_eq!(
status,
http::StatusCode::OK,
"an exchange naming no audience at all must not be refused as though it named one: {json}"
);
assert!(
json.get("access_token").and_then(|v| v.as_str()).is_some(),
"RFC 8693 s2.2.1 requires an access_token in the response: {json}"
);
}
#[cfg(feature = "token-exchange")]
fn urlencode_urn(raw: &str) -> String {
raw.chars()
.map(|c| match c {
':' => "%3A".to_string(),
'/' => "%2F".to_string(),
other => other.to_string(),
})
.collect()
}