use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use oauth_as::{
AuthorizationServer, Client, ClientAuth, ClientId, Clock, ErrorCode, GrantType, MemoryStorage,
ScopeSet, ServerConfig, TokenRequest,
};
#[derive(Clone)]
struct ManualClock(Arc<Mutex<SystemTime>>);
impl ManualClock {
fn at_epoch() -> Self {
ManualClock(Arc::new(Mutex::new(
UNIX_EPOCH + Duration::from_secs(1_700_000_000),
)))
}
}
impl Clock for ManualClock {
fn now(&self) -> SystemTime {
*self.0.lock().unwrap()
}
}
const SECRET: &str = "hunter2-correct-secret";
fn public_client() -> Client {
Client {
client_id: ClientId::new("public-client"),
auth: ClientAuth::Public,
grant_types: vec![GrantType::DeviceCode],
redirect_uris: vec![],
allowed_scopes: ScopeSet::parse("read").unwrap(),
default_scopes: ScopeSet::parse("read").unwrap(),
name: None,
registration: None,
}
}
fn confidential_client() -> Client {
Client {
client_id: ClientId::new("confidential-client"),
auth: ClientAuth::ConfidentialSecret {
secret: SECRET.into(),
},
grant_types: vec![
GrantType::AuthorizationCode,
GrantType::RefreshToken,
GrantType::ClientCredentials,
GrantType::DeviceCode,
],
redirect_uris: vec!["https://app.example/cb".into()],
allowed_scopes: ScopeSet::parse("read").unwrap(),
default_scopes: ScopeSet::parse("read").unwrap(),
name: None,
registration: None,
}
}
fn no_grants_client() -> Client {
Client {
client_id: ClientId::new("no-grants-client"),
auth: ClientAuth::ConfidentialSecret {
secret: SECRET.into(),
},
grant_types: vec![],
redirect_uris: vec![],
allowed_scopes: ScopeSet::parse("read").unwrap(),
default_scopes: ScopeSet::parse("read").unwrap(),
name: None,
registration: None,
}
}
async fn server_with(clients: Vec<Client>) -> AuthorizationServer<MemoryStorage, ManualClock> {
let cfg = ServerConfig::new("https://as.example", "https://as.example/device");
let srv = AuthorizationServer::with_clock(cfg, MemoryStorage::new(), ManualClock::at_epoch());
for c in clients {
srv.register_client(c).await.unwrap();
}
srv
}
#[test]
fn public_client_accepts_no_secret_and_rejects_any_presented_secret() {
let auth = ClientAuth::Public;
assert!(
auth.verify(None),
"no secret presented: the only correct case for a public client"
);
assert!(
!auth.verify(Some("")),
"an empty presented secret is still a presented secret"
);
assert!(!auth.verify(Some("anything")));
assert!(
!auth.verify(Some(SECRET)),
"even a string equal to another client's real secret"
);
}
#[test]
fn confidential_client_requires_the_exact_secret() {
let auth = ClientAuth::ConfidentialSecret {
secret: SECRET.into(),
};
assert!(auth.verify(Some(SECRET)), "the exact secret must succeed");
assert!(!auth.verify(None), "no secret presented");
assert!(!auth.verify(Some("")), "empty secret presented");
assert!(
!auth.verify(Some(&SECRET[..SECRET.len() - 1])),
"a correct prefix, one byte short, must not verify"
);
assert!(
!auth.verify(Some(&format!("{SECRET}x"))),
"a superstring of the real secret (the real secret plus one byte) must not verify"
);
assert!(
!auth.verify(Some(&SECRET.to_uppercase())),
"secrets are compared byte for byte, not case-insensitively"
);
assert!(!auth.verify(Some("totally-different")));
}
#[tokio::test]
async fn public_client_presenting_a_secret_at_the_server_is_invalid_client() {
let srv = server_with(vec![public_client()]).await;
let err = srv
.device_authorization(&ClientId::new("public-client"), Some("unexpected"), None)
.await
.unwrap_err();
assert_eq!(err.error, ErrorCode::InvalidClient);
assert_eq!(err.http_status(), 401);
}
#[tokio::test]
async fn unknown_client_id_and_wrong_secret_produce_the_identical_error() {
let srv = server_with(vec![confidential_client()]).await;
let unknown_id = srv
.token(TokenRequest::ClientCredentials {
client_id: ClientId::new("this-client-id-was-never-registered"),
client_secret: Some("does-not-matter".into()),
scope: None,
})
.await
.unwrap_err();
let wrong_secret = srv
.token(TokenRequest::ClientCredentials {
client_id: ClientId::new("confidential-client"),
client_secret: Some("wrong-secret".into()),
scope: None,
})
.await
.unwrap_err();
assert_eq!(
unknown_id, wrong_secret,
"an unknown client_id and a known client with a wrong secret must be the same error \
value in every field, or the token endpoint becomes a client-id oracle"
);
assert_eq!(unknown_id.error, ErrorCode::InvalidClient);
assert_eq!(unknown_id.http_status(), 401);
let no_secret = srv
.token(TokenRequest::ClientCredentials {
client_id: ClientId::new("confidential-client"),
client_secret: None,
scope: None,
})
.await
.unwrap_err();
assert_eq!(unknown_id, no_secret);
}
#[tokio::test]
async fn unknown_client_id_and_wrong_secret_agree_on_the_device_authorization_path() {
let srv = server_with(vec![confidential_client()]).await;
let unknown_id = srv
.device_authorization(
&ClientId::new("also-never-registered"),
Some("irrelevant"),
None,
)
.await
.unwrap_err();
let wrong_secret = srv
.device_authorization(
&ClientId::new("confidential-client"),
Some("wrong-secret"),
None,
)
.await
.unwrap_err();
assert_eq!(unknown_id, wrong_secret);
}
#[tokio::test]
async fn client_without_the_grant_in_its_registration_is_unauthorized_client_for_every_grant_type()
{
let srv = server_with(vec![no_grants_client()]).await;
let client_id = ClientId::new("no-grants-client");
let authorization_code = srv
.token(TokenRequest::AuthorizationCode {
client_id: client_id.clone(),
client_secret: Some(SECRET.into()),
code: "irrelevant-code".into(),
redirect_uri: None,
code_verifier: None,
})
.await
.unwrap_err();
assert_eq!(authorization_code.error, ErrorCode::UnauthorizedClient);
let client_credentials = srv
.token(TokenRequest::ClientCredentials {
client_id: client_id.clone(),
client_secret: Some(SECRET.into()),
scope: None,
})
.await
.unwrap_err();
assert_eq!(client_credentials.error, ErrorCode::UnauthorizedClient);
let device_code = srv
.token(TokenRequest::DeviceCode {
client_id: client_id.clone(),
client_secret: Some(SECRET.into()),
device_code: "irrelevant-device-code".into(),
})
.await
.unwrap_err();
assert_eq!(device_code.error, ErrorCode::UnauthorizedClient);
let refresh_token = srv
.token(TokenRequest::RefreshToken {
client_id: client_id.clone(),
client_secret: Some(SECRET.into()),
refresh_token: "irrelevant-refresh-token".into(),
scope: None,
})
.await
.unwrap_err();
assert_eq!(refresh_token.error, ErrorCode::UnauthorizedClient);
let device_authorization = srv
.device_authorization(&client_id, Some(SECRET), None)
.await
.unwrap_err();
assert_eq!(device_authorization.error, ErrorCode::UnauthorizedClient);
}
#[cfg(any(feature = "client-assertion", feature = "dpop"))]
#[derive(Default)]
struct Recorder(Arc<Mutex<Vec<String>>>);
#[cfg(any(feature = "client-assertion", feature = "dpop"))]
impl oauth_as::events::EventSink for Recorder {
fn on_event(&self, event: oauth_as::events::Event<'_>) {
self.0.lock().unwrap().push(format!("{event:?}"));
}
}
#[cfg(feature = "client-assertion")]
#[tokio::test]
async fn an_assertion_refusal_tells_the_audit_channel_which_check_failed() {
use oauth_as::client_assertion::AssertionFailure;
use oauth_as::server::{ClientCredential, TokenRequestContext};
let seen = Arc::new(Mutex::new(Vec::new()));
let srv = server_with(vec![confidential_client()])
.await
.with_event_sink(Box::new(Recorder(seen.clone())));
let client_id = ClientId::new("confidential-client");
let refused = srv
.token_with_context(
TokenRequest::ClientCredentials {
client_id: client_id.clone(),
client_secret: None,
scope: None,
},
TokenRequestContext::new(ClientCredential::assertion(
Some("urn:example:some-other-assertion-format"),
"not.a.jwt",
)),
)
.await
.unwrap_err();
assert_eq!(
refused.error,
ErrorCode::InvalidClient,
"the WIRE collapse is unchanged: that is the half that must not move"
);
assert!(
refused.error_description.is_none(),
"and the wire still says nothing about which check failed"
);
srv.token_with_context(
TokenRequest::ClientCredentials {
client_id,
client_secret: None,
scope: None,
},
TokenRequestContext::new(ClientCredential::assertion(
Some("urn:ietf:params:oauth:client-assertion-type:jwt-bearer"),
"not.a.jwt",
)),
)
.await
.unwrap_err();
let events = seen.lock().unwrap().clone();
let malformed = format!("{}", AssertionFailure::Malformed);
let wrong_principal = format!("{}", AssertionFailure::WrongPrincipal);
assert!(
events.iter().any(|e| e.contains("Malformed")),
"the first refusal must reach the sink as its own reason ({malformed}), got {events:?}"
);
assert!(
events.iter().any(|e| e.contains("WrongPrincipal")),
"and the second as a DIFFERENT one ({wrong_principal}); one reason for both is the \
collapse this test exists to forbid, got {events:?}"
);
}
#[cfg(all(feature = "dpop", feature = "jwt-p256"))]
#[tokio::test]
async fn a_refused_dpop_proof_reaches_the_audit_channel_with_its_reason() {
use oauth_as::server::{ClientCredential, TokenRequestContext};
let seen = Arc::new(Mutex::new(Vec::new()));
let srv = server_with(vec![confidential_client()])
.await
.with_event_sink(Box::new(Recorder(seen.clone())));
let refused = srv
.token_with_context(
TokenRequest::ClientCredentials {
client_id: ClientId::new("confidential-client"),
client_secret: Some(SECRET.into()),
scope: None,
},
TokenRequestContext::new(ClientCredential::secret(Some(SECRET)))
.with_dpop_proof("this-is-not-a-compact-jws"),
)
.await
.unwrap_err();
assert_eq!(refused.error, ErrorCode::InvalidDpopProof);
let events = seen.lock().unwrap().clone();
assert!(
events.iter().any(|e| e.contains("DpopProofRefused")),
"a refused proof must be reported at all; through 0.9.0 nothing was emitted, got {events:?}"
);
assert!(
events.iter().any(|e| e.contains("Malformed")),
"and it must carry WHICH check failed, or the event is the same non-answer the wire gives: \
{events:?}"
);
}
#[cfg(all(feature = "client-assertion", feature = "jwt-p256"))]
struct ReplayStoreOutage(MemoryStorage);
#[cfg(all(feature = "client-assertion", feature = "jwt-p256"))]
impl oauth_as::Storage for ReplayStoreOutage {
async fn claim_replay_id(
&self,
_id: &str,
_expires_at: SystemTime,
) -> Result<bool, oauth_as::store::StorageError> {
Err(oauth_as::store::StorageError::new(
"the replay table is unreachable",
))
}
async fn get_client(
&self,
client_id: &ClientId,
) -> Result<Option<Arc<Client>>, oauth_as::store::StorageError> {
self.0.get_client(client_id).await
}
async fn put_client(&self, client: Client) -> Result<(), oauth_as::store::StorageError> {
self.0.put_client(client).await
}
async fn compare_and_swap_client(
&self,
expected: &Client,
updated: Client,
) -> Result<bool, oauth_as::store::StorageError> {
self.0.compare_and_swap_client(expected, updated).await
}
async fn delete_client(
&self,
client_id: &ClientId,
window: oauth_as::store::RevocationWindow,
) -> Result<bool, oauth_as::store::StorageError> {
self.0.delete_client(client_id, window).await
}
async fn put_device_grant(
&self,
grant: oauth_as::device::DeviceGrant,
) -> Result<(), oauth_as::store::StorageError> {
self.0.put_device_grant(grant).await
}
async fn get_device_grant(
&self,
device_code: &str,
) -> Result<Option<oauth_as::device::DeviceGrant>, oauth_as::store::StorageError> {
self.0.get_device_grant(device_code).await
}
async fn find_device_grant_by_user_code(
&self,
normalized_user_code: &str,
) -> Result<Option<oauth_as::device::DeviceGrant>, oauth_as::store::StorageError> {
self.0
.find_device_grant_by_user_code(normalized_user_code)
.await
}
async fn take_device_grant(
&self,
device_code: &str,
) -> Result<Option<oauth_as::device::DeviceGrant>, oauth_as::store::StorageError> {
self.0.take_device_grant(device_code).await
}
async fn compare_and_swap_device_grant(
&self,
expected: &oauth_as::device::DeviceGrantState,
updated: oauth_as::device::DeviceGrant,
) -> Result<bool, oauth_as::store::StorageError> {
self.0
.compare_and_swap_device_grant(expected, updated)
.await
}
async fn put_authorization_code(
&self,
record: oauth_as::authorization::AuthorizationCodeRecord,
) -> Result<(), oauth_as::store::StorageError> {
self.0.put_authorization_code(record).await
}
async fn compare_and_swap_authorization_code(
&self,
expected: &oauth_as::authorization::AuthorizationCodeState,
updated: oauth_as::authorization::AuthorizationCodeRecord,
) -> Result<bool, oauth_as::store::StorageError> {
self.0
.compare_and_swap_authorization_code(expected, updated)
.await
}
async fn take_authorization_code(
&self,
code: &str,
) -> Result<
Option<oauth_as::authorization::AuthorizationCodeRecord>,
oauth_as::store::StorageError,
> {
self.0.take_authorization_code(code).await
}
#[cfg(feature = "par")]
async fn put_pushed_authorization_request(
&self,
record: oauth_as::par::PushedAuthorizationRequest,
) -> Result<oauth_as::store::WriteOutcome, oauth_as::store::StorageError> {
self.0.put_pushed_authorization_request(record).await
}
#[cfg(feature = "par")]
async fn take_pushed_authorization_request(
&self,
request_uri: &str,
) -> Result<Option<oauth_as::par::PushedAuthorizationRequest>, oauth_as::store::StorageError>
{
self.0.take_pushed_authorization_request(request_uri).await
}
async fn put_token(
&self,
token: oauth_as::token::IssuedToken,
) -> Result<oauth_as::store::WriteOutcome, oauth_as::store::StorageError> {
self.0.put_token(token).await
}
async fn get_token(
&self,
access_token: &str,
) -> Result<Option<Arc<oauth_as::token::IssuedToken>>, oauth_as::store::StorageError> {
self.0.get_token(access_token).await
}
async fn delete_token(&self, access_token: &str) -> Result<(), oauth_as::store::StorageError> {
self.0.delete_token(access_token).await
}
async fn put_refresh_token(
&self,
record: oauth_as::token::RefreshTokenRecord,
) -> Result<oauth_as::store::WriteOutcome, oauth_as::store::StorageError> {
self.0.put_refresh_token(record).await
}
async fn get_refresh_token(
&self,
refresh_token: &str,
) -> Result<Option<Arc<oauth_as::token::RefreshTokenRecord>>, oauth_as::store::StorageError>
{
self.0.get_refresh_token(refresh_token).await
}
async fn take_refresh_token(
&self,
refresh_token: &str,
) -> Result<Option<oauth_as::token::RefreshTokenRecord>, oauth_as::store::StorageError> {
self.0.take_refresh_token(refresh_token).await
}
async fn revoke_token_family(
&self,
family_id: &str,
window: oauth_as::store::RevocationWindow,
) -> Result<u64, oauth_as::store::StorageError> {
self.0.revoke_token_family(family_id, window).await
}
#[cfg(feature = "consent")]
async fn put_consent(
&self,
record: oauth_as::consent::ConsentRecord,
) -> Result<(), oauth_as::store::StorageError> {
self.0.put_consent(record).await
}
#[cfg(feature = "consent")]
async fn compare_and_swap_consent(
&self,
expected: Option<&oauth_as::consent::ConsentRecord>,
updated: oauth_as::consent::ConsentRecord,
) -> Result<bool, oauth_as::store::StorageError> {
self.0.compare_and_swap_consent(expected, updated).await
}
#[cfg(feature = "consent")]
async fn get_consent(
&self,
consent_id: &str,
) -> Result<Option<Arc<oauth_as::consent::ConsentRecord>>, oauth_as::store::StorageError> {
self.0.get_consent(consent_id).await
}
#[cfg(feature = "consent")]
async fn find_consent(
&self,
client_id: &ClientId,
subject: &str,
) -> Result<Option<Arc<oauth_as::consent::ConsentRecord>>, oauth_as::store::StorageError> {
self.0.find_consent(client_id, subject).await
}
#[cfg(feature = "consent")]
async fn consents_for_subject(
&self,
subject: &str,
) -> Result<Vec<Arc<oauth_as::consent::ConsentRecord>>, oauth_as::store::StorageError> {
self.0.consents_for_subject(subject).await
}
#[cfg(feature = "consent")]
async fn revoke_consent(
&self,
consent_id: &str,
window: oauth_as::store::RevocationWindow,
) -> Result<u64, oauth_as::store::StorageError> {
self.0.revoke_consent(consent_id, window).await
}
async fn sweep_expired(&self, now: SystemTime) -> Result<u64, oauth_as::store::StorageError> {
self.0.sweep_expired(now).await
}
}
#[cfg(all(feature = "client-assertion", feature = "jwt-p256"))]
#[tokio::test]
async fn a_replay_store_outage_is_not_reported_as_a_captured_and_replayed_assertion() {
use oauth_as::client_assertion::AssertionKeys;
use oauth_as::jwt::{compact_jws, EcdsaP256Key};
use oauth_as::server::{ClientCredential, TokenRequestContext};
use oauth_as::Storage as _;
let key = EcdsaP256Key::generate("client-key");
let store = ReplayStoreOutage(MemoryStorage::new());
store
.put_client(Client {
client_id: ClientId::new("pkjwt"),
auth: ClientAuth::ConfidentialAssertion {
keys: AssertionKeys::PublicKeys {
keys: vec![key.to_public_jwk()],
},
},
grant_types: vec![GrantType::ClientCredentials],
redirect_uris: vec![],
allowed_scopes: ScopeSet::parse("read").unwrap(),
default_scopes: ScopeSet::parse("read").unwrap(),
name: None,
registration: None,
})
.await
.unwrap();
let seen = Arc::new(Mutex::new(Vec::new()));
let srv = AuthorizationServer::new(
ServerConfig::new("https://as.example", "https://as.example/device"),
store,
)
.with_event_sink(Box::new(Recorder(seen.clone())));
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let claims = serde_json::json!({
"iss": "pkjwt",
"sub": "pkjwt",
"aud": "https://as.example/token",
"exp": now + 120,
"iat": now,
"jti": "a-1",
});
let assertion = compact_jws(
br#"{"alg":"ES256","typ":"JWT"}"#,
&serde_json::to_vec(&claims).unwrap(),
|input| key.sign_signing_input(input).unwrap(),
);
let refused = srv
.token_with_context(
TokenRequest::ClientCredentials {
client_id: ClientId::new("pkjwt"),
client_secret: None,
scope: None,
},
TokenRequestContext::new(ClientCredential::assertion(
Some("urn:ietf:params:oauth:client-assertion-type:jwt-bearer"),
&assertion,
)),
)
.await
.expect_err("a claim that could not be recorded fails closed");
assert_eq!(
refused.error,
ErrorCode::InvalidClient,
"the WIRE answer is unchanged: failing closed is the correct posture"
);
let events = seen.lock().unwrap().clone();
assert!(
events.iter().any(|e| e.contains("AssertionInvalid")),
"the refusal must still reach the audit channel: {events:?}"
);
assert!(
!events.iter().any(|e| e.contains("Replayed")),
"a store outage must not be reported as a captured-and-replayed assertion: {events:?}"
);
}
#[cfg(all(feature = "dpop", not(feature = "jwt-p256")))]
#[tokio::test]
async fn a_proof_refused_for_want_of_a_verifier_still_reaches_the_audit_channel() {
use oauth_as::server::{ClientCredential, TokenRequestContext};
let seen = Arc::new(Mutex::new(Vec::new()));
let srv = server_with(vec![confidential_client()])
.await
.with_event_sink(Box::new(Recorder(seen.clone())));
let refused = srv
.token_with_context(
TokenRequest::ClientCredentials {
client_id: ClientId::new("confidential-client"),
client_secret: Some(SECRET.into()),
scope: None,
},
TokenRequestContext::new(ClientCredential::secret(Some(SECRET)))
.with_dpop_proof("any.proof.at.all"),
)
.await
.unwrap_err();
assert_eq!(refused.error, ErrorCode::InvalidDpopProof);
let events = seen.lock().unwrap().clone();
assert!(
events.iter().any(|e| e.contains("DpopProofRefused")),
"a deployment refusing every proof for want of a backend must be able to SEE that: \
{events:?}"
);
}
#[cfg(all(feature = "http", feature = "dpop", feature = "token-exchange"))]
#[tokio::test]
async fn the_service_s_own_dpop_refusals_reach_the_audit_channel() {
use oauth_as::http::{Body, ServiceBuilder};
async fn events_for(extra: Vec<(&str, Vec<u8>)>, body: String) -> Vec<String> {
let seen = Arc::new(Mutex::new(Vec::new()));
let srv = server_with(vec![confidential_client()])
.await
.with_event_sink(Box::new(Recorder(seen.clone())));
let service = ServiceBuilder::new(Arc::new(srv)).build().expect("service");
let mut request = http::Request::builder()
.method("POST")
.uri("/token")
.header("content-type", "application/x-www-form-urlencoded");
for (name, value) in extra {
request = request.header(name, http::HeaderValue::from_bytes(&value).unwrap());
}
let response = service
.handle(
request
.body(Body::from(body))
.expect("a well-formed request"),
)
.await;
assert_eq!(
response.status(),
http::StatusCode::BAD_REQUEST,
"the wire answer is unchanged: RFC 9449 s5 invalid_dpop_proof"
);
let events = seen.lock().unwrap().clone();
events
}
let credentials = format!(
"grant_type=client_credentials&client_id=confidential-client&client_secret={SECRET}"
);
let events = events_for(
vec![
("DPoP", b"first.proof.here".to_vec()),
("DPoP", b"second.proof.here".to_vec()),
],
credentials.clone(),
)
.await;
assert!(
events.iter().any(|e| e.contains("DpopProofRefused")),
"two DPoP headers must reach the audit channel: {events:?}"
);
let events = events_for(vec![("DPoP", vec![0x80, 0x81])], credentials).await;
assert!(
events.iter().any(|e| e.contains("DpopProofRefused")),
"a non-ASCII DPoP header must reach the audit channel: {events:?}"
);
let events = events_for(
vec![("DPoP", b"a.proof.here".to_vec())],
format!(
"grant_type=urn:ietf:params:oauth:grant-type:token-exchange&client_id=confidential-client\
&client_secret={SECRET}&subject_token=x&subject_token_type=urn:ietf:params:oauth:token-type:access_token"
),
)
.await;
assert!(
events.iter().any(|e| e.contains("DpopProofRefused")),
"a proof sent with token exchange must reach the audit channel: {events:?}"
);
}
#[cfg(feature = "client-assertion")]
#[tokio::test]
async fn an_unknown_client_id_costs_an_es256_verification_on_the_assertion_path_too() {
use std::sync::atomic::{AtomicUsize, Ordering};
use oauth_as::client_assertion::AssertionKeys;
use oauth_as::jwt::{Es256Verifier, Jwk, PublicJwk};
use oauth_as::server::{ClientCredential, TokenRequestContext};
struct Counting(Arc<AtomicUsize>);
impl Es256Verifier for Counting {
fn verify(&self, _key: &PublicJwk, _signing_input: &[u8], _signature: &[u8]) -> bool {
self.0.fetch_add(1, Ordering::SeqCst);
false
}
}
let registered = Jwk {
kty: "EC",
crv: "P-256",
x: "LIZkYOSRaSLc5uMxzlzV9pgt1ARaDl_3tZfRkt9mzFY".to_string(),
y: "fBSzqWfCploda0TpKf3N56v6fk-fORAiVsXUmkWYWkw".to_string(),
kid: "client-key".to_string(),
use_: "sig",
alg: "ES256",
}
.to_public_jwk();
let calls = Arc::new(AtomicUsize::new(0));
let srv = server_with(vec![Client {
client_id: ClientId::new("pkjwt-client"),
auth: ClientAuth::ConfidentialAssertion {
keys: AssertionKeys::PublicKeys {
keys: vec![registered],
},
},
grant_types: vec![GrantType::ClientCredentials],
redirect_uris: vec![],
allowed_scopes: ScopeSet::parse("read").unwrap(),
default_scopes: ScopeSet::parse("read").unwrap(),
name: None,
registration: None,
}])
.await
.with_es256_verifier(Arc::new(Counting(calls.clone())));
async fn probe(
srv: &oauth_as::AuthorizationServer<oauth_as::MemoryStorage, impl oauth_as::Clock>,
client_id: &str,
) {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let claims = serde_json::json!({
"iss": client_id,
"sub": client_id,
"aud": "https://as.example/token",
"exp": now + 120,
"iat": now,
"jti": format!("probe-{client_id}"),
});
let assertion = oauth_as::jwt::compact_jws(
br#"{"alg":"ES256","typ":"JWT"}"#,
&serde_json::to_vec(&claims).unwrap(),
|_input| vec![7u8; 64],
);
let refused = srv
.token_with_context(
TokenRequest::ClientCredentials {
client_id: ClientId::new(client_id),
client_secret: None,
scope: None,
},
TokenRequestContext::new(ClientCredential::assertion(
Some("urn:ietf:params:oauth:client-assertion-type:jwt-bearer"),
&assertion,
)),
)
.await
.expect_err("neither probe authenticates");
assert_eq!(
refused.error,
ErrorCode::InvalidClient,
"the wire answer is the same for both, which is the half that already worked"
);
}
probe(&srv, "pkjwt-client").await;
probe(&srv, "no-such-client").await;
assert_eq!(
calls.load(Ordering::SeqCst),
2,
"an unknown client id must cost the same ES256 verification a known one does, or the \
wall clock answers the question the wire refuses to"
);
}
#[cfg(feature = "client-assertion")]
#[tokio::test]
async fn a_registered_client_that_does_not_use_assertions_costs_what_an_unknown_id_costs() {
use std::sync::atomic::{AtomicUsize, Ordering};
use oauth_as::jwt::{Es256Verifier, PublicJwk};
use oauth_as::server::{ClientCredential, TokenRequestContext};
struct Counting(Arc<AtomicUsize>);
impl Es256Verifier for Counting {
fn verify(&self, _key: &PublicJwk, _signing_input: &[u8], _signature: &[u8]) -> bool {
self.0.fetch_add(1, Ordering::SeqCst);
false
}
}
fn assertion_for(client_id: &str) -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let claims = serde_json::json!({
"iss": client_id,
"sub": client_id,
"aud": "https://as.example/token",
"exp": now + 120,
"iat": now,
"jti": format!("kind-probe-{client_id}"),
});
oauth_as::jwt::compact_jws(
br#"{"alg":"ES256","typ":"JWT"}"#,
&serde_json::to_vec(&claims).unwrap(),
|_input| vec![7u8; 64],
)
}
async fn probe(
srv: &AuthorizationServer<MemoryStorage, ManualClock>,
client_id: &str,
assertion_type: Option<&str>,
) {
let assertion = assertion_for(client_id);
let refused = srv
.token_with_context(
TokenRequest::ClientCredentials {
client_id: ClientId::new(client_id),
client_secret: None,
scope: None,
},
TokenRequestContext::new(ClientCredential::assertion(assertion_type, &assertion)),
)
.await
.expect_err("neither probe authenticates");
assert_eq!(refused.error, ErrorCode::InvalidClient);
}
async fn counts(assertion_type: Option<&str>) -> (usize, usize) {
let calls = Arc::new(AtomicUsize::new(0));
let srv = server_with(vec![Client {
client_id: ClientId::new("secret-client"),
auth: ClientAuth::ConfidentialSecret {
secret: SECRET.into(),
},
grant_types: vec![GrantType::ClientCredentials],
redirect_uris: vec![],
allowed_scopes: ScopeSet::parse("read").unwrap(),
default_scopes: ScopeSet::parse("read").unwrap(),
name: None,
registration: None,
}])
.await
.with_es256_verifier(Arc::new(Counting(calls.clone())));
probe(&srv, "secret-client", assertion_type).await;
let known = calls.load(Ordering::SeqCst);
probe(&srv, "no-such-client", assertion_type).await;
let unknown = calls.load(Ordering::SeqCst) - known;
(known, unknown)
}
let (known, unknown) = counts(Some(oauth_as::CLIENT_ASSERTION_TYPE)).await;
assert_eq!(
known, unknown,
"a registered id that does not use assertions must cost what an unknown id costs \
(known {known}, unknown {unknown})"
);
let (known, unknown) = counts(Some("urn:example:not-a-real-assertion-type")).await;
assert_eq!(
(known, unknown),
(0, 0),
"a request that could not have reached a verification must not pay for one"
);
}
#[tokio::test]
async fn a_known_id_that_verifies_nothing_costs_what_an_unknown_id_costs() {
use std::sync::atomic::{AtomicUsize, Ordering};
use oauth_as::client::{SecretHash, SecretVerifier};
use oauth_as::server::{ClientCredential, TokenRequestContext};
use oauth_as::DynamicRegistration;
struct Counting(Arc<AtomicUsize>);
impl SecretVerifier for Counting {
fn verify(&self, _stored: &SecretHash, _presented: &str) -> bool {
self.0.fetch_add(1, Ordering::SeqCst);
false
}
fn dummy_hash(&self) -> Option<SecretHash> {
Some(SecretHash::custom(
"host-scheme",
"dummy-encoding-nobody-knows-the-secret-for",
))
}
}
async fn counts(known: Client) -> (usize, usize) {
let calls = Arc::new(AtomicUsize::new(0));
let srv = server_with(vec![known])
.await
.with_secret_verifier(Box::new(Counting(calls.clone())));
async fn probe(srv: &AuthorizationServer<MemoryStorage, ManualClock>, client_id: &str) {
let refused = srv
.token_with_context(
TokenRequest::ClientCredentials {
client_id: ClientId::new(client_id),
client_secret: None,
scope: None,
},
TokenRequestContext::new(ClientCredential::secret(Some("junk-secret"))),
)
.await
.expect_err("neither probe authenticates");
assert_eq!(refused.error, ErrorCode::InvalidClient);
}
probe(&srv, "probed-client").await;
let known_calls = calls.load(Ordering::SeqCst);
probe(&srv, "no-such-client").await;
(known_calls, calls.load(Ordering::SeqCst) - known_calls)
}
fn client_with(auth: ClientAuth, registration: Option<DynamicRegistration>) -> Client {
let registration = registration.map(Box::new);
Client {
client_id: ClientId::new("probed-client"),
auth,
grant_types: vec![GrantType::ClientCredentials],
redirect_uris: vec![],
allowed_scopes: ScopeSet::parse("read").unwrap(),
default_scopes: ScopeSet::parse("read").unwrap(),
name: None,
registration,
}
}
let (known, unknown) = counts(client_with(ClientAuth::Public, None)).await;
assert_eq!(
known, unknown,
"a registered public client must cost what an unknown id costs (known {known}, \
unknown {unknown})"
);
let registration = DynamicRegistration {
registration_access_token_hash: SecretHash::sha256("unused-for-this-probe"),
client_id_issued_at: Some(0),
client_secret_expires_at: Some(1),
token_endpoint_auth_method: "client_secret_post".to_string(),
};
let (known, unknown) = counts(client_with(
ClientAuth::ConfidentialSecret {
secret: SECRET.into(),
},
Some(registration),
))
.await;
assert_eq!(
known, unknown,
"an expired registration must cost what an unknown id costs (known {known}, \
unknown {unknown})"
);
#[cfg(feature = "client-assertion")]
{
use oauth_as::client_assertion::{AssertionKeys, ClientSecretKey};
let (known, unknown) = counts(client_with(
ClientAuth::ConfidentialAssertion {
keys: AssertionKeys::ClientSecret {
secret: ClientSecretKey::new(SECRET).expect("fixture secret clears the floor"),
},
},
None,
))
.await;
assert_eq!(
known, unknown,
"an assertion registration must cost what an unknown id costs (known {known}, \
unknown {unknown})"
);
}
#[cfg(feature = "mtls")]
{
use oauth_as::mtls::{ExpectedSubject, MtlsClientRegistration};
let (known, unknown) = counts(client_with(
ClientAuth::Mtls {
registration: MtlsClientRegistration::TlsClientAuth(ExpectedSubject::SanDns(
"probed.example".to_string(),
)),
},
None,
))
.await;
assert_eq!(
known, unknown,
"a mutual-TLS registration must cost what an unknown id costs (known {known}, \
unknown {unknown})"
);
}
}
#[cfg(feature = "dpop")]
#[tokio::test]
async fn a_dpop_refusal_says_no_more_on_the_wire_than_any_other_dpop_refusal() {
use oauth_as::server::{ClientCredential, TokenRequestContext};
let srv = server_with(vec![confidential_client()]).await;
let refused = srv
.token_with_context(
TokenRequest::ClientCredentials {
client_id: ClientId::new("confidential-client"),
client_secret: Some(SECRET.into()),
scope: None,
},
TokenRequestContext::new(ClientCredential::secret(Some(SECRET)))
.with_dpop_proof("a.proof.here"),
)
.await
.unwrap_err();
assert_eq!(refused.error, ErrorCode::InvalidDpopProof);
assert_eq!(
refused.error_description, None,
"RFC 9449 s5 gives every one of these the same code, and this crate's own docs put the \
distinction in the audit channel rather than on the wire: {refused:?}"
);
}
#[cfg(feature = "client-assertion")]
#[tokio::test]
async fn a_known_id_refused_before_reading_its_assertion_costs_what_an_unknown_id_costs() {
use std::sync::atomic::{AtomicUsize, Ordering};
use oauth_as::client::{SecretHash, SecretVerifier};
use oauth_as::server::{ClientCredential, TokenRequestContext};
struct Counting(Arc<AtomicUsize>);
impl SecretVerifier for Counting {
fn verify(&self, _stored: &SecretHash, _presented: &str) -> bool {
self.0.fetch_add(1, Ordering::SeqCst);
false
}
fn dummy_hash(&self) -> Option<SecretHash> {
Some(SecretHash::custom(
"host-scheme",
"dummy-encoding-nobody-knows-the-secret-for",
))
}
}
async fn counts(assertion_type: Option<&str>) -> (usize, usize) {
let calls = Arc::new(AtomicUsize::new(0));
let srv = server_with(vec![Client {
client_id: ClientId::new("probed-client"),
auth: ClientAuth::ConfidentialSecretHash {
hash: SecretHash::custom("host-scheme", "whatever-the-host-stored"),
},
grant_types: vec![GrantType::ClientCredentials],
redirect_uris: vec![],
allowed_scopes: ScopeSet::parse("read").unwrap(),
default_scopes: ScopeSet::parse("read").unwrap(),
name: None,
registration: None,
}])
.await
.with_secret_verifier(Box::new(Counting(calls.clone())));
async fn probe(
srv: &AuthorizationServer<MemoryStorage, ManualClock>,
client_id: &str,
assertion_type: Option<&str>,
) {
let mut cred = ClientCredential::secret(Some("junk-secret"));
cred.client_assertion_type = assertion_type;
cred.client_assertion = Some("x.y.z");
let refused = srv
.token_with_context(
TokenRequest::ClientCredentials {
client_id: ClientId::new(client_id),
client_secret: None,
scope: None,
},
TokenRequestContext::new(cred),
)
.await
.expect_err("two credentials at once is refused either way (RFC 6749 s2.3)");
assert_eq!(refused.error, ErrorCode::InvalidClient);
}
probe(&srv, "probed-client", assertion_type).await;
let known = calls.load(Ordering::SeqCst);
probe(&srv, "no-such-client", assertion_type).await;
(known, calls.load(Ordering::SeqCst) - known)
}
let (known, unknown) = counts(Some(oauth_as::CLIENT_ASSERTION_TYPE)).await;
assert_eq!(
known, unknown,
"a known id refused for presenting two credentials must cost what an unknown id costs \
(known {known}, unknown {unknown})"
);
let (known, unknown) = counts(Some("urn:example:not-a-real-assertion-type")).await;
assert_eq!(
known, unknown,
"a known id refused for an unrecognised assertion type must cost what an unknown id costs \
(known {known}, unknown {unknown})"
);
let (known, unknown) = counts(None).await;
assert_eq!(
known, unknown,
"a known id refused for a missing assertion type must cost what an unknown id costs \
(known {known}, unknown {unknown})"
);
}