Skip to content

feat(auth): accept Keycloak access tokens next to API-issued ones - #728

Draft
ThbltLmr wants to merge 16 commits into
mainfrom
feat/keycloak
Draft

ThbltLmr wants to merge 16 commits into
mainfrom
feat/keycloak

Conversation

@ThbltLmr

@ThbltLmr ThbltLmr commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator
  • Accept Keycloak access tokens alongside API-issued tokens so platform login can migrate without changing cameras, the engine, the annotator or scripts.
  • Verify Keycloak signatures and claims locally, then introspect each authenticated request so logout or revocation takes effect on the next request. Map admin / operator / viewer to API scopes admin / agent / user, using the organization_id claim for organization access.
  • Camera tokens are unchanged. Legacy user tokens remain enabled by default; LEGACY_USER_TOKENS_ENABLED=false rejects them and disables /login/creds with a 410.
  • Invalid tokens return 401; Keycloak outages return 503 with a 10-second backoff. /login/validate now returns a string sub and auth_source. Swagger authorization takes a pasted bearer token.

Set KEYCLOAK_ISSUER and KEYCLOAK_CLIENT_SECRET to enable Keycloak. KEYCLOAK_BACKCHANNEL_URL can point to its internal Docker address.

Tests cover token validation, role mapping, legacy compatibility, revocation and outage handling with mocked Keycloak responses. Local suite: 803 passed, 1 skipped. Logout was also checked against the envdev Keycloak.

Related: pyronear/new-pyro-platform#295, pyronear/pyro-envdev#68.

Demo

Keycloak token accepted by /login/validate, then rejected after an admin logs the user out.

keycloak-api-demo.mp4

Platform login through Keycloak and authenticated API calls. The initial 401 is a platform request sent without an Authorization header, tracked in pyronear/new-pyro-platform#295.

keycloak-platform-demo.mp4

Comment thread src/tests/endpoints/test_keycloak_auth.py
Comment thread src/tests/endpoints/test_keycloak_auth.py
Comment thread src/tests/endpoints/test_keycloak_auth.py
Comment thread src/tests/endpoints/test_keycloak_auth.py Fixed
@codecov

codecov Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.23656% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.00%. Comparing base (68bde16) to head (b2f8dfd).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
src/app/core/keycloak.py 90.78% 7 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #728      +/-   ##
==========================================
+ Coverage   94.87%   95.00%   +0.13%     
==========================================
  Files          60       61       +1     
  Lines        3278     3425     +147     
==========================================
+ Hits         3110     3254     +144     
- Misses        168      171       +3     
Flag Coverage Δ
backend 95.16% <96.23%> (+0.12%) ⬆️
client 91.36% <ø> (+0.06%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

RS256 verification of Keycloak tokens needs cryptography, which PyJWT only
pulls in through its crypto extra.
A malformed token used to get a 406 and a token with a bad payload a 422.
Both are authentication failures, so they now get a 401 with the
WWW-Authenticate header, like expired tokens.
Keycloak subjects are user UUIDs, so TokenPayload.sub becomes a string.
API-issued tokens must still carry a positive numeric id, and camera-only
routes read it through the new camera_id property. Telemetry distinct ids
are now strings, which is how PostHog already stores them.
OAuth2PasswordBearer tied Swagger's Authorize button to the /login/creds
form. HTTPBearer takes any pasted bearer token, which is what Keycloak
tokens need. A missing header still answers 401 "Not authenticated".
The token header's alg picks the verification path, and each path only
accepts its own algorithm, which blocks algorithm confusion (e.g. an HS256
token signed with the realm public key).

RS256 tokens are verified locally against the realm signing keys, cached
for JWKS_CACHE_SECONDS: signature, expiry, issuer, audience and claims.
Realm roles map to API scopes (admin -> admin, operator -> agent,
viewer -> user) and the organization_id claim gives the org. No realm role
maps to camera. When the signing keys can't be fetched, the request gets a
503.

Keycloak tokens are rejected while KEYCLOAK_ISSUER is unset, so the API
behaves as before without configuration.
Once a token passes the local checks, the API asks Keycloak whether it is
still active, authenticated as its own pyro-api client. A revoked session,
a disabled user or a logout gets a 401 ("Token is no longer active.") on
the next request, even though the token hasn't expired. Tokens rejected
locally never reach Keycloak, and API-issued tokens are never introspected.

KEYCLOAK_CLIENT_SECRET is required once KEYCLOAK_ISSUER is set, and startup
fails without it. When Keycloak can't answer, the request gets a 503.
After a failed call to Keycloak (signing keys or introspection), Keycloak
tokens get an immediate 503 for KEYCLOAK_FAILURE_BACKOFF_SECONDS instead of
each waiting on the network. Without it, an outage would queue every RS256
request behind a 5s timeout and starve the threadpool that camera requests
share. Camera tokens never touch Keycloak, and a malformed token never
trips the backoff.
LEGACY_USER_TOKENS_ENABLED (default true) keeps /login/creds and the user
tokens it issued working during the migration. Set to false, /login/creds
answers 410 before checking the password, and its user tokens get a 401.
Camera tokens are always accepted.
Pass the Keycloak variables through docker-compose and list them in
.env.example and CONTRIBUTING.
Comment thread src/tests/endpoints/test_keycloak_auth.py
PyJWT raises the same PyJWKClientError for a key set it can't parse as
for an unknown key id, and lets socket errors raised while reading the
response escape. A Keycloak answering 200 with an error body got 401s
with no backoff, and a reset connection got a 500.

Translate every key set fetch failure into KeycloakUnavailableError in
a PyJWKClient subclass, where the token isn't involved yet. An unknown
key id is still a 401.
Requests decoded Keycloak tokens in the threadpool and queued on a
threading lock while one of them fetched the realm keys. A stalled
fetch held a worker thread per waiting request for up to 5s, starving
the camera requests that share the pool.

Queue on an asyncio lock instead, so only the fetching request holds a
thread.
Every authenticated request opened and closed its own client, so each
introspection paid a new TCP and TLS handshake to Keycloak. Keep one
client per process, closed with the app like the temporal client.
Two validators mapped Compose's empty values for the Keycloak settings:
one to None, one to the field default. The optional settings default to
None when blank, so the second one covers both.

env_ignore_empty would cover every setting, but defaults aren't
validated, so a blank SENTRY_DSN or POSTHOG_KEY would stay "" instead
of None.
The signing key and introspection URLs strip it, but the issuer was
compared to the iss claim as is. With a trailing slash, keys were
fetched fine and every Keycloak token got an opaque 401.

This branch has not been deployed

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant