#![allow(missing_docs)]
use entropy_auth::{
AuthorizeError, AuthorizeErrorCode, AuthorizeRequest, ClientAuthResult, ClientType,
CodeRedemptionDenied, CodeRedemptionVerdict, DisplayErrorReason, IdTokenBuilder,
IntrospectionResponse, JsonValue, JwtSigningAlgorithm, PkceChallenge, RefreshOutcome,
RefreshPresented, RegisteredClient, StoredAuthCode, StoredRefreshToken, Timestamp,
TokenRequestPresented, evaluate_code_redemption, evaluate_refresh, validate_authorize_request,
verify_jwt,
};
fn website_client() -> RegisteredClient {
RegisteredClient::builder("entropy_website", ClientType::Confidential)
.redirect_uri("https://entropysoftworks.com/api/auth/callback/keycloak")
.allowed_scope("openid")
.allowed_scope("profile")
.allowed_scope("email")
.require_pkce(true)
.build()
.expect("valid test client")
}
#[test]
fn full_authorization_code_round_trip() {
let client = website_client();
let client_secret = b"website-client-secret";
let pkce = PkceChallenge::generate().unwrap();
let req = AuthorizeRequest {
response_type: "code",
client_id: "entropy_website",
redirect_uri: Some("https://entropysoftworks.com/api/auth/callback/keycloak"),
scope: Some("openid profile email"),
state: Some("opaque-state"),
code_challenge: Some(pkce.challenge()),
code_challenge_method: Some("S256"),
};
let validated = validate_authorize_request(&req, Some(&client)).unwrap();
assert_eq!(validated.scope(), "openid profile email");
assert_eq!(validated.state(), Some("opaque-state"));
let bound_challenge = validated.code_challenge().unwrap().to_owned();
let now = Timestamp::from_unix_secs(1_700_000_000);
let stored = StoredAuthCode {
client_id: "entropy_website",
redirect_uri: validated.redirect_uri(),
code_challenge: Some(&bound_challenge),
require_pkce: true,
expires_at: Timestamp::from_unix_secs(1_700_000_060),
consumed: false,
};
let presented = TokenRequestPresented {
client_id: "entropy_website",
client_auth: ClientAuthResult::Authenticated,
redirect_uri: "https://entropysoftworks.com/api/auth/callback/keycloak",
code_verifier: Some(pkce.verifier()),
};
assert_eq!(
evaluate_code_redemption(&stored, &presented, now),
CodeRedemptionVerdict::Accepted,
);
let id_token = IdTokenBuilder::new(
"https://auth.entropysoftworks.com",
"user-frodo",
"entropy_website",
)
.issued_at(1_700_000_000)
.expiration(1_700_003_600)
.email("frodo@example.com", true)
.preferred_username("frodo")
.roles(["user", "developer"])
.tenant("entropy")
.sign(JwtSigningAlgorithm::Hs256, client_secret);
let (_, claims) = verify_jwt(&id_token, client_secret).unwrap();
assert_eq!(claims.iss(), Some("https://auth.entropysoftworks.com"));
assert_eq!(claims.sub(), Some("user-frodo"));
assert!(claims.validate_aud("entropy_website"));
assert_eq!(claims.exp(), Some(1_700_003_600));
}
#[test]
fn replayed_code_is_denied() {
let now = Timestamp::from_unix_secs(1_700_000_000);
let stored = StoredAuthCode {
client_id: "entropy_website",
redirect_uri: "https://entropysoftworks.com/cb",
code_challenge: None,
require_pkce: false,
expires_at: Timestamp::from_unix_secs(1_700_000_060),
consumed: true, };
let presented = TokenRequestPresented {
client_id: "entropy_website",
client_auth: ClientAuthResult::Authenticated,
redirect_uri: "https://entropysoftworks.com/cb",
code_verifier: None,
};
assert_eq!(
evaluate_code_redemption(&stored, &presented, now),
CodeRedemptionVerdict::Denied(CodeRedemptionDenied::AlreadyConsumed),
);
}
#[test]
fn unknown_client_is_display_error_not_redirect() {
let req = AuthorizeRequest {
response_type: "code",
client_id: "ghost",
redirect_uri: Some("https://attacker.example.com/cb"),
scope: Some("openid"),
state: Some("s"),
code_challenge: None,
code_challenge_method: None,
};
let err = validate_authorize_request(&req, None).unwrap_err();
assert_eq!(
err,
AuthorizeError::Display(DisplayErrorReason::UnknownClient)
);
}
#[test]
fn scope_escalation_redirects_with_state() {
let client = website_client();
let pkce = PkceChallenge::generate().unwrap();
let req = AuthorizeRequest {
response_type: "code",
client_id: "entropy_website",
redirect_uri: Some("https://entropysoftworks.com/api/auth/callback/keycloak"),
scope: Some("openid admin"),
state: Some("keep-me"),
code_challenge: Some(pkce.challenge()),
code_challenge_method: Some("S256"),
};
match validate_authorize_request(&req, Some(&client)).unwrap_err() {
AuthorizeError::Redirect(e) => {
assert_eq!(e.code(), AuthorizeErrorCode::InvalidScope);
assert_eq!(e.state(), Some("keep-me"));
assert_eq!(
e.redirect_uri(),
"https://entropysoftworks.com/api/auth/callback/keycloak"
);
}
AuthorizeError::Display(r) => panic!("expected redirect, got display: {r:?}"),
other => panic!("unexpected variant: {other:?}"),
}
}
#[test]
fn refresh_rotation_then_reuse_detection() {
let now = Timestamp::from_unix_secs(1_700_000_000);
let presented = RefreshPresented {
client_id: "entropy_website",
client_auth: ClientAuthResult::Authenticated,
};
let fresh = StoredRefreshToken {
client_id: "entropy_website",
revoked: false,
rotated: false,
expires_at: Timestamp::from_unix_secs(1_702_592_000),
};
assert_eq!(
evaluate_refresh(&fresh, &presented, now),
RefreshOutcome::Rotate
);
let rotated = StoredRefreshToken {
rotated: true,
..fresh
};
assert_eq!(
evaluate_refresh(&rotated, &presented, now),
RefreshOutcome::ReuseDetected,
);
}
#[test]
fn introspection_active_and_inactive() {
let active = IntrospectionResponse::active()
.scope("openid profile")
.client_id("entropy_website")
.subject("user-frodo")
.token_type("Bearer")
.expiration(1_700_003_600)
.to_json();
let v = JsonValue::parse(&active).unwrap();
assert_eq!(v.get_bool("active"), Some(true));
assert_eq!(v.get_str("client_id"), Some("entropy_website"));
assert_eq!(v.get_i64("exp"), Some(1_700_003_600));
assert_eq!(
IntrospectionResponse::inactive().to_json(),
r#"{"active":false}"#,
);
}