mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-04 12:13:05 +00:00
Address the high/medium correctness findings on the OIDC/SCIM PR: - Login CSRF / session fixation: bind `state` to a Secure/HttpOnly/SameSite=Lax cookie at login and require the callback to echo it, so a code+state captured from another browser can't silently sign a victim into the attacker's account. - Require `exp` on session JWTs under AUTH_TYPE=oidc (require_exp), so an exp-less HS256 token signed with JWT_SECRET_KEY can't authenticate forever or outlive the denylist. - Denylist now keys revocation on an `iat` watermark instead of a deletable flag: a fresh login (newer iat) self-supersedes a revocation without clearing it, so sessions revoked on other devices stay revoked. Drops the login/SCIM-reactivation denylist-clearing paths (allow_user/allow_idp_sub). - Refresh: gate the disabled-account check on the post-grant identity (not just the old sub); attempt the IdP grant before consuming the refresh token and return a retryable 503 (restoring the token) on transient IdP errors instead of force-logging-out a live session. - Gate the oidc blueprint at request time on AUTH_TYPE=oidc, so non-oidc deployments cleanly 404 these routes instead of 500-ing on an unset OIDC_ISSUER (mirrors SCIM_ENABLED). - Surface revocation write failures: back-channel logout returns 502, and SCIM deactivation rolls back and returns 503, when the denylist write fails — so the IdP retries instead of recording a logout/deprovision that didn't revoke. - Back-channel logout: require `jti`, run the replay check unconditionally, and reject stale `iat` beyond the replay-cache window. - Make migration 0017 idempotent (IF NOT EXISTS) so re-apply can't wedge startup. - SCIM userName matching is case-insensitive (caseExact=false) for the list filter and create-dedup. Tests added/updated across test_oidc.py, test_scim.py, test_auth.py, test_app_routes.py and the SCIM integration test.
108 lines
3.8 KiB
Python
108 lines
3.8 KiB
Python
"""Redis-backed session denylist for OIDC revocation.
|
|
|
|
Back-channel logout and SCIM deactivation drop identifiers here; the
|
|
request path refuses any session token whose identifiers match. Each entry
|
|
stores a revocation *watermark* (a Unix timestamp): a session is denied
|
|
only when it was issued (``iat``) at or before the watermark. Storing a
|
|
watermark instead of a boolean is what lets a fresh login self-supersede a
|
|
prior revocation — its newer ``iat`` simply sits above the watermark —
|
|
without deleting the entry and thereby resurrecting still-live sessions
|
|
that were revoked on other devices.
|
|
|
|
Entries live slightly longer than ``OIDC_SESSION_LIFETIME_SECONDS`` —
|
|
every session issued at or before the watermark expires before the entry
|
|
does, so nothing needs to be stored durably.
|
|
|
|
Revocation is best-effort by design: if Redis is unreachable the check
|
|
fails open (sessions keep working) rather than taking the whole API down.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
|
|
from application.cache import get_redis_instance
|
|
from application.core.settings import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_USER_PREFIX = "oidc:deny:user:"
|
|
_SUB_PREFIX = "oidc:deny:sub:"
|
|
_SID_PREFIX = "oidc:deny:sid:"
|
|
|
|
|
|
def _ttl_seconds() -> int:
|
|
return settings.OIDC_SESSION_LIFETIME_SECONDS + 60
|
|
|
|
|
|
def _set(key: str) -> bool:
|
|
redis = get_redis_instance()
|
|
if redis is None:
|
|
logger.error("Redis unavailable — could not denylist %s", key)
|
|
return False
|
|
try:
|
|
# Store the revocation instant; existing entries are overwritten with a
|
|
# newer watermark (revoking again only ever moves it forward in time).
|
|
redis.set(key, str(int(time.time())), ex=_ttl_seconds())
|
|
return True
|
|
except Exception:
|
|
logger.error("Failed to denylist %s", key, exc_info=True)
|
|
return False
|
|
|
|
|
|
def deny_user(user_id: str) -> bool:
|
|
"""Revoke every live session of the DocsGPT user ``user_id``."""
|
|
return _set(_USER_PREFIX + user_id)
|
|
|
|
|
|
def deny_idp_sub(sub: str) -> bool:
|
|
"""Revoke sessions by IdP ``sub`` (back-channel logout tokens carry this)."""
|
|
return _set(_SUB_PREFIX + sub)
|
|
|
|
|
|
def deny_sid(sid: str) -> bool:
|
|
"""Revoke sessions of one IdP session id (``sid``-only logout tokens)."""
|
|
return _set(_SID_PREFIX + sid)
|
|
|
|
|
|
def _watermark(value) -> float:
|
|
"""Parse a stored watermark to a float; unparseable values deny everything."""
|
|
if isinstance(value, bytes):
|
|
value = value.decode("utf-8", "ignore")
|
|
try:
|
|
return float(value)
|
|
except (TypeError, ValueError):
|
|
# Corrupt/legacy entry: treat as "deny" (a watermark far in the future).
|
|
return float("inf")
|
|
|
|
|
|
def is_denied(decoded_token: dict) -> bool:
|
|
"""True when the token was issued at/before a matching revocation watermark."""
|
|
keys = []
|
|
if decoded_token.get("sub"):
|
|
keys.append(_USER_PREFIX + str(decoded_token["sub"]))
|
|
if decoded_token.get("oidc_sub"):
|
|
keys.append(_SUB_PREFIX + str(decoded_token["oidc_sub"]))
|
|
if decoded_token.get("oidc_sid"):
|
|
keys.append(_SID_PREFIX + str(decoded_token["oidc_sid"]))
|
|
if not keys:
|
|
return False
|
|
redis = get_redis_instance()
|
|
if redis is None:
|
|
return False
|
|
try:
|
|
values = redis.mget(keys)
|
|
except Exception:
|
|
logger.warning("Denylist check failed — allowing request", exc_info=True)
|
|
return False
|
|
try:
|
|
iat = float(decoded_token.get("iat"))
|
|
except (TypeError, ValueError):
|
|
# No usable issue time — if any revocation exists for this identity we
|
|
# cannot prove the token post-dates it, so deny.
|
|
return any(value is not None for value in values)
|
|
# Strict ``<``: a session issued in the same second as (or after) the
|
|
# revocation — e.g. an immediate re-login — is allowed.
|
|
return any(value is not None and iat < _watermark(value) for value in values)
|