Keycloak OAuth2/OIDC Integration Tests: Design
Goal
Add real, protocol-compliant integration tests for every OpenAPI
authentication method oapi.client.Client actually implements
client-side behavior for, using a real Keycloak instance (via docker
compose) rather than a fake/hand-rolled token endpoint -- catching
real protocol-compliance gaps a lenient fake server can't.
Scope: which auth methods need Keycloak, and which don't
OpenAPI defines four security scheme types: apiKey, http (basic/
bearer), oauth2 (four flows: authorizationCode, implicit,
password, clientCredentials), and openIdConnect. Of these:
apiKey(header/query/cookie) andhttp(basic/bearer) need no real server.Client's handling of these is pure value placement (a header, a query parameter, a cookie, anAuthorization: Basic .../Bearer ...header) -- there is no protocol exchange to get wrong. These are already fully covered by the existinghttp_test_server-backed tests intests/test_client.py(real HTTP requests, real header/query/cookie inspection).oauth2'sauthorizationCodeandimplicitflows are not implemented client-side at all.Client._oauth2_authenticate_requestonly ever emits awarnings.warn(...)for these two ("not currently implemented" / "requires the client be initialized with..."). There is no request-building code to integration-test -- this is already covered bytests/test_client.py'stest_oauth2_authenticate_request_warns_for_unconfigured_flows.oauth2'spasswordandclientCredentialsgrants, andopenIdConnectdiscovery, are exactly where a real server adds value.Clientbuilds and sends real token-request bodies for these; the existing tests (tests/test_client.py's oauth2-flow section) already validate this against a fake token endpoint (http_test_serverreturning a canned JSON response) -- which provesClient's own logic (caching, retry-on-redirect,scopehandling, the realtimeout=0bug already documented) but cannot prove the wire formatClientsends is something a real, spec-compliant OAuth2/OIDC server will actually accept.
This plan therefore targets exactly three flows against a real Keycloak: password grant, client-credentials grant, and OIDC discovery (which itself just derives the token URL, then delegates to whichever grant is configured).
What was found by hand-validating this before writing a plan
Every claim below was verified live against a real, running Keycloak
26.0 instance (quay.io/keycloak/keycloak:26.0, docker compose up -d
--wait), not assumed:
oapi's wire format is protocol-compliant for both grants -- this is a real, valuable negative result, not a bug report. Neither grant needed any workaround onClient's side.- Password grant (
_request_oauth2_password_authorization) never sends aclient_secret-- onlyclient_id,username,password,grant_type=password. Per RFC 6749, this only works against a public client (or one that otherwise doesn't require client authentication). Verified: succeeds against a real Keycloak public client; a confidential client would need aclient_secretClientnever sends (not tested here, sinceClientgenuinely can't do it -- worth knowing, not a bug to fix in this test-only effort). - Client-credentials grant sends
client_id/client_secretas form-body parameters, not an HTTP BasicAuthorizationheader. RFC 6749 ยง2.3.1 permits this for confidential clients, and Keycloak's default client authenticator (client-secret) accepts it. Verified: succeeds against a real Keycloak confidential client with a service account. - A real Keycloak realm-configuration gotcha, not an
oapibug: Keycloak 26's default "User Profile" feature requiresemail,firstName, andlastNameon a user for the direct-grant (password) flow to consider the account "fully set up" -- a user with only a username/password credential (no profile fields) gets a real400 invalid_grant: "Account is not fully set up"from Keycloak itself, confirmed by reproducing with rawcurl, independent ofoapi. The realm fixture (below) includes a complete profile for exactly this reason. - Negative paths produce real, informative errors: wrong password
-> real
401invalid_grant: "Invalid user credentials"; wrong client secret -> real401unauthorized_client: "Invalid client or Invalid client credentials". - Token caching works against a real
expires_in: a secondClient.request()call reuses the cachedAuthorizationheader (verified viaClient.headers["Authorization"]equality across two real requests), matching the existing fake-server-backed test but now against a real token lifetime. - A full end-to-end path works: a
Clientconfigured for the client-credentials flow, givenoauth2_scope="openid", correctly authenticates a real request against Keycloak's own/userinfoendpoint (used as a stand-in protected resource) -- proving the token isn't just successfully parsed, but genuinely accepted by a real resource server. - Keycloak startstart-up is fast:
start-dev --import-realmwith the default in-memory H2 store becomes healthy in ~9-12 seconds, confirmed repeatedly across multiple fresh container starts -- acceptable for CI. - A real Docker bind-mount gotcha, worth documenting for whoever
executes this plan: if
docker compose upever runs before the realm file exists at the expected host path, Docker silently creates an empty directory at that path (both on the host and in the container) instead of erroring -- silently breaking the realm import with no obvious error (Keycloak just starts with an empty realm, no import log line, no crash). The fix, if this happens, is torm -rfthe wrongly-created directory and recreate the real file. The plan's task order (create the real file, then first rundocker compose up) avoids ever hitting this, but it's worth knowing about if a future contributor changes the file first without realizing it might already be a stale directory.
Design decisions
- The
keycloak_urlfixture manages its own Docker Compose lifecycle -- no manualdocker compose upstep required, by contributors or by CI. The session-scoped fixture first does a real reachability check (urllib.request.urlopenagainst the realm's discovery URL, 2 second timeout). If already reachable (someone else started it), it's used as-is and left running. Otherwise, ifdockeris onPATH, the fixture runsdocker compose up -d --waititself, re-checks reachability, and tears the service back down (docker compose down) at the end of the session -- but only if this fixture was the one that started it, so it never stops an instance a developer is deliberately running for their own use. Ifdockerisn't installed, or Keycloak never becomes reachable, every test depending on the fixture is skipped rather than failed. This mirrors the existingtest_languagetoolnetwork-dependent-test pattern already in this codebase (skip via a live check, don't fail), while going one step further and self-provisioning the dependency instead of just detecting it. The fixture makes no platform assumption -- it works anywhere a workingdocker composeis onPATH, including a contributor's local macOS or Windows machine, not just Linux. Verified live, on macOS: a cold run (no containers running) auto -starts Keycloak, all 7 tests pass in ~10s total, and the container is confirmed gone afterward; a warm run (already running) reuses it in under a second and leaves it running afterward. - No CI workflow changes at all (superseding an earlier version of
this design that added a dedicated
ubuntu-latest-only job)..github/workflows/test.yml's existing cross-platformtestjob already runs the entiretests/suite across its matrix, so the new Keycloak test file runs there automatically with no extra job, no platform gate, and no explicitdocker composestep: it auto-provisions and passes onubuntu-latestlegs (Docker is present), and cleanly skips wherever Docker isn't available or compatible (macos-latest, andwindows-latestunless it happens to have a working Linux-container Docker setup). This was the simpler design once the fixture became fully self-provisioning -- a separate job would only have duplicated coverage the matrix already provides. - A new
tests/docker-compose.yml(not repo root, superseding an earlier version of this design that put it at the top level) -- it exists solely to support this test suite and isn't part of the package's runtime or distribution, so it's scoped undertests/alongside everything else that exists only for testing. Thekeycloak_urlfixture invokes it explicitly viadocker compose -f tests/docker-compose.yml ..., sodocker compose's default discovery behavior (looking for a compose file in the current directory) doesn't matter for automated use; a contributor invoking it manually needs-f tests/docker-compose.ymltoo (orcd testsfirst). - A new
tests/keycloak/realm.json(nottests/input-data/) -- the existingtests/input-data/directory holds OpenAPI documents (a specific, consistent fixture type consumed byOpenAPI(...)); a Keycloak realm export is a different kind of fixture entirely, so it gets its own directory rather than being mixed in. - A new, separate test file,
tests/test_client_keycloak_integration.py-- a deliberate, explained exception to the "one test file per source module" consolidation already done for the rest of this suite. Every test in this file exercisesClient(so by that rule alone it would belong intests/test_client.py), but it has a fundamentally different operational profile than everything else in that file: it requires an external service, is expected to skip in most environments, and is the one place in the whole suite wheredocker composeinvolvement matters. Keeping it separate makes the distinction obvious at a glance (file name,git diff, CI job mapping) without requiring anyone to know import-time skip logic exists inside a 2900-line file to understand why some tests "don't run." - No changes to any
src/oapi/*.pyfile. Every finding above is either a confirmation of correct, spec-compliant behavior, or a realm-configuration nuance -- there is nothing to fix inoapiitself as a result of this investigation.
Files this plan will add/change
tests/docker-compose.yml(new)tests/keycloak/realm.json(new)tests/conftest.py(add one new fixture,keycloak_url)tests/test_client_keycloak_integration.py(new, 7 tests)Makefile-- no changes;make testalready runs the full suite, which now includes the Keycloak tests (see design decisions above). A contributor who wants just these tests locally can runhatch test tests/test_client_keycloak_integration.py -c -vvdirectly..github/workflows/test.yml-- no changes; the existingtestmatrix job already covers this (see design decisions above)