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 {
resource: Vec::new(),
#[cfg(feature = "rar")]
authorization_details: Default::default(),
response_type: Some("code".into()),
client_id: Some("public-app".into()),
redirect_uri: Some(PUBLIC_REDIRECT.into()),
scope: Some("read write".into()),
state: Some("opaque-state".into()),
code_challenge: Some(challenge.to_string().into()),
code_challenge_method: Some("S256".into()),
#[cfg(feature = "consent")]
acr_values: None,
#[cfg(feature = "consent")]
max_age: None,
}
}
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('#') || location.contains("%23"),
"an unencoded fragment marker would truncate the query"
);
}
#[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");
}
}