lpotthast / axum-keycloak-auth

Protect axum routes with a JWT emitted by Keycloak.
https://crates.io/crates/axum-keycloak-auth
Apache License 2.0
31 stars 12 forks source link

Request with invalid token triggers keycloak server request and needs more time #16

Open Tockra opened 5 months ago

Tockra commented 5 months ago

Reproduce:

  1. Take a JWT token that is no longer valid or a JWT token you received from a different service/keycloak server.
  2. Make a protected request to your Rust backend.
  3. Notice that each request to your backend triggers a request to Keycloak:
    [2024-02-21T14:22:18Z DEBUG axum_keycloak_auth::decode] Decoded JWT header jwt_header=Header { typ: Some("JWT"), alg: RS256, cty: None, jku: None, jwk: None, kid: Some("a1818f44894225f461d22b56084720371760f59b"), x5u: None, x5c: None, x5t: None, x5t_s256: None }
    [2024-02-21T14:22:18Z INFO axum_keycloak_auth::instance] perform_oidc_discovery; kc_instance_id=018dcbe7-ab89-7903-9905-ad48a07b42cb kc_server="http://localhost:8888/" kc_realm="test-company" oidc_discovery_endpoint="http://localhost:8888/realms/test-company/.well-known/openid-configuration"
    [2024-02-21T14:22:18Z INFO axum_keycloak_auth::instance] Starting OIDC discovery.
    [2024-02-21T14:22:18Z DEBUG try_again] retry_async; retry_strategy=Retry { max_tries: 5, delay: Some(Static { delay: 1s }) } delay_strategy=TokioSleep
    [2024-02-21T14:22:18Z DEBUG reqwest::connect] starting new connection: http://localhost:8888/
    [2024-02-21T14:22:18Z DEBUG hyper::client::connect::dns] resolving host="localhost"
    [2024-02-21T14:22:18Z DEBUG hyper::proto::h1::io] flushed 105 bytes
    [2024-02-21T14:22:18Z DEBUG hyper::proto::h1::io] parsed 8 headers
    [2024-02-21T14:22:18Z DEBUG hyper::proto::h1::conn] incoming body is content-length (6053 bytes)
    [2024-02-21T14:22:18Z DEBUG hyper::proto::h1::conn] incoming body completed
    [2024-02-21T14:22:18Z DEBUG hyper::client::pool] pooling idle connection for ("http", localhost:8888)
    [2024-02-21T14:22:18Z DEBUG try_again] retry_async; retry_strategy=Retry { max_tries: 5, delay: Some(Static { delay: 1s }) } delay_strategy=TokioSleep
    [2024-02-21T14:22:18Z DEBUG reqwest::connect] starting new connection: http://localhost:8888/
    [2024-02-21T14:22:18Z DEBUG hyper::client::connect::dns] resolving host="localhost"
    [2024-02-21T14:22:18Z DEBUG hyper::proto::h1::io] flushed 102 bytes
    [2024-02-21T14:22:18Z DEBUG hyper::proto::h1::io] parsed 8 headers
    [2024-02-21T14:22:18Z DEBUG hyper::proto::h1::conn] incoming body is content-length (2949 bytes)
    [2024-02-21T14:22:18Z DEBUG hyper::proto::h1::conn] incoming body completed
    [2024-02-21T14:22:18Z DEBUG hyper::client::pool] pooling idle connection for ("http", localhost:8888)
    [2024-02-21T14:22:18Z INFO axum_keycloak_auth::instance] Received new jwk_set containing 2 keys.

Additionally, you can measure this behavior: On my local setup, a successful token request without OIDC discovery takes 5ms, but an unsuccessful request takes 24ms. If the Keycloak server is running on a slower machine elsewhere in the network, there could be a longer response time.

I don't think this implementation is optimal (or is it mandated by some standard?). The advantage of JWT token validation in your backend is that there's no need to interact with the Keycloak server after the initial public key exchange. You can simply inspect the JWT token, check the auth_time, the entire content, and the signature (to verify if the token is signed by the public key of our server). The downside of this solution is that you won't know if the server has revoked your token or if the server has revoked some roles. The roles in your token will remain the same until it expires according to auth_time. Updating the public key does not address this issue. There are different mechanisms to "solve" these issues, each with their own downsides.

The only reason to request a new public key would be if our server rotates its RSA keys used for JWT signing, but for this scenario, we should provide a different solution.

I would like to hear your opinions.

lmm-git commented 4 months ago

Another crate jwt-authorizer aiming at a similar use-case as yours solves this issue by refreshing JWKS only if the used key is not included and/or after a certain time (see the code for reference)

I think just refreshing JWKS after a certain time should be sufficient, as Keycloak supports rotating the keys manually and slowly. The only time this might be an issue is when private keys got leaked and thus they have to get revoked. Therefore, I would propose not to set a too high default (maybe every 2-5 minutes or so, like in the same range as Access Tokens). This should ensure that there is only a minimal load on the IdP and the revocation of a signing key is reasonable fast.

lpotthast commented 4 months ago

Hi, I had this in mind when changing this library to discover the public keys. Performing the discovery request on every failure would provide a DoS attack vector to the underlying Keycloak instance. As we delay the request in flight wich could not be validated to then try to re-validate it and hopefully not drop/reject it, this requests time will increase, potentially making the call to the IDM server visible to callers of a protected service. Giving users a configurable throttle duration for this is something I always wanted to add. Thanks @lmm-git for the reference. Looks very interesting! I will take a look at it.