mod support;
use oauth_as::server::UserApproval;
use std::time::Duration;
use oauth_as::{
AuthorizationError, AuthorizationRequest, ClientId, CodeChallengeMethod, ErrorCode,
TokenRequest,
};
use support::{
device_only_client, public_client, server_with, two_redirect_client, ManualClock,
PUBLIC_REDIRECT, RFC7636_VERIFIER, SECOND_REDIRECT,
};
fn challenge() -> String {
oauth_as::pkce::code_challenge_s256(RFC7636_VERIFIER)
}
fn good_request(challenge: &str) -> AuthorizationRequest<'static> {
AuthorizationRequest::from_pairs([
("response_type", "code".to_string()),
("client_id", "public-app".to_string()),
("redirect_uri", PUBLIC_REDIRECT.to_string()),
("scope", "read write".to_string()),
("state", "opaque-state".to_string()),
("code_challenge", challenge.to_string()),
("code_challenge_method", "S256".to_string()),
])
}
fn redeem(code: &str, verifier: &str) -> TokenRequest {
TokenRequest::AuthorizationCode {
client_id: ClientId::new("public-app"),
client_secret: None,
code: code.to_string(),
redirect_uri: Some(PUBLIC_REDIRECT.to_string()),
code_verifier: Some(verifier.to_string()),
}
}
#[tokio::test]
async fn valid_request_issues_a_code_that_redeems_once() {
let clock = ManualClock::at_epoch();
let srv = server_with(clock.clone(), vec![public_client()]).await;
let c = challenge();
let validated = srv
.validate_authorization_request(&good_request(&c))
.await
.expect("a complete, valid request must validate");
assert_eq!(validated.redirect_uri, PUBLIC_REDIRECT);
assert_eq!(validated.code_challenge_method, CodeChallengeMethod::S256);
let response = srv
.issue_authorization_code(UserApproval::granted(&validated, "user-1"))
.await
.expect("issuing a code for a validated request");
assert_eq!(
response.state.as_deref(),
Some("opaque-state"),
"RFC 6749 s4.1.2: state is echoed back unmodified"
);
assert!(!response.code.is_empty());
let issued = srv
.token(redeem(&response.code, RFC7636_VERIFIER))
.await
.expect("the code must redeem with the matching verifier");
assert!(!issued.access_token.is_empty());
assert_eq!(issued.expires_in, 3600);
assert_eq!(
issued.scope.as_deref(),
Some("read write"),
"the granted scope is reported (RFC 6749 s5.1)"
);
let introspected = srv.introspect(&issued.access_token).await.unwrap().unwrap();
assert_eq!(introspected.subject.as_deref(), Some("user-1"));
}
#[tokio::test]
async fn success_redirect_url_encodes_parameters() {
let clock = ManualClock::at_epoch();
let srv = server_with(clock, vec![public_client()]).await;
let c = challenge();
let mut req = good_request(&c);
req.state = Some("a b&c=d#e".into());
let validated = srv.validate_authorization_request(&req).await.unwrap();
let response = srv
.issue_authorization_code(UserApproval::granted(&validated, "user-1"))
.await
.unwrap();
let location = response.location(PUBLIC_REDIRECT);
assert!(location.starts_with(&format!("{PUBLIC_REDIRECT}?")));
assert!(
location.contains("state=a%20b%26c%3Dd%23e"),
"reserved characters in state must be percent-encoded, got {location}"
);
assert!(
!location.contains('#'),
"an unencoded fragment marker would truncate the query, and every reserved character in \
`state` must already be percent-encoded: {location}"
);
}
#[tokio::test]
async fn redirect_url_appends_to_an_existing_query() {
let clock = ManualClock::at_epoch();
let srv = server_with(clock, vec![public_client()]).await;
let c = challenge();
let validated = srv
.validate_authorization_request(&good_request(&c))
.await
.unwrap();
let response = srv
.issue_authorization_code(UserApproval::granted(&validated, "user-1"))
.await
.unwrap();
let location = response.location("https://app.example/cb?tenant=acme");
assert!(
location.starts_with("https://app.example/cb?tenant=acme&"),
"existing query must be preserved, got {location}"
);
}
#[tokio::test]
async fn unknown_client_does_not_redirect() {
let clock = ManualClock::at_epoch();
let srv = server_with(clock, vec![public_client()]).await;
let c = challenge();
let mut req = good_request(&c);
req.client_id = Some("no-such-client".into());
match srv.validate_authorization_request(&req).await {
Err(AuthorizationError::Direct(e)) => assert_eq!(e.error, ErrorCode::InvalidRequest),
other => panic!("an unknown client_id must not produce a redirect, got {other:?}"),
}
}
#[tokio::test]
async fn unregistered_redirect_uri_does_not_redirect() {
let clock = ManualClock::at_epoch();
let srv = server_with(clock, vec![public_client()]).await;
let c = challenge();
let mut req = good_request(&c);
req.redirect_uri = Some("https://attacker.example/steal".into());
match srv.validate_authorization_request(&req).await {
Err(AuthorizationError::Direct(_)) => {}
other => panic!("an unregistered redirect_uri must not be redirected to, got {other:?}"),
}
}
#[tokio::test]
async fn redirect_uri_matching_is_exact() {
let clock = ManualClock::at_epoch();
let srv = server_with(clock, vec![public_client()]).await;
let c = challenge();
for near_miss in [
"https://app.example/cb/",
"https://app.example/CB",
"https://app.example/cb?extra=1",
"https://app.example/cb#frag",
"http://app.example/cb",
] {
let mut req = good_request(&c);
req.redirect_uri = Some(near_miss.into());
assert!(
matches!(
srv.validate_authorization_request(&req).await,
Err(AuthorizationError::Direct(_))
),
"{near_miss} must not match the registered URI"
);
}
}
#[tokio::test]
async fn omitted_redirect_uri_uses_the_single_registration() {
let clock = ManualClock::at_epoch();
let srv = server_with(clock, vec![public_client()]).await;
let c = challenge();
let mut req = good_request(&c);
req.redirect_uri = None;
let validated = srv.validate_authorization_request(&req).await.unwrap();
assert_eq!(validated.redirect_uri, PUBLIC_REDIRECT);
}
#[tokio::test]
async fn omitted_redirect_uri_with_multiple_registrations_is_refused_without_redirecting() {
let clock = ManualClock::at_epoch();
let srv = server_with(clock, vec![two_redirect_client()]).await;
let c = challenge();
let mut req = good_request(&c);
req.client_id = Some("multi-redirect".into());
req.redirect_uri = None;
match srv.validate_authorization_request(&req).await {
Err(AuthorizationError::Direct(e)) => assert_eq!(e.error, ErrorCode::InvalidRequest),
other => panic!("ambiguous redirect target must not be guessed, got {other:?}"),
}
}
#[tokio::test]
async fn either_registered_redirect_uri_is_accepted_when_named() {
let clock = ManualClock::at_epoch();
let srv = server_with(clock, vec![two_redirect_client()]).await;
let c = challenge();
let mut req = good_request(&c);
req.client_id = Some("multi-redirect".into());
req.redirect_uri = Some(SECOND_REDIRECT.into());
req.scope = Some("read".into());
let validated = srv.validate_authorization_request(&req).await.unwrap();
assert_eq!(validated.redirect_uri, SECOND_REDIRECT);
}
async fn redirect_error(req: &AuthorizationRequest<'_>) -> ErrorCode {
let clock = ManualClock::at_epoch();
let srv = server_with(
clock,
vec![public_client(), two_redirect_client(), device_only_client()],
)
.await;
match srv.validate_authorization_request(req).await {
Err(AuthorizationError::Redirect(r)) => r.error.error,
other => panic!("expected a redirected error, got {other:?}"),
}
}
#[tokio::test]
async fn missing_pkce_challenge_is_invalid_request() {
let c = challenge();
let mut req = good_request(&c);
req.code_challenge = None;
req.code_challenge_method = None;
assert_eq!(redirect_error(&req).await, ErrorCode::InvalidRequest);
}
#[tokio::test]
async fn plain_pkce_method_is_refused() {
let c = challenge();
let mut req = good_request(&c);
req.code_challenge_method = Some("plain".into());
assert_eq!(redirect_error(&req).await, ErrorCode::InvalidRequest);
let mut req = good_request(&c);
req.code_challenge_method = Some("S512".into());
assert_eq!(redirect_error(&req).await, ErrorCode::InvalidRequest);
}
#[tokio::test]
async fn malformed_code_challenge_is_refused() {
let c = challenge();
for bad in ["", "too-short", &"a".repeat(200), "not+base64url/at=all"] {
let mut req = good_request(&c);
req.code_challenge = Some(bad.to_string().into());
assert_eq!(
redirect_error(&req).await,
ErrorCode::InvalidRequest,
"challenge {bad:?} must be refused"
);
}
}
#[tokio::test]
async fn implicit_response_type_is_unsupported() {
let c = challenge();
let mut req = good_request(&c);
req.response_type = Some("token".into());
assert_eq!(
redirect_error(&req).await,
ErrorCode::UnsupportedResponseType
);
}
#[tokio::test]
async fn missing_response_type_is_invalid_request() {
let c = challenge();
let mut req = good_request(&c);
req.response_type = None;
assert_eq!(redirect_error(&req).await, ErrorCode::InvalidRequest);
}
#[tokio::test]
async fn scope_beyond_the_registration_is_invalid_scope() {
let c = challenge();
let mut req = good_request(&c);
req.scope = Some("read write admin superuser".into());
assert_eq!(redirect_error(&req).await, ErrorCode::InvalidScope);
}
#[tokio::test]
async fn client_without_the_grant_is_unauthorized_client() {
let c = challenge();
let mut req = good_request(&c);
req.client_id = Some("device-only".into());
req.scope = Some("read".into());
assert_eq!(redirect_error(&req).await, ErrorCode::UnauthorizedClient);
}
#[tokio::test]
async fn denial_redirects_with_access_denied_and_the_state() {
let clock = ManualClock::at_epoch();
let srv = server_with(clock, vec![public_client()]).await;
let c = challenge();
let validated = srv
.validate_authorization_request(&good_request(&c))
.await
.unwrap();
let denial = validated.denied();
assert_eq!(denial.error.error, ErrorCode::AccessDenied);
assert_eq!(denial.state.as_deref(), Some("opaque-state"));
let location = denial.location();
assert!(location.starts_with(PUBLIC_REDIRECT));
assert!(location.contains("error=access_denied"));
assert!(
location.contains("state=opaque-state"),
"RFC 6749 s4.1.2.1: state is echoed on the error redirect too"
);
assert!(
!location.contains("code="),
"a denial must not carry a code"
);
}
#[tokio::test]
async fn wrong_verifier_is_invalid_grant() {
let clock = ManualClock::at_epoch();
let srv = server_with(clock, vec![public_client()]).await;
let c = challenge();
let validated = srv
.validate_authorization_request(&good_request(&c))
.await
.unwrap();
let response = srv
.issue_authorization_code(UserApproval::granted(&validated, "user-1"))
.await
.unwrap();
let err = srv
.token(redeem(&response.code, &"z".repeat(43)))
.await
.expect_err("RFC 7636 s4.6: a verifier that does not match the challenge");
assert_eq!(err.error, ErrorCode::InvalidGrant);
}
#[tokio::test]
async fn missing_verifier_is_invalid_grant() {
let clock = ManualClock::at_epoch();
let srv = server_with(clock, vec![public_client()]).await;
let c = challenge();
let validated = srv
.validate_authorization_request(&good_request(&c))
.await
.unwrap();
let response = srv
.issue_authorization_code(UserApproval::granted(&validated, "user-1"))
.await
.unwrap();
let err = srv
.token(TokenRequest::AuthorizationCode {
client_id: ClientId::new("public-app"),
client_secret: None,
code: response.code,
redirect_uri: Some(PUBLIC_REDIRECT.to_string()),
code_verifier: None,
})
.await
.expect_err("a recorded challenge makes code_verifier mandatory");
assert_eq!(err.error, ErrorCode::InvalidGrant);
}
#[tokio::test]
async fn mismatched_redirect_uri_at_redemption_is_invalid_grant() {
let clock = ManualClock::at_epoch();
let srv = server_with(clock, vec![public_client()]).await;
let c = challenge();
let validated = srv
.validate_authorization_request(&good_request(&c))
.await
.unwrap();
let response = srv
.issue_authorization_code(UserApproval::granted(&validated, "user-1"))
.await
.unwrap();
let err = srv
.token(TokenRequest::AuthorizationCode {
client_id: ClientId::new("public-app"),
client_secret: None,
code: response.code,
redirect_uri: Some("https://app.example/other".to_string()),
code_verifier: Some(RFC7636_VERIFIER.to_string()),
})
.await
.expect_err("redirect_uri must match the authorization request");
assert_eq!(err.error, ErrorCode::InvalidGrant);
}
#[tokio::test]
async fn cross_client_redemption_is_invalid_grant() {
let clock = ManualClock::at_epoch();
let srv = server_with(
clock,
vec![public_client(), two_redirect_client(), device_only_client()],
)
.await;
let c = challenge();
let validated = srv
.validate_authorization_request(&good_request(&c))
.await
.unwrap();
let response = srv
.issue_authorization_code(UserApproval::granted(&validated, "user-1"))
.await
.unwrap();
let err = srv
.token(TokenRequest::AuthorizationCode {
client_id: ClientId::new("multi-redirect"),
client_secret: None,
code: response.code,
redirect_uri: Some(PUBLIC_REDIRECT.to_string()),
code_verifier: Some(RFC7636_VERIFIER.to_string()),
})
.await
.expect_err("a code belongs to the client it was issued to");
assert_eq!(err.error, ErrorCode::InvalidGrant);
}
#[tokio::test]
async fn expired_code_is_invalid_grant() {
let clock = ManualClock::at_epoch();
let srv = server_with(clock.clone(), vec![public_client()]).await;
let c = challenge();
let validated = srv
.validate_authorization_request(&good_request(&c))
.await
.unwrap();
let response = srv
.issue_authorization_code(UserApproval::granted(&validated, "user-1"))
.await
.unwrap();
clock.advance(Duration::from_secs(61));
let err = srv
.token(redeem(&response.code, RFC7636_VERIFIER))
.await
.expect_err("the default code lifetime is 60 seconds");
assert_eq!(err.error, ErrorCode::InvalidGrant);
}
#[tokio::test]
async fn fabricated_code_is_invalid_grant() {
let clock = ManualClock::at_epoch();
let srv = server_with(clock, vec![public_client()]).await;
let err = srv
.token(redeem("not-a-real-code", RFC7636_VERIFIER))
.await
.expect_err("an unknown code");
assert_eq!(err.error, ErrorCode::InvalidGrant);
}
#[tokio::test]
async fn replayed_code_is_refused_and_revokes_what_it_minted() {
let clock = ManualClock::at_epoch();
let srv = server_with(clock, vec![public_client()]).await;
let c = challenge();
let validated = srv
.validate_authorization_request(&good_request(&c))
.await
.unwrap();
let response = srv
.issue_authorization_code(UserApproval::granted(&validated, "user-1"))
.await
.unwrap();
let issued = srv
.token(redeem(&response.code, RFC7636_VERIFIER))
.await
.unwrap();
assert!(
srv.introspect(&issued.access_token)
.await
.unwrap()
.is_some(),
"the first redemption's token is live"
);
let err = srv
.token(redeem(&response.code, RFC7636_VERIFIER))
.await
.expect_err("a code is single use");
assert_eq!(err.error, ErrorCode::InvalidGrant);
assert!(
srv.introspect(&issued.access_token)
.await
.unwrap()
.is_none(),
"replay must revoke the access token the code already minted (RFC 9700 s4.1.1)"
);
let refresh = issued.refresh_token.expect("a refresh token was issued");
let err = srv
.token(TokenRequest::RefreshToken {
client_id: ClientId::new("public-app"),
client_secret: None,
refresh_token: refresh,
scope: None,
})
.await
.expect_err("replay must revoke the refresh chain too");
assert_eq!(err.error, ErrorCode::InvalidGrant);
}
#[tokio::test]
async fn a_third_presentation_is_still_refused() {
let clock = ManualClock::at_epoch();
let srv = server_with(clock, vec![public_client()]).await;
let c = challenge();
let validated = srv
.validate_authorization_request(&good_request(&c))
.await
.unwrap();
let response = srv
.issue_authorization_code(UserApproval::granted(&validated, "user-1"))
.await
.unwrap();
srv.token(redeem(&response.code, RFC7636_VERIFIER))
.await
.unwrap();
for _ in 0..2 {
let err = srv
.token(redeem(&response.code, RFC7636_VERIFIER))
.await
.expect_err("still single use");
assert_eq!(err.error, ErrorCode::InvalidGrant);
}
}
#[tokio::test]
async fn codes_are_unpredictable() {
let clock = ManualClock::at_epoch();
let srv = server_with(clock, vec![public_client()]).await;
let c = challenge();
let mut seen = std::collections::HashSet::new();
for _ in 0..8 {
let validated = srv
.validate_authorization_request(&good_request(&c))
.await
.unwrap();
let r = srv
.issue_authorization_code(UserApproval::granted(&validated, "user-1"))
.await
.unwrap();
assert!(
r.code.len() >= 32,
"at least 128 bits of entropy, hex coded"
);
assert!(seen.insert(r.code), "codes must never repeat");
}
}
#[tokio::test]
async fn a_code_verifier_below_the_rfc_7636_minimum_is_refused() {
const SHORT: &str = "abcdef";
let short_challenge = oauth_as::pkce::code_challenge_s256(SHORT);
let clock = ManualClock::at_epoch();
let srv = server_with(clock, vec![public_client()]).await;
let req = AuthorizationRequest::from_pairs([
("response_type", "code"),
("client_id", "public-app"),
("redirect_uri", PUBLIC_REDIRECT),
("scope", "read"),
("code_challenge", short_challenge.as_str()),
("code_challenge_method", "S256"),
]);
let validated = srv.validate_authorization_request(&req).await.unwrap();
let issued = srv
.issue_authorization_code(UserApproval::granted(&validated, "user-1"))
.await
.unwrap();
let redeemed = srv
.token(TokenRequest::AuthorizationCode {
client_id: ClientId::new("public-app"),
client_secret: None,
code: issued.code,
redirect_uri: Some(PUBLIC_REDIRECT.to_string()),
code_verifier: Some(SHORT.to_string()),
})
.await;
assert_eq!(
redeemed.unwrap_err().error,
ErrorCode::InvalidGrant,
"a six character code_verifier hashed to the recorded challenge and was accepted, so the \
RFC 7636 s4.1 bound is enforced nowhere and a stolen code plus the challenge from a proxy \
log is redeemable"
);
}
#[tokio::test]
async fn a_code_from_a_request_that_omitted_redirect_uri_redeems_without_one() {
let clock = ManualClock::at_epoch();
let srv = server_with(clock, vec![public_client()]).await;
let challenge = challenge();
let validated = srv
.validate_authorization_request(&AuthorizationRequest::from_pairs([
("response_type", "code".to_string()),
("client_id", "public-app".to_string()),
("scope", "read".to_string()),
("code_challenge", challenge.clone()),
("code_challenge_method", "S256".to_string()),
]))
.await
.expect("RFC 6749 s3.1.2.3: one registered URI means the parameter may be omitted");
assert_eq!(
validated.redirect_uri, PUBLIC_REDIRECT,
"the server fills the omitted parameter from the single registration"
);
let response = srv
.issue_authorization_code(UserApproval::granted(&validated, "user-1"))
.await
.expect("the approved request mints a code");
let issued = srv
.token(TokenRequest::AuthorizationCode {
client_id: ClientId::new("public-app"),
client_secret: None,
code: response.code,
redirect_uri: None,
code_verifier: Some(RFC7636_VERIFIER.to_string()),
})
.await
.expect("RFC 6749 s4.1.3 makes the parameter conditional on the authorization request");
assert!(!issued.access_token.is_empty());
}
#[tokio::test]
async fn the_parameter_is_still_required_when_the_authorization_request_sent_one() {
let clock = ManualClock::at_epoch();
let srv = server_with(clock, vec![public_client()]).await;
let challenge = challenge();
let validated = srv
.validate_authorization_request(&good_request(&challenge))
.await
.unwrap();
let response = srv
.issue_authorization_code(UserApproval::granted(&validated, "user-1"))
.await
.unwrap();
let refused = srv
.token(TokenRequest::AuthorizationCode {
client_id: ClientId::new("public-app"),
client_secret: None,
code: response.code,
redirect_uri: None,
code_verifier: Some(RFC7636_VERIFIER.to_string()),
})
.await
.expect_err("s4.1.3 makes it REQUIRED when the authorization request included it");
assert_eq!(refused.error, ErrorCode::InvalidGrant);
let validated = srv
.validate_authorization_request(&AuthorizationRequest::from_pairs([
("response_type", "code".to_string()),
("client_id", "public-app".to_string()),
("scope", "read".to_string()),
("code_challenge", challenge.clone()),
("code_challenge_method", "S256".to_string()),
]))
.await
.unwrap();
let response = srv
.issue_authorization_code(UserApproval::granted(&validated, "user-1"))
.await
.unwrap();
let issued = srv
.token(TokenRequest::AuthorizationCode {
client_id: ClientId::new("public-app"),
client_secret: None,
code: response.code,
redirect_uri: Some(PUBLIC_REDIRECT.to_string()),
code_verifier: Some(RFC7636_VERIFIER.to_string()),
})
.await;
assert!(
issued.is_ok(),
"presenting the URI the code actually records is still a match, whichever way the record \
obtained it"
);
}
fn as_written_by_0_9_0<T: serde::Serialize + serde::de::DeserializeOwned>(
value: &T,
drop_key: &str,
) -> T {
let mut json = serde_json::to_value(value).expect("the record serializes");
assert!(
json.as_object_mut()
.expect("records serialize as JSON objects")
.remove(drop_key)
.is_some(),
"the key {drop_key} must be present to begin with, or this test removes nothing"
);
serde_json::from_value(json).expect(
"a 0.9.0 payload must still deserialize; without a serde default every record that release \
wrote becomes unreadable the moment this one starts, which is a server_error per request \
on a patch bump",
)
}
fn reprovisioned_public_app(client_id: &ClientId) -> oauth_as::Client {
oauth_as::Client {
client_id: client_id.clone(),
auth: oauth_as::ClientAuth::Public,
grant_types: vec![oauth_as::GrantType::AuthorizationCode],
redirect_uris: vec!["https://app.example/cb".to_string()],
allowed_scopes: oauth_as::ScopeSet::parse("read").unwrap(),
default_scopes: oauth_as::ScopeSet::parse("read").unwrap(),
name: None,
registration: None,
}
}
#[tokio::test]
async fn a_token_record_from_0_9_0_dates_from_the_epoch_and_stays_revoked() {
use oauth_as::store::{RevocationWindow, Storage};
use oauth_as::{IssuedToken, MemoryStorage, RefreshTokenRecord, ScopeSet};
use std::time::UNIX_EPOCH;
let revoked_at = UNIX_EPOCH + Duration::from_secs(100);
let after = revoked_at + Duration::from_secs(1);
let window = RevocationWindow {
recorded_at: revoked_at,
until: revoked_at + Duration::from_secs(100_000),
};
let client_id = ClientId::new("public-app");
let store = MemoryStorage::new();
store.delete_client(&client_id, window).await.unwrap();
store
.put_client(reprovisioned_public_app(&client_id))
.await
.unwrap();
let mut token = IssuedToken::new(
"at-1",
client_id.clone(),
Some("user-1".to_string()),
ScopeSet::parse("read").unwrap(),
after,
after + Duration::from_secs(3600),
);
token.family_id = Some("fam-1".to_string());
token.grant_established_at = after;
let control = token.clone();
let old = as_written_by_0_9_0(&token, "grant_established_at");
assert_eq!(
old.grant_established_at, UNIX_EPOCH,
"a record with no stated grant instant must date from before every barrier, not after one"
);
assert!(
store.put_token(old).await.unwrap().is_refused(),
"a 0.9.0 access token in a revoked family must stay revoked across the upgrade"
);
assert!(
!store.put_token(control).await.unwrap().is_refused(),
"the same record with an instant AFTER the revocation is admitted, so the refusal above is \
the instant comparison and not a barrier that refuses everything"
);
let store = MemoryStorage::new();
store.delete_client(&client_id, window).await.unwrap();
store
.put_client(reprovisioned_public_app(&client_id))
.await
.unwrap();
let mut chain = RefreshTokenRecord::new(
"rt-1",
client_id.clone(),
Some("user-1".to_string()),
ScopeSet::parse("read").unwrap(),
"fam-2",
);
chain.grant_established_at = after;
let control = chain.clone();
let old = as_written_by_0_9_0(&chain, "grant_established_at");
assert_eq!(old.grant_established_at, UNIX_EPOCH);
assert!(
store.put_refresh_token(old).await.unwrap().is_refused(),
"a 0.9.0 refresh chain in a revoked family must stay revoked across the upgrade"
);
assert!(
!store.put_refresh_token(control).await.unwrap().is_refused(),
"and one established after the revocation is still a new grant"
);
}
#[cfg(feature = "consent")]
#[tokio::test]
async fn a_0_9_0_authorization_code_redeems_into_nothing_when_a_withdrawal_stands() {
use oauth_as::store::{RevocationWindow, Storage};
use oauth_as::{AuthorizationCodeRecord, ConsentRecord, ScopeSet};
async fn redeem_a_code_dated(
issued_at_key_present: bool,
issued_at: std::time::SystemTime,
) -> Result<oauth_as::TokenResponse, oauth_as::ErrorResponse> {
let clock = ManualClock::at_epoch();
let now = <ManualClock as oauth_as::Clock>::now(&clock);
let revoked_at = now - Duration::from_secs(60);
let srv = server_with(clock, vec![public_client()]).await;
let consent = ConsentRecord {
consent_id: "consent-1".into(),
client_id: ClientId::new("public-app"),
subject: "user-1".into(),
scope: ScopeSet::parse("read").unwrap(),
resource: Vec::new(),
granted_at: revoked_at - Duration::from_secs(60),
authentication: None,
};
srv.store().put_consent(consent).await.unwrap();
srv.store()
.revoke_consent(
"consent-1",
RevocationWindow {
recorded_at: revoked_at,
until: now + Duration::from_secs(100_000),
},
)
.await
.unwrap();
let mut code = AuthorizationCodeRecord::new(
"code-1",
ClientId::new("public-app"),
PUBLIC_REDIRECT,
ScopeSet::parse("read").unwrap(),
"user-1",
challenge(),
now + Duration::from_secs(60),
);
code.issued_at = issued_at;
let code = if issued_at_key_present {
code
} else {
let stripped: AuthorizationCodeRecord = as_written_by_0_9_0(&code, "issued_at");
assert_eq!(
stripped.issued_at,
std::time::UNIX_EPOCH,
"a code with no stated decision instant must date from before every barrier"
);
stripped
};
srv.store().put_authorization_code(code).await.unwrap();
srv.token(TokenRequest::AuthorizationCode {
client_id: ClientId::new("public-app"),
client_secret: None,
code: "code-1".to_string(),
redirect_uri: Some(PUBLIC_REDIRECT.to_string()),
code_verifier: Some(RFC7636_VERIFIER.to_string()),
})
.await
}
let clock = ManualClock::at_epoch();
let now = <ManualClock as oauth_as::Clock>::now(&clock);
let after_the_withdrawal = now - Duration::from_secs(30);
let refused = redeem_a_code_dated(false, after_the_withdrawal)
.await
.expect_err(
"a 0.9.0 code carries no decision instant, so it dates from the epoch and the \
withdrawal still refuses what it would mint",
);
assert_eq!(refused.error, ErrorCode::InvalidGrant);
let issued = redeem_a_code_dated(true, after_the_withdrawal)
.await
.expect("a code whose decision postdates the withdrawal is a new grant and is served");
assert!(!issued.access_token.is_empty());
}
#[tokio::test]
async fn the_authorization_endpoint_is_throttled_at_validation_and_again_at_issuance() {
use oauth_as::events::{Attempt, RateLimitDecision, RateLimiter};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
struct Counting {
seen: Arc<AtomicUsize>,
allow_first: usize,
}
impl RateLimiter for Counting {
fn check(&self, attempt: Attempt<'_>) -> RateLimitDecision {
match attempt {
Attempt::AuthorizationRequest { .. } => {
let n = self.seen.fetch_add(1, Ordering::SeqCst);
if n < self.allow_first {
RateLimitDecision::Allow
} else {
RateLimitDecision::Deny
}
}
_ => RateLimitDecision::Allow,
}
}
}
let seen = Arc::new(AtomicUsize::new(0));
let srv = server_with(ManualClock::at_epoch(), vec![public_client()])
.await
.with_rate_limiter(Box::new(Counting {
seen: seen.clone(),
allow_first: 0,
}));
let c = challenge();
match srv.validate_authorization_request(&good_request(&c)).await {
Err(AuthorizationError::Direct(e)) => {
assert_eq!(e.error, ErrorCode::TemporarilyUnavailable)
}
other => {
panic!("a throttled authorization request must be refused directly, got {other:?}")
}
}
assert_eq!(
seen.load(Ordering::SeqCst),
1,
"the endpoint must consult the limiter at all"
);
let seen = Arc::new(AtomicUsize::new(0));
let srv = server_with(ManualClock::at_epoch(), vec![public_client()])
.await
.with_rate_limiter(Box::new(Counting {
seen: seen.clone(),
allow_first: 1,
}));
let validated = srv
.validate_authorization_request(&good_request(&c))
.await
.expect("the first attempt is allowed");
match srv
.issue_authorization_code(UserApproval::granted(&validated, "user-1"))
.await
{
Err(AuthorizationError::Redirect(r)) => {
assert_eq!(r.error.error, ErrorCode::TemporarilyUnavailable);
assert_eq!(r.state.as_deref(), Some("opaque-state"));
}
other => panic!("the WRITE must be charged separately from the read, got {other:?}"),
}
assert_eq!(
seen.load(Ordering::SeqCst),
2,
"issuance must be a second charge, not a free ride on the validation's"
);
let seen = Arc::new(AtomicUsize::new(0));
let srv = server_with(ManualClock::at_epoch(), vec![public_client()])
.await
.with_rate_limiter(Box::new(Counting {
seen: seen.clone(),
allow_first: 2,
}));
let validated = srv
.validate_authorization_request(&good_request(&c))
.await
.unwrap();
let response = srv
.issue_authorization_code(UserApproval::granted(&validated, "user-1"))
.await
.expect("within the budget the endpoint works exactly as it did before");
assert!(!response.code.is_empty());
}
#[tokio::test]
async fn a_redirect_uri_the_authorization_endpoint_could_never_reproduce_is_not_registerable() {
use oauth_as::{Client, ClientAuth, MemoryStorage, ScopeSet, ServerConfig, Storage};
fn client_with(redirect_uri: &str) -> Client {
Client {
client_id: ClientId::new("bad-uri-app"),
auth: ClientAuth::Public,
grant_types: vec![oauth_as::GrantType::AuthorizationCode],
redirect_uris: vec![redirect_uri.to_string()],
allowed_scopes: ScopeSet::parse("read").unwrap(),
default_scopes: ScopeSet::parse("read").unwrap(),
name: None,
registration: None,
}
}
let srv = oauth_as::AuthorizationServer::with_clock(
ServerConfig::new("https://as.example", "https://as.example/device"),
MemoryStorage::new(),
ManualClock::at_epoch(),
);
for bad in [
"https://app.example/cb?next=a b",
"https://app.example/cb#frag",
"/cb",
] {
let refused = srv
.register_client(client_with(bad))
.await
.expect_err("a redirect_uri this server can never match must not be registerable");
assert!(
refused.to_string().contains(bad),
"the operator who just supplied the value needs to see WHICH one: {refused}"
);
assert!(
srv.store()
.get_client(&ClientId::new("bad-uri-app"))
.await
.unwrap()
.is_none(),
"a refused registration must leave no row behind, or the next start-up reads it back"
);
}
let c = challenge();
match srv
.validate_authorization_request(&AuthorizationRequest::from_pairs([
("response_type", "code".to_string()),
("client_id", "bad-uri-app".to_string()),
(
"redirect_uri",
"https://app.example/cb?next=a b".to_string(),
),
("scope", "read".to_string()),
("code_challenge", c),
("code_challenge_method", "S256".to_string()),
]))
.await
{
Err(AuthorizationError::Direct(e)) => assert_eq!(e.error, ErrorCode::InvalidRequest),
other => panic!("nothing was registered, so nothing may be authorized: {other:?}"),
}
srv.register_client(client_with("https://app.example/cb"))
.await
.expect("a conforming redirect_uri is unaffected");
srv.store()
.put_client(client_with("https://app.example/cb?next=a b"))
.await
.expect("the store takes what it is given: this is the unvalidated door");
assert_eq!(
srv.store()
.get_client(&ClientId::new("bad-uri-app"))
.await
.unwrap()
.expect("written")
.redirect_uris,
vec!["https://app.example/cb?next=a b".to_string()],
"a redirect_uri no validator in this crate approved is now live"
);
}