#![cfg(all(feature = "jar", feature = "jwt-p256", feature = "par"))]
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use oauth_as::jwt::{compact_jws, EcdsaP256Key};
use oauth_as::{
AuthorizationError, AuthorizationServer, Client, ClientAuth, ClientId, Clock, ErrorCode,
GrantType, JarConfig, MemoryStorage, ParConfig, RegisteredRequestObjectKey, RequestObjectKeys,
ScopeSet, ServerConfig,
};
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const VERIFIER: &str = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
const BASE: u64 = 1_700_000_000;
#[derive(Clone)]
struct ManualClock(Arc<Mutex<SystemTime>>);
impl ManualClock {
fn at_base() -> Self {
ManualClock(Arc::new(Mutex::new(UNIX_EPOCH + Duration::from_secs(BASE))))
}
fn advance(&self, d: Duration) {
*self.0.lock().expect("no test panics while holding this") += d;
}
}
impl Clock for ManualClock {
fn now(&self) -> SystemTime {
*self.0.lock().expect("no test panics while holding this")
}
}
struct Keys(RegisteredRequestObjectKey);
impl RequestObjectKeys for Keys {
fn registered_key(&self, client_id: &ClientId) -> Option<RegisteredRequestObjectKey> {
(client_id.as_str() == "app").then(|| self.0.clone())
}
}
fn client() -> Client {
Client {
client_id: ClientId::new("app"),
auth: ClientAuth::Public,
grant_types: vec![GrantType::AuthorizationCode],
redirect_uris: vec!["https://app.example/cb".to_string()],
allowed_scopes: ScopeSet::parse("read write").unwrap(),
default_scopes: ScopeSet::parse("read").unwrap(),
name: None,
registration: None,
}
}
async fn server_at(
key: &EcdsaP256Key,
jar: Option<JarConfig>,
par: Option<ParConfig>,
clock: ManualClock,
) -> AuthorizationServer<MemoryStorage, ManualClock> {
let mut cfg = ServerConfig::new("https://as.example", "https://as.example/device");
cfg.jar = jar.map(Box::new);
cfg.par = par.map(Box::new);
let jwk = key.public_jwk();
let registered = RegisteredRequestObjectKey::es256_from_jwk_coordinates(
Some(jwk.kid.clone()),
&jwk.x,
&jwk.y,
)
.expect("a JWK this crate emitted registers");
let server = AuthorizationServer::with_clock(cfg, MemoryStorage::new(), clock)
.with_request_object_keys(Box::new(Keys(registered)));
server.register_client(client()).await.unwrap();
server
}
async fn server(
key: &EcdsaP256Key,
jar: Option<JarConfig>,
par: Option<ParConfig>,
) -> AuthorizationServer<MemoryStorage, ManualClock> {
server_at(key, jar, par, ManualClock::at_base()).await
}
fn signed(key: &EcdsaP256Key, header: &str, extra: &str) -> String {
let challenge = oauth_as::pkce::code_challenge_s256(VERIFIER);
let payload = format!(
r#"{{"client_id":"app","response_type":"code","redirect_uri":"https://app.example/cb","scope":"read","code_challenge":"{challenge}","code_challenge_method":"S256"{extra}}}"#
);
compact_jws(header.as_bytes(), payload.as_bytes(), |input| {
key.sign_signing_input(input)
.expect("the fixture key signs")
})
}
fn plain_header(key: &EcdsaP256Key) -> String {
format!(r#"{{"alg":"ES256","kid":"{}"}}"#, key.public_jwk().kid)
}
fn live_exp() -> String {
format!(r#","exp":{}"#, BASE + 30)
}
#[tokio::test]
async fn a_signed_request_object_does_not_satisfy_a_par_only_policy() {
let key = EcdsaP256Key::generate("client-key");
let mut par = ParConfig::new();
par.require_pushed_authorization_requests = true;
let server = server(&key, Some(JarConfig::new()), Some(par)).await;
let object = signed(&key, &plain_header(&key), &live_exp());
match server
.validate_signed_authorization_request("app", &object)
.await
{
Err(AuthorizationError::Direct(error)) => assert_eq!(
error.error,
ErrorCode::InvalidRequest,
"RFC 9126 s4 is about where the data travelled, which a signature does not answer"
),
other => panic!(
"with require_pushed_authorization_requests set, a query-borne request object must \
be refused, got {other:?}"
),
}
}
#[tokio::test]
async fn a_request_object_with_no_expiry_is_refused_rather_than_replayable_forever() {
let key = EcdsaP256Key::generate("client-key");
let clock = ManualClock::at_base();
let server = server_at(&key, Some(JarConfig::new()), None, clock.clone()).await;
let object = signed(&key, &plain_header(&key), "");
match server
.validate_signed_authorization_request("app", &object)
.await
{
Err(AuthorizationError::Direct(error)) => assert_eq!(
error.error,
ErrorCode::InvalidRequestObject,
"a request object with no lifetime is a bearer credential, not a request"
),
other => panic!("a request object with no exp must be refused, got {other:?}"),
}
clock.advance(Duration::from_secs(10 * 365 * 24 * 3600));
assert!(
server
.validate_signed_authorization_request("app", &object)
.await
.is_err(),
"and it is still refused ten years later, which is how long it used to work for"
);
}
#[tokio::test]
async fn a_request_object_whose_remaining_life_exceeds_the_ceiling_is_refused() {
let key = EcdsaP256Key::generate("client-key");
let server = server(&key, Some(JarConfig::new()), None).await;
let object = signed(
&key,
&plain_header(&key),
&format!(r#","exp":{}"#, BASE + 365 * 24 * 3600),
);
match server
.validate_signed_authorization_request("app", &object)
.await
{
Err(AuthorizationError::Direct(error)) => assert_eq!(
error.error,
ErrorCode::InvalidRequestObject,
"an exp a year out is an unbounded object with a lifetime claim stapled to it"
),
other => panic!("a request object may not name its own replay window, got {other:?}"),
}
}
#[tokio::test]
async fn a_short_lived_request_object_works_and_then_stops_working() {
let key = EcdsaP256Key::generate("client-key");
let clock = ManualClock::at_base();
let server = server_at(&key, Some(JarConfig::new()), None, clock.clone()).await;
let object = signed(&key, &plain_header(&key), &live_exp());
server
.validate_signed_authorization_request("app", &object)
.await
.expect("an object with a 30 second lifetime is inside the ceiling");
clock.advance(Duration::from_secs(31));
match server
.validate_signed_authorization_request("app", &object)
.await
{
Err(AuthorizationError::Direct(error)) => {
assert_eq!(error.error, ErrorCode::InvalidRequestObject)
}
other => panic!("past its exp the same object is refused, got {other:?}"),
}
}
#[tokio::test]
async fn a_pushed_plain_request_does_not_satisfy_a_signed_request_object_policy() {
let key = EcdsaP256Key::generate("client-key");
let mut jar = JarConfig::new();
jar.require_signed_request_object = true;
let server = server(&key, Some(jar), Some(ParConfig::new())).await;
let challenge = oauth_as::pkce::code_challenge_s256(VERIFIER);
let refused = server
.pushed_authorization_request(
&ClientId::new("app"),
None,
&[
("response_type", "code"),
("client_id", "app"),
("redirect_uri", "https://app.example/cb"),
("scope", "read"),
("code_challenge", &challenge),
("code_challenge_method", "S256"),
],
)
.await
.expect_err("RFC 9101 s10.5 applies to a pushed request that carried no object");
assert_eq!(refused.error, ErrorCode::InvalidRequest);
}
#[tokio::test]
async fn a_crit_header_naming_an_unimplemented_extension_is_refused() {
let key = EcdsaP256Key::generate("client-key");
let server = server(&key, Some(JarConfig::new()), None).await;
let header = format!(
r#"{{"alg":"ES256","kid":"{}","crit":["b64"],"b64":false}}"#,
key.public_jwk().kid
);
let object = signed(&key, &header, &live_exp());
match server
.validate_signed_authorization_request("app", &object)
.await
{
Err(AuthorizationError::Direct(error)) => {
assert_eq!(error.error, ErrorCode::InvalidRequestObject)
}
other => {
panic!("RFC 7515 s4.1.11: an unsupported crit makes the JWS invalid, got {other:?}")
}
}
}
#[tokio::test]
async fn a_fractional_exp_in_the_past_is_still_expired() {
let key = EcdsaP256Key::generate("client-key");
let server = server(&key, Some(JarConfig::new()), None).await;
let object = signed(
&key,
&plain_header(&key),
&format!(r#","exp":{}.5"#, BASE - 3600),
);
match server
.validate_signed_authorization_request("app", &object)
.await
{
Err(AuthorizationError::Direct(error)) => {
assert_eq!(error.error, ErrorCode::InvalidRequestObject);
assert!(
error
.error_description
.unwrap_or_default()
.contains("has expired"),
"a fractional exp must be READ and found expired, not treated as absent"
);
}
other => panic!(
"an expired request object must be refused however exp is spelled, got {other:?}"
),
}
}
#[tokio::test]
async fn an_exponent_notation_exp_in_the_past_is_still_expired() {
let key = EcdsaP256Key::generate("client-key");
let server = server(&key, Some(JarConfig::new()), None).await;
let object = signed(&key, &plain_header(&key), r#","exp":1.0e9"#);
match server
.validate_signed_authorization_request("app", &object)
.await
{
Err(AuthorizationError::Direct(error)) => {
assert_eq!(error.error, ErrorCode::InvalidRequestObject);
assert!(
error
.error_description
.unwrap_or_default()
.contains("has expired"),
"an exponent-notation exp must be READ and found expired, not treated as absent"
);
}
other => panic!(
"an expired request object must be refused however exp is spelled, got {other:?}"
),
}
}
#[tokio::test]
async fn an_nbf_inside_the_clock_skew_leeway_is_accepted() {
let key = EcdsaP256Key::generate("client-key");
let server = server(&key, Some(JarConfig::new()), None).await;
let object = signed(
&key,
&plain_header(&key),
&format!(r#","nbf":{}{}"#, BASE + 5, live_exp()),
);
server
.validate_signed_authorization_request("app", &object)
.await
.expect("a client five seconds fast is inside the crate-wide 60s leeway");
}
#[tokio::test]
async fn an_nbf_far_beyond_the_leeway_is_still_refused() {
let key = EcdsaP256Key::generate("client-key");
let server = server(&key, Some(JarConfig::new()), None).await;
let object = signed(
&key,
&plain_header(&key),
&format!(r#","nbf":{}{}"#, BASE + 86_400, live_exp()),
);
match server
.validate_signed_authorization_request("app", &object)
.await
{
Err(AuthorizationError::Direct(error)) => {
assert_eq!(error.error, ErrorCode::InvalidRequestObject)
}
other => panic!("an nbf a day out is not clock skew, got {other:?}"),
}
}
#[tokio::test]
async fn a_non_numeric_exp_is_malformed_rather_than_ignored() {
let key = EcdsaP256Key::generate("client-key");
let server = server(&key, Some(JarConfig::new()), None).await;
let object = signed(&key, &plain_header(&key), r#","exp":"soon""#);
match server
.validate_signed_authorization_request("app", &object)
.await
{
Err(AuthorizationError::Direct(error)) => {
assert_eq!(error.error, ErrorCode::InvalidRequestObject);
assert!(
error
.error_description
.unwrap_or_default()
.contains("is not a NumericDate"),
"a string exp must be refused as MALFORMED, not reported as a missing exp"
);
}
other => panic!("a string exp is not a NumericDate, got {other:?}"),
}
}
#[tokio::test]
async fn the_same_object_validates_when_no_policy_forbids_it() {
let key = EcdsaP256Key::generate("client-key");
let server = server(&key, Some(JarConfig::new()), None).await;
let object = signed(&key, &plain_header(&key), &live_exp());
server
.validate_signed_authorization_request("app", &object)
.await
.expect("the fixture object must validate when nothing refuses it");
}
#[test]
fn the_fixture_encoding_is_unpadded_base64url() {
for len in 1..=3 {
let bytes = vec![b'a'; len];
assert!(
!URL_SAFE_NO_PAD.encode(&bytes).contains('='),
"{len} byte(s) must encode without padding"
);
let jws = compact_jws(&bytes, &bytes, |_| vec![0u8; len]);
assert!(
!jws.contains('='),
"the fixtures' own encoder padded a {len}-byte segment: {jws}"
);
}
}
#[tokio::test]
async fn a_request_object_at_exactly_the_ceiling_is_accepted() {
let key = EcdsaP256Key::generate("client-key");
let clock = ManualClock::at_base();
let server = server_at(&key, Some(JarConfig::new()), None, clock.clone()).await;
let object = signed(
&key,
&plain_header(&key),
&format!(r#","exp":{}"#, BASE + 300),
);
server
.validate_signed_authorization_request("app", &object)
.await
.expect(
"an object whose remaining life is EXACTLY max_request_object_lifetime must be \
accepted: the ceiling is the largest allowed lifetime, not the smallest refused one",
);
let too_long = signed(
&key,
&plain_header(&key),
&format!(r#","exp":{}"#, BASE + 301),
);
assert!(
server
.validate_signed_authorization_request("app", &too_long)
.await
.is_err(),
"one second past the ceiling must be refused, or the bound is not a bound"
);
}