Skip to content

feat: support private_key_jwt client authentication#718

Open
andrey-berenda wants to merge 4 commits into
marcospereirampj:masterfrom
andrey-berenda:feat/private-key-jwt
Open

feat: support private_key_jwt client authentication#718
andrey-berenda wants to merge 4 commits into
marcospereirampj:masterfrom
andrey-berenda:feat/private-key-jwt

Conversation

@andrey-berenda

@andrey-berenda andrey-berenda commented May 6, 2026

Copy link
Copy Markdown

Summary

First-class private_key_jwt (RFC 7521 / RFC 7523) support in both KeycloakOpenID and KeycloakOpenIDConnection/KeycloakAdmin.

KeycloakOpenID

  • client_id: strclient_id: str | None. Sanctions the runtime behavior required when the auth server derives client identity from the assertion's sub claim (requests already strips None-valued keys from data=).
  • New client_assertion constructor parameter — str or zero-arg Callable[[], str]. When set, the token endpoint receives client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer + client_assertion=<jwt>, and client_secret is not sent.
  • New per-call client_assertion= argument on token() for callers that mint a fresh JWT per request.

KeycloakOpenIDConnection (and therefore KeycloakAdmin)

  • client_assertion plumbed through into the underlying KeycloakOpenID, so the admin client works natively with private_key_jwt — including auto-refresh, which re-invokes a callable assertion on each mint.
  • grant_type defaults to client_credentials when only an assertion is set.
  • client_id may now be None as long as client_assertion is set.

Closes #717.

Behavior matrix

Configuration Wire body
client_secret_key only (status quo) client_secret=...
client_assertion (str) client_assertion_type=...&client_assertion=<jwt>
client_assertion (callable) callable invoked per request, same as above
Both client_secret_key and client_assertion set assertion wins, no client_secret
token(client_assertion=...) per-call assertion, client_assertion_type auto-added

Usage

# Direct token exchange.
oid = KeycloakOpenID(server_url=..., realm_name="platform",
                    client_id=None, client_assertion=signed_jwt)
oid.token(grant_type="client_credentials")

# Admin client (auto-refresh works).
admin = KeycloakAdmin(connection=KeycloakOpenIDConnection(
    server_url=..., realm_name="platform",
    client_id=None, client_assertion=signed_jwt,
))
admin.get_users()

Test plan

  • Unit tests in tests/test_keycloak_openid.py (assertion str/callable, suppression of client_secret, secret-only path unchanged, token() payload shape).
  • Unit tests in tests/test_keycloak_admin.py (assertion plumbing, callable laziness, missing-creds error).
  • Manual verification against a live Keycloak realm configured for private_key_jwt (Kubernetes ServiceAccount JWT, EKS OIDC issuer):
    • KeycloakOpenID with str / callable / per-call / client_secret_key + client_assertion co-existence — all 200 OK.
    • KeycloakAdmin via KeycloakOpenIDConnection(client_assertion=...)get_realm, get_users (384), get_clients (7) all succeed; callable invoked once per mint and re-invoked on forced re-issue.
  • CI integration tests against a stock Keycloak realm.

🤖 Generated with Claude Code

aberenda-optifino and others added 2 commits May 6, 2026 08:50
- Allow client_id=None on KeycloakOpenID (sanctions the runtime behavior
  required when the auth server derives client identity from the
  assertion's `sub` claim).
- Add `client_assertion` constructor parameter (str or zero-arg callable).
  When set, _add_secret_key emits client_assertion_type/client_assertion
  and skips client_secret. Closes marcospereirampj#717.
- Add per-call `client_assertion` parameter on token().
- Tests for instance/callable/per-call assertions, suppression of
  client_secret, and the unchanged secret path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plumbs `client_assertion` through KeycloakOpenIDConnection into the
underlying KeycloakOpenID, so KeycloakAdmin works natively with
private_key_jwt clients (including auto-refresh, which re-invokes the
configured assertion callable).

- `client_id: str` -> `client_id: str | None` on KeycloakOpenIDConnection.
- New `client_assertion: str | Callable[[], str] | None` parameter,
  forwarded to the inner KeycloakOpenID constructor.
- `grant_type` defaults to `client_credentials` when an assertion is set.
- `keycloak_openid` accessor now requires either `client_id` or
  `client_assertion` (not just `client_id`).
- Tests for assertion plumbing, callable laziness, and the missing-creds
  error path.

Verified end-to-end against a live Keycloak realm with a Kubernetes SA
JWT: get_realm/get_users/get_clients all succeed; callable assertion
is invoked once per token mint and re-invoked on forced re-issue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@andrey-berenda andrey-berenda changed the title feat(openid): support private_key_jwt client authentication feat: support private_key_jwt client authentication May 6, 2026
aberenda-optifino and others added 2 commits May 6, 2026 09:08
Resolves CI lint failures introduced by the previous two commits:
- Move `Callable` import to `collections.abc` and into a TYPE_CHECKING
  block (UP035 + TC003); both modules already use
  `from __future__ import annotations`, so it is type-only.
- Suppress S106 on three test fixtures that intentionally pass dummy
  values to `client_secret_key` to verify wire-payload behavior.
- Apply `ruff format` to the new test block.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`test_counter_part` enforces parameter parity between sync `token()` and
async `a_token()`. The previous commit added `client_assertion` only to
the sync method, which broke that invariant on Python 3.10/22.0 CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@andrey-berenda andrey-berenda marked this pull request as ready for review May 6, 2026 05:19
@andrey-berenda

Copy link
Copy Markdown
Author

Heads-up on the failing checks: the failures are limited to the test (X.Y, latest) matrix (Keycloak latest image) and are unrelated to this PR. All five new private_key_jwt tests pass on every matrix entry, including latest. The actual failures are in unrelated admin/UMA endpoints (test_clients, test_get_bruteforce_status_for_user, test_has_uma_access, test_uma_resource_sets, etc.) and look like upstream Keycloak API changes.

The same failures are present on master — the scheduled Daily check workflow on master has been red for the last three days (2026-05-12, -13, -14), so this isn't a regression introduced here.

All pinned Keycloak versions (22.0–26.0) on every Python version are green. Happy to rebase once master is green again.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support client_assertion (private_key_jwt) in KeycloakOpenID.token()

2 participants