entropy-auth 2026.7.31

Authentication and authorization for Entropy Softworks server and API projects
#![allow(missing_docs)]

//! Authorization-server (`IdP`) primitives exercised through the public API,
//! including a full authorize → code-redeem → ID-token round trip and the
//! refresh-rotation reuse-detection path.

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 {
    // Mirrors the keycloak `entropy_website` registration from the plan.
    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";

    // 1. Client builds its PKCE challenge (client-side type, reused server side).
    let pkce = PkceChallenge::generate().unwrap();

    // 2. Authorize-endpoint validation.
    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();

    // 3. (Server mints + stores a code with the bound challenge.) At the
    //    token endpoint, redemption is evaluated.
    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,
    );

    // 4. Server mints an ID token signed with the client secret (HS256).
    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);

    // 5. The relying party verifies it with the existing verify_jwt.
    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, // already redeemed
    };
    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,
    };

    // First use: valid, not yet rotated → Rotate.
    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
    );

    // Replay of the now-rotated token → ReuseDetected (revoke the family).
    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}"#,
    );
}