diff --git a/.env-template b/.env-template
index 66bbb818..eb9e5828 100644
--- a/.env-template
+++ b/.env-template
@@ -51,3 +51,13 @@ MICROSOFT_AUTHORITY=https://{tenantId}.ciamlogin.com/{tenantId}
# OIDC_USER_ID_CLAIM=sub
# OIDC_REDIRECT_URI=
# OIDC_SESSION_LIFETIME_SECONDS=28800
+# OIDC_PROVIDER_NAME=
+# OIDC_ALLOWED_GROUPS=
+# OIDC_GROUPS_CLAIM=groups
+# Add offline_access to OIDC_SCOPES for silent session renewal on IdPs that
+# require it for refresh tokens (Authentik does; Keycloak does not).
+
+# SCIM 2.0 provisioning (IdP-driven user create/deactivate at /scim/v2;
+# pair with OIDC_USER_ID_CLAIM=email so SCIM userName matches the OIDC user id)
+# SCIM_ENABLED=false
+# SCIM_TOKEN=
diff --git a/application/alembic/versions/0017_oidc_scim.py b/application/alembic/versions/0017_oidc_scim.py
new file mode 100644
index 00000000..5e2bb38b
--- /dev/null
+++ b/application/alembic/versions/0017_oidc_scim.py
@@ -0,0 +1,45 @@
+"""0017 oidc scim — users.active flag + auth_events audit table.
+
+``users.active`` backs SCIM deprovisioning: deactivated users are refused new
+OIDC sessions and their live sessions are denylisted until they expire.
+``auth_events`` is an append-only audit trail of login / logout / provisioning
+events keyed by ``user_id``.
+
+Revision ID: 0017_oidc_scim
+Revises: 0016_conversation_visibility
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+
+
+revision: str = "0017_oidc_scim"
+down_revision: Union[str, None] = "0016_conversation_visibility"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.execute("ALTER TABLE users ADD COLUMN active BOOLEAN NOT NULL DEFAULT TRUE;")
+ op.execute(
+ """
+ CREATE TABLE auth_events (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id TEXT NOT NULL,
+ event TEXT NOT NULL,
+ ip TEXT,
+ user_agent TEXT,
+ metadata JSONB NOT NULL DEFAULT '{}',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ );
+ """
+ )
+ op.execute(
+ "CREATE INDEX auth_events_user_idx ON auth_events (user_id, created_at DESC);"
+ )
+
+
+def downgrade() -> None:
+ op.execute("DROP TABLE IF EXISTS auth_events;")
+ op.execute("ALTER TABLE users DROP COLUMN IF EXISTS active;")
diff --git a/application/api/async_sse.py b/application/api/async_sse.py
index 7c4efe32..f38b217c 100644
--- a/application/api/async_sse.py
+++ b/application/api/async_sse.py
@@ -24,6 +24,7 @@ from starlette.requests import Request
from starlette.responses import JSONResponse, StreamingResponse
from starlette.routing import Route
+from application.api.oidc.denylist import is_denied as oidc_session_denied
from application.auth import handle_auth
from application.core.settings import settings
from application.events.keys import connection_counter_key
@@ -184,6 +185,10 @@ async def stream_message_events(request: Request) -> JSONResponse | StreamingRes
user_id = decoded.get("sub") if isinstance(decoded, dict) else None
if not user_id:
return _json("Authentication required", 401)
+ if settings.AUTH_TYPE == "oidc" and await anyio.to_thread.run_sync(
+ oidc_session_denied, decoded
+ ):
+ return _json("Authentication error: session revoked", 401)
message_id = request.path_params["message_id"]
if not _MESSAGE_ID_RE.match(message_id):
diff --git a/application/api/oidc/denylist.py b/application/api/oidc/denylist.py
new file mode 100644
index 00000000..4070bcf2
--- /dev/null
+++ b/application/api/oidc/denylist.py
@@ -0,0 +1,97 @@
+"""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. Entries
+live slightly longer than ``OIDC_SESSION_LIFETIME_SECONDS`` — every
+session minted before the revocation expires before its denylist 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
+
+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:
+ redis.set(key, "1", 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 allow_user(user_id: str) -> None:
+ """Clear a user-level denylist entry (SCIM reactivation)."""
+ _delete(_USER_PREFIX + user_id)
+
+
+def allow_idp_sub(sub: str) -> None:
+ """Clear an IdP-sub denylist entry (fresh login supersedes a back-channel logout)."""
+ _delete(_SUB_PREFIX + sub)
+
+
+def _delete(key: str) -> None:
+ redis = get_redis_instance()
+ if redis is None:
+ return
+ try:
+ redis.delete(key)
+ except Exception:
+ logger.warning("Failed to clear denylist key %s", key, exc_info=True)
+
+
+def is_denied(decoded_token: dict) -> bool:
+ """True when any identifier in a decoded session token is denylisted."""
+ 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:
+ return any(value is not None for value in redis.mget(keys))
+ except Exception:
+ logger.warning("Denylist check failed — allowing request", exc_info=True)
+ return False
diff --git a/application/api/oidc/provider.py b/application/api/oidc/provider.py
index d9c790f6..c3b1d664 100644
--- a/application/api/oidc/provider.py
+++ b/application/api/oidc/provider.py
@@ -1,4 +1,4 @@
-"""OIDC provider client: discovery, JWKS, code exchange, ID-token validation."""
+"""OIDC provider client: discovery, JWKS, token grants, ID/logout-token validation."""
from __future__ import annotations
@@ -8,14 +8,17 @@ import time
import requests
from jose import jwt
+from jose.exceptions import ExpiredSignatureError, JWTClaimsError
from application.core.settings import settings
logger = logging.getLogger(__name__)
DISCOVERY_TTL_SECONDS = 3600
+FORCE_REFETCH_COOLDOWN_SECONDS = 10
LEEWAY_SECONDS = 60
HTTP_TIMEOUT_SECONDS = 10
+BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout"
# Asymmetric algorithms only: a symmetric alg here would let an attacker
# forge ID tokens signed with the (public) JWKS material.
ALLOWED_ID_TOKEN_ALGS = [
@@ -25,7 +28,13 @@ ALLOWED_ID_TOKEN_ALGS = [
]
_lock = threading.Lock()
-_cache: dict = {"discovery": None, "discovery_at": 0.0, "jwks": None, "jwks_at": 0.0}
+_cache: dict = {
+ "discovery": None,
+ "discovery_at": 0.0,
+ "jwks": None,
+ "jwks_at": 0.0,
+ "jwks_force_at": 0.0,
+}
class OIDCError(Exception):
@@ -35,7 +44,9 @@ class OIDCError(Exception):
def reset_cache() -> None:
"""Clear the cached discovery document and JWKS (used by tests)."""
with _lock:
- _cache.update({"discovery": None, "discovery_at": 0.0, "jwks": None, "jwks_at": 0.0})
+ _cache.update(
+ {"discovery": None, "discovery_at": 0.0, "jwks": None, "jwks_at": 0.0, "jwks_force_at": 0.0}
+ )
def _fetch_json(url: str) -> dict:
@@ -64,12 +75,18 @@ def get_discovery() -> dict:
def get_jwks(force: bool = False) -> dict:
"""Return the IdP JWKS; ``force=True`` bypasses the cache (key rotation)."""
with _lock:
- if (
- not force
- and _cache["jwks"] is not None
+ fresh = (
+ _cache["jwks"] is not None
and time.time() - _cache["jwks_at"] < DISCOVERY_TTL_SECONDS
- ):
+ )
+ if not force and fresh:
return _cache["jwks"]
+ if force and fresh:
+ # Rate-limit forced refetches: unauthenticated callers (back-channel
+ # logout) must not be able to hammer the IdP through us.
+ if time.time() - _cache["jwks_force_at"] < FORCE_REFETCH_COOLDOWN_SECONDS:
+ return _cache["jwks"]
+ _cache["jwks_force_at"] = time.time()
jwks = _fetch_json(get_discovery()["jwks_uri"])
with _lock:
_cache["jwks"] = jwks
@@ -84,14 +101,14 @@ def _find_key(kid: str | None) -> dict | None:
return next((key for key in keys if key.get("kid") == kid), None)
-def validate_id_token(id_token: str, nonce: str) -> dict:
- """Verify the ID token's signature, iss, aud, exp, and nonce; return claims."""
+def _resolve_signing_key(token: str) -> dict:
+ """Return the JWKS key matching the token header, refetching once on unknown kid."""
try:
- header = jwt.get_unverified_header(id_token)
+ header = jwt.get_unverified_header(token)
except Exception as exc:
- raise OIDCError(f"Malformed id_token: {exc}") from exc
+ raise OIDCError(f"Malformed token: {exc}") from exc
if header.get("alg") not in ALLOWED_ID_TOKEN_ALGS:
- raise OIDCError(f"Disallowed id_token alg: {header.get('alg')}")
+ raise OIDCError(f"Disallowed token alg: {header.get('alg')}")
key = _find_key(header.get("kid"))
if key is None:
@@ -99,53 +116,137 @@ def validate_id_token(id_token: str, nonce: str) -> dict:
key = _find_key(header.get("kid"))
if key is None:
raise OIDCError("No matching key in IdP JWKS")
+ return key
+
+def _decode_verified(token: str, options: dict) -> dict:
+ """Decode ``token`` against the JWKS, retrying once if the IdP re-keyed.
+
+ A signature failure can mean the IdP replaced its signing key while
+ reusing the same kid — the kid-miss refetch never triggers then, so
+ retry once against a freshly fetched JWKS (rate-limited in get_jwks).
+ """
+ key = _resolve_signing_key(token)
+ decode_kwargs = {
+ "algorithms": ALLOWED_ID_TOKEN_ALGS,
+ "audience": settings.OIDC_CLIENT_ID,
+ # Compare against the discovery document's own issuer value —
+ # some IdPs (Authentik) use a trailing slash the operator may
+ # not have typed into OIDC_ISSUER.
+ "issuer": get_discovery()["issuer"],
+ "options": options,
+ }
try:
- claims = jwt.decode(
- id_token,
- key,
- algorithms=ALLOWED_ID_TOKEN_ALGS,
- audience=settings.OIDC_CLIENT_ID,
- # Compare against the discovery document's own issuer value —
- # some IdPs (Authentik) use a trailing slash the operator may
- # not have typed into OIDC_ISSUER.
- issuer=get_discovery()["issuer"],
- options={
- "verify_at_hash": False,
- "leeway": LEEWAY_SECONDS,
- "require_iss": True,
- "require_aud": True,
- "require_exp": True,
- "require_sub": True,
- },
- )
- except Exception as exc:
- raise OIDCError(f"id_token validation failed: {exc}") from exc
- if claims.get("nonce") != nonce:
+ return jwt.decode(token, key, **decode_kwargs)
+ except (ExpiredSignatureError, JWTClaimsError) as exc:
+ raise OIDCError(f"token validation failed: {exc}") from exc
+ except Exception:
+ get_jwks(force=True)
+ key = _find_key(jwt.get_unverified_header(token).get("kid"))
+ if key is None:
+ raise OIDCError("No matching key in IdP JWKS")
+ try:
+ return jwt.decode(token, key, **decode_kwargs)
+ except Exception as exc:
+ raise OIDCError(f"token validation failed: {exc}") from exc
+
+
+def validate_id_token(id_token: str, nonce: str | None = None) -> dict:
+ """Verify the ID token's signature, iss, aud, exp, and (when given) nonce; return claims."""
+ claims = _decode_verified(
+ id_token,
+ options={
+ "verify_at_hash": False,
+ "leeway": LEEWAY_SECONDS,
+ "require_iss": True,
+ "require_aud": True,
+ "require_exp": True,
+ "require_sub": True,
+ },
+ )
+ # Refresh-issued id_tokens carry no nonce; callers pass None to skip the check.
+ if nonce is not None and claims.get("nonce") != nonce:
raise OIDCError("nonce mismatch")
return claims
-def exchange_code(code: str, code_verifier: str, redirect_uri: str) -> dict:
- """Exchange the authorization code at the IdP token endpoint."""
- data = {
- "grant_type": "authorization_code",
- "code": code,
- "redirect_uri": redirect_uri,
- "client_id": settings.OIDC_CLIENT_ID,
- "code_verifier": code_verifier,
- }
+def validate_logout_token(logout_token: str) -> dict:
+ """Verify a back-channel logout token per OIDC Back-Channel Logout 1.0; return claims."""
+ claims = _decode_verified(
+ logout_token,
+ options={
+ "verify_at_hash": False,
+ "leeway": LEEWAY_SECONDS,
+ "require_iss": True,
+ "require_aud": True,
+ "require_iat": True,
+ "require_exp": False,
+ },
+ )
+ events = claims.get("events")
+ if not isinstance(events, dict) or BACKCHANNEL_LOGOUT_EVENT not in events:
+ raise OIDCError("logout_token missing the backchannel-logout event")
+ if "nonce" in claims:
+ raise OIDCError("logout_token must not contain a nonce")
+ if not claims.get("sub") and not claims.get("sid"):
+ raise OIDCError("logout_token must contain sub or sid")
+ return claims
+
+
+def _token_request(data: dict) -> dict:
+ """POST to the token endpoint using the discovery-advertised client auth method."""
+ discovery = get_discovery()
+ data = {**data, "client_id": settings.OIDC_CLIENT_ID}
+ post_kwargs: dict = {"data": data, "timeout": HTTP_TIMEOUT_SECONDS}
if settings.OIDC_CLIENT_SECRET:
- data["client_secret"] = settings.OIDC_CLIENT_SECRET
+ # Absent metadata means the RFC 8414 default, client_secret_basic.
+ methods = discovery.get("token_endpoint_auth_methods_supported") or ["client_secret_basic"]
+ if "client_secret_post" in methods:
+ data["client_secret"] = settings.OIDC_CLIENT_SECRET
+ else:
+ post_kwargs["auth"] = (settings.OIDC_CLIENT_ID, settings.OIDC_CLIENT_SECRET)
try:
- response = requests.post(
- get_discovery()["token_endpoint"], data=data, timeout=HTTP_TIMEOUT_SECONDS
- )
+ response = requests.post(discovery["token_endpoint"], **post_kwargs)
except requests.RequestException as exc:
- raise OIDCError(f"Token exchange request failed: {exc}") from exc
+ raise OIDCError(f"Token request failed: {exc}") from exc
if response.status_code != 200:
logger.error(
- "OIDC token exchange failed (%s): %s", response.status_code, response.text[:500]
+ "OIDC token request failed (%s): %s", response.status_code, response.text[:500]
)
- raise OIDCError(f"Token exchange returned {response.status_code}")
+ raise OIDCError(f"Token endpoint returned {response.status_code}")
+ return response.json()
+
+
+def exchange_code(code: str, code_verifier: str, redirect_uri: str) -> dict:
+ """Exchange the authorization code at the IdP token endpoint."""
+ return _token_request(
+ {
+ "grant_type": "authorization_code",
+ "code": code,
+ "redirect_uri": redirect_uri,
+ "code_verifier": code_verifier,
+ }
+ )
+
+
+def refresh_grant(refresh_token: str) -> dict:
+ """Redeem a refresh token at the IdP token endpoint."""
+ return _token_request({"grant_type": "refresh_token", "refresh_token": refresh_token})
+
+
+def fetch_userinfo(access_token: str) -> dict:
+ """Fetch claims from the IdP userinfo endpoint with a Bearer access token."""
+ endpoint = get_discovery().get("userinfo_endpoint")
+ if not endpoint:
+ raise OIDCError("No userinfo_endpoint in discovery document")
+ try:
+ response = requests.get(
+ endpoint,
+ headers={"Authorization": f"Bearer {access_token}"},
+ timeout=HTTP_TIMEOUT_SECONDS,
+ )
+ except requests.RequestException as exc:
+ raise OIDCError(f"userinfo request failed: {exc}") from exc
+ if response.status_code != 200:
+ raise OIDCError(f"userinfo endpoint returned {response.status_code}")
return response.json()
diff --git a/application/api/oidc/routes.py b/application/api/oidc/routes.py
index 3de8dcc0..6a6b6cc6 100644
--- a/application/api/oidc/routes.py
+++ b/application/api/oidc/routes.py
@@ -1,4 +1,4 @@
-"""Login, callback, and session-token endpoints for AUTH_TYPE=oidc.
+"""Login, callback, session-token, logout, and refresh endpoints for AUTH_TYPE=oidc.
Flow: the backend redirects the browser to the IdP (Authorization Code +
PKCE), validates the ID token at the callback, mints a local HS256 session
@@ -15,19 +15,26 @@ import json
import logging
import secrets
import time
+import uuid
from urllib.parse import quote, urlencode
-from flask import Blueprint, jsonify, make_response, redirect, request
+from flask import Blueprint, Response, jsonify, make_response, redirect, request
from jose import jwt
-from application.api.oidc import provider
+from application.api.oidc import denylist, provider
+from application.auth import handle_auth
from application.cache import get_redis_instance
from application.core.settings import settings
+from application.storage.db.repositories.auth_events import AuthEventsRepository
+from application.storage.db.repositories.users import UsersRepository
+from application.storage.db.session import db_readonly, db_session
logger = logging.getLogger(__name__)
STATE_TTL_SECONDS = 600
HANDOFF_TTL_SECONDS = 60
+LOGOUT_JTI_TTL_SECONDS = 600
+MAX_PICTURE_CLAIM_CHARS = 2048
def _state_key(state: str) -> str:
@@ -38,6 +45,14 @@ def _handoff_key(code: str) -> str:
return f"oidc:handoff:{code}"
+def _refresh_key(jti: str) -> str:
+ return f"oidc:refresh:{jti}"
+
+
+def _logout_jti_key(jti: str) -> str:
+ return f"oidc:bcl:jti:{jti}"
+
+
def _pkce_challenge(verifier: str) -> str:
digest = hashlib.sha256(verifier.encode("ascii")).digest()
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
@@ -56,6 +71,128 @@ def _frontend_redirect(fragment: str):
return redirect(f"{base}/#{fragment}", code=302)
+def _no_store(payload, status: int = 200) -> Response:
+ """Build a response marked non-cacheable (back-channel logout requirement)."""
+ response = make_response(payload, status)
+ response.headers["Cache-Control"] = "no-store"
+ return response
+
+
+def _allowed_groups() -> list[str]:
+ """Parse the comma-separated group allowlist; empty/unset means everyone."""
+ raw = settings.OIDC_ALLOWED_GROUPS or ""
+ return [group.strip() for group in raw.split(",") if group.strip()]
+
+
+def _claim_groups(claims: dict) -> list[str]:
+ """Read the groups claim as a list of strings; missing means no groups."""
+ value = claims.get(settings.OIDC_GROUPS_CLAIM)
+ if value is None:
+ return []
+ if isinstance(value, str):
+ return [value]
+ if isinstance(value, (list, tuple)):
+ return [str(member) for member in value]
+ return [str(value)]
+
+
+def _effective_claims(tokens: dict, claims: dict) -> dict:
+ """Merge userinfo into the id_token claims when required claims are missing."""
+ effective = dict(claims)
+ need_user_id = not effective.get(settings.OIDC_USER_ID_CLAIM)
+ need_groups = bool(_allowed_groups()) and settings.OIDC_GROUPS_CLAIM not in effective
+ if not (need_user_id or need_groups) or not tokens.get("access_token"):
+ return effective
+ try:
+ userinfo = provider.fetch_userinfo(tokens["access_token"])
+ except provider.OIDCError:
+ logger.warning("OIDC userinfo fetch failed; continuing with id_token claims", exc_info=True)
+ return effective
+ if userinfo.get("sub") != claims.get("sub"):
+ raise provider.OIDCError("userinfo sub does not match id_token sub")
+ for key, value in userinfo.items():
+ effective.setdefault(key, value)
+ return effective
+
+
+def _mint_session_token(identity: dict) -> tuple[str, str]:
+ """Mint the local HS256 session JWT for ``identity``; returns (token, jti)."""
+ now = int(time.time())
+ jti = str(uuid.uuid4())
+ payload = {
+ "sub": str(identity["sub"]),
+ "jti": jti,
+ "iat": now,
+ "exp": now + settings.OIDC_SESSION_LIFETIME_SECONDS,
+ }
+ if identity.get("oidc_sub"):
+ payload["oidc_sub"] = str(identity["oidc_sub"])
+ if identity.get("oidc_sid"):
+ payload["oidc_sid"] = str(identity["oidc_sid"])
+ for claim in ("email", "name"):
+ if identity.get(claim):
+ payload[claim] = identity[claim]
+ picture = identity.get("picture")
+ if picture and isinstance(picture, str) and len(picture) < MAX_PICTURE_CLAIM_CHARS:
+ payload["picture"] = picture
+ return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm="HS256"), jti
+
+
+def _record_login_denied(user_id: str, metadata: dict) -> None:
+ """Best-effort audit of a denied login; never raises."""
+ try:
+ with db_session() as conn:
+ AuthEventsRepository(conn).insert(
+ user_id,
+ "oidc_login_denied",
+ ip=request.remote_addr,
+ user_agent=request.headers.get("User-Agent"),
+ metadata=metadata,
+ )
+ except Exception:
+ logger.warning("Failed to record oidc_login_denied for %s", user_id, exc_info=True)
+
+
+def _gate_and_audit_login(user_id: str, effective: dict, groups: list[str]) -> bool:
+ """Reject disabled users, provision new ones, audit the login.
+
+ Returns False only when the user row was readable and marked inactive;
+ a DB outage logs an error and lets the login proceed.
+ """
+ disabled = False
+ try:
+ with db_session() as conn:
+ users = UsersRepository(conn)
+ row = users.get(user_id)
+ if row is not None and row.get("active") is False:
+ disabled = True
+ AuthEventsRepository(conn).insert(
+ user_id,
+ "oidc_login_denied",
+ ip=request.remote_addr,
+ user_agent=request.headers.get("User-Agent"),
+ metadata={"reason": "account_disabled"},
+ )
+ else:
+ if row is None:
+ users.upsert(user_id)
+ AuthEventsRepository(conn).insert(
+ user_id,
+ "oidc_login",
+ ip=request.remote_addr,
+ user_agent=request.headers.get("User-Agent"),
+ metadata={"email": effective.get("email"), "groups": groups or None},
+ )
+ except Exception:
+ logger.error(
+ "OIDC provisioning/audit failed for %s%s",
+ user_id,
+ "" if disabled else "; continuing login",
+ exc_info=True,
+ )
+ return not disabled
+
+
def oidc_login():
"""Start the Authorization Code + PKCE flow with a 302 to the IdP."""
redis = get_redis_instance()
@@ -110,25 +247,50 @@ def oidc_callback():
try:
tokens = provider.exchange_code(code, stored["code_verifier"], _redirect_uri())
claims = provider.validate_id_token(tokens["id_token"], stored["nonce"])
+ effective = _effective_claims(tokens, claims)
except (provider.OIDCError, KeyError):
logger.error("OIDC callback failed", exc_info=True)
return _frontend_redirect("oidc_error=auth_failed")
- user_id = claims.get(settings.OIDC_USER_ID_CLAIM)
+ user_id = effective.get(settings.OIDC_USER_ID_CLAIM)
if not user_id:
logger.error("OIDC id_token missing user id claim %r", settings.OIDC_USER_ID_CLAIM)
return _frontend_redirect("oidc_error=missing_claim")
+ user_id = str(user_id)
- now = int(time.time())
- payload = {
- "sub": str(user_id),
- "iat": now,
- "exp": now + settings.OIDC_SESSION_LIFETIME_SECONDS,
- }
- for claim in ("email", "name"):
- if claims.get(claim):
- payload[claim] = claims[claim]
- session_token = jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm="HS256")
+ allowed = _allowed_groups()
+ groups = _claim_groups(effective)
+ if allowed and not set(groups) & set(allowed):
+ logger.info("OIDC login denied for %s: groups %s not in allowlist", user_id, groups)
+ _record_login_denied(user_id, {"reason": "not_authorized", "groups": groups})
+ return _frontend_redirect("oidc_error=not_authorized")
+
+ if not _gate_and_audit_login(user_id, effective, groups):
+ return _frontend_redirect("oidc_error=account_disabled")
+
+ # A fresh IdP-blessed authentication supersedes session-level revocations
+ # (back-channel logout denylists the IdP sub; without this, re-login would
+ # stay blocked until the denylist TTL ran out).
+ denylist.allow_user(str(user_id))
+ denylist.allow_idp_sub(str(claims["sub"]))
+
+ session_token, jti = _mint_session_token(
+ {
+ "sub": user_id,
+ "email": effective.get("email"),
+ "name": effective.get("name"),
+ "picture": effective.get("picture"),
+ "oidc_sub": claims["sub"],
+ "oidc_sid": claims.get("sid"),
+ }
+ )
+
+ refresh_token = tokens.get("refresh_token")
+ if refresh_token:
+ try:
+ redis.set(_refresh_key(jti), refresh_token, ex=settings.OIDC_SESSION_LIFETIME_SECONDS)
+ except Exception:
+ logger.warning("Failed to store OIDC refresh token", exc_info=True)
handoff = secrets.token_urlsafe(32)
redis.set(_handoff_key(handoff), session_token, ex=HANDOFF_TTL_SECONDS, nx=True)
@@ -151,6 +313,157 @@ def oidc_token():
return jsonify({"token": token})
+def oidc_refresh():
+ """Rotate the stored IdP refresh token and mint a fresh session JWT."""
+ decoded = handle_auth(request)
+ if (
+ not isinstance(decoded, dict)
+ or "error" in decoded
+ or not decoded.get("sub")
+ or not decoded.get("jti")
+ ):
+ error = "invalid_token"
+ if isinstance(decoded, dict) and decoded.get("error") == "token_expired":
+ error = "token_expired"
+ return make_response(jsonify({"error": error}), 401)
+
+ if denylist.is_denied(decoded):
+ return make_response(jsonify({"error": "token_revoked"}), 401)
+
+ try:
+ with db_readonly() as conn:
+ row = UsersRepository(conn).get(str(decoded["sub"]))
+ except Exception:
+ logger.error("User lookup failed during OIDC refresh", exc_info=True)
+ row = None
+ if row is not None and row.get("active") is False:
+ return make_response(jsonify({"error": "account_disabled"}), 401)
+
+ redis = get_redis_instance()
+ if redis is None:
+ return make_response(jsonify({"error": "redis_unavailable"}), 503)
+ raw = redis.getdel(_refresh_key(str(decoded["jti"])))
+ if raw is None:
+ return make_response(jsonify({"error": "no_refresh_token"}), 404)
+ refresh_token = raw.decode("utf-8") if isinstance(raw, bytes) else str(raw)
+
+ try:
+ tokens = provider.refresh_grant(refresh_token)
+ except provider.OIDCError:
+ logger.warning("OIDC refresh grant failed", exc_info=True)
+ return make_response(jsonify({"error": "refresh_failed"}), 401)
+
+ identity = {
+ "sub": str(decoded["sub"]),
+ "email": decoded.get("email"),
+ "name": decoded.get("name"),
+ "picture": decoded.get("picture"),
+ "oidc_sub": decoded.get("oidc_sub"),
+ "oidc_sid": decoded.get("oidc_sid"),
+ }
+ id_token = tokens.get("id_token")
+ if id_token:
+ try:
+ claims = provider.validate_id_token(id_token, nonce=None)
+ effective = _effective_claims(tokens, claims)
+ except provider.OIDCError:
+ logger.warning("Refresh-issued id_token failed validation", exc_info=True)
+ return make_response(jsonify({"error": "refresh_failed"}), 401)
+ # Re-gate group membership on every renewal that carries fresh
+ # claims — otherwise removal from the allowlist would never bite
+ # while silent renewal keeps extending the session.
+ allowed = _allowed_groups()
+ groups = _claim_groups(effective)
+ if allowed and not (set(groups) & set(allowed)):
+ denied_user = str(effective.get(settings.OIDC_USER_ID_CLAIM) or decoded["sub"])
+ logger.info("OIDC refresh denied for %s: groups %s not allowed", denied_user, groups)
+ _record_login_denied(
+ denied_user,
+ {"reason": "not_authorized", "via": "refresh", "groups": groups},
+ )
+ return make_response(jsonify({"error": "not_authorized"}), 401)
+ user_id = effective.get(settings.OIDC_USER_ID_CLAIM)
+ if user_id:
+ identity["sub"] = str(user_id)
+ for claim in ("email", "name", "picture"):
+ if effective.get(claim):
+ identity[claim] = effective[claim]
+ identity["oidc_sub"] = effective.get("sub") or identity["oidc_sub"]
+ if effective.get("sid"):
+ identity["oidc_sid"] = effective["sid"]
+
+ new_token, new_jti = _mint_session_token(identity)
+ new_refresh = tokens.get("refresh_token") or refresh_token
+ try:
+ redis.set(_refresh_key(new_jti), new_refresh, ex=settings.OIDC_SESSION_LIFETIME_SECONDS)
+ except Exception:
+ logger.warning("Failed to store rotated OIDC refresh token", exc_info=True)
+
+ try:
+ with db_session() as conn:
+ AuthEventsRepository(conn).insert(
+ identity["sub"],
+ "oidc_refresh",
+ ip=request.remote_addr,
+ user_agent=request.headers.get("User-Agent"),
+ )
+ except Exception:
+ logger.warning("Failed to record oidc_refresh for %s", identity["sub"], exc_info=True)
+
+ return jsonify({"token": new_token})
+
+
+def oidc_backchannel_logout():
+ """Revoke sessions named by a signed IdP back-channel logout token."""
+ logout_token = request.form.get("logout_token")
+ if not logout_token:
+ body = request.get_json(silent=True)
+ if isinstance(body, dict):
+ logout_token = body.get("logout_token")
+ if not logout_token or not isinstance(logout_token, str):
+ return _no_store(jsonify({"error": "missing_logout_token"}), 400)
+
+ try:
+ claims = provider.validate_logout_token(logout_token)
+ except provider.OIDCError:
+ logger.warning("Rejected OIDC back-channel logout token", exc_info=True)
+ return _no_store(jsonify({"error": "invalid_logout_token"}), 400)
+
+ jti = claims.get("jti")
+ if jti:
+ redis = get_redis_instance()
+ if redis is not None:
+ try:
+ fresh = redis.set(_logout_jti_key(str(jti)), "1", ex=LOGOUT_JTI_TTL_SECONDS, nx=True)
+ except Exception:
+ logger.warning("Logout-token jti replay check failed; accepting token", exc_info=True)
+ fresh = True
+ if not fresh:
+ return _no_store(jsonify({"error": "invalid_logout_token"}), 400)
+
+ sub = claims.get("sub")
+ sid = claims.get("sid")
+ if sub:
+ denylist.deny_idp_sub(str(sub))
+ if sid:
+ denylist.deny_sid(str(sid))
+
+ user_id = str(sub) if sub else f"sid:{sid}"
+ try:
+ with db_session() as conn:
+ AuthEventsRepository(conn).insert(
+ user_id,
+ "backchannel_logout",
+ ip=request.remote_addr,
+ user_agent=request.headers.get("User-Agent"),
+ metadata={"sid": str(sid)} if sid else None,
+ )
+ except Exception:
+ logger.warning("Failed to record backchannel_logout for %s", user_id, exc_info=True)
+
+ return _no_store("", 200)
+
+
def oidc_logout():
"""Redirect to the IdP end-session endpoint, falling back to the frontend."""
frontend = _frontend_url()
@@ -178,6 +491,15 @@ def register(bp: Blueprint) -> None:
bp.add_url_rule(
"/api/auth/oidc/token", view_func=oidc_token, methods=["POST"], endpoint="token"
)
+ bp.add_url_rule(
+ "/api/auth/oidc/refresh", view_func=oidc_refresh, methods=["POST"], endpoint="refresh"
+ )
+ bp.add_url_rule(
+ "/api/auth/oidc/backchannel-logout",
+ view_func=oidc_backchannel_logout,
+ methods=["POST"],
+ endpoint="backchannel_logout",
+ )
bp.add_url_rule(
"/api/auth/oidc/logout", view_func=oidc_logout, methods=["GET"], endpoint="logout"
)
diff --git a/application/api/scim/__init__.py b/application/api/scim/__init__.py
new file mode 100644
index 00000000..041174cc
--- /dev/null
+++ b/application/api/scim/__init__.py
@@ -0,0 +1,11 @@
+"""Flask blueprint for SCIM 2.0 user provisioning (/scim/v2)."""
+
+from __future__ import annotations
+
+from flask import Blueprint
+
+from .routes import register as register_routes
+
+
+scim_bp = Blueprint("scim", __name__)
+register_routes(scim_bp)
diff --git a/application/api/scim/routes.py b/application/api/scim/routes.py
new file mode 100644
index 00000000..faddcbfb
--- /dev/null
+++ b/application/api/scim/routes.py
@@ -0,0 +1,432 @@
+"""SCIM 2.0 user-provisioning endpoints (RFC 7643/7644 subset for IdP clients).
+
+IdPs (Okta, Authentik, Entra) push user lifecycle into DocsGPT through
+``/scim/v2``: create users ahead of first login and deactivate them on
+offboarding. Deactivation also revokes live sessions via the Redis
+denylist; login refuses inactive users elsewhere. Only ``userName`` and
+``active`` are honored — everything else IdPs send is ignored.
+"""
+
+from __future__ import annotations
+
+import hmac
+import json
+import logging
+import re
+from typing import Any, Optional
+
+from flask import Blueprint, Response, request
+from sqlalchemy import Connection
+
+from application.api.oidc.denylist import allow_user, deny_user
+from application.core.settings import settings
+from application.storage.db.repositories.auth_events import AuthEventsRepository
+from application.storage.db.repositories.users import UsersRepository
+from application.storage.db.session import db_readonly, db_session
+
+
+logger = logging.getLogger(__name__)
+
+_SCIM_MEDIA_TYPE = "application/scim+json"
+_ERROR_URN = "urn:ietf:params:scim:api:messages:2.0:Error"
+_LIST_RESPONSE_URN = "urn:ietf:params:scim:api:messages:2.0:ListResponse"
+_USER_URN = "urn:ietf:params:scim:schemas:core:2.0:User"
+
+_DEFAULT_COUNT = 100
+_MAX_COUNT = 200
+
+# The only filter IdPs need for provisioning: exact userName lookup.
+_USERNAME_EQ_FILTER = re.compile(r'^\s*userName\s+eq\s+"([^"]*)"\s*$', re.IGNORECASE)
+
+_SERVICE_PROVIDER_CONFIG = {
+ "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
+ "patch": {"supported": True},
+ "bulk": {"supported": False},
+ "filter": {"supported": True, "maxResults": _MAX_COUNT},
+ "changePassword": {"supported": False},
+ "sort": {"supported": False},
+ "etag": {"supported": False},
+ "authenticationSchemes": [
+ {
+ "type": "oauthbearertoken",
+ "name": "Bearer Token",
+ "description": "Authorization header carrying the configured SCIM bearer token",
+ }
+ ],
+}
+
+_USER_RESOURCE_TYPE = {
+ "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
+ "id": "User",
+ "name": "User",
+ "endpoint": "/scim/v2/Users",
+ "schema": _USER_URN,
+ "meta": {"resourceType": "ResourceType", "location": "/scim/v2/ResourceTypes/User"},
+}
+
+_USER_SCHEMA = {
+ "id": _USER_URN,
+ "name": "User",
+ "description": "DocsGPT user account",
+ "attributes": [
+ {
+ "name": "userName",
+ "type": "string",
+ "multiValued": False,
+ "required": True,
+ "caseExact": True,
+ "mutability": "immutable",
+ "returned": "default",
+ "uniqueness": "server",
+ },
+ {
+ "name": "active",
+ "type": "boolean",
+ "multiValued": False,
+ "required": False,
+ "mutability": "readWrite",
+ "returned": "default",
+ },
+ ],
+ "meta": {"resourceType": "Schema", "location": f"/scim/v2/Schemas/{_USER_URN}"},
+}
+
+
+# ----------------------------------------------------------------------
+# Response helpers
+# ----------------------------------------------------------------------
+def _scim_response(payload: Optional[dict], status: int, headers: Optional[dict] = None) -> Response:
+ """Build a response with the SCIM media type; ``None`` payload means empty body."""
+ body = "" if payload is None else json.dumps(payload)
+ response = Response(body, status=status, mimetype=_SCIM_MEDIA_TYPE)
+ for key, value in (headers or {}).items():
+ response.headers[key] = value
+ return response
+
+
+def _scim_error(status: int, detail: str, scim_type: Optional[str] = None) -> Response:
+ """Build an RFC 7644 error response."""
+ payload: dict = {"schemas": [_ERROR_URN], "status": str(status), "detail": detail}
+ if scim_type:
+ payload["scimType"] = scim_type
+ return _scim_response(payload, status)
+
+
+def _static_list_response(resources: list) -> dict:
+ """Wrap fixed resources in a SCIM ListResponse."""
+ return {
+ "schemas": [_LIST_RESPONSE_URN],
+ "totalResults": len(resources),
+ "startIndex": 1,
+ "itemsPerPage": len(resources),
+ "Resources": resources,
+ }
+
+
+def _iso(value: Any) -> Optional[str]:
+ """Return an ISO-8601 string (repository rows may carry str or datetime)."""
+ if value is None:
+ return None
+ return value if isinstance(value, str) else value.isoformat()
+
+
+def _serialize_user(row: dict) -> dict:
+ """Map a ``users`` row to a SCIM User resource."""
+ pk = str(row["id"])
+ user_name = row["user_id"]
+ resource = {
+ "schemas": [_USER_URN],
+ "id": pk,
+ "userName": user_name,
+ "active": bool(row["active"]),
+ }
+ if "@" in user_name:
+ resource["emails"] = [{"value": user_name, "primary": True}]
+ resource["meta"] = {
+ "resourceType": "User",
+ "created": _iso(row.get("created_at")),
+ "lastModified": _iso(row.get("updated_at")),
+ "location": f"/scim/v2/Users/{pk}",
+ }
+ return resource
+
+
+# ----------------------------------------------------------------------
+# Request parsing helpers
+# ----------------------------------------------------------------------
+def _coerce_active(value: Any) -> Optional[bool]:
+ """Coerce a SCIM ``active`` value to bool; ``None`` when invalid (Okta sends strings)."""
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, str) and value.lower() in ("true", "false"):
+ return value.lower() == "true"
+ return None
+
+
+def _int_arg(name: str, default: int) -> int:
+ """Read an integer query parameter, falling back to ``default``."""
+ raw = request.args.get(name)
+ if raw is None:
+ return default
+ try:
+ return int(raw)
+ except ValueError:
+ return default
+
+
+def _parse_filter(raw: Optional[str]) -> tuple[Optional[str], Optional[Response]]:
+ """Parse the ``filter`` query param; only ``userName eq "value"`` is supported."""
+ if raw is None or not raw.strip():
+ return None, None
+ match = _USERNAME_EQ_FILTER.match(raw)
+ if match is None:
+ return None, _scim_error(400, 'Only the filter userName eq "value" is supported', "invalidFilter")
+ return match.group(1), None
+
+
+# ----------------------------------------------------------------------
+# Side effects
+# ----------------------------------------------------------------------
+def _audit(conn: Connection, user_id: str, event: str) -> None:
+ """Best-effort audit insert in a savepoint; failure never fails the request."""
+ try:
+ with conn.begin_nested():
+ AuthEventsRepository(conn).insert(user_id, event, metadata={"via": "scim"})
+ except Exception:
+ logger.error("SCIM audit insert failed for user %s event %s", user_id, event, exc_info=True)
+
+
+def _apply_active(conn: Connection, row: dict, desired: bool) -> dict:
+ """Apply an ``active`` transition; side effects run only when the value changes."""
+ if bool(row["active"]) == desired:
+ return row
+ updated = UsersRepository(conn).set_active(str(row["id"]), desired) or row
+ user_id = row["user_id"]
+ if desired:
+ allow_user(user_id)
+ _audit(conn, user_id, "scim_reactivated")
+ else:
+ deny_user(user_id)
+ _audit(conn, user_id, "scim_deactivated")
+ return updated
+
+
+# ----------------------------------------------------------------------
+# Bearer-token gate
+# ----------------------------------------------------------------------
+def _enforce_scim_auth() -> Optional[Response]:
+ """Gate every SCIM request on SCIM_ENABLED and the shared bearer token."""
+ if not settings.SCIM_ENABLED:
+ return _scim_error(404, "SCIM provisioning is not enabled")
+ token = settings.SCIM_TOKEN
+ if not token:
+ logger.error("SCIM is enabled but SCIM_TOKEN is not configured — rejecting request")
+ return _scim_error(503, "SCIM is enabled but no SCIM_TOKEN is configured")
+ scheme, _, presented = request.headers.get("Authorization", "").partition(" ")
+ if scheme.lower() != "bearer" or not hmac.compare_digest(
+ presented.strip().encode("utf-8"), token.encode("utf-8")
+ ):
+ return _scim_error(401, "Invalid or missing bearer token")
+ return None
+
+
+# ----------------------------------------------------------------------
+# Discovery endpoints
+# ----------------------------------------------------------------------
+def service_provider_config():
+ """Static service-provider capabilities document."""
+ return _scim_response(_SERVICE_PROVIDER_CONFIG, 200)
+
+
+def resource_types():
+ """Advertise the User resource type."""
+ return _scim_response(_static_list_response([_USER_RESOURCE_TYPE]), 200)
+
+
+def schemas():
+ """Advertise the User schema."""
+ return _scim_response(_static_list_response([_USER_SCHEMA]), 200)
+
+
+# ----------------------------------------------------------------------
+# Users
+# ----------------------------------------------------------------------
+def list_users():
+ """List users with optional exact userName filter and 1-based pagination."""
+ user_name, error = _parse_filter(request.args.get("filter"))
+ if error is not None:
+ return error
+ start_index = max(1, _int_arg("startIndex", 1))
+ count = min(max(0, _int_arg("count", _DEFAULT_COUNT)), _MAX_COUNT)
+ with db_readonly() as conn:
+ total, rows = UsersRepository(conn).list_paginated(user_name, start_index - 1, count)
+ return _scim_response(
+ {
+ "schemas": [_LIST_RESPONSE_URN],
+ "totalResults": total,
+ "startIndex": start_index,
+ "itemsPerPage": len(rows),
+ "Resources": [_serialize_user(row) for row in rows],
+ },
+ 200,
+ )
+
+
+def create_user():
+ """Create a user from ``userName`` (+ optional ``active``); 409 on duplicates."""
+ body = request.get_json(force=True, silent=True)
+ if not isinstance(body, dict):
+ return _scim_error(400, "Request body must be a JSON object", "invalidValue")
+ user_name = body.get("userName")
+ if not isinstance(user_name, str) or not user_name.strip():
+ return _scim_error(400, "userName is required", "invalidValue")
+ active = _coerce_active(body.get("active", True))
+ if active is None:
+ return _scim_error(400, "active must be a boolean", "invalidValue")
+ with db_session() as conn:
+ row = UsersRepository(conn).create(user_name, active=active)
+ if row is None:
+ return _scim_error(409, f"User {user_name} already exists", "uniqueness")
+ _audit(conn, user_name, "scim_created")
+ resource = _serialize_user(row)
+ return _scim_response(resource, 201, headers={"Location": resource["meta"]["location"]})
+
+
+def get_user(user_pk: str):
+ """Fetch one user by primary key."""
+ with db_readonly() as conn:
+ row = UsersRepository(conn).get_by_pk(user_pk)
+ if not row:
+ return _scim_error(404, "User not found")
+ return _scim_response(_serialize_user(row), 200)
+
+
+def replace_user(user_pk: str):
+ """Full replace; only ``active`` is honored and ``userName`` is immutable."""
+ body = request.get_json(force=True, silent=True)
+ if not isinstance(body, dict):
+ return _scim_error(400, "Request body must be a JSON object", "invalidValue")
+ desired: Optional[bool] = None
+ if "active" in body:
+ desired = _coerce_active(body["active"])
+ if desired is None:
+ return _scim_error(400, "active must be a boolean", "invalidValue")
+ with db_session() as conn:
+ row = UsersRepository(conn).get_by_pk(user_pk)
+ if not row:
+ return _scim_error(404, "User not found")
+ if "userName" in body and body["userName"] != row["user_id"]:
+ return _scim_error(400, "userName is immutable", "mutability")
+ if desired is not None:
+ row = _apply_active(conn, row, desired)
+ return _scim_response(_serialize_user(row), 200)
+
+
+def patch_user(user_pk: str):
+ """Apply PatchOp replace operations targeting ``active``."""
+ body = request.get_json(force=True, silent=True)
+ if not isinstance(body, dict):
+ return _scim_error(400, "Request body must be a JSON object", "invalidValue")
+ operations = body.get("Operations")
+ if not isinstance(operations, list) or not operations:
+ return _scim_error(400, "PatchOp body with Operations is required", "invalidValue")
+ desired: Optional[bool] = None
+ for operation in operations:
+ if not isinstance(operation, dict) or str(operation.get("op", "")).strip().lower() != "replace":
+ return _scim_error(400, "Only the replace operation is supported", "invalidPath")
+ path = str(operation.get("path") or "").strip()
+ if not path:
+ value = operation.get("value")
+ if not isinstance(value, dict):
+ return _scim_error(400, "replace without path requires an object value", "invalidValue")
+ if "active" not in value:
+ continue # Only "active" is honored; other attributes are ignored.
+ candidate = value["active"]
+ elif path.lower() == "active":
+ candidate = operation.get("value")
+ else:
+ return _scim_error(400, f"Unsupported path: {path}", "invalidPath")
+ coerced = _coerce_active(candidate)
+ if coerced is None:
+ return _scim_error(400, "active must be a boolean", "invalidValue")
+ desired = coerced
+ with db_session() as conn:
+ row = UsersRepository(conn).get_by_pk(user_pk)
+ if not row:
+ return _scim_error(404, "User not found")
+ if desired is not None:
+ row = _apply_active(conn, row, desired)
+ return _scim_response(_serialize_user(row), 200)
+
+
+def delete_user(user_pk: str):
+ """Soft delete: deactivate the user and revoke live sessions."""
+ with db_session() as conn:
+ row = UsersRepository(conn).get_by_pk(user_pk)
+ if not row:
+ return _scim_error(404, "User not found")
+ _apply_active(conn, row, False)
+ return _scim_response(None, 204)
+
+
+# ----------------------------------------------------------------------
+# Groups (not supported — answered so IdP probes don't hard-fail)
+# ----------------------------------------------------------------------
+def list_groups():
+ """Groups are not provisioned; always an empty ListResponse."""
+ return _scim_response(_static_list_response([]), 200)
+
+
+def create_group():
+ """Group creation is not supported."""
+ return _scim_error(501, "Group provisioning is not supported")
+
+
+def group_detail(group_id: str):
+ """Individual groups never exist; mutations are unsupported."""
+ if request.method == "GET":
+ return _scim_error(404, "Group not found")
+ return _scim_error(501, "Group provisioning is not supported")
+
+
+def register(bp: Blueprint) -> None:
+ """Attach the SCIM routes and bearer-token gate to ``bp``."""
+ bp.before_request(_enforce_scim_auth)
+ bp.add_url_rule(
+ "/scim/v2/ServiceProviderConfig", view_func=service_provider_config, methods=["GET"],
+ endpoint="service_provider_config",
+ )
+ bp.add_url_rule(
+ "/scim/v2/ResourceTypes", view_func=resource_types, methods=["GET"], endpoint="resource_types",
+ )
+ bp.add_url_rule(
+ "/scim/v2/Schemas", view_func=schemas, methods=["GET"], endpoint="schemas",
+ )
+ bp.add_url_rule(
+ "/scim/v2/Users", view_func=list_users, methods=["GET"], endpoint="list_users",
+ )
+ bp.add_url_rule(
+ "/scim/v2/Users", view_func=create_user, methods=["POST"], endpoint="create_user",
+ )
+ bp.add_url_rule(
+ "/scim/v2/Users/", view_func=get_user, methods=["GET"], endpoint="get_user",
+ )
+ bp.add_url_rule(
+ "/scim/v2/Users/", view_func=replace_user, methods=["PUT"], endpoint="replace_user",
+ )
+ bp.add_url_rule(
+ "/scim/v2/Users/", view_func=patch_user, methods=["PATCH"], endpoint="patch_user",
+ )
+ bp.add_url_rule(
+ "/scim/v2/Users/", view_func=delete_user, methods=["DELETE"], endpoint="delete_user",
+ )
+ bp.add_url_rule(
+ "/scim/v2/Groups", view_func=list_groups, methods=["GET"], endpoint="list_groups",
+ )
+ bp.add_url_rule(
+ "/scim/v2/Groups", view_func=create_group, methods=["POST"], endpoint="create_group",
+ )
+ bp.add_url_rule(
+ "/scim/v2/Groups/", view_func=group_detail,
+ methods=["GET", "PUT", "PATCH", "DELETE"], endpoint="group_detail",
+ )
diff --git a/application/app.py b/application/app.py
index d335b6b6..feec15a3 100644
--- a/application/app.py
+++ b/application/app.py
@@ -20,6 +20,8 @@ from application.api.devices import devices_bp # noqa: E402
from application.api.events.routes import events # noqa: E402
from application.api.internal.routes import internal # noqa: E402
from application.api.oidc import oidc_bp # noqa: E402
+from application.api.oidc.denylist import is_denied as oidc_session_denied # noqa: E402
+from application.api.scim import scim_bp # noqa: E402
from application.api.user.routes import user # noqa: E402
from application.api.connector.routes import connector # noqa: E402
from application.api.v1 import v1_bp # noqa: E402
@@ -63,6 +65,7 @@ app.register_blueprint(internal)
app.register_blueprint(connector)
app.register_blueprint(devices_bp)
app.register_blueprint(oidc_bp)
+app.register_blueprint(scim_bp)
app.register_blueprint(v1_bp)
app.config.update(
UPLOAD_FOLDER="inputs",
@@ -123,6 +126,7 @@ def get_config():
response["oidc"] = {
"login_path": "/api/auth/oidc/login",
"logout_path": "/api/auth/oidc/logout",
+ "provider_name": settings.OIDC_PROVIDER_NAME,
}
return jsonify(response)
@@ -216,11 +220,27 @@ def authenticate_request():
if request.path.startswith("/api/auth/oidc/"):
request.decoded_token = None
return None
+ # SCIM provisioning authenticates with its own bearer token (SCIM_TOKEN),
+ # validated inside the blueprint.
+ if request.path.startswith("/scim/"):
+ request.decoded_token = None
+ return None
decoded_token = handle_auth(request)
if not decoded_token:
request.decoded_token = None
elif "error" in decoded_token:
return jsonify(decoded_token), 401
+ elif settings.AUTH_TYPE == "oidc" and oidc_session_denied(decoded_token):
+ # Back-channel logout / SCIM deactivation revoked this session.
+ return (
+ jsonify(
+ {
+ "message": "Authentication error: session revoked",
+ "error": "token_revoked",
+ }
+ ),
+ 401,
+ )
else:
request.decoded_token = decoded_token
diff --git a/application/core/settings.py b/application/core/settings.py
index 73fa5110..2afa1615 100644
--- a/application/core/settings.py
+++ b/application/core/settings.py
@@ -28,6 +28,13 @@ class Settings(BaseSettings):
OIDC_FRONTEND_URL: Optional[str] = None # browser-facing app origin, e.g. http://localhost:5173
OIDC_REDIRECT_URI: Optional[str] = None # override; default /api/auth/oidc/callback
OIDC_SESSION_LIFETIME_SECONDS: int = 28800 # minted session JWT lifetime (8h)
+ OIDC_PROVIDER_NAME: Optional[str] = None # sign-in button label, e.g. "Acme SSO"
+ OIDC_ALLOWED_GROUPS: Optional[str] = None # comma-separated allowlist; unset = any authenticated user
+ OIDC_GROUPS_CLAIM: str = "groups" # ID-token/userinfo claim carrying group membership
+
+ # SCIM 2.0 provisioning (IdP-driven user create/deactivate at /scim/v2)
+ SCIM_ENABLED: bool = False
+ SCIM_TOKEN: Optional[str] = None # bearer token for IdP SCIM clients (required when enabled)
LLM_PROVIDER: str = "docsgpt"
LLM_NAME: Optional[str] = None # if LLM_PROVIDER is openai, LLM_NAME can be gpt-4 or gpt-3.5-turbo
diff --git a/application/storage/db/models.py b/application/storage/db/models.py
index b0c96d7f..a8189d83 100644
--- a/application/storage/db/models.py
+++ b/application/storage/db/models.py
@@ -49,10 +49,23 @@ users_table = Table(
server_default='{"pinned": [], "shared_with_me": []}',
),
Column("tool_preferences", JSONB, nullable=False, server_default="{}"),
+ Column("active", Boolean, nullable=False, server_default="true"),
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
)
+auth_events_table = Table(
+ "auth_events",
+ metadata,
+ Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()),
+ Column("user_id", Text, nullable=False),
+ Column("event", Text, nullable=False),
+ Column("ip", Text),
+ Column("user_agent", Text),
+ Column("metadata", JSONB, nullable=False, server_default="{}"),
+ Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
+)
+
prompts_table = Table(
"prompts",
metadata,
diff --git a/application/storage/db/repositories/auth_events.py b/application/storage/db/repositories/auth_events.py
new file mode 100644
index 00000000..299e78b0
--- /dev/null
+++ b/application/storage/db/repositories/auth_events.py
@@ -0,0 +1,59 @@
+"""Repository for the ``auth_events`` audit table."""
+
+from __future__ import annotations
+
+import json
+from typing import Optional
+
+from sqlalchemy import Connection, text
+
+from application.storage.db.base_repository import row_to_dict
+
+
+class AuthEventsRepository:
+ """Append-only audit trail of login / logout / provisioning events."""
+
+ def __init__(self, conn: Connection) -> None:
+ self._conn = conn
+
+ def insert(
+ self,
+ user_id: str,
+ event: str,
+ ip: Optional[str] = None,
+ user_agent: Optional[str] = None,
+ metadata: Optional[dict] = None,
+ ) -> dict:
+ """Record one auth event and return the inserted row."""
+ result = self._conn.execute(
+ text(
+ """
+ INSERT INTO auth_events (user_id, event, ip, user_agent, metadata)
+ VALUES (:user_id, :event, :ip, :user_agent, CAST(:metadata AS jsonb))
+ RETURNING *
+ """
+ ),
+ {
+ "user_id": user_id,
+ "event": event,
+ "ip": ip,
+ "user_agent": user_agent,
+ "metadata": json.dumps(metadata or {}),
+ },
+ )
+ return row_to_dict(result.fetchone())
+
+ def list_recent(self, user_id: str, limit: int = 50) -> list[dict]:
+ """Return the newest events for ``user_id``, newest first."""
+ result = self._conn.execute(
+ text(
+ """
+ SELECT * FROM auth_events
+ WHERE user_id = :user_id
+ ORDER BY created_at DESC
+ LIMIT :limit
+ """
+ ),
+ {"user_id": user_id, "limit": limit},
+ )
+ return [row_to_dict(row) for row in result.fetchall()]
diff --git a/application/storage/db/repositories/users.py b/application/storage/db/repositories/users.py
index a128612f..3d50be01 100644
--- a/application/storage/db/repositories/users.py
+++ b/application/storage/db/repositories/users.py
@@ -24,6 +24,7 @@ rollback-per-test connection (tests).
from __future__ import annotations
from typing import Iterable, Optional
+from uuid import UUID
from sqlalchemy import Connection, text
@@ -33,6 +34,14 @@ from application.storage.db.base_repository import row_to_dict
_DEFAULT_PREFERENCES = '{"pinned": [], "shared_with_me": []}'
+def _canonical_uuid(value: str) -> Optional[str]:
+ """Return the canonical UUID string for ``value``, or ``None`` when malformed."""
+ try:
+ return str(UUID(str(value)))
+ except (TypeError, ValueError):
+ return None
+
+
class UsersRepository:
"""Postgres-backed replacement for Mongo ``users_collection`` writes/reads."""
@@ -236,6 +245,81 @@ class UsersRepository:
{"user_id": user_id, "tool_name": tool_name},
)
+ # ------------------------------------------------------------------
+ # SCIM provisioning
+ # ------------------------------------------------------------------
+ def create(self, user_id: str, active: bool = True) -> Optional[dict]:
+ """Insert a new user row; ``None`` means ``user_id`` already exists."""
+ result = self._conn.execute(
+ text(
+ """
+ INSERT INTO users (user_id, agent_preferences, active)
+ VALUES (:user_id, CAST(:default_prefs AS jsonb), :active)
+ ON CONFLICT (user_id) DO NOTHING
+ RETURNING *
+ """
+ ),
+ {"user_id": user_id, "default_prefs": _DEFAULT_PREFERENCES, "active": active},
+ )
+ row = result.fetchone()
+ return row_to_dict(row) if row is not None else None
+
+ def get_by_pk(self, pk: str) -> Optional[dict]:
+ """Return the user row by primary-key ``id``, or ``None`` (including malformed UUIDs)."""
+ canonical = _canonical_uuid(pk)
+ if canonical is None:
+ return None
+ result = self._conn.execute(
+ text("SELECT * FROM users WHERE id = CAST(:pk AS uuid)"),
+ {"pk": canonical},
+ )
+ row = result.fetchone()
+ return row_to_dict(row) if row is not None else None
+
+ def set_active(self, pk: str, active: bool) -> Optional[dict]:
+ """Set ``active`` on the row with primary-key ``id`` and return the updated row."""
+ canonical = _canonical_uuid(pk)
+ if canonical is None:
+ return None
+ result = self._conn.execute(
+ text(
+ """
+ UPDATE users
+ SET active = :active, updated_at = now()
+ WHERE id = CAST(:pk AS uuid)
+ RETURNING *
+ """
+ ),
+ {"pk": canonical, "active": active},
+ )
+ row = result.fetchone()
+ return row_to_dict(row) if row is not None else None
+
+ def list_paginated(
+ self, user_name: Optional[str], offset: int, limit: int
+ ) -> tuple[int, list[dict]]:
+ """Return ``(total, page)`` ordered by ``created_at, id``; optional exact ``user_id`` filter."""
+ where = ""
+ filter_params: dict = {}
+ if user_name is not None:
+ where = "WHERE user_id = :user_name"
+ filter_params = {"user_name": user_name}
+ total = self._conn.execute(
+ text(f"SELECT count(*) FROM users {where}"), filter_params
+ ).scalar_one()
+ result = self._conn.execute(
+ text(
+ f"""
+ SELECT * FROM users
+ {where}
+ ORDER BY created_at, id
+ LIMIT :limit OFFSET :offset
+ """
+ ),
+ {**filter_params, "limit": limit, "offset": offset},
+ )
+ return int(total), [row_to_dict(row) for row in result.fetchall()]
+
# ------------------------------------------------------------------
# Private helpers
# ------------------------------------------------------------------
diff --git a/docs/content/Deploying/DocsGPT-Settings.mdx b/docs/content/Deploying/DocsGPT-Settings.mdx
index 1ba9fefb..098c2c42 100644
--- a/docs/content/Deploying/DocsGPT-Settings.mdx
+++ b/docs/content/Deploying/DocsGPT-Settings.mdx
@@ -277,6 +277,7 @@ JWT_SECRET_KEY=your_secret_key_here
- The frontend redirects users to your identity provider to sign in (OAuth2 Authorization Code + PKCE).
- After a successful sign-in, DocsGPT issues its own session JWT; API requests carry it in the `Authorization` header like the other modes.
- Stable per-user identities come from the provider — see the full setup guide: [SSO with OIDC](/Deploying/OIDC-SSO).
+ - The same guide covers the optional access controls: group allowlists, silent session renewal, back-channel logout, SCIM provisioning, and login auditing.
#### Security Notes
diff --git a/docs/content/Deploying/OIDC-SSO.mdx b/docs/content/Deploying/OIDC-SSO.mdx
index 65fc206e..04c44d1b 100644
--- a/docs/content/Deploying/OIDC-SSO.mdx
+++ b/docs/content/Deploying/OIDC-SSO.mdx
@@ -1,12 +1,14 @@
---
title: SSO with OIDC
-description: Sign users into DocsGPT through any OpenID Connect identity provider — Authentik, Keycloak, Okta, and others.
+description: Sign users into DocsGPT through any OpenID Connect identity provider (Authentik, Keycloak, Okta, ...) — with group allowlists, silent session renewal, back-channel logout, and SCIM provisioning.
---
# SSO with OIDC
Setting `AUTH_TYPE=oidc` makes DocsGPT delegate sign-in to an external OpenID Connect identity provider (IdP). Any spec-compliant IdP with a discovery document works; this guide uses [Authentik](https://goauthentik.io/) as the reference provider and includes a short note for Keycloak.
+Beyond basic sign-in, this page covers the optional access controls: [group allowlists](#restricting-sign-in-by-group), [silent session renewal](#silent-session-renewal), [back-channel logout](#back-channel-logout), [SCIM user provisioning](#scim-user-provisioning), and [login auditing](#login-auditing).
+
## How the flow works
1. A user opens DocsGPT without a session. The frontend redirects the browser to `GET /api/auth/oidc/login` on the DocsGPT API.
@@ -17,7 +19,14 @@ Setting `AUTH_TYPE=oidc` makes DocsGPT delegate sign-in to an external OpenID Co
The user's identity (`sub` claim by default) becomes the DocsGPT `user_id`, so every user gets their own conversations, sources, agents, and settings.
-> Redis must be reachable by the API (it stores the short-lived login state and handoff codes). Redis is already a required DocsGPT dependency, so no extra infrastructure is needed.
+Sessions last `OIDC_SESSION_LIFETIME_SECONDS` (8 hours by default) and renew without interrupting the user — see [Silent session renewal](#silent-session-renewal).
+
+> Redis must be reachable by the API — it stores the short-lived login state, handoff codes, server-side refresh tokens, and the session revocation denylist. Redis is already a required DocsGPT dependency, so no extra infrastructure is needed.
+
+### IdP compatibility notes
+
+- **Token-endpoint authentication** follows the IdP's discovery document (`token_endpoint_auth_methods_supported`): `client_secret_post` when the IdP advertises it, otherwise HTTP Basic (the RFC default). Okta's default web-app configuration works without extra toggles.
+- **Userinfo fallback**: when the ID token lacks the user-id claim (`OIDC_USER_ID_CLAIM`) — or the groups claim while a group allowlist is configured — the backend fetches the IdP's userinfo endpoint and merges the missing claims. ID-token values win on conflict, and the userinfo `sub` must match the ID token's.
## Settings reference
@@ -28,12 +37,17 @@ The user's identity (`sub` claim by default) becomes the DocsGPT `user_id`, so e
| `OIDC_CLIENT_ID` | yes | — | Client ID registered at the IdP. |
| `OIDC_FRONTEND_URL` | yes | — | Browser-facing URL of the DocsGPT frontend (where users land after login/logout), e.g. `https://docsgpt.example.com`. |
| `OIDC_CLIENT_SECRET` | no | — | Set when the IdP client is *confidential*. PKCE is always used, so *public* clients work without a secret. |
-| `OIDC_SCOPES` | no | `openid profile email` | Scopes requested at the IdP. |
-| `OIDC_USER_ID_CLAIM` | no | `sub` | ID-token claim used as the DocsGPT user id. Set to `email` or `preferred_username` for human-readable ids. |
+| `OIDC_SCOPES` | no | `openid profile email` | Scopes requested at the IdP. Add `offline_access` when your IdP requires it for refresh tokens (Authentik does). |
+| `OIDC_USER_ID_CLAIM` | no | `sub` | ID-token claim used as the DocsGPT user id. Set to `email` or `preferred_username` for human-readable ids; use `email` when provisioning over [SCIM](#scim-user-provisioning). |
| `OIDC_REDIRECT_URI` | no | derived | Full callback URL registered at the IdP. Defaults to `/api/auth/oidc/callback`; set it explicitly when the API runs behind a reverse proxy. |
-| `OIDC_SESSION_LIFETIME_SECONDS` | no | `28800` (8h) | Lifetime of the DocsGPT session JWT. After expiry the user is silently redirected through the IdP again. |
+| `OIDC_SESSION_LIFETIME_SECONDS` | no | `28800` (8h) | Lifetime of the DocsGPT session JWT. Sessions renew before expiry — see [Silent session renewal](#silent-session-renewal). |
+| `OIDC_PROVIDER_NAME` | no | — | Display name on the sign-in button: `Acme SSO` renders "Sign in with Acme SSO". Unset, the button shows a generic "SSO". |
+| `OIDC_ALLOWED_GROUPS` | no | — | Comma-separated group allowlist. Unset, any authenticated IdP user may sign in — see [Restricting sign-in by group](#restricting-sign-in-by-group). |
+| `OIDC_GROUPS_CLAIM` | no | `groups` | ID-token/userinfo claim carrying the user's group membership. |
| `JWT_SECRET_KEY` | recommended | auto-generated | Signs DocsGPT session tokens. Set it explicitly in production — required when running multiple API replicas. |
+`SCIM_ENABLED` and `SCIM_TOKEN` are listed in the [SCIM section](#scim-user-provisioning).
+
## Setting up with Authentik
1. **Create a provider.** In the Authentik admin UI go to **Applications → Providers → Create** and pick **OAuth2/OpenID Provider**:
@@ -57,9 +71,11 @@ The user's identity (`sub` claim by default) becomes the DocsGPT `user_id`, so e
JWT_SECRET_KEY=
```
+> Planning to use [silent session renewal](#silent-session-renewal)? Authentik only issues refresh tokens when the `offline_access` scope is requested — set `OIDC_SCOPES=openid profile email offline_access`.
+
### Which claim becomes the user id?
-Authentik's provider setting **Subject mode** controls what lands in the `sub` claim (the default is a hashed user ID — stable but opaque). If you'd rather key DocsGPT users on something readable, either change Subject mode (e.g. *based on username*) or leave Authentik alone and set `OIDC_USER_ID_CLAIM=email` in DocsGPT. Pick one strategy before going live: changing it later gives existing users fresh, empty accounts.
+Authentik's provider setting **Subject mode** controls what lands in the `sub` claim (the default is a hashed user ID — stable but opaque). If you'd rather key DocsGPT users on something readable, either change Subject mode (e.g. *based on username*) or leave Authentik alone and set `OIDC_USER_ID_CLAIM=email` in DocsGPT. Pick one strategy before going live: changing it later gives existing users fresh, empty accounts. If you plan to provision users over [SCIM](#scim-user-provisioning), use `OIDC_USER_ID_CLAIM=email` — SCIM matches users by `userName`, which IdPs typically send as the email.
## Keycloak (and other IdPs)
@@ -72,12 +88,126 @@ OIDC_CLIENT_ID=
Create the client with *Standard flow* enabled and PKCE method `S256`; register the same `/api/auth/oidc/callback` redirect URI.
+The feature sections below carry their own per-IdP notes — group claims, refresh tokens, back-channel logout, and SCIM each need one IdP-side setting.
+
+## Restricting sign-in by group
+
+By default any user who can authenticate at the IdP may use DocsGPT. To restrict access to specific IdP groups:
+
+```env
+OIDC_ALLOWED_GROUPS=docsgpt-users,platform-admins
+# OIDC_GROUPS_CLAIM=groups # only if your IdP uses a different claim name
+```
+
+At login the backend reads the `OIDC_GROUPS_CLAIM` claim (default `groups`) from the ID token, falling back to the userinfo endpoint when the claim is absent. A user whose groups share no entry with the allowlist is rejected with a clean "not authorized" screen (`oidc_error=not_authorized`), and the denial lands in the [audit log](#login-auditing).
+
+Group changes take effect at the next sign-in **or** the next [silent renewal](#silent-session-renewal): whenever the IdP returns a fresh ID token during renewal, the allowlist is re-checked — so removing a user from the allowed group cuts off their session at the next renewal instead of whenever they happen to sign in again.
+
+Getting groups into the token:
+
+- **Authentik** includes group names in the `groups` claim through its default `profile` scope — no extra configuration needed.
+- **Keycloak** does not emit groups by default. On the client, open **Client scopes → the client's dedicated scope → Add mapper → By configuration → Group Membership**, set the claim name to `groups`, and turn **Full group path** off so the claim carries plain names (`devs`) rather than paths (`/devs`).
+
+## Silent session renewal
+
+The DocsGPT session JWT lives for `OIDC_SESSION_LIFETIME_SECONDS` (default 8 hours). Sessions renew without user-visible interruptions, in one of two ways:
+
+- **With a refresh token.** When the IdP issues one, the backend stores it server-side (in Redis — never in the browser) and the frontend calls `POST /api/auth/oidc/refresh` about 15 minutes before the session expires. The backend redeems the refresh token at the IdP, re-validates the fresh ID token (including the [group allowlist](#restricting-sign-in-by-group)), mints a new session JWT, and rotates the stored refresh token. The user notices nothing.
+- **Without a refresh token.** The frontend lets the session run to expiry and then redirects through the IdP again. While the IdP session is still alive, this round-trip is also silent; the user only sees a sign-in page once the IdP session is gone too.
+
+Getting a refresh token:
+
+- **Keycloak** issues refresh tokens for the authorization-code flow by default — nothing to change.
+- **Authentik** only issues refresh tokens when the `offline_access` scope is requested:
+ ```env
+ OIDC_SCOPES=openid profile email offline_access
+ ```
+
+Revoking the user's consent or sessions at the IdP makes the next renewal fail, and the user must sign in again. For revocation that doesn't wait for the next renewal, configure [back-channel logout](#back-channel-logout).
+
+## Back-channel logout
+
+DocsGPT implements [OIDC Back-Channel Logout 1.0](https://openid.net/specs/openid-connect-backchannel-1_0.html). The IdP POSTs a signed `logout_token` to:
+
+```
+POST https:///api/auth/oidc/backchannel-logout
+```
+
+DocsGPT validates the token (signature via JWKS, issuer, audience, replay protection) and immediately revokes the user's live sessions through a Redis denylist — revoked requests get `401` with `error: token_revoked`. Signing the user out at the IdP, or an admin revoking their sessions there, takes effect on their next DocsGPT request instead of at session expiry.
+
+The endpoint is called server-to-server, so it must be reachable from the IdP (it is not a browser redirect).
+
+- **Keycloak**: open the client → **Settings** and set **Backchannel logout URL** to `https:///api/auth/oidc/backchannel-logout`.
+- **Authentik** (2025.8.0 and later; marked Preview): on the OAuth2/OpenID provider set **Logout Method** to *Back-channel* and **Logout URI** to the same URL — see the [Authentik logout docs](https://docs.goauthentik.io/add-secure-apps/providers/oauth2/frontchannel_and_backchannel_logout/). Authentik sends the logout token when a user logs out, an admin deletes their session, the account is deactivated, or the session is revoked. On older Authentik versions back-channel logout is unavailable — revocation latency then falls back to the session lifetime, or use [SCIM deactivation](#scim-user-provisioning), which also revokes sessions instantly.
+
+## SCIM user provisioning
+
+DocsGPT exposes a [SCIM 2.0](https://datatracker.ietf.org/doc/html/rfc7644) endpoint so your IdP can drive the user lifecycle: create accounts ahead of first login and — more importantly — deactivate them on offboarding. Deactivating a user revokes their live sessions immediately and blocks future sign-ins (they see an "account disabled" screen); reactivating restores access.
+
+| Setting | Required | Default | Description |
+| --- | --- | --- | --- |
+| `SCIM_ENABLED` | yes | `false` | Set to `true` to serve the `/scim/v2` endpoints. |
+| `SCIM_TOKEN` | yes | — | Bearer token the IdP's SCIM client must present. Use a long random string. |
+
+The base URL is `https:///scim/v2`; every request must carry `Authorization: Bearer `.
+
+### Match the SCIM userName to the OIDC user id
+
+SCIM identifies users by `userName`, which DocsGPT matches against its user id — the value of `OIDC_USER_ID_CLAIM`. With the default `sub` claim, the `userName` your IdP sends (typically the email) would never line up with the opaque `sub` of the same user signing in, and DocsGPT would treat them as two unrelated accounts. **When using SCIM, set `OIDC_USER_ID_CLAIM=email` and have the IdP send the email as the SCIM `userName`.**
+
+### What the endpoint supports
+
+| Operation | Support |
+| --- | --- |
+| `GET /scim/v2/ServiceProviderConfig`, `/ResourceTypes`, `/Schemas` | Discovery documents. |
+| `GET /scim/v2/Users` | List, with the exact filter `userName eq "..."` and `startIndex`/`count` pagination (1-based, max 200 per page). |
+| `POST /scim/v2/Users` | Create; returns `409` when the `userName` already exists. |
+| `GET /scim/v2/Users/` | Read. |
+| `PUT` / `PATCH /scim/v2/Users/` | Activate/deactivate via the `active` attribute (Okta's string `"true"`/`"false"` values are accepted). `userName` is immutable; other attributes are ignored. |
+| `DELETE /scim/v2/Users/` | Soft delete — deactivates the account instead of removing data. |
+| `/scim/v2/Groups` | Group provisioning is **not** supported: listing returns an empty result so IdP probes don't fail, and mutations return `501`. Use the [group allowlist](#restricting-sign-in-by-group) for group-based access control instead. |
+
+### IdP setup pointers
+
+- **Okta**: add SCIM provisioning to the app integration with **SCIM connector base URL** = `https:///scim/v2` and authentication mode **HTTP Header** carrying the bearer token. Enable creating and deactivating users; skip group push.
+- **Authentik**: create a **SCIM provider** with the same base URL and the token, and attach it to the application as a backchannel provider. Sync users only — leave group mappings out, since DocsGPT answers group provisioning with `501`.
+
+## Login auditing
+
+Authentication activity is recorded in `auth_events`, an append-only Postgres table carrying the user id, event name, IP address, user agent, a JSONB `metadata` column, and a timestamp:
+
+| Event | Recorded when |
+| --- | --- |
+| `oidc_login` | A user signs in successfully. |
+| `oidc_login_denied` | A sign-in is rejected — `metadata.reason` is `not_authorized` (group allowlist) or `account_disabled`. |
+| `oidc_refresh` | A session is silently renewed. |
+| `backchannel_logout` | The IdP revokes sessions via back-channel logout. |
+| `scim_created` / `scim_deactivated` / `scim_reactivated` | SCIM lifecycle changes. |
+
+There is no UI for these events yet — query the table directly:
+
+```sql
+SELECT created_at, event, user_id, ip, metadata
+FROM auth_events
+ORDER BY created_at DESC
+LIMIT 50;
+```
+
## Troubleshooting
-- **Redirected back with `oidc_error=auth_failed`** — check the API logs. The most common causes:
- - *Issuer mismatch*: `OIDC_ISSUER` must be the URL the discovery document itself reports as `issuer` (for Authentik this includes the application slug and trailing slash).
- - *Clock skew*: ID-token validation allows 60 seconds of skew; sync clocks if the API host drifts more than that.
-- **`oidc_error=missing_claim`** — the ID token doesn't contain `OIDC_USER_ID_CLAIM`. Make sure the matching scope is requested (`OIDC_SCOPES`) and the IdP actually emits the claim, or switch the setting back to `sub`.
+When sign-in fails, the browser lands back on the frontend with an `#oidc_error=` fragment and the sign-in screen shows a matching message:
+
+| Code | Cause |
+| --- | --- |
+| `invalid_state` | The login attempt expired (the state is held for 10 minutes) or was replayed. Retrying the sign-in usually fixes it. |
+| `auth_failed` | Token exchange or ID-token validation failed — check the API logs. Most common: `OIDC_ISSUER` doesn't match the issuer the discovery document reports (for Authentik this includes the application slug and trailing slash), or clock skew beyond the allowed 60 seconds. |
+| `missing_claim` | Neither the ID token nor userinfo contains `OIDC_USER_ID_CLAIM`. Make sure the matching scope is requested (`OIDC_SCOPES`) and the IdP actually emits the claim, or switch the setting back to `sub`. |
+| `not_authorized` | The user's groups don't intersect `OIDC_ALLOWED_GROUPS` — see [Restricting sign-in by group](#restricting-sign-in-by-group). |
+| `account_disabled` | The account was deactivated via [SCIM](#scim-user-provisioning) or by an operator. Reactivate it over SCIM to restore access. |
+
+Other issues:
+
- **IdP shows a redirect URI error** — the callback URL registered at the IdP must match exactly. Behind a reverse proxy, set `OIDC_REDIRECT_URI` to the public callback URL instead of relying on the derived default.
-- **Revoked users can still access DocsGPT** — DocsGPT sessions outlive IdP revocation for up to `OIDC_SESSION_LIFETIME_SECONDS`. Lower it if you need tighter revocation latency.
+- **Revoked users can still access DocsGPT** — without back-channel logout, sessions outlive IdP-side revocation until the next renewal or expiry. Configure [back-channel logout](#back-channel-logout) for instant revocation, deactivate the user over [SCIM](#scim-user-provisioning), or lower `OIDC_SESSION_LIFETIME_SECONDS`.
+- **SCIM requests fail** — `404`: `SCIM_ENABLED` is not `true`. `503`: SCIM is enabled but `SCIM_TOKEN` is unset. `401`: the presented bearer token doesn't match `SCIM_TOKEN`.
- **Login endpoints return 503** — Redis is unreachable or the IdP discovery document can't be fetched from the API host.
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index e1c823c1..c4028d9c 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -25,15 +25,27 @@ import ToolApprovalToast from './notifications/ToolApprovalToast';
function AuthWrapper({ children }: { children: React.ReactNode }) {
const { t } = useTranslation();
- const { isAuthLoading, oidcFailed, retryOidcLogin } = useTokenAuth();
+ const {
+ isAuthLoading,
+ oidcFailed,
+ oidcErrorCode,
+ oidcProviderName,
+ retryOidcLogin,
+ } = useTokenAuth();
useDataInitializer(isAuthLoading);
if (oidcFailed) {
+ const message =
+ oidcErrorCode === 'not_authorized'
+ ? t('auth.notAuthorized')
+ : oidcErrorCode === 'account_disabled'
+ ? t('auth.accountDisabled')
+ : t('auth.signInToContinue');
return (