#![allow(clippy::result_large_err)]
mod support;
use std::panic::{catch_unwind, AssertUnwindSafe};
use oauth_as::server::UserApproval;
use oauth_as::{
AuthorizationRequest, AuthorizationServer, Client, ClientAuth, ClientId, GrantType,
MemoryStorage, ScopeSet, ServerConfig, TokenRequest,
};
use support::alloc::{measure, CountingAllocator, Delta, TEST_LOCK};
#[global_allocator]
static ALLOC: CountingAllocator = CountingAllocator;
#[test]
fn ungated_path_allocation_gates() {
let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let gates: &[(&str, fn())] = &[
(
"authorization_request_validation_bound",
authorization_request_validation_bound,
),
(
"authorization_code_issuance_bound",
authorization_code_issuance_bound,
),
("device_approval_bound", device_approval_bound),
("revocation_bound", revocation_bound),
(
"revocation_of_an_unknown_token_bound",
revocation_of_an_unknown_token_bound,
),
("dynamic_registration_bound", dynamic_registration_bound),
("par_push_bound", par_push_bound),
("par_redemption_bound", par_redemption_bound),
#[cfg(all(feature = "dpop", feature = "jwt-p256"))]
(
"dpop_proof_verification_bound",
dpop_proof_verification_bound,
),
(
"token_request_with_no_dpop_proof_bound",
token_request_with_no_dpop_proof_bound,
),
(
"client_assertion_verification_bound",
client_assertion_verification_bound,
),
("token_exchange_bound", token_exchange_bound),
("rar_parse_bound", rar_parse_bound),
("rar_narrowing_bound", rar_narrowing_bound),
("consent_lookup_bound", consent_lookup_bound),
("acr_values_refusal_bound", acr_values_refusal_bound),
#[cfg(feature = "jwt-p256")]
("jwks_serving_bound", jwks_serving_bound),
#[cfg(feature = "jwt-p256")]
("jwt_signing_bound", jwt_signing_bound),
];
let mut failures = Vec::new();
for (name, gate) in gates {
if let Err(cause) = catch_unwind(AssertUnwindSafe(gate)) {
let msg = cause
.downcast_ref::<String>()
.cloned()
.or_else(|| cause.downcast_ref::<&str>().map(|s| s.to_string()))
.unwrap_or_else(|| "panicked with a non-string payload".to_string());
failures.push(format!("{name}: {msg}"));
}
}
assert!(
failures.is_empty(),
"{} of {} path gate(s) failed:\n{}",
failures.len(),
gates.len(),
failures.join("\n")
);
}
fn current_thread_runtime() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_current_thread()
.build()
.expect("current-thread runtime")
}
const REDIRECT: &str = "https://app.example/cb";
const SECRET: &str = "a-high-entropy-registered-client-secret";
fn config() -> ServerConfig {
ServerConfig::new("https://as.example", "https://as.example/device")
}
fn app_client() -> Client {
Client {
client_id: ClientId::new("app"),
auth: ClientAuth::ConfidentialSecret {
secret: SECRET.to_string(),
},
grant_types: vec![
GrantType::AuthorizationCode,
GrantType::RefreshToken,
GrantType::ClientCredentials,
GrantType::DeviceCode,
#[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,
}
}
fn server(rt: &tokio::runtime::Runtime, cfg: ServerConfig) -> AuthorizationServer<MemoryStorage> {
rt.block_on(async {
let srv = AuthorizationServer::new(cfg, MemoryStorage::new());
srv.register_client(app_client()).await.unwrap();
srv
})
}
fn authorization_pairs(challenge: &str) -> [(&str, &str); 7] {
[
("response_type", "code"),
("client_id", "app"),
("redirect_uri", REDIRECT),
("scope", "read write"),
("state", "opaque-state"),
("code_challenge", challenge),
("code_challenge_method", "S256"),
]
}
fn authorization_request_validation_bound() {
let rt = current_thread_runtime();
let srv = server(&rt, config());
let challenge = oauth_as::pkce::code_challenge_s256(support::RFC7636_VERIFIER);
let pairs = authorization_pairs(&challenge);
let request = AuthorizationRequest::from_pairs(pairs);
let (validated, d) = measure(|| rt.block_on(srv.validate_authorization_request(&request)));
assert_eq!(validated.unwrap().client_id, ClientId::new("app"));
check("authorization request validation", d, AUTHZ_VALIDATE);
}
fn authorization_code_issuance_bound() {
let rt = current_thread_runtime();
let srv = server(&rt, config());
let challenge = oauth_as::pkce::code_challenge_s256(support::RFC7636_VERIFIER);
let pairs = authorization_pairs(&challenge);
let request = AuthorizationRequest::from_pairs(pairs);
let validated = rt
.block_on(srv.validate_authorization_request(&request))
.unwrap();
rt.block_on(srv.issue_authorization_code(UserApproval::granted(&validated, "user-1")))
.unwrap();
let (response, d) = measure(|| {
rt.block_on(srv.issue_authorization_code(UserApproval::granted(&validated, "user-1")))
});
assert!(!response.unwrap().code.is_empty());
check("authorization code issuance", d, AUTHZ_ISSUE);
}
fn device_approval_bound() {
let rt = current_thread_runtime();
let srv = server(&rt, config());
let user_code = rt
.block_on(srv.device_authorization(&ClientId::new("app"), Some(SECRET), None))
.unwrap()
.user_code;
let (approved, d) = measure(|| rt.block_on(srv.approve_device(&user_code, "user-1")));
approved.expect("the grant is approved");
check("device approval", d, DEVICE_APPROVE);
}
fn revocation_bound() {
let rt = current_thread_runtime();
let srv = server(&rt, config());
let refresh = rt
.block_on(async {
let challenge = oauth_as::pkce::code_challenge_s256(support::RFC7636_VERIFIER);
let pairs = authorization_pairs(&challenge);
let request = AuthorizationRequest::from_pairs(pairs);
let validated = srv.validate_authorization_request(&request).await.unwrap();
let code = srv
.issue_authorization_code(UserApproval::granted(&validated, "user-1"))
.await
.unwrap()
.code;
srv.token(TokenRequest::AuthorizationCode {
client_id: ClientId::new("app"),
client_secret: Some(SECRET.to_string()),
code,
redirect_uri: Some(REDIRECT.to_string()),
code_verifier: Some(support::RFC7636_VERIFIER.to_string()),
})
.await
})
.unwrap()
.refresh_token
.unwrap();
let client_id = ClientId::new("app");
let (result, d) = measure(|| rt.block_on(srv.revoke(&client_id, Some(SECRET), &refresh, None)));
result.expect("a client may revoke its own token");
check("revocation", d, REVOKE);
}
fn revocation_of_an_unknown_token_bound() {
let rt = current_thread_runtime();
let srv = server(&rt, config());
let client_id = ClientId::new("app");
let (result, d) = measure(|| {
rt.block_on(srv.revoke(
&client_id,
Some(SECRET),
"not-a-token-this-server-ever-issued",
None,
))
});
result.expect("RFC 7009 s2.2: an unknown token is still a 200");
check("revocation of an unknown token", d, REVOKE_UNKNOWN);
}
struct AllowAll;
impl oauth_as::RegistrationPolicy for AllowAll {
fn authorize(
&self,
_attempt: &oauth_as::RegistrationAttempt<'_>,
) -> oauth_as::RegistrationDecision {
oauth_as::RegistrationDecision::Allow
}
}
fn dynamic_registration_bound() {
let rt = current_thread_runtime();
let mut cfg = config();
let mut registration = oauth_as::RegistrationConfig::new();
registration.allowed_scopes = ScopeSet::parse("read write").unwrap();
cfg.registration = Some(Box::new(registration));
let srv = AuthorizationServer::new(cfg, MemoryStorage::new())
.with_registration_policy(Box::new(AllowAll));
let metadata = oauth_as::ClientMetadata {
redirect_uris: vec![REDIRECT.to_string()],
..Default::default()
};
rt.block_on(srv.register_dynamic_client(&metadata, None))
.unwrap();
let (info, d) = measure(|| rt.block_on(srv.register_dynamic_client(&metadata, None)));
assert!(info.unwrap().client_secret.is_some());
check("dynamic registration", d, REGISTRATION);
}
#[cfg(feature = "par")]
fn par_config() -> ServerConfig {
let mut cfg = config();
cfg.par = Some(Box::new(oauth_as::ParConfig::new()));
cfg
}
#[cfg(feature = "par")]
fn par_push_bound() {
let rt = current_thread_runtime();
let srv = server(&rt, par_config());
let challenge = oauth_as::pkce::code_challenge_s256(support::RFC7636_VERIFIER);
let parameters = authorization_pairs(&challenge);
rt.block_on(srv.pushed_authorization_request(&ClientId::new("app"), Some(SECRET), ¶meters))
.unwrap();
let (pushed, d) = measure(|| {
rt.block_on(srv.pushed_authorization_request(
&ClientId::new("app"),
Some(SECRET),
¶meters,
))
});
assert!(pushed.unwrap().request_uri.starts_with("urn:"));
check("PAR push", d, PAR_PUSH);
}
#[cfg(not(feature = "par"))]
fn par_push_bound() {}
#[cfg(feature = "par")]
fn par_redemption_bound() {
let rt = current_thread_runtime();
let srv = server(&rt, par_config());
let challenge = oauth_as::pkce::code_challenge_s256(support::RFC7636_VERIFIER);
let parameters = authorization_pairs(&challenge);
let request_uri = rt
.block_on(srv.pushed_authorization_request(
&ClientId::new("app"),
Some(SECRET),
¶meters,
))
.unwrap()
.request_uri;
let (validated, d) =
measure(|| rt.block_on(srv.validate_pushed_authorization_request("app", &request_uri)));
assert_eq!(validated.unwrap().client_id, ClientId::new("app"));
check("PAR redemption", d, PAR_REDEEM);
}
#[cfg(not(feature = "par"))]
fn par_redemption_bound() {}
#[cfg(all(feature = "jwt-p256", feature = "dpop"))]
fn dpop_proof(key: &oauth_as::jwt::EcdsaP256Key, jti: &str) -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let header = serde_json::json!({
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": serde_json::to_value(key.to_public_jwk()).unwrap(),
});
let claims = serde_json::json!({
"jti": jti, "htm": "POST", "htu": "https://as.example/token", "iat": now,
});
oauth_as::jwt::compact_jws(
&serde_json::to_vec(&header).unwrap(),
&serde_json::to_vec(&claims).unwrap(),
|input| key.sign_signing_input(input).unwrap(),
)
}
#[cfg(all(feature = "jwt-p256", feature = "dpop"))]
fn dpop_proof_verification_bound() {
let rt = current_thread_runtime();
let srv = server(&rt, config());
let key = oauth_as::jwt::EcdsaP256Key::generate("client-key");
rt.block_on(srv.token_with_context(
TokenRequest::ClientCredentials {
client_id: ClientId::new("app"),
client_secret: Some(SECRET.to_string()),
scope: None,
},
oauth_as::TokenRequestContext::default().with_dpop_proof(&dpop_proof(&key, "warm")),
))
.unwrap();
let proof = dpop_proof(&key, "measured");
let request = TokenRequest::ClientCredentials {
client_id: ClientId::new("app"),
client_secret: Some(SECRET.to_string()),
scope: None,
};
let (response, d) = measure(|| {
rt.block_on(srv.token_with_context(
request,
oauth_as::TokenRequestContext::default().with_dpop_proof(&proof),
))
});
assert_eq!(response.unwrap().token_type, oauth_as::TokenType::Dpop);
check("DPoP proof verification", d, DPOP_PROOF);
}
#[cfg(all(feature = "dpop", feature = "jwt-p256"))]
fn token_request_with_no_dpop_proof_bound() {
let rt = current_thread_runtime();
let srv = server(&rt, config());
rt.block_on(srv.token(TokenRequest::ClientCredentials {
client_id: ClientId::new("app"),
client_secret: Some(SECRET.to_string()),
scope: None,
}))
.unwrap();
let credentials = || TokenRequest::ClientCredentials {
client_id: ClientId::new("app"),
client_secret: Some(SECRET.to_string()),
scope: None,
};
let with_proof = {
let key = oauth_as::jwt::EcdsaP256Key::generate("client-key");
let proof = dpop_proof(&key, "isolate");
let request = credentials();
let (_, d) = measure(|| {
rt.block_on(srv.token_with_context(
request,
oauth_as::TokenRequestContext::default().with_dpop_proof(&proof),
))
});
d.allocs
};
let request = credentials();
let (_, without) = measure(|| rt.block_on(srv.token(request)));
assert!(
with_proof > without.allocs,
"a presented proof must cost more than an absent one: {with_proof} against {without:?}"
);
check("token request with no DPoP proof", without, DPOP_ABSENT);
}
#[cfg(not(all(feature = "dpop", feature = "jwt-p256")))]
fn token_request_with_no_dpop_proof_bound() {}
#[cfg(all(feature = "client-assertion", feature = "jwt-p256"))]
fn client_assertion_verification_bound() {
use oauth_as::client_assertion::{AssertionKeys, CLIENT_ASSERTION_TYPE};
use std::time::{SystemTime, UNIX_EPOCH};
let rt = current_thread_runtime();
let key = oauth_as::jwt::EcdsaP256Key::generate("client-key");
let srv = rt.block_on(async {
let srv = AuthorizationServer::new(config(), MemoryStorage::new());
srv.register_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 write").unwrap(),
default_scopes: ScopeSet::parse("read").unwrap(),
name: None,
registration: None,
})
.await
.unwrap();
srv
});
let assertion = |jti: &str| {
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": jti,
});
oauth_as::jwt::compact_jws(
br#"{"alg":"ES256","typ":"JWT"}"#,
&serde_json::to_vec(&claims).unwrap(),
|input| key.sign_signing_input(input).unwrap(),
)
};
let request = || TokenRequest::ClientCredentials {
client_id: ClientId::new("pkjwt"),
client_secret: None,
scope: None,
};
fn context(a: &str) -> oauth_as::TokenRequestContext<'_> {
oauth_as::TokenRequestContext::new(oauth_as::ClientCredential::assertion(
Some(CLIENT_ASSERTION_TYPE),
a,
))
}
let warm = assertion("warm");
rt.block_on(srv.token_with_context(request(), context(&warm)))
.unwrap();
let measured = assertion("measured");
let measured_request = request();
let (response, d) =
measure(|| rt.block_on(srv.token_with_context(measured_request, context(&measured))));
assert!(!response.unwrap().access_token.is_empty());
check("client assertion verification", d, CLIENT_ASSERTION);
}
#[cfg(not(all(feature = "client-assertion", feature = "jwt-p256")))]
fn client_assertion_verification_bound() {}
#[cfg(feature = "token-exchange")]
fn token_exchange_bound() {
use oauth_as::token_exchange::{TokenExchange, TokenExchangeRequest, TokenTypeIdentifier};
let rt = current_thread_runtime();
let srv = server(&rt, config());
let subject = rt
.block_on(srv.token(TokenRequest::ClientCredentials {
client_id: ClientId::new("app"),
client_secret: Some(SECRET.to_string()),
scope: None,
}))
.unwrap()
.access_token;
let client_id = ClientId::new("app");
let exchange = || {
let mut request =
TokenExchangeRequest::new(&client_id, &subject, TokenTypeIdentifier::AccessToken);
request.client_secret = Some(SECRET);
request
};
rt.block_on(srv.exchange_token(&exchange())).unwrap();
let (response, d) = measure(|| rt.block_on(srv.exchange_token(&exchange())));
assert!(!response.unwrap().response.access_token.is_empty());
check("token exchange", d, TOKEN_EXCHANGE);
}
#[cfg(not(feature = "token-exchange"))]
fn token_exchange_bound() {}
#[cfg(feature = "rar")]
fn rar_parse_bound() {
let raw = r#"[{"type":"payment_initiation","actions":["initiate","status"],
"locations":["https://rs.example/payments"]}]"#;
let (parsed, d) = measure(|| oauth_as::rar::AuthorizationDetails::parse(raw));
assert_eq!(parsed.unwrap().len(), 1);
check("RAR parse", d, RAR_PARSE);
}
#[cfg(not(feature = "rar"))]
fn rar_parse_bound() {}
#[cfg(feature = "rar")]
fn rar_narrowing_bound() {
let granted = oauth_as::rar::AuthorizationDetails::parse(
r#"[{"type":"payment_initiation","actions":["initiate","status"]}]"#,
)
.unwrap();
let requested = oauth_as::rar::AuthorizationDetails::parse(
r#"[{"type":"payment_initiation","actions":["status"]}]"#,
)
.unwrap();
let (narrowed, d) = measure(|| granted.narrow(&requested));
assert_eq!(narrowed.unwrap().len(), 1);
check("RAR narrowing", d, RAR_NARROW);
}
#[cfg(not(feature = "rar"))]
fn rar_narrowing_bound() {}
#[cfg(feature = "consent")]
fn consent_lookup_bound() {
let rt = current_thread_runtime();
let srv = server(&rt, config());
let scope = ScopeSet::parse("read").unwrap();
rt.block_on(srv.record_consent(&ClientId::new("app"), "user-1", &scope, &[], None))
.unwrap();
let client_id = ClientId::new("app");
let (found, d) = measure(|| rt.block_on(srv.remembered_consent(&client_id, "user-1")));
assert!(found.unwrap().is_some());
check("consent lookup", d, CONSENT_LOOKUP);
}
#[cfg(not(feature = "consent"))]
fn consent_lookup_bound() {}
#[cfg(feature = "consent")]
fn acr_values_refusal_bound() {
use oauth_as::consent::{AuthenticationRequirement, MAX_ACR_VALUES};
let oversized = "a ".repeat(20_000);
let (refused, d) =
measure(|| AuthenticationRequirement::from_pairs([("acr_values", &oversized)]));
check("acr_values refusal", d, ACR_REFUSAL);
assert!(
refused.is_err(),
"acr_values past the cap is refused, not truncated: it parsed {} classes",
refused.map(|r| r.acr_values.len()).unwrap_or(0)
);
let at_cap = vec!["urn:acr:phr"; MAX_ACR_VALUES].join(" ");
let accepted = AuthenticationRequirement::from_pairs([("acr_values", &at_cap)])
.expect("a list AT the cap is accepted");
assert_eq!(accepted.acr_values.len(), MAX_ACR_VALUES);
}
#[cfg(not(feature = "consent"))]
fn acr_values_refusal_bound() {}
#[cfg(feature = "jwt-p256")]
fn jwks_serving_bound() {
let rt = current_thread_runtime();
let mut cfg = config();
cfg.access_token_format =
oauth_as::jwt::AccessTokenFormat::Jwt(Box::new(oauth_as::jwt::JwtConfig::new(
oauth_as::jwt::EcdsaP256Key::generate("sign"),
"https://rs.example",
)));
let srv = server(&rt, cfg);
let _ = srv.jwks();
let (jwks, d) = measure(|| srv.jwks());
assert_eq!(jwks.unwrap().keys.len(), 1);
check("JWKS document build", d, JWKS_BUILD);
}
#[cfg(feature = "jwt-p256")]
fn jwt_signing_bound() {
let rt = current_thread_runtime();
let mut cfg = config();
cfg.access_token_format =
oauth_as::jwt::AccessTokenFormat::Jwt(Box::new(oauth_as::jwt::JwtConfig::new(
oauth_as::jwt::EcdsaP256Key::generate("sign"),
"https://rs.example",
)));
let srv = server(&rt, cfg);
let credentials = || TokenRequest::ClientCredentials {
client_id: ClientId::new("app"),
client_secret: Some(SECRET.to_string()),
scope: None,
};
rt.block_on(srv.token(credentials())).unwrap();
let request = credentials();
let (response, d) = measure(|| rt.block_on(srv.token(request)));
assert!(
response.unwrap().access_token.starts_with("eyJ"),
"the fixture must actually be signing JWTs"
);
check("JWT access token issuance", d, JWT_SIGN);
}
fn check(name: &str, d: Delta, bound: (usize, usize)) {
let (allocs, bytes) = bound;
assert!(
d.allocs <= allocs,
"{name} allocation count regressed past {allocs}: {d:?}"
);
assert!(
d.bytes <= bytes,
"{name} allocation bytes regressed past {bytes}: {d:?}"
);
println!("{name}: {} allocs, {} bytes", d.allocs, d.bytes);
}
const AUTHZ_VALIDATE: (usize, usize) = (12, 512);
const AUTHZ_ISSUE: (usize, usize) = (17, 800);
const DEVICE_APPROVE: (usize, usize) = (14, 672);
const REVOKE: (usize, usize) = (12, 800);
const REVOKE_UNKNOWN: (usize, usize) = (0, 0);
const REGISTRATION: (usize, usize) = (42, 1620);
#[cfg(feature = "par")]
const PAR_PUSH: (usize, usize) = (30, 1200);
#[cfg(feature = "par")]
const PAR_REDEEM: (usize, usize) = (12, 512);
#[cfg(all(feature = "dpop", feature = "jwt-p256"))]
const DPOP_PROOF: (usize, usize) = (74, 6144);
#[cfg(all(feature = "dpop", feature = "jwt-p256"))]
const DPOP_ABSENT: (usize, usize) = (16, 2224);
#[cfg(all(feature = "client-assertion", feature = "jwt-p256"))]
const CLIENT_ASSERTION: (usize, usize) = (49, 4448);
#[cfg(feature = "token-exchange")]
const TOKEN_EXCHANGE: (usize, usize) = (14, 1368);
#[cfg(feature = "rar")]
const RAR_PARSE: (usize, usize) = (12, 930);
#[cfg(feature = "rar")]
const RAR_NARROW: (usize, usize) = (7, 210);
#[cfg(feature = "consent")]
const CONSENT_LOOKUP: (usize, usize) = (0, 0);
#[cfg(feature = "consent")]
const ACR_REFUSAL: (usize, usize) = (0, 0);
#[cfg(feature = "jwt-p256")]
const JWT_SIGN: (usize, usize) = (26, 4352);
#[cfg(feature = "jwt-p256")]
const JWKS_BUILD: (usize, usize) = (6, 296);