Backend (main.py + auth.py): - Validate Bearer JWT against the intense-sale realm JWKS (RS256, issuer checked, aud skipped). Missing/invalid token = anonymous, never an error, so the public map keeps working when Keycloak is down. - Anonymous truncation: /api/meta hides brand/manager/status facets; /api/bookings blanks brand/company_name/manager/task_id/task_status (bars stay, one neutral colour); /api/map keeps occupied_now but empties booking details. Hidden facet filters are ignored for anonymous callers. - boards stay visible to all (physical inventory: address/city/dimension). Frontend (keycloak-js): - Silent SSO (check-sso) — anonymous by default, Войти/Выйти button. - Bearer token attached + refreshed on every API call. - Anonymous UI hides Brand/Manager/Status filters, Brand/Company toggles, appearance menu, collisions and labels; board detail panel (Planfix deep-links) gated to managers. Test manager: user / 1234567890 (realm role `manager`).
60 lines
2.3 KiB
Python
60 lines
2.3 KiB
Python
"""Keycloak JWT validation for role-based response truncation.
|
|
|
|
The map dashboard has two tiers:
|
|
* anonymous — no token (or an invalid/expired one). Sees a truncated view.
|
|
* manager — a valid Keycloak access token carrying the realm role `manager`.
|
|
Sees the full data.
|
|
|
|
Design rule: a MISSING or INVALID token is NOT an error — it simply means the
|
|
caller is anonymous. So role_from_authorization() never raises; the worst case
|
|
degrades to the anonymous tier. This keeps the public map working even when
|
|
Keycloak is unreachable or a token has expired.
|
|
|
|
Validation checks the signature (RS256, keys fetched from the realm JWKS with
|
|
in-process caching + automatic rotation on unknown kid) and the issuer. The
|
|
access token's audience is `account` (Keycloak default), so we intentionally do
|
|
NOT verify `aud`; authorization is decided by the presence of the realm role.
|
|
"""
|
|
|
|
import os
|
|
|
|
import jwt
|
|
from jwt import PyJWKClient
|
|
|
|
# Public realm URL — matches the `iss` Keycloak stamps into the token, so issuer
|
|
# validation lines up without extra config. Overridable via env for other envs.
|
|
KC_REALM_URL = os.environ.get(
|
|
"KC_REALM_URL", "https://keycloak.intense-sale.ru/realms/intense-sale"
|
|
)
|
|
KC_ISSUER = KC_REALM_URL
|
|
JWKS_URL = f"{KC_REALM_URL}/protocol/openid-connect/certs"
|
|
MANAGER_ROLE = "manager"
|
|
|
|
# PyJWKClient caches signing keys and re-fetches when it sees an unknown `kid`
|
|
# (key rotation), so we build it once at import time.
|
|
_jwk_client = PyJWKClient(JWKS_URL)
|
|
|
|
|
|
def role_from_authorization(authorization: str | None) -> str:
|
|
"""Return "manager" for a valid token with the manager role, else "anonymous".
|
|
|
|
Never raises: any problem (no header, wrong scheme, bad signature, expired,
|
|
JWKS fetch failure) falls through to "anonymous".
|
|
"""
|
|
if not authorization or not authorization.lower().startswith("bearer "):
|
|
return "anonymous"
|
|
token = authorization[7:].strip()
|
|
try:
|
|
signing_key = _jwk_client.get_signing_key_from_jwt(token)
|
|
claims = jwt.decode(
|
|
token,
|
|
signing_key.key,
|
|
algorithms=["RS256"],
|
|
issuer=KC_ISSUER,
|
|
options={"verify_aud": False},
|
|
)
|
|
except Exception:
|
|
return "anonymous"
|
|
roles = claims.get("realm_access", {}).get("roles", []) or []
|
|
return MANAGER_ROLE if MANAGER_ROLE in roles else "anonymous"
|