mapdash/auth.py
aaverbitskiy b6b9c27cb0 auth: Keycloak OIDC — anonymous (truncated) vs manager (full) tiers
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`).
2026-08-14 11:05:47 +00:00

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"