Skip to content

Latest commit

 

History

History
106 lines (73 loc) · 18.5 KB

README.md

File metadata and controls

106 lines (73 loc) · 18.5 KB

Keycloak SDK for Python

Authentication (OIDC / OAuth2) and the Admin REST API for Keycloak behind one consistent facade, with hardened JWT validation and a full async mirror.

Part of a nine-language polyglot SDK (Java · Python · Node · Go · C# · PHP · Rust · Ruby · Kotlin) — one API surface, isomorphic across all of them: github.com/xzawed/KeyCloakSDK.

1.0.7 is on PyPI — a bare pip install keycloak-sdk resolves it. A patch release on top of 1.0.6; no public API changed. Security: the admin client now holds the access token it sends as a Bearer to one rule shared by all nine SDKs: leading and trailing SP, HTAB, CR and LF are stripped, and unless what remains is a non-empty RFC 6750 b64token (ASCII letters and digits and -._~+/, with = only at the end) the request is not sent and the call fails with KeycloakAuthError, which does not quote the token; the SDK's token providers do not cache such a token. Sync and aio now judge alike: sync used to send a leading tab, an inner space, Latin-1, C1 or NBSP as is, aio sent an inner space, and both rejected a trailing newline instead of stripping it. ⚠️ A token that ends in a newline used to be rejected and is now stripped and sent. The responses the SDK reads itself (token, introspection, JWKS and admin) are now read in full within the configured timeout, counted from when the request is sent. The timeout used to apply to each read only, so a server that trickled bytes, each just inside the read timeout, held one call for about the size cap times the read timeout (7 seconds with a 1-second setting); such a call now ends with the existing timeout error, within the deadline plus one read (exactly at the deadline on aio). The sync lane also checks that deadline on every raw read from the socket — the status line, headers, chunk-size lines, trailers and TLS records included — because with the body deadline alone, a server that trickled those held one http.client call for 7 seconds (1-second setting); it now ends at about 1.0 second with the same KeycloakTransportError. Still open: an HTTPS proxy (TLS inside TLS). A JWKS key set now expires 300 seconds after the load that fetched it finished, and an expired set is treated as a cold cache: the next validation fetches it again through the cold-load path (the same single flight and the same failure backoff) and rejects the token if that fetch fails, instead of validating with the stale keys. Until now the Python SDK gave a key set no lifetime, so it kept accepting a key the IdP had removed until a token with a new kid arrived. ⚠️ If the JWKS cannot be reached for more than 300 seconds, validation fails — by design, because the SDK cannot tell an unreachable JWKS from a key the IdP has removed. The failure backoff's jitter (×[0.5, 1.0)) was drawn again at every check, so the same failure could refuse a validation at 190 ms and send the next one to the IdP at 191 ms; it is now drawn once, when the failure happens. The backoff's exponent had no cap, so after about 1,025 consecutive failures an OverflowError kept the JWKS from ever being fetched again, even once the IdP was back (reproduced at 1,100); the exponent is now capped at 16. Fixed: this README said keycloak_sdk.aio does not block the event loop — every request is awaited, but AsyncKeycloakClient.create(config) and the first kc.admin access build the HTTP client and read the TLS trust store on the calling thread, which can take close to 1 second on Windows. The README now says so: create the client once when the application starts and reuse it. The behaviour is unchanged. Changed: ⚠️ a sync response now fails with KeycloakTransportError when it exceeds its byte budget, the body cap plus 65,536 bytes — 1,114,112 for token, introspection, logout and admin error responses, 116,736 for the JWKS, and none for an admin success body. Sync used to receive 16 MiB of trailers (on CPython versions that do not bound the number of trailers) and the tail after a gzip token body, and an admin error response with large framing came out as KeycloakNotFoundError and the like. A JWKS error message may now carry HTTP response exceeds 116736 bytes on the wire. ⚠️ An admin response — lists and raw success bodies included — must now arrive in full within the configured timeout (the response deadline above); a large list over a slow link used to be received to its end and is now a timeout error, so raise the timeout if you fetch large lists over a slow link. The Go, Rust, Node, PHP and .NET admin clients already behaved this way. ⚠️ The four KeycloakConfigError messages that were in Korean are now in English — admin API access requires client_secret (client-credentials grant) and admin access requires config (use KeycloakClient.create(config)), in the same form on aio. The exception types and cause chains are unchanged; only code that compares message strings is affected. The docstrings help() and IDEs show for the public API (what __all__ of keycloak_sdk and keycloak_sdk.aio reaches) and the comments of the two examples this README links are now in English; the code is unchanged. 1.0.0 was the first release carrying the stability guarantee: a breaking change to the public API requires a major bump.

1.0.6 was a patch release on top of 1.0.5; no public API changed. Security: the admin client read a response body to its end even after it had the result. python-keycloak sends its requests without stream, so requests (sync) and httpx (aio) read the whole body before the result is looked at: a server wrote all of the 64 MiB it attached to the 201 body of a create or the 2xx body of an update or delete, and a server that stalled after a 201's head turned a create that already had its Location into a KeycloakTransportError at the end of the read timeout (4,008–4,019 ms with a 4-second timeout). Error bodies were read whole too — a 404 with 64 MiB put 67,108,864 characters in keycloak_error (a peak of 201 MB for one call), a 65,278-byte gzip error body inflated to 64 MiB, and the tail after a gzip member was read to its end as well — sync and aio alike. The facade's write calls (create, update and delete on the five resources, sync and aio) now leave a 2xx body unread (the server wrote at most 458,754 bytes; 2–10 ms in front of a stalled server, plaintext and TLS 1.3), and an error body (3xx and above) is held only up to 1,048,576 bytes, the token-response cap: one byte more is read to see whether it goes over, the SDK itself decompresses within that cap, and nothing after the end of a compressed stream is read (a peak of 3.5 MB for one call with a 64 MiB error body). The exception class (KeycloakNotFoundError and the like) is unchanged and keycloak_error is cut at that length. A python-keycloak method called through raw still receives a 2xx body (its error body has the same cap), success bodies (get, search, lists) are not capped, the connection of a response not read to its end is discarded, and the connection of a 2xx with no body (an empty 201, a 204) is reused as before. Admin requests now ask for Accept-Encoding: gzip, deflate only — what the SDK can decompress within the cap. ⚠️ When update or delete gets a 2xx with a body instead of a 204 (python-keycloak expects only 204), the error is unchanged but its keycloak_error is empty, and against a server that sends a JSON body with a 201, realms.create no longer raises the KeycloakTransportError (python-keycloak's TypeError) it used to. validate() (sync and aio) also loaded the JWKS before it looked at the token, so on a cold cache one garbage token cost one IdP request. A token whose len() exceeds 256,000 is now rejected with TokenValidationError (token exceeds 256000 bytes — not a signature-failure subtype, so it triggers no refetch) before the JWKS is loaded or the token decoded, and the id_token check of a code exchange does the same. joserfc's limit on the payload segment (128,000 bytes by default) is raised to this cap — left at its default, it would reject a genuinely signed token within the cap (255,993 characters) as TokenSignatureError, and Python would accept different tokens from the other eight languages. The 512-byte limit on the header segment, which is decoded as JSON before the signature, is unchanged.

1.0.5 was a patch release on top of 1.0.4; no public API changed. Security: when the admin client's own token grant fails, KeycloakAdminError.keycloak_error is now None. It used to carry the token endpoint's error body, so an IdP that echoed what it received (Bad credentials: <secret> in error_description) left the admin client secret — raw, form- or percent-encoded, or base64 as client_id:secret — or the admin refresh token in that attribute (vars(e)), sync and aio alike. The type, the message (Keycloak admin error (HTTP <status>)) and status_code are unchanged, and an error from an admin REST call itself still carries its body. An access token an HTTP header cannot carry — a C0 control character other than HTAB (CR, LF and NUL included), DEL, or a character the lane's transport cannot put in a header — is now refused with KeycloakAuthError before any admin request; the facade used to report some of these as a connection failure (KeycloakTransportError) and send others to the server in Authorization. A token the SDK's admin grant receives is not cached (admin token grant returned an access token an HTTP header cannot carry (…); the next call requests a new one), and one that arrived another way — an injected KeycloakAdmin(token=…) or the raw.connection.token setter — fails as admin request not sent: …; neither message quotes the token. ⚠️ A python-keycloak method called through raw now stops on such a token with KeycloakAuthError instead of keycloak.exceptions.KeycloakConnectionError, and that error's chain no longer quotes the bearer as the one from requests or h11 did. Fixed: the sync client (KeycloakClient) no longer sends a request a second time on its own after the connection breaks. python-keycloak's session retry (max_retries=1, POST included — after a 503 with Retry-After it first slept that long) made the IdP receive token grants, introspection, logout, JWKS fetches, the admin client's creates, updates, deletes and reads, and raw actions twice, so a create could run twice and an authorization code or refresh token could be presented twice. The failure now raises the same SDK error at once (KeycloakTransportError for a transport failure). ⚠️ A call that a retry used to rescue on a connection that breaks once now fails, connection establishment included, and an adapter mounted through raw has its retry setting overwritten — retry the calls that are safe to repeat yourself. aio never sent a request twice and is unchanged.

⚠️ One breaking change since 0.1.0, and it is narrow (it landed in 0.2.0; 1.0.0 adds none): the keycloak_sdk.jwt module moved to keycloak_sdk._internal.jwt. Only code that imported JwtValidator from that path directly is affected — it was never in __all__ and appears in no quickstart. The normal validation path, kc.auth.validate(token), is unchanged. The move was structural: py.typed makes every module's signatures part of the public type surface, so a module under the top level was publishing joserfc's KeySet as part of this SDK's API.

Requirements

  • Python 3.10+
  • Ships the PEP 561 py.typed marker, so consumers can type-check with mypy too

Install

pip install keycloak-sdk

The distribution name is keycloak-sdk; the import package is keycloak_sdk.

Quickstart

from keycloak_sdk import KeycloakClient, KeycloakConfig

config = KeycloakConfig(
    server_url="https://kc.example.com",
    realm="myrealm",
    client_id="admin-cli",
    client_secret="changeme",  # load the real value from an env var / secrets manager
)

# The `with` block closes both sessions on exit — the auth session and, since the admin facade owns its own `requests.Session` pair, those too (`AdminClient.close()` is not a no-op).
with KeycloakClient.create(config) as kc:
    # 1) Issue a client-credentials token. repr(TokenSet) masks every token value.
    token = kc.auth.client_credentials_token()

    # 2) Validate it — algorithm pinning, exact iss, aud containment, mandatory exp, clock skew.
    validated = kc.auth.validate(token.access_token)
    print(f"subject={validated.subject} aud={validated.audience}")

    # 3) Admin API — admin is created lazily on first access. create() returns the new user id.
    user_id = kc.admin.users.create({"username": "alice", "enabled": True})
    users = kc.admin.users.search(first=0, max=20)

validate() expects the token's aud to contain client_id by default, but a stock realm does not put the client id into a client-credentials token. Either set expected_audience="my-api" on the config to check the audience your tokens actually carry, or add an audience mapper to the client in Keycloak (Client scopes → dedicated scope → Add mapper → Audience). expected_audience applies to access tokens only: the id_token that exchange_code(..., nonce=...) verifies must always carry client_id in aud (OIDC Core §3.1.3.7).

Async

keycloak_sdk.aio is a complete async mirror — same method names (the sync close() is aclose() here), value types, and exceptions — and every network call is awaited, so requests do not block the event loop (FastAPI and friends). Construction does block it: AsyncKeycloakClient.create(config) and the first access to kc.admin build their HTTP clients and load the TLS trust store on the calling thread, which takes a noticeable fraction of a second on Windows. Create one client when the application starts and reuse it:

from keycloak_sdk import KeycloakConfig
from keycloak_sdk.aio import AsyncKeycloakClient


async def run(config: KeycloakConfig) -> None:
    async with AsyncKeycloakClient.create(config) as kc:
        token = await kc.auth.client_credentials_token()
        validated = await kc.auth.validate(token.access_token)
        users = await kc.admin.users.search(first=0, max=20)

Among the client's methods, only authorization_url stays synchronous — it assembles a URL and needs no network.

Security defaults

The SDK replaces the unsafe library defaults rather than inheriting them:

  • Algorithm pinning — the header-supplied alg is never trusted, so alg: none and HS/RS confusion are rejected structurally: joserfc decodes against the configured allowlist, and an empty allowlist is refused at construction rather than falling back to joserfc's permissive default set.
  • Strict claim checks — exact iss match, aud containment, mandatory exp, nbf enforced when present, and a bounded clock skew.
  • DoS-safe JWKS — a key set is used for at most 300 s after its fetch completed; the next validation then fetches it again like a cold cache, and if that fetch fails the validation fails (no fallback to the old keys, even during an IdP outage). Within that age, a refetch is triggered only by an unresolved key ID and never by a bad signature, and is rate-limited to a minimum interval (jwks_min_refetch_seconds, 30s by default) — so once the key set has been fetched, no volume of forged tokens makes the SDK issue more than one forced JWKS request per interval. A failed fetch is bounded separately: consecutive failures back off exponentially (0.2s, doubling, capped at 5s, with jitter), and inside that window the SDK fails fast without contacting the IdP. So a cold cache during an IdP outage no longer turns every validation into a request (measured: 20 attempts → 1 request, down from 20; the sync and async clients share the same state machine). ⚠️ The SDK never sleeps — it returns the error immediately, so retry pacing stays the caller's decision.
  • Bounded token input — a token longer than 256,000 bytes is rejected with TokenValidationError before it is decoded and before any JWKS request.
  • Secret handling — repr() of the config and token types masks secrets and tokens as *** (no prefix leak), and TLS verification is on by default.

Masking covers this SDK's own repr(); it cannot cover what your logging framework or a traceback does with a value you hand it. Python has no erasable string type, so the client secret lives in an ordinary str for its lifetime — masking is defence in depth, not an erasure guarantee.

Versioning and support

This SDK is 1.0 and follows SemVer: a breaking change to the public API requires a major bump. That promise is machine-backed — CI diffs this lane's public API against the previously published artifact on every build (griffe check), and a removal or an incompatible change fails the build. ⚠️ The gate compares the API surface. A change that leaves the surface identical but alters behaviour is not caught by it, so read the release notes before upgrading.

Only the newest released version of each language SDK receives security fixes; there are no long-term-support lines and older releases are not backported to.

Each of the nine languages versions independently. All nine reached 1.0.0 in the same release wave because they earned the same guarantee at the same time — they do not move in lockstep afterwards.

Documentation

License

Apache-2.0