#![cfg(all(feature = "jar", feature = "par", feature = "jwt-p256"))]
use std::time::{Duration, SystemTime};
use oauth_as::jwt::{compact_jws, EcdsaP256Key};
use oauth_as::{
AuthorizationError, AuthorizationServer, Client, ClientAuth, ClientId, Clock, ErrorCode,
GrantType, JarConfig, MemoryStorage, RegisteredRequestObjectKey, RequestObjectKeys, ScopeSet,
ServerConfig,
};
const VERIFIER: &str = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
const ISSUER: &str = "https://as.example";
const NOW: u64 = 1_700_000_000;
#[derive(Clone)]
struct FrozenClock;
impl Clock for FrozenClock {
fn now(&self) -> SystemTime {
SystemTime::UNIX_EPOCH + Duration::from_secs(NOW)
}
}
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())
}
}
async fn server(key: &EcdsaP256Key) -> AuthorizationServer<MemoryStorage, FrozenClock> {
let mut cfg = ServerConfig::new(ISSUER, "https://as.example/device");
cfg.jar = Some(Box::new(JarConfig::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(), FrozenClock)
.with_request_object_keys(Box::new(Keys(registered)));
server
.register_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,
})
.await
.unwrap();
server
}
fn request_object(key: &EcdsaP256Key, extra: serde_json::Value) -> String {
let exp = NOW + 60;
let mut claims = serde_json::json!({
"client_id": "app",
"response_type": "code",
"redirect_uri": "https://app.example/cb",
"scope": "read",
"code_challenge": oauth_as::pkce::code_challenge_s256(VERIFIER),
"code_challenge_method": "S256",
"exp": exp,
});
let object = claims.as_object_mut().expect("an object");
for (k, v) in extra.as_object().expect("extra must be an object") {
object.insert(k.clone(), v.clone());
}
let header = format!(r#"{{"alg":"ES256","kid":"{}"}}"#, key.kid());
compact_jws(
header.as_bytes(),
&serde_json::to_vec(&claims).unwrap(),
|input| key.sign_signing_input(input).unwrap(),
)
}
async fn refusal(extra: serde_json::Value) -> Option<ErrorCode> {
let key = EcdsaP256Key::generate("client-key");
let server = server(&key).await;
let object = request_object(&key, extra);
match server
.validate_signed_authorization_request("app", &object)
.await
{
Ok(_) => None,
Err(AuthorizationError::Direct(e)) => Some(e.error),
Err(AuthorizationError::Redirect(r)) => Some(r.error.error),
}
}
#[tokio::test]
async fn an_aud_array_naming_this_server_is_accepted_and_one_naming_another_is_not() {
assert_eq!(
refusal(serde_json::json!({"aud": [ISSUER]})).await,
None,
"RFC 7519 s4.1.3: an aud array naming this issuer addresses this server"
);
assert_eq!(
refusal(serde_json::json!({"aud": ["https://other.example", ISSUER]})).await,
None,
"an aud array is satisfied when ANY entry names this server"
);
assert_eq!(
refusal(serde_json::json!({"aud": ["https://other.example"]})).await,
Some(ErrorCode::InvalidRequestObject),
"an object addressed only to another AS, replayed here, is the mix-up aud exists to stop"
);
}
#[tokio::test]
async fn a_request_object_valid_from_this_instant_is_accepted() {
assert_eq!(
refusal(serde_json::json!({"nbf": NOW})).await,
None,
"RFC 7519 s4.1.5: at nbf the object has become valid, it has not stopped being valid"
);
assert_eq!(
refusal(serde_json::json!({"nbf": NOW + 59})).await,
None,
"one second inside the sixty-second skew leeway is still accepted"
);
}
#[tokio::test]
async fn a_request_object_not_yet_valid_is_refused() {
assert_eq!(
refusal(serde_json::json!({"nbf": NOW + 60})).await,
Some(ErrorCode::InvalidRequestObject),
"nbf exactly one leeway ahead is the FIRST instant the bound refuses"
);
assert_eq!(
refusal(serde_json::json!({"nbf": NOW + 3600})).await,
Some(ErrorCode::InvalidRequestObject),
"an object stamped an hour into the future is pre-minted, and is what a bound that only \
refused the exact boundary would hand back"
);
}
#[tokio::test]
async fn a_request_object_valid_since_the_past_is_accepted() {
assert_eq!(
refusal(serde_json::json!({"nbf": NOW - 300, "iat": NOW - 300})).await,
None,
"an object whose validity window opened five minutes ago is inside it"
);
}
#[test]
fn the_reason_a_registered_key_was_refused_is_the_actual_reason() {
const X: &str = "MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4";
const Y: &str = "4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM";
let bad_x = RegisteredRequestObjectKey::es256_from_jwk_coordinates(None, "not base64url!!", Y)
.expect_err("a mangled x is not a key");
let bad_y = RegisteredRequestObjectKey::es256_from_jwk_coordinates(None, X, "not base64url!!")
.expect_err("a mangled y is not a key");
let short = base64_url(&[1u8; 31]);
let narrow = RegisteredRequestObjectKey::es256_from_jwk_coordinates(None, &short, Y)
.expect_err("a 31 byte coordinate is not a key");
let wrong_form = RegisteredRequestObjectKey::es256_from_sec1(None, &[0x00; 65])
.expect_err("65 bytes that do not begin 0x04 are not an uncompressed point");
assert!(
bad_x.detail().contains('x'),
"the detail must name WHICH coordinate failed to decode, got {:?}",
bad_x.detail()
);
assert!(
bad_y.detail().contains('y'),
"the detail must name WHICH coordinate failed to decode, got {:?}",
bad_y.detail()
);
assert!(
narrow.detail().contains("32 bytes"),
"the detail must say what width was expected, got {:?}",
narrow.detail()
);
assert!(
wrong_form.detail().contains("0x04"),
"the detail must say which SEC 1 encoding was expected, got {:?}",
wrong_form.detail()
);
let details = [
bad_x.detail(),
bad_y.detail(),
narrow.detail(),
wrong_form.detail(),
];
for (i, a) in details.iter().enumerate() {
for b in details.iter().skip(i + 1) {
assert_ne!(a, b, "each refusal must be distinguishable from the others");
}
}
}
fn base64_url(bytes: &[u8]) -> String {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
URL_SAFE_NO_PAD.encode(bytes)
}