Repository navigation
Conversation
Codecov Report❌ Patch coverage is
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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
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.
Nothing depends on it.
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.
ThbltLmr
force-pushed
the
feat/keycloak
branch
from
October 29, 2026 17:41
323b520 to
172b7e5
Compare
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
admin/operator/viewerto API scopesadmin/agent/user, using theorganization_idclaim for organization access.LEGACY_USER_TOKENS_ENABLED=falserejects them and disables/login/credswith a 410./login/validatenow returns a stringsubandauth_source. Swagger authorization takes a pasted bearer token.Set
KEYCLOAK_ISSUERandKEYCLOAK_CLIENT_SECRETto enable Keycloak.KEYCLOAK_BACKCHANNEL_URLcan 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