mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-13 04:14:42 +00:00
36 files changed
+5540
-12
No files matched your search
@@ -37,3 +37,27 @@ MICROSOFT_AUTHORITY=https://{tenantId}.ciamlogin.com/{tenantId}
|
||||
|
||||
|
||||
# POSTGRES_URI=postgresql://docsgpt:docsgpt@localhost:5432/docsgpt
|
||||
|
||||
# Authentication (optional - default is no auth; see docs: Deploying -> App Configuration)
|
||||
# AUTH_TYPE=None|simple_jwt|session_jwt|oidc
|
||||
# JWT_SECRET_KEY=<long random string; auto-generated into .jwt_secret_key if unset>
|
||||
|
||||
# OIDC SSO (only when AUTH_TYPE=oidc; works with Authentik, Keycloak, Okta, ...)
|
||||
# OIDC_ISSUER=<issuer URL, e.g. https://auth.example.com/application/o/docsgpt/>
|
||||
# OIDC_CLIENT_ID=<client id registered at the IdP>
|
||||
# OIDC_CLIENT_SECRET=<only for confidential clients; PKCE is always used>
|
||||
# OIDC_FRONTEND_URL=<browser-facing app URL, e.g. http://localhost:5173>
|
||||
# OIDC_SCOPES=openid profile email
|
||||
# OIDC_USER_ID_CLAIM=sub
|
||||
# OIDC_REDIRECT_URI=<override callback URL when behind a reverse proxy>
|
||||
# OIDC_SESSION_LIFETIME_SECONDS=28800
|
||||
# OIDC_PROVIDER_NAME=<sign-in button label, e.g. Acme SSO; unset shows "SSO">
|
||||
# OIDC_ALLOWED_GROUPS=<comma-separated IdP group allowlist; unset = any authenticated user>
|
||||
# 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=<long random bearer token presented by the IdP's SCIM client>
|
||||
@@ -0,0 +1,48 @@
|
||||
"""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:
|
||||
# IF NOT EXISTS keeps the migration idempotent: re-applying it over a
|
||||
# partially-migrated or out-of-band-patched schema must not abort the whole
|
||||
# upgrade (which would wedge startup with alembic_version stuck behind head).
|
||||
op.execute("ALTER TABLE users ADD COLUMN IF NOT EXISTS active BOOLEAN NOT NULL DEFAULT TRUE;")
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS 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;")
|
||||
@@ -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):
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Flask blueprint for OIDC SSO (AUTH_TYPE=oidc)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint
|
||||
|
||||
from .routes import register as register_routes
|
||||
|
||||
|
||||
oidc_bp = Blueprint("oidc", __name__)
|
||||
register_routes(oidc_bp)
|
||||
@@ -0,0 +1,107 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,270 @@
|
||||
"""OIDC provider client: discovery, JWKS, token grants, ID/logout-token validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
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 = [
|
||||
"RS256", "RS384", "RS512",
|
||||
"ES256", "ES384", "ES512",
|
||||
"PS256", "PS384", "PS512",
|
||||
]
|
||||
|
||||
_lock = threading.Lock()
|
||||
_cache: dict = {
|
||||
"discovery": None,
|
||||
"discovery_at": 0.0,
|
||||
"jwks": None,
|
||||
"jwks_at": 0.0,
|
||||
"jwks_force_at": 0.0,
|
||||
}
|
||||
|
||||
|
||||
class OIDCError(Exception):
|
||||
"""Raised when an OIDC flow step fails."""
|
||||
|
||||
|
||||
class OIDCTransientError(OIDCError):
|
||||
"""Raised when the IdP was unreachable or returned 5xx — the call can be retried.
|
||||
|
||||
Subclasses OIDCError so existing ``except OIDCError`` handlers still catch
|
||||
it; callers that can retry (e.g. session refresh) catch this first.
|
||||
"""
|
||||
|
||||
|
||||
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, "jwks_force_at": 0.0}
|
||||
)
|
||||
|
||||
|
||||
def _fetch_json(url: str) -> dict:
|
||||
try:
|
||||
response = requests.get(url, timeout=HTTP_TIMEOUT_SECONDS)
|
||||
except requests.RequestException as exc:
|
||||
raise OIDCError(f"OIDC request to {url} failed: {exc}") from exc
|
||||
if response.status_code != 200:
|
||||
raise OIDCError(f"OIDC request to {url} returned {response.status_code}")
|
||||
return response.json()
|
||||
|
||||
|
||||
def get_discovery() -> dict:
|
||||
"""Return the IdP discovery document, fetching/caching it per process."""
|
||||
with _lock:
|
||||
if _cache["discovery"] is not None and time.time() - _cache["discovery_at"] < DISCOVERY_TTL_SECONDS:
|
||||
return _cache["discovery"]
|
||||
url = settings.OIDC_ISSUER.rstrip("/") + "/.well-known/openid-configuration"
|
||||
document = _fetch_json(url)
|
||||
with _lock:
|
||||
_cache["discovery"] = document
|
||||
_cache["discovery_at"] = time.time()
|
||||
return document
|
||||
|
||||
|
||||
def get_jwks(force: bool = False) -> dict:
|
||||
"""Return the IdP JWKS; ``force=True`` bypasses the cache (key rotation)."""
|
||||
with _lock:
|
||||
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
|
||||
_cache["jwks_at"] = time.time()
|
||||
return jwks
|
||||
|
||||
|
||||
def _find_key(kid: str | None) -> dict | None:
|
||||
keys = get_jwks().get("keys", [])
|
||||
if kid is None:
|
||||
return keys[0] if len(keys) == 1 else None
|
||||
return next((key for key in keys if key.get("kid") == kid), None)
|
||||
|
||||
|
||||
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(token)
|
||||
except Exception as exc:
|
||||
raise OIDCError(f"Malformed token: {exc}") from exc
|
||||
if header.get("alg") not in ALLOWED_ID_TOKEN_ALGS:
|
||||
raise OIDCError(f"Disallowed token alg: {header.get('alg')}")
|
||||
|
||||
key = _find_key(header.get("kid"))
|
||||
if key is None:
|
||||
get_jwks(force=True)
|
||||
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:
|
||||
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 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,
|
||||
# jti is REQUIRED by OIDC Back-Channel Logout 1.0 and underpins
|
||||
# replay protection — reject tokens that omit it. exp is not a
|
||||
# required logout-token claim, so it stays optional (the caller
|
||||
# bounds replay via iat freshness instead).
|
||||
"require_jti": 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:
|
||||
# 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(discovery["token_endpoint"], **post_kwargs)
|
||||
except requests.RequestException as exc:
|
||||
# Network failure — retryable.
|
||||
raise OIDCTransientError(f"Token request failed: {exc}") from exc
|
||||
if response.status_code != 200:
|
||||
logger.error(
|
||||
"OIDC token request failed (%s): %s", response.status_code, response.text[:500]
|
||||
)
|
||||
# 5xx is an IdP-side hiccup (retryable); 4xx (e.g. invalid_grant) is a
|
||||
# definitive rejection the caller must not retry.
|
||||
if response.status_code >= 500:
|
||||
raise OIDCTransientError(f"Token endpoint 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()
|
||||
@@ -0,0 +1,609 @@
|
||||
"""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
|
||||
JWT, and hands it to the SPA via a short-lived single-use code in the URL
|
||||
fragment. Browser redirects only ever target the configured
|
||||
``OIDC_FRONTEND_URL`` or IdP endpoints taken from discovery.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
import uuid
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
from flask import Blueprint, Response, jsonify, make_response, redirect, request
|
||||
from jose import jwt
|
||||
|
||||
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
|
||||
# Browser-bound CSRF guard for the login flow: the callback requires this
|
||||
# cookie to echo the ``state`` it received. Scoped to the oidc paths so it is
|
||||
# only ever sent on the callback.
|
||||
STATE_COOKIE_NAME = "oidc_state"
|
||||
STATE_COOKIE_PATH = "/api/auth/oidc/"
|
||||
|
||||
|
||||
def _state_key(state: str) -> str:
|
||||
return f"oidc:state:{state}"
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
def _redirect_uri() -> str:
|
||||
return settings.OIDC_REDIRECT_URI or request.host_url.rstrip("/") + "/api/auth/oidc/callback"
|
||||
|
||||
|
||||
def _frontend_url() -> str:
|
||||
return (settings.OIDC_FRONTEND_URL or "").rstrip("/") or "/"
|
||||
|
||||
|
||||
def _state_cookie_secure() -> bool:
|
||||
"""Mark the state cookie ``Secure`` when the app is served over HTTPS."""
|
||||
return (settings.OIDC_FRONTEND_URL or "").lower().startswith("https")
|
||||
|
||||
|
||||
def _frontend_redirect(fragment: str):
|
||||
base = (settings.OIDC_FRONTEND_URL or "").rstrip("/")
|
||||
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()
|
||||
if redis is None:
|
||||
return make_response(jsonify({"error": "redis_unavailable"}), 503)
|
||||
try:
|
||||
authorization_endpoint = provider.get_discovery()["authorization_endpoint"]
|
||||
except (provider.OIDCError, KeyError):
|
||||
logger.error("OIDC discovery failed during login", exc_info=True)
|
||||
return make_response(jsonify({"error": "idp_unavailable"}), 503)
|
||||
|
||||
state = secrets.token_urlsafe(32)
|
||||
nonce = secrets.token_urlsafe(32)
|
||||
code_verifier = secrets.token_urlsafe(64)
|
||||
redis.set(
|
||||
_state_key(state),
|
||||
json.dumps({"code_verifier": code_verifier, "nonce": nonce}),
|
||||
ex=STATE_TTL_SECONDS,
|
||||
nx=True,
|
||||
)
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"client_id": settings.OIDC_CLIENT_ID,
|
||||
"redirect_uri": _redirect_uri(),
|
||||
"scope": settings.OIDC_SCOPES,
|
||||
"state": state,
|
||||
"nonce": nonce,
|
||||
"code_challenge": _pkce_challenge(code_verifier),
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
response = redirect(f"{authorization_endpoint}?{urlencode(params)}", code=302)
|
||||
# Bind this login to the browser: the callback rejects any state that
|
||||
# isn't echoed by this cookie, so a code+state captured from another
|
||||
# browser (login CSRF / session fixation) can't complete the flow.
|
||||
response.set_cookie(
|
||||
STATE_COOKIE_NAME,
|
||||
state,
|
||||
max_age=STATE_TTL_SECONDS,
|
||||
httponly=True,
|
||||
secure=_state_cookie_secure(),
|
||||
samesite="Lax",
|
||||
path=STATE_COOKIE_PATH,
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
def oidc_callback():
|
||||
"""Validate the IdP response, mint a session JWT, redirect with a handoff code."""
|
||||
if request.args.get("error"):
|
||||
return _frontend_redirect("oidc_error=" + quote(request.args["error"]))
|
||||
state = request.args.get("state")
|
||||
code = request.args.get("code")
|
||||
if not state or not code:
|
||||
return _frontend_redirect("oidc_error=invalid_state")
|
||||
|
||||
# Browser binding: the state must match the cookie set at login. Without
|
||||
# this, an attacker could feed a victim a code+state from the attacker's
|
||||
# own login and silently sign the victim into the attacker's account.
|
||||
cookie_state = request.cookies.get(STATE_COOKIE_NAME)
|
||||
if not cookie_state or not hmac.compare_digest(cookie_state, state):
|
||||
logger.warning("OIDC callback rejected: state cookie missing or mismatched")
|
||||
return _frontend_redirect("oidc_error=invalid_state")
|
||||
|
||||
redis = get_redis_instance()
|
||||
if redis is None:
|
||||
logger.error("Redis unavailable during OIDC callback")
|
||||
return _frontend_redirect("oidc_error=auth_failed")
|
||||
raw_state = redis.getdel(_state_key(state))
|
||||
if raw_state is None:
|
||||
return _frontend_redirect("oidc_error=invalid_state")
|
||||
stored = json.loads(raw_state)
|
||||
|
||||
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 = 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)
|
||||
|
||||
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")
|
||||
|
||||
# No need to lift prior revocations here: the denylist keys on a revocation
|
||||
# timestamp and the session minted below carries a newer ``iat``, so it is
|
||||
# allowed automatically while still-live sessions revoked on other devices
|
||||
# stay denied.
|
||||
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)
|
||||
return _frontend_redirect("oidc_code=" + handoff)
|
||||
|
||||
|
||||
def oidc_token():
|
||||
"""Redeem a single-use handoff code for the minted session JWT."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
code = body.get("code")
|
||||
if not code or not isinstance(code, str):
|
||||
return make_response(jsonify({"error": "invalid_code"}), 401)
|
||||
redis = get_redis_instance()
|
||||
if redis is None:
|
||||
return make_response(jsonify({"error": "redis_unavailable"}), 503)
|
||||
raw = redis.getdel(_handoff_key(code))
|
||||
if raw is None:
|
||||
return make_response(jsonify({"error": "invalid_code"}), 401)
|
||||
token = raw.decode("utf-8") if isinstance(raw, bytes) else raw
|
||||
return jsonify({"token": token})
|
||||
|
||||
|
||||
def _is_account_disabled(user_id: str) -> bool:
|
||||
"""True only when a readable user row is marked inactive (DB outage fails open)."""
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
row = UsersRepository(conn).get(user_id)
|
||||
except Exception:
|
||||
logger.error("User lookup failed during OIDC refresh", exc_info=True)
|
||||
return False
|
||||
return bool(row is not None and row.get("active") is False)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
# Gate the current session identity before spending the refresh token.
|
||||
if _is_account_disabled(str(decoded["sub"])):
|
||||
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.OIDCTransientError:
|
||||
# IdP unreachable / 5xx — the refresh token is still valid. Put it back
|
||||
# and tell the client to retry instead of killing a live session over a
|
||||
# transient blip (the frontend reschedules a renewal on 503).
|
||||
try:
|
||||
redis.set(
|
||||
_refresh_key(str(decoded["jti"])),
|
||||
refresh_token,
|
||||
ex=settings.OIDC_SESSION_LIFETIME_SECONDS,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Failed to restore refresh token after transient error", exc_info=True)
|
||||
logger.warning("OIDC refresh grant failed transiently", exc_info=True)
|
||||
return make_response(jsonify({"error": "refresh_unavailable"}), 503)
|
||||
except provider.OIDCError:
|
||||
# invalid_grant / 4xx — the refresh token is spent or revoked; leave it
|
||||
# consumed so the client falls back to a fresh login.
|
||||
logger.warning("OIDC refresh grant rejected", 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:
|
||||
user_id = str(user_id)
|
||||
# The pre-grant gate only saw the old sub. If the refreshed identity
|
||||
# maps to a different user id, re-check that account is enabled
|
||||
# before minting a session for it.
|
||||
if user_id != str(decoded["sub"]) and _is_account_disabled(user_id):
|
||||
return make_response(jsonify({"error": "account_disabled"}), 401)
|
||||
identity["sub"] = 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"]
|
||||
|
||||
# Re-check revocation right before minting, against the (possibly remapped)
|
||||
# identity but with the ORIGINAL session's iat: a back-channel logout or
|
||||
# SCIM deny that landed during the IdP grant — or one targeting the
|
||||
# refreshed sub/sid — sets a watermark newer than this iat and must block
|
||||
# renewal. The renewed token's own (fresh) iat would post-date the
|
||||
# watermark and slip past the per-request check, so anchor on the old iat.
|
||||
if denylist.is_denied(
|
||||
{
|
||||
"sub": identity["sub"],
|
||||
"oidc_sub": identity.get("oidc_sub"),
|
||||
"oidc_sid": identity.get("oidc_sid"),
|
||||
"iat": decoded.get("iat"),
|
||||
}
|
||||
):
|
||||
return make_response(jsonify({"error": "token_revoked"}), 401)
|
||||
|
||||
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)
|
||||
|
||||
# Reject stale tokens: past the jti replay-cache window we can no longer
|
||||
# detect replays by jti, so bound acceptance to that window (logout tokens
|
||||
# are short-lived). Combined with the always-on jti check below, a captured
|
||||
# token can be replayed neither within the window (jti dedupe) nor after it
|
||||
# (iat too old).
|
||||
now = int(time.time())
|
||||
iat = claims.get("iat")
|
||||
if not isinstance(iat, (int, float)) or now - iat > LOGOUT_JTI_TTL_SECONDS:
|
||||
logger.warning("Rejected stale OIDC back-channel logout token")
|
||||
return _no_store(jsonify({"error": "invalid_logout_token"}), 400)
|
||||
|
||||
jti = claims.get("jti")
|
||||
redis = get_redis_instance()
|
||||
if redis is not None and jti:
|
||||
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")
|
||||
revoked = True
|
||||
if sub:
|
||||
revoked = bool(denylist.deny_idp_sub(str(sub))) and revoked
|
||||
if sid:
|
||||
revoked = bool(denylist.deny_sid(str(sid))) and revoked
|
||||
if not revoked:
|
||||
# The denylist write failed (Redis down). Report failure so the IdP
|
||||
# retries rather than recording a logout that never took effect.
|
||||
logger.error("Back-channel logout could not persist the revocation")
|
||||
return _no_store(jsonify({"error": "revocation_unavailable"}), 502)
|
||||
|
||||
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()
|
||||
try:
|
||||
end_session = provider.get_discovery().get("end_session_endpoint")
|
||||
except provider.OIDCError:
|
||||
end_session = None
|
||||
if not end_session:
|
||||
return redirect(frontend, code=302)
|
||||
params = {
|
||||
"post_logout_redirect_uri": frontend,
|
||||
"client_id": settings.OIDC_CLIENT_ID,
|
||||
}
|
||||
return redirect(f"{end_session}?{urlencode(params)}", code=302)
|
||||
|
||||
|
||||
def _require_oidc_enabled() -> Response | None:
|
||||
"""404 every oidc route unless OIDC is the active auth mode.
|
||||
|
||||
Registration is unconditional (so the import-time auth mode doesn't matter),
|
||||
but the endpoints only work under ``AUTH_TYPE=oidc`` — otherwise OIDC_ISSUER
|
||||
is unset and ``get_discovery`` would dereference ``None`` and 500. Mirrors
|
||||
SCIM's ``SCIM_ENABLED`` gate.
|
||||
"""
|
||||
if settings.AUTH_TYPE != "oidc":
|
||||
return make_response(jsonify({"error": "oidc_not_enabled"}), 404)
|
||||
return None
|
||||
|
||||
|
||||
def register(bp: Blueprint) -> None:
|
||||
"""Attach the oidc auth routes to ``bp``."""
|
||||
bp.before_request(_require_oidc_enabled)
|
||||
bp.add_url_rule(
|
||||
"/api/auth/oidc/login", view_func=oidc_login, methods=["GET"], endpoint="login"
|
||||
)
|
||||
bp.add_url_rule(
|
||||
"/api/auth/oidc/callback", view_func=oidc_callback, methods=["GET"], endpoint="callback"
|
||||
)
|
||||
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"
|
||||
)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,452 @@
|
||||
"""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 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,
|
||||
# RFC 7643 §8.7.1 defines userName as caseExact=false; matching is
|
||||
# case-insensitive (see UsersRepository.list_paginated / create).
|
||||
"caseExact": False,
|
||||
"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
|
||||
# ----------------------------------------------------------------------
|
||||
class _RevocationUnavailable(Exception):
|
||||
"""A SCIM deactivation could not persist its session revocation (Redis down)."""
|
||||
|
||||
|
||||
def _revocation_unavailable(_exc: _RevocationUnavailable) -> Response:
|
||||
return _scim_error(503, "Session revocation backend unavailable; retry")
|
||||
|
||||
|
||||
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:
|
||||
# Reactivation needs no denylist write: deactivation set a revocation
|
||||
# watermark that already lets a fresh login through (newer iat) while
|
||||
# keeping the pre-deactivation sessions revoked.
|
||||
_audit(conn, user_id, "scim_reactivated")
|
||||
else:
|
||||
if not deny_user(user_id):
|
||||
# Redis is down — we cannot revoke live sessions. Fail the request
|
||||
# (the surrounding transaction rolls the deactivation back) so the
|
||||
# IdP retries instead of recording a deprovision that didn't revoke.
|
||||
raise _RevocationUnavailable(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 str(body["userName"]).lower() != str(row["user_id"]).lower():
|
||||
# userName is case-insensitive (caseExact=false), so a PUT that
|
||||
# echoes the stored name under different casing is NOT a change —
|
||||
# rejecting it would block deprovision for case-divergent IdPs.
|
||||
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.register_error_handler(_RevocationUnavailable, _revocation_unavailable)
|
||||
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/<user_pk>", view_func=get_user, methods=["GET"], endpoint="get_user",
|
||||
)
|
||||
bp.add_url_rule(
|
||||
"/scim/v2/Users/<user_pk>", view_func=replace_user, methods=["PUT"], endpoint="replace_user",
|
||||
)
|
||||
bp.add_url_rule(
|
||||
"/scim/v2/Users/<user_pk>", view_func=patch_user, methods=["PATCH"], endpoint="patch_user",
|
||||
)
|
||||
bp.add_url_rule(
|
||||
"/scim/v2/Users/<user_pk>", 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/<group_id>", view_func=group_detail,
|
||||
methods=["GET", "PUT", "PATCH", "DELETE"], endpoint="group_detail",
|
||||
)
|
||||
+43
-2
@@ -19,6 +19,9 @@ from application.api.answer import answer # noqa: E402
|
||||
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
|
||||
@@ -61,6 +64,8 @@ app.register_blueprint(events)
|
||||
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",
|
||||
@@ -71,7 +76,7 @@ app.config.update(
|
||||
celery.config_from_object("application.celeryconfig")
|
||||
api.init_app(app)
|
||||
|
||||
if settings.AUTH_TYPE in ("simple_jwt", "session_jwt") and not settings.JWT_SECRET_KEY:
|
||||
if settings.AUTH_TYPE in ("simple_jwt", "session_jwt", "oidc") and not settings.JWT_SECRET_KEY:
|
||||
key_file = ".jwt_secret_key"
|
||||
try:
|
||||
with open(key_file, "r") as f:
|
||||
@@ -83,6 +88,14 @@ if settings.AUTH_TYPE in ("simple_jwt", "session_jwt") and not settings.JWT_SECR
|
||||
settings.JWT_SECRET_KEY = new_key
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to setup JWT_SECRET_KEY: {e}")
|
||||
if settings.AUTH_TYPE == "oidc":
|
||||
_missing_oidc = [
|
||||
name
|
||||
for name in ("OIDC_ISSUER", "OIDC_CLIENT_ID", "OIDC_FRONTEND_URL")
|
||||
if not getattr(settings, name)
|
||||
]
|
||||
if _missing_oidc:
|
||||
raise RuntimeError(f"AUTH_TYPE=oidc requires settings: {', '.join(_missing_oidc)}")
|
||||
SIMPLE_JWT_TOKEN = None
|
||||
if settings.AUTH_TYPE == "simple_jwt":
|
||||
payload = {"sub": "local"}
|
||||
@@ -107,8 +120,14 @@ def health():
|
||||
def get_config():
|
||||
response = {
|
||||
"auth_type": settings.AUTH_TYPE,
|
||||
"requires_auth": settings.AUTH_TYPE in ["simple_jwt", "session_jwt"],
|
||||
"requires_auth": settings.AUTH_TYPE in ["simple_jwt", "session_jwt", "oidc"],
|
||||
}
|
||||
if settings.AUTH_TYPE == "oidc":
|
||||
response["oidc"] = {
|
||||
"login_path": "/api/auth/oidc/login",
|
||||
"logout_path": "/api/auth/oidc/logout",
|
||||
"provider_name": settings.OIDC_PROVIDER_NAME,
|
||||
}
|
||||
return jsonify(response)
|
||||
|
||||
|
||||
@@ -195,11 +214,33 @@ def authenticate_request():
|
||||
):
|
||||
request.decoded_token = None
|
||||
return None
|
||||
# OIDC login/callback/token endpoints must stay reachable even when the
|
||||
# browser still carries a stale or expired Bearer token — otherwise the
|
||||
# 401 below would lock the user out of the only path to a fresh session.
|
||||
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
|
||||
|
||||
|
||||
+15
-2
@@ -1,24 +1,37 @@
|
||||
from jose import jwt
|
||||
from jose.exceptions import ExpiredSignatureError
|
||||
|
||||
from application.core.settings import settings
|
||||
|
||||
|
||||
def handle_auth(request, data={}):
|
||||
if settings.AUTH_TYPE in ["simple_jwt", "session_jwt"]:
|
||||
if settings.AUTH_TYPE in ["simple_jwt", "session_jwt", "oidc"]:
|
||||
jwt_token = request.headers.get("Authorization")
|
||||
if not jwt_token:
|
||||
return None
|
||||
|
||||
jwt_token = jwt_token.replace("Bearer ", "")
|
||||
|
||||
is_oidc = settings.AUTH_TYPE == "oidc"
|
||||
try:
|
||||
decoded_token = jwt.decode(
|
||||
jwt_token,
|
||||
settings.JWT_SECRET_KEY,
|
||||
algorithms=["HS256"],
|
||||
options={"verify_exp": False},
|
||||
# oidc sessions are minted with an exp at the login callback and
|
||||
# must carry one: require_exp rejects any exp-less HS256 token
|
||||
# signed with JWT_SECRET_KEY (e.g. a legacy simple_jwt/session_jwt
|
||||
# token), which would otherwise authenticate forever and be
|
||||
# unrevocable. simple_jwt/session_jwt never carried an exp, so the
|
||||
# requirement is scoped to oidc.
|
||||
options={"verify_exp": is_oidc, "require_exp": is_oidc},
|
||||
)
|
||||
return decoded_token
|
||||
except ExpiredSignatureError:
|
||||
return {
|
||||
"message": "Authentication error: token expired",
|
||||
"error": "token_expired",
|
||||
}
|
||||
except Exception:
|
||||
return {
|
||||
"message": "Authentication error: invalid token",
|
||||
|
||||
@@ -17,7 +17,25 @@ from application.core.db_uri import ( # noqa: E402
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(extra="ignore")
|
||||
|
||||
AUTH_TYPE: Optional[str] = None # simple_jwt, session_jwt, or None
|
||||
AUTH_TYPE: Optional[str] = None # simple_jwt, session_jwt, oidc, or None
|
||||
|
||||
# OIDC SSO (AUTH_TYPE=oidc) — any OpenID Connect IdP with discovery (Authentik, Keycloak, ...)
|
||||
OIDC_ISSUER: Optional[str] = None # e.g. https://auth.example.com/application/o/docsgpt/
|
||||
OIDC_CLIENT_ID: Optional[str] = None
|
||||
OIDC_CLIENT_SECRET: Optional[str] = None # optional; PKCE is always used
|
||||
OIDC_SCOPES: str = "openid profile email"
|
||||
OIDC_USER_ID_CLAIM: str = "sub" # ID-token claim mapped to the DocsGPT user id
|
||||
OIDC_FRONTEND_URL: Optional[str] = None # browser-facing app origin, e.g. http://localhost:5173
|
||||
OIDC_REDIRECT_URI: Optional[str] = None # override; default <request host>/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
|
||||
EMBEDDINGS_NAME: str = "huggingface_sentence-transformers/all-mpnet-base-v2"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()]
|
||||
@@ -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,96 @@ 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 a user with that id already exists.
|
||||
|
||||
SCIM ``userName`` is case-insensitive (caseExact=false), so a row that
|
||||
differs only by case counts as a duplicate. The pre-check keeps SCIM
|
||||
from provisioning a second row for a user the OIDC login already
|
||||
created under different casing; the ``ON CONFLICT`` clause remains an
|
||||
exact-match backstop for the concurrent-insert race.
|
||||
"""
|
||||
existing = self._conn.execute(
|
||||
text("SELECT 1 FROM users WHERE lower(user_id) = lower(:user_id)"),
|
||||
{"user_id": user_id},
|
||||
).first()
|
||||
if existing is not None:
|
||||
return None
|
||||
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:
|
||||
# SCIM userName is case-insensitive (caseExact=false); match on
|
||||
# lower(user_id) so the IdP finds the account regardless of casing.
|
||||
where = "WHERE lower(user_id) = lower(: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
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -230,6 +230,7 @@ The `AUTH_TYPE` setting in your `.env` file or `settings.py` determines the auth
|
||||
| `None` | No authentication is used. Anyone can access the app. |
|
||||
| `simple_jwt` | A single, long-lived JWT token is generated at startup. All requests use this shared token. |
|
||||
| `session_jwt` | Unique JWT tokens are generated for each session/user. |
|
||||
| `oidc` | Users sign in through an external OpenID Connect provider (Authentik, Keycloak, Okta, ...). See [SSO with OIDC](/Deploying/OIDC-SSO). |
|
||||
|
||||
#### How to Configure
|
||||
|
||||
@@ -246,6 +247,13 @@ JWT_SECRET_KEY=your_secret_key_here
|
||||
# OR: Session JWT (per-user/session tokens)
|
||||
AUTH_TYPE=session_jwt
|
||||
JWT_SECRET_KEY=your_secret_key_here
|
||||
|
||||
# OR: SSO via an OpenID Connect provider (Authentik, Keycloak, Okta, ...)
|
||||
AUTH_TYPE=oidc
|
||||
OIDC_ISSUER=https://auth.example.com/application/o/docsgpt/
|
||||
OIDC_CLIENT_ID=your_client_id
|
||||
OIDC_FRONTEND_URL=https://docsgpt.example.com
|
||||
JWT_SECRET_KEY=your_secret_key_here
|
||||
```
|
||||
|
||||
- If `AUTH_TYPE` is set to `simple_jwt` or `session_jwt`, a `JWT_SECRET_KEY` is required.
|
||||
@@ -265,6 +273,11 @@ JWT_SECRET_KEY=your_secret_key_here
|
||||
- Clients can request a new token from `/api/generate_token`.
|
||||
- Use the received token in the `Authorization` header for subsequent requests.
|
||||
- Each user/session gets a unique token.
|
||||
- **oidc**:
|
||||
- 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
|
||||
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
---
|
||||
title: SSO with OIDC
|
||||
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.
|
||||
2. The backend starts an **OAuth2 Authorization Code + PKCE** flow and redirects to your IdP's sign-in page.
|
||||
3. After sign-in, the IdP redirects back to `GET /api/auth/oidc/callback`. The backend exchanges the code server-side, validates the ID token (signature via JWKS, issuer, audience, expiry, nonce), and mints a **DocsGPT session JWT** signed with `JWT_SECRET_KEY`.
|
||||
4. The browser returns to your frontend with a short-lived single-use code in the URL fragment; the frontend exchanges it for the session JWT and stores it. From here on, requests are authenticated exactly like the other `AUTH_TYPE` modes (`Authorization: Bearer <token>`).
|
||||
5. Signing out clears the local session and redirects through the IdP's end-session endpoint.
|
||||
|
||||
The user's identity (`sub` claim by default) becomes the DocsGPT `user_id`, so every user gets their own conversations, sources, agents, and settings.
|
||||
|
||||
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
|
||||
|
||||
| Setting | Required | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `AUTH_TYPE` | yes | — | Set to `oidc`. |
|
||||
| `OIDC_ISSUER` | yes | — | Issuer URL of your IdP. Discovery is read from `<issuer>/.well-known/openid-configuration`. |
|
||||
| `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. 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 `<request host>/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. 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**:
|
||||
- **Authorization flow**: your preferred flow (e.g. *explicit consent*).
|
||||
- **Client type**: `Public` (no secret, PKCE only) or `Confidential` (also set `OIDC_CLIENT_SECRET` in DocsGPT).
|
||||
- **Redirect URIs**: `https://<your-docsgpt-api>/api/auth/oidc/callback`
|
||||
- **Signing key**: select a certificate so ID tokens are RS256-signed.
|
||||
2. **Create an application** (Applications → Applications → Create), link it to the provider, and note its **slug**.
|
||||
3. **Find the issuer.** With Authentik's default per-provider issuer mode it is:
|
||||
```
|
||||
https://<your-authentik-host>/application/o/<application-slug>/
|
||||
```
|
||||
(the trailing slash is part of the issuer — copy it exactly; the discovery document lives at `.../<application-slug>/.well-known/openid-configuration`).
|
||||
4. **Configure DocsGPT** in `.env` and restart:
|
||||
```env
|
||||
AUTH_TYPE=oidc
|
||||
OIDC_ISSUER=https://auth.example.com/application/o/docsgpt/
|
||||
OIDC_CLIENT_ID=<client id from step 1>
|
||||
# OIDC_CLIENT_SECRET=<only for Confidential client type>
|
||||
OIDC_FRONTEND_URL=https://docsgpt.example.com
|
||||
JWT_SECRET_KEY=<long random string>
|
||||
```
|
||||
|
||||
> 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. 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)
|
||||
|
||||
Any OIDC provider with discovery works the same way. For Keycloak:
|
||||
|
||||
```env
|
||||
OIDC_ISSUER=https://keycloak.example.com/realms/<realm>
|
||||
OIDC_CLIENT_ID=<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://<your-docsgpt-api>/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://<your-docsgpt-api>/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://<your-docsgpt-api>/scim/v2`; every request must carry `Authorization: Bearer <SCIM_TOKEN>`.
|
||||
|
||||
### 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/<id>` | Read. |
|
||||
| `PUT` / `PATCH /scim/v2/Users/<id>` | 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/<id>` | 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://<your-docsgpt-api>/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
|
||||
|
||||
When sign-in fails, the browser lands back on the frontend with an `#oidc_error=<code>` 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** — 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.
|
||||
@@ -3,6 +3,10 @@ export default {
|
||||
"title": "⚙️ App Configuration",
|
||||
"href": "/Deploying/DocsGPT-Settings"
|
||||
},
|
||||
"OIDC-SSO": {
|
||||
"title": "🔐 SSO with OIDC",
|
||||
"href": "/Deploying/OIDC-SSO"
|
||||
},
|
||||
"Docker-Deploying": {
|
||||
"title": "🛳️ Docker Setup",
|
||||
"href": "/Deploying/Docker-Deploying"
|
||||
|
||||
+35
-1
@@ -1,12 +1,15 @@
|
||||
import './locale/i18n';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Outlet, Route, Routes, useLocation } from 'react-router-dom';
|
||||
|
||||
import Agents from './agents';
|
||||
import SharedAgentGate from './agents/SharedAgentGate';
|
||||
import DocsGPT3 from './assets/cute_docsgpt3.svg';
|
||||
import ActionButtons from './components/ActionButtons';
|
||||
import Spinner from './components/Spinner';
|
||||
import { Button } from './components/ui/button';
|
||||
import UploadToast from './components/UploadToast';
|
||||
import Conversation from './conversation/Conversation';
|
||||
import { SharedConversation } from './conversation/SharedConversation';
|
||||
@@ -21,9 +24,40 @@ import Notification from './components/Notification';
|
||||
import ToolApprovalToast from './notifications/ToolApprovalToast';
|
||||
|
||||
function AuthWrapper({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthLoading } = useTokenAuth();
|
||||
const { t } = useTranslation();
|
||||
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 (
|
||||
<div className="flex h-screen flex-col items-center justify-center gap-6">
|
||||
<img src={DocsGPT3} alt="DocsGPT" className="size-14" />
|
||||
<p className="text-foreground max-w-md px-6 text-center text-sm dark:text-white">
|
||||
{message}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={retryOidcLogin}
|
||||
className="rounded-3xl px-5"
|
||||
data-testid="oidc-signin"
|
||||
>
|
||||
{t('auth.signInWith', { provider: oidcProviderName || 'SSO' })}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isAuthLoading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
|
||||
@@ -2,6 +2,10 @@ const endpoints = {
|
||||
USER: {
|
||||
CONFIG: '/api/config',
|
||||
NEW_TOKEN: '/api/generate_token',
|
||||
OIDC_LOGIN: '/api/auth/oidc/login',
|
||||
OIDC_TOKEN: '/api/auth/oidc/token',
|
||||
OIDC_REFRESH: '/api/auth/oidc/refresh',
|
||||
OIDC_LOGOUT: '/api/auth/oidc/logout',
|
||||
MODELS: '/api/models',
|
||||
DOCS: '/api/sources',
|
||||
DOCS_PAGINATED: '/api/sources/paginated',
|
||||
|
||||
@@ -7,6 +7,12 @@ const userService = {
|
||||
throttledApiClient.get(endpoints.USER.CONFIG, null),
|
||||
getNewToken: (): Promise<any> =>
|
||||
throttledApiClient.get(endpoints.USER.NEW_TOKEN, null),
|
||||
// Token deliberately null: a stale Authorization header must not be able
|
||||
// to interfere with redeeming the one-time OIDC handoff code.
|
||||
exchangeOidcCode: (code: string): Promise<any> =>
|
||||
apiClient.post(endpoints.USER.OIDC_TOKEN, { code }, null),
|
||||
refreshOidcSession: (token: string | null): Promise<any> =>
|
||||
apiClient.post(endpoints.USER.OIDC_REFRESH, {}, token),
|
||||
getDocs: (token: string | null): Promise<any> =>
|
||||
apiClient.get(`${endpoints.USER.DOCS}`, token),
|
||||
getDocsWithPagination: (query: string, token: string | null): Promise<any> =>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
updateConversationId,
|
||||
} from '../conversation/conversationSlice';
|
||||
import { Button } from './ui/button';
|
||||
import ProfileButton from './ProfileButton';
|
||||
|
||||
interface ActionButtonsProps {
|
||||
className?: string;
|
||||
@@ -86,7 +87,7 @@ export default function ActionButtons({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div>{/* <UserButton /> */}</div>
|
||||
<ProfileButton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { LogOut } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import useTokenAuth from '../hooks/useTokenAuth';
|
||||
import { Avatar } from './ui/avatar';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
|
||||
|
||||
/**
|
||||
* Top-right account menu for OIDC sessions: an avatar that opens a popover
|
||||
* showing the signed-in name/email and a sign-out action. Renders nothing for
|
||||
* other auth modes, which carry no user identity.
|
||||
*/
|
||||
export default function ProfileButton() {
|
||||
const { t } = useTranslation();
|
||||
const { authType, userName, userEmail, userPicture, logout } = useTokenAuth();
|
||||
|
||||
if (authType !== 'oidc' || (!userName && !userEmail)) return null;
|
||||
|
||||
const initial = (userName || userEmail || '?').charAt(0).toUpperCase();
|
||||
const renderAvatar = (size: string, text: string) =>
|
||||
userPicture ? (
|
||||
<Avatar
|
||||
src={userPicture}
|
||||
alt={userName || userEmail || 'User avatar'}
|
||||
className={size}
|
||||
imgClassName={`${size} rounded-full object-cover`}
|
||||
/>
|
||||
) : (
|
||||
<Avatar className={size}>
|
||||
<span
|
||||
className={`bg-primary text-primary-foreground flex ${size} items-center justify-center rounded-full ${text} font-medium`}
|
||||
>
|
||||
{initial}
|
||||
</span>
|
||||
</Avatar>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('auth.account')}
|
||||
className="ring-offset-background focus-visible:ring-ring hover:ring-primary/40 rounded-full transition outline-none hover:ring-2 hover:ring-offset-2 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
{renderAvatar('size-8', 'text-sm')}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" sideOffset={8} className="w-64 p-0">
|
||||
<div className="flex items-center gap-3 p-4">
|
||||
{renderAvatar('size-10', 'text-base')}
|
||||
<span className="flex min-w-0 flex-col">
|
||||
{userName && (
|
||||
<p className="text-foreground truncate text-sm font-medium dark:text-white">
|
||||
{userName}
|
||||
</p>
|
||||
)}
|
||||
{userEmail && (
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{userEmail}
|
||||
</p>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="dark:border-sidebar-border border-t" />
|
||||
<div className="p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
data-testid="oidc-signout"
|
||||
className="text-foreground hover:bg-sidebar-accent flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm dark:text-white"
|
||||
>
|
||||
<LogOut
|
||||
className="text-muted-foreground size-4 shrink-0"
|
||||
strokeWidth={1.75}
|
||||
/>
|
||||
{t('auth.signOut')}
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,73 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { baseURL } from '../api/client';
|
||||
import endpoints from '../api/endpoints';
|
||||
import userService from '../api/services/userService';
|
||||
import { selectToken, setToken } from '../preferences/preferenceSlice';
|
||||
import {
|
||||
decodeJwtPayload,
|
||||
getJwtRemainingMs,
|
||||
isJwtExpired,
|
||||
} from '../utils/jwtUtils';
|
||||
|
||||
const OIDC_ATTEMPT_KEY = 'oidc_login_attempted';
|
||||
const OIDC_RETURN_TO_KEY = 'oidc_return_to';
|
||||
|
||||
// Renew the OIDC session when less than this much lifetime remains.
|
||||
const OIDC_RENEWAL_THRESHOLD_MS = 15 * 60 * 1000;
|
||||
// Delay before retrying a renewal that failed transiently (network/503).
|
||||
const OIDC_RENEWAL_RETRY_MS = 60 * 1000;
|
||||
// setTimeout treats delays above 2^31 - 1 ms as 0 — clamp to avoid firing
|
||||
// immediately for far-future expiries.
|
||||
const MAX_TIMER_DELAY_MS = 2 ** 31 - 1;
|
||||
|
||||
// Module-level so the two hook instances (AuthWrapper + Navigation) and
|
||||
// StrictMode's double-invoked effects share one exchange/redirect — the
|
||||
// handoff code is single-use server-side, so a second POST would fail.
|
||||
let oidcExchangePromise: Promise<string | null> | null = null;
|
||||
let oidcRedirectStarted = false;
|
||||
|
||||
// Renewal state is module-level for the same reason: at most one renewal
|
||||
// timer and one in-flight refresh may exist app-wide, because the server
|
||||
// rotates the refresh token on every renewal — concurrent renewals would
|
||||
// invalidate each other.
|
||||
let oidcRenewalTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let oidcRenewalPromise: Promise<void> | null = null;
|
||||
// Set when the server reports 404 no_refresh_token: the IdP issued no
|
||||
// refresh token for this session, so silent renewal is impossible until a
|
||||
// new token is stored. Expiry then falls back to the redirect-login path,
|
||||
// which re-authenticates silently while the IdP session is still alive.
|
||||
let oidcRenewalUnavailable = false;
|
||||
|
||||
const claimString = (value: unknown): string | undefined =>
|
||||
typeof value === 'string' && value !== '' ? value : undefined;
|
||||
|
||||
function exchangeOidcCodeOnce(code: string): Promise<string | null> {
|
||||
if (!oidcExchangePromise) {
|
||||
oidcExchangePromise = (async () => {
|
||||
try {
|
||||
const response = await userService.exchangeOidcCode(code);
|
||||
if (!response.ok) return null;
|
||||
const { token: newToken } = await response.json();
|
||||
return newToken ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
}
|
||||
return oidcExchangePromise;
|
||||
}
|
||||
|
||||
function redirectToOidcLogin() {
|
||||
sessionStorage.setItem(OIDC_ATTEMPT_KEY, '1');
|
||||
sessionStorage.setItem(
|
||||
OIDC_RETURN_TO_KEY,
|
||||
window.location.pathname + window.location.search,
|
||||
);
|
||||
oidcRedirectStarted = true;
|
||||
window.location.replace(`${baseURL}${endpoints.USER.OIDC_LOGIN}`);
|
||||
}
|
||||
|
||||
export default function useAuth() {
|
||||
const dispatch = useDispatch();
|
||||
@@ -10,6 +75,9 @@ export default function useAuth() {
|
||||
const [authType, setAuthType] = useState(null);
|
||||
const [showTokenModal, setShowTokenModal] = useState(false);
|
||||
const [isAuthLoading, setIsAuthLoading] = useState(true);
|
||||
const [oidcFailed, setOidcFailed] = useState(false);
|
||||
const [oidcErrorCode, setOidcErrorCode] = useState<string | null>(null);
|
||||
const [oidcProviderName, setOidcProviderName] = useState<string | null>(null);
|
||||
const isGeneratingToken = useRef(false);
|
||||
|
||||
const generateNewToken = async () => {
|
||||
@@ -30,6 +98,70 @@ export default function useAuth() {
|
||||
}
|
||||
};
|
||||
|
||||
const stripUrlFragment = () => {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
window.location.pathname + window.location.search,
|
||||
);
|
||||
};
|
||||
|
||||
const handleOidcAuth = async () => {
|
||||
const hash = window.location.hash;
|
||||
if (hash.startsWith('#oidc_code=')) {
|
||||
const code = hash.slice('#oidc_code='.length);
|
||||
const newToken = await exchangeOidcCodeOnce(code);
|
||||
if (newToken) {
|
||||
// A fresh session may carry a refresh token even if the previous
|
||||
// one did not.
|
||||
oidcRenewalUnavailable = false;
|
||||
localStorage.setItem('authToken', newToken);
|
||||
setOidcErrorCode(null);
|
||||
sessionStorage.removeItem(OIDC_ATTEMPT_KEY);
|
||||
const returnTo = sessionStorage.getItem(OIDC_RETURN_TO_KEY);
|
||||
sessionStorage.removeItem(OIDC_RETURN_TO_KEY);
|
||||
if (returnTo && returnTo !== '/') {
|
||||
// Full reload: React Router doesn't observe history.replaceState,
|
||||
// so this is the simplest way back to the pre-login deep link.
|
||||
window.location.replace(returnTo);
|
||||
return;
|
||||
}
|
||||
stripUrlFragment();
|
||||
dispatch(setToken(newToken)); // effect re-runs and finishes loading
|
||||
return;
|
||||
}
|
||||
stripUrlFragment();
|
||||
setOidcFailed(true);
|
||||
setIsAuthLoading(false);
|
||||
return;
|
||||
}
|
||||
if (hash.startsWith('#oidc_error=')) {
|
||||
const errorCode = decodeURIComponent(hash.slice('#oidc_error='.length));
|
||||
console.error('OIDC login failed:', errorCode);
|
||||
setOidcErrorCode(errorCode);
|
||||
stripUrlFragment();
|
||||
setOidcFailed(true);
|
||||
setIsAuthLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!token || isJwtExpired(token)) {
|
||||
if (token) {
|
||||
localStorage.removeItem('authToken');
|
||||
}
|
||||
if (oidcRedirectStarted) return; // navigation underway — keep spinner
|
||||
if (sessionStorage.getItem(OIDC_ATTEMPT_KEY)) {
|
||||
// Already round-tripped to the IdP once without obtaining a
|
||||
// session — show the retry screen instead of redirect-looping.
|
||||
setOidcFailed(true);
|
||||
setIsAuthLoading(false);
|
||||
return;
|
||||
}
|
||||
redirectToOidcLogin();
|
||||
return;
|
||||
}
|
||||
setIsAuthLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Re-fires when ``token`` flips to null mid-session (e.g.
|
||||
// ``useEventStream`` dispatches ``setToken(null)`` after repeated
|
||||
@@ -43,9 +175,12 @@ export default function useAuth() {
|
||||
const config = await configRes.json();
|
||||
resolvedAuthType = config.auth_type;
|
||||
setAuthType(resolvedAuthType);
|
||||
setOidcProviderName(config.oidc?.provider_name ?? null);
|
||||
}
|
||||
|
||||
if (resolvedAuthType === 'session_jwt' && !token) {
|
||||
if (resolvedAuthType === 'oidc') {
|
||||
await handleOidcAuth();
|
||||
} else if (resolvedAuthType === 'session_jwt' && !token) {
|
||||
await generateNewToken();
|
||||
} else if (resolvedAuthType === 'simple_jwt' && !token) {
|
||||
setShowTokenModal(true);
|
||||
@@ -61,10 +196,141 @@ export default function useAuth() {
|
||||
initializeAuth();
|
||||
}, [token, authType]);
|
||||
|
||||
useEffect(() => {
|
||||
// Silent session renewal: refresh the OIDC session JWT shortly before
|
||||
// it expires so users never hit a mid-session login redirect. Re-runs
|
||||
// on every token change, which is what reschedules the timer after a
|
||||
// successful renewal.
|
||||
if (authType !== 'oidc') return;
|
||||
const remaining = token ? getJwtRemainingMs(token) : null;
|
||||
if (
|
||||
!token ||
|
||||
remaining === null ||
|
||||
isJwtExpired(token) ||
|
||||
oidcRenewalUnavailable
|
||||
) {
|
||||
// No renewable session (initializeAuth owns expired-token recovery)
|
||||
// — drop any pending renewal/retry timer.
|
||||
if (oidcRenewalTimer !== null) {
|
||||
clearTimeout(oidcRenewalTimer);
|
||||
oidcRenewalTimer = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const scheduleRetry = () => {
|
||||
if (oidcRenewalTimer !== null) clearTimeout(oidcRenewalTimer);
|
||||
oidcRenewalTimer = setTimeout(renew, OIDC_RENEWAL_RETRY_MS);
|
||||
};
|
||||
|
||||
const renew = () => {
|
||||
if (oidcRenewalPromise) return; // single renewal in flight app-wide
|
||||
oidcRenewalPromise = (async () => {
|
||||
try {
|
||||
// Another tab may have renewed first — its rotation invalidated
|
||||
// our refresh token, so adopt the stored session instead of
|
||||
// making a doomed network call.
|
||||
const stored = localStorage.getItem('authToken');
|
||||
if (stored !== token) {
|
||||
dispatch(setToken(stored));
|
||||
return;
|
||||
}
|
||||
const response = await userService.refreshOidcSession(token);
|
||||
if (response.ok) {
|
||||
const { token: newToken } = await response.json();
|
||||
if (!newToken) {
|
||||
scheduleRetry();
|
||||
return;
|
||||
}
|
||||
oidcRenewalUnavailable = false;
|
||||
localStorage.setItem('authToken', newToken);
|
||||
// The token-keyed effect re-runs and schedules the next one.
|
||||
dispatch(setToken(newToken));
|
||||
return;
|
||||
}
|
||||
if (response.status === 404) {
|
||||
// no_refresh_token: stop scheduling for this session.
|
||||
oidcRenewalUnavailable = true;
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) {
|
||||
// Session unusable (expired/revoked/disabled) — drop the
|
||||
// token so initializeAuth walks through a fresh login.
|
||||
localStorage.removeItem('authToken');
|
||||
dispatch(setToken(null));
|
||||
return;
|
||||
}
|
||||
// 503 or unexpected status: transient — retry once in a minute.
|
||||
scheduleRetry();
|
||||
} catch {
|
||||
scheduleRetry(); // network error: transient
|
||||
} finally {
|
||||
oidcRenewalPromise = null;
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
// One timer app-wide: replace whatever an earlier run (or the other
|
||||
// hook instance) scheduled. A zero delay renews immediately.
|
||||
if (oidcRenewalTimer !== null) clearTimeout(oidcRenewalTimer);
|
||||
const delay = Math.min(
|
||||
Math.max(remaining - OIDC_RENEWAL_THRESHOLD_MS, 0),
|
||||
MAX_TIMER_DELAY_MS,
|
||||
);
|
||||
const timer = setTimeout(renew, delay);
|
||||
oidcRenewalTimer = timer;
|
||||
return () => {
|
||||
// Clear only the timer this effect run set — another instance may
|
||||
// have replaced it with its own since.
|
||||
if (oidcRenewalTimer === timer) {
|
||||
clearTimeout(timer);
|
||||
oidcRenewalTimer = null;
|
||||
}
|
||||
};
|
||||
}, [authType, token, dispatch]);
|
||||
|
||||
const handleTokenSubmit = (enteredToken: string) => {
|
||||
localStorage.setItem('authToken', enteredToken);
|
||||
dispatch(setToken(enteredToken));
|
||||
setShowTokenModal(false);
|
||||
};
|
||||
return { authType, showTokenModal, isAuthLoading, token, handleTokenSubmit };
|
||||
|
||||
const retryOidcLogin = () => {
|
||||
sessionStorage.removeItem(OIDC_ATTEMPT_KEY);
|
||||
setOidcErrorCode(null);
|
||||
setOidcFailed(false);
|
||||
setIsAuthLoading(true);
|
||||
redirectToOidcLogin();
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('authToken');
|
||||
sessionStorage.removeItem(OIDC_ATTEMPT_KEY);
|
||||
sessionStorage.removeItem(OIDC_RETURN_TO_KEY);
|
||||
// Ends the IdP session too; the IdP redirects back to the app, which
|
||||
// then walks through a fresh login.
|
||||
window.location.href = `${baseURL}${endpoints.USER.OIDC_LOGOUT}`;
|
||||
};
|
||||
|
||||
const oidcClaims =
|
||||
authType === 'oidc' && token ? decodeJwtPayload(token) : null;
|
||||
const userEmail = claimString(oidcClaims?.email);
|
||||
const userName = claimString(oidcClaims?.name);
|
||||
const userPicture = claimString(oidcClaims?.picture);
|
||||
|
||||
return {
|
||||
authType,
|
||||
showTokenModal,
|
||||
isAuthLoading,
|
||||
token,
|
||||
handleTokenSubmit,
|
||||
oidcFailed,
|
||||
oidcErrorCode,
|
||||
oidcProviderName,
|
||||
retryOidcLogin,
|
||||
logout,
|
||||
userEmail,
|
||||
userName,
|
||||
userPicture,
|
||||
};
|
||||
}
|
||||
@@ -30,6 +30,15 @@
|
||||
"query": "Write potential questions for context"
|
||||
}
|
||||
],
|
||||
"auth": {
|
||||
"signInToContinue": "Sign in to continue to DocsGPT",
|
||||
"signInWithSSO": "Sign in with SSO",
|
||||
"signInWith": "Sign in with {{provider}}",
|
||||
"notAuthorized": "Your account isn't authorized to access this workspace. Sign in with a different account or contact your administrator.",
|
||||
"accountDisabled": "Your account has been disabled. Contact your administrator.",
|
||||
"signOut": "Sign out",
|
||||
"account": "Account"
|
||||
},
|
||||
"settings": {
|
||||
"label": "Settings",
|
||||
"general": {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { decodeJwtPayload, getJwtRemainingMs, isJwtExpired } from './jwtUtils';
|
||||
|
||||
const b64url = (value: object) =>
|
||||
btoa(JSON.stringify(value))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
|
||||
const makeToken = (payload: object) =>
|
||||
`${b64url({ alg: 'HS256', typ: 'JWT' })}.${b64url(payload)}.signature`;
|
||||
|
||||
describe('decodeJwtPayload', () => {
|
||||
it('decodes the payload of a well-formed token', () => {
|
||||
const token = makeToken({ sub: 'user-1', email: 'u@example.com' });
|
||||
expect(decodeJwtPayload(token)).toEqual({
|
||||
sub: 'user-1',
|
||||
email: 'u@example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for garbage input', () => {
|
||||
expect(decodeJwtPayload('not-a-jwt')).toBeNull();
|
||||
expect(decodeJwtPayload('a.%%%.c')).toBeNull();
|
||||
expect(decodeJwtPayload('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isJwtExpired', () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
it('returns true for an expired token', () => {
|
||||
expect(isJwtExpired(makeToken({ sub: 'u', exp: now - 3600 }))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for a token expiring well in the future', () => {
|
||||
expect(isJwtExpired(makeToken({ sub: 'u', exp: now + 3600 }))).toBe(false);
|
||||
});
|
||||
|
||||
it('treats tokens inside the skew window as expired', () => {
|
||||
expect(isJwtExpired(makeToken({ sub: 'u', exp: now + 10 }))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when the exp claim is absent (legacy tokens)', () => {
|
||||
expect(isJwtExpired(makeToken({ sub: 'local' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for undecodable tokens', () => {
|
||||
expect(isJwtExpired('garbage')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getJwtRemainingMs', () => {
|
||||
it('returns the milliseconds left until expiry', () => {
|
||||
const exp = Math.floor(Date.now() / 1000) + 3600;
|
||||
const remaining = getJwtRemainingMs(makeToken({ sub: 'u', exp }));
|
||||
expect(remaining).not.toBeNull();
|
||||
// exp is truncated to whole seconds, so allow up to 1s of slack.
|
||||
expect(remaining!).toBeGreaterThan(3599_000 - 1000);
|
||||
expect(remaining!).toBeLessThanOrEqual(3600_000);
|
||||
});
|
||||
|
||||
it('returns a negative value for an already-expired token', () => {
|
||||
const exp = Math.floor(Date.now() / 1000) - 60;
|
||||
const remaining = getJwtRemainingMs(makeToken({ sub: 'u', exp }));
|
||||
expect(remaining).not.toBeNull();
|
||||
expect(remaining!).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('returns null when the exp claim is absent', () => {
|
||||
expect(getJwtRemainingMs(makeToken({ sub: 'u' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the exp claim is not a number', () => {
|
||||
expect(getJwtRemainingMs(makeToken({ sub: 'u', exp: 'soon' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for undecodable tokens', () => {
|
||||
expect(getJwtRemainingMs('garbage')).toBeNull();
|
||||
expect(getJwtRemainingMs('')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
export function decodeJwtPayload(
|
||||
token: string,
|
||||
): Record<string, unknown> | null {
|
||||
try {
|
||||
const segment = token.split('.')[1];
|
||||
if (!segment) return null;
|
||||
const base64 = segment.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = base64.padEnd(
|
||||
base64.length + ((4 - (base64.length % 4)) % 4),
|
||||
'=',
|
||||
);
|
||||
return JSON.parse(atob(padded));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isJwtExpired(token: string, skewMs = 30000): boolean {
|
||||
const exp = decodeJwtPayload(token)?.exp;
|
||||
// Tokens without an exp claim (simple_jwt / session_jwt) never expire.
|
||||
if (typeof exp !== 'number') return false;
|
||||
return exp * 1000 <= Date.now() + skewMs;
|
||||
}
|
||||
|
||||
export function getJwtRemainingMs(token: string): number | null {
|
||||
const exp = decodeJwtPayload(token)?.exp;
|
||||
if (typeof exp !== 'number') return null;
|
||||
return exp * 1000 - Date.now();
|
||||
}
|
||||
+22
-1
@@ -46,10 +46,31 @@ export FLASK_DEBUG_MODE="false"
|
||||
# -----------------------------------------------------------------------------
|
||||
# Auth (specs can override AUTH_TYPE per-launch via process env)
|
||||
# -----------------------------------------------------------------------------
|
||||
export AUTH_TYPE="session_jwt"
|
||||
export AUTH_TYPE="${AUTH_TYPE:-session_jwt}"
|
||||
export JWT_SECRET_KEY="e2e-fixed-secret-never-use-in-prod"
|
||||
export ENCRYPTION_SECRET_KEY="e2e-fixed-encryption-key-never-use-in-prod"
|
||||
|
||||
# OIDC mode (AUTH_TYPE=oidc) — points at the mock IdP that oidc.spec.ts
|
||||
# spawns on demand (scripts/e2e/mock_oidc_idp.py, port 7999). Discovery is
|
||||
# lazy, so Flask boots fine before the IdP is up. Every OIDC_* var is pinned
|
||||
# here because the app's load_dotenv() walks up and reads the repo .env —
|
||||
# whatever a developer keeps there must not leak into the e2e stack.
|
||||
if [[ "${AUTH_TYPE}" == "oidc" ]]; then
|
||||
export OIDC_ISSUER="${OIDC_ISSUER:-http://127.0.0.1:7999}"
|
||||
export OIDC_CLIENT_ID="${OIDC_CLIENT_ID:-docsgpt-e2e}"
|
||||
export OIDC_FRONTEND_URL="${OIDC_FRONTEND_URL:-http://127.0.0.1:5179}"
|
||||
export OIDC_CLIENT_SECRET="${OIDC_CLIENT_SECRET:-}"
|
||||
export OIDC_SCOPES="${OIDC_SCOPES:-openid profile email}"
|
||||
export OIDC_USER_ID_CLAIM="${OIDC_USER_ID_CLAIM:-sub}"
|
||||
export OIDC_REDIRECT_URI="${OIDC_REDIRECT_URI:-}"
|
||||
export OIDC_SESSION_LIFETIME_SECONDS="${OIDC_SESSION_LIFETIME_SECONDS:-28800}"
|
||||
export OIDC_PROVIDER_NAME="${OIDC_PROVIDER_NAME:-}"
|
||||
export OIDC_ALLOWED_GROUPS="${OIDC_ALLOWED_GROUPS:-}"
|
||||
export OIDC_GROUPS_CLAIM="${OIDC_GROUPS_CLAIM:-groups}"
|
||||
export SCIM_ENABLED="${SCIM_ENABLED:-false}"
|
||||
export SCIM_TOKEN="${SCIM_TOKEN:-}"
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# LLM → mock stub on 127.0.0.1:7899
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
"""Mock OpenID Connect provider for the DocsGPT e2e suite and local dev.
|
||||
|
||||
Speaks the minimum OIDC surface the backend's ``AUTH_TYPE=oidc`` flow needs:
|
||||
|
||||
* ``GET /.well-known/openid-configuration`` (discovery)
|
||||
* ``GET /authorize`` (auto-approves and redirects back with a code)
|
||||
* ``POST /token`` (single-use code + PKCE S256 check, RS256 ``id_token``,
|
||||
single-use rotating ``refresh_token``; also ``grant_type=refresh_token``)
|
||||
* ``GET /jwks`` (public key for ID-token verification)
|
||||
* ``GET /userinfo`` (Bearer access token from ``/token``)
|
||||
* ``GET /end-session`` (honors ``post_logout_redirect_uri``)
|
||||
* ``POST /trigger-backchannel-logout`` (test hook: signs and delivers a
|
||||
back-channel logout token to a given URL)
|
||||
* ``GET /healthz`` (liveness probe)
|
||||
|
||||
There is no login form: every ``/authorize`` request is approved as the user
|
||||
configured via ``MOCK_OIDC_SUB`` / ``MOCK_OIDC_EMAIL`` (overridable per
|
||||
request with ``?sub=``/``?email=`` for multi-user tests). Group membership
|
||||
comes from ``MOCK_OIDC_GROUPS`` (comma-separated).
|
||||
|
||||
Run standalone (does NOT import anything from ``application/``). Dependencies
|
||||
(flask, python-jose, cryptography, requests) are all in
|
||||
``application/requirements.txt``.
|
||||
|
||||
Usage::
|
||||
|
||||
python scripts/e2e/mock_oidc_idp.py
|
||||
|
||||
Defaults to ``127.0.0.1:7999`` to match ``scripts/e2e/env.sh``'s
|
||||
``OIDC_ISSUER`` default for ``AUTH_TYPE=oidc`` runs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
import secrets
|
||||
import sys
|
||||
import time
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import requests
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from flask import Flask, Response, jsonify, redirect, request
|
||||
from jose import jwk
|
||||
from jose import jwt as jose_jwt
|
||||
|
||||
HOST = os.environ.get("MOCK_OIDC_HOST", "127.0.0.1")
|
||||
PORT = int(os.environ.get("MOCK_OIDC_PORT", "7999"))
|
||||
ISSUER = f"http://{HOST}:{PORT}"
|
||||
DEFAULT_SUB = os.environ.get("MOCK_OIDC_SUB", "mock-oidc-user")
|
||||
DEFAULT_EMAIL = os.environ.get("MOCK_OIDC_EMAIL", "mock-oidc-user@example.com")
|
||||
DEFAULT_NAME = os.environ.get("MOCK_OIDC_NAME", "Mock OIDC User")
|
||||
DEFAULT_GROUPS = [
|
||||
group.strip()
|
||||
for group in os.environ.get("MOCK_OIDC_GROUPS", "docsgpt-users").split(",")
|
||||
if group.strip()
|
||||
]
|
||||
DEFAULT_CLIENT_ID = os.environ.get("MOCK_OIDC_CLIENT_ID", "docsgpt-e2e")
|
||||
ID_TOKEN_TTL_SECONDS = 300
|
||||
LOGOUT_TOKEN_TTL_SECONDS = 120
|
||||
BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout"
|
||||
# Random per process: each restart generates a fresh RSA key, and a fresh
|
||||
# kid lets relying parties detect the change via their kid-miss refetch
|
||||
# path instead of failing signature checks against a stale cached JWKS.
|
||||
KID = f"mock-oidc-key-{secrets.token_hex(4)}"
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
_private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
PRIVATE_PEM = _private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
).decode("ascii")
|
||||
PUBLIC_JWK = {
|
||||
**jwk.construct(PRIVATE_PEM, algorithm="RS256").public_key().to_dict(),
|
||||
"kid": KID,
|
||||
"use": "sig",
|
||||
}
|
||||
|
||||
# code -> {client_id, redirect_uri, code_challenge, nonce, sub, email, name, groups}
|
||||
_codes: dict[str, dict] = {}
|
||||
# access_token -> {client_id, sub, email, name, groups}
|
||||
_access_tokens: dict[str, dict] = {}
|
||||
# refresh_token -> {client_id, sub, email, name, groups} (single-use, rotated)
|
||||
_refresh_tokens: dict[str, dict] = {}
|
||||
_last_client_id: str | None = None
|
||||
|
||||
|
||||
def _log(message: str) -> None:
|
||||
sys.stderr.write(f"[mock-oidc] {message}\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
|
||||
def _pkce_challenge(verifier: str) -> str:
|
||||
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
||||
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _user_record(record: dict) -> dict:
|
||||
"""Identity fields carried from /authorize through tokens and userinfo."""
|
||||
return {
|
||||
"client_id": record["client_id"],
|
||||
"sub": record["sub"],
|
||||
"email": record["email"],
|
||||
"name": record["name"],
|
||||
"groups": list(record.get("groups") or DEFAULT_GROUPS),
|
||||
}
|
||||
|
||||
|
||||
def _issue_tokens(record: dict, nonce: str | None) -> dict:
|
||||
"""Mint an id_token (+ tracked access/refresh tokens) for ``record``."""
|
||||
now = int(time.time())
|
||||
claims = {
|
||||
"iss": ISSUER,
|
||||
"aud": record["client_id"],
|
||||
"sub": record["sub"],
|
||||
"email": record["email"],
|
||||
"name": record["name"],
|
||||
"groups": list(record.get("groups") or DEFAULT_GROUPS),
|
||||
"iat": now,
|
||||
"exp": now + ID_TOKEN_TTL_SECONDS,
|
||||
}
|
||||
if nonce:
|
||||
claims["nonce"] = nonce
|
||||
id_token = jose_jwt.encode(claims, PRIVATE_PEM, algorithm="RS256", headers={"kid": KID})
|
||||
access_token = secrets.token_urlsafe(24)
|
||||
refresh_token = secrets.token_urlsafe(24)
|
||||
_access_tokens[access_token] = _user_record(record)
|
||||
_refresh_tokens[refresh_token] = _user_record(record)
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": ID_TOKEN_TTL_SECONDS,
|
||||
"id_token": id_token,
|
||||
"refresh_token": refresh_token,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/.well-known/openid-configuration")
|
||||
def discovery() -> Response:
|
||||
return jsonify(
|
||||
{
|
||||
"issuer": ISSUER,
|
||||
"authorization_endpoint": f"{ISSUER}/authorize",
|
||||
"token_endpoint": f"{ISSUER}/token",
|
||||
"jwks_uri": f"{ISSUER}/jwks",
|
||||
"userinfo_endpoint": f"{ISSUER}/userinfo",
|
||||
"end_session_endpoint": f"{ISSUER}/end-session",
|
||||
"backchannel_logout_supported": True,
|
||||
"response_types_supported": ["code"],
|
||||
"grant_types_supported": ["authorization_code", "refresh_token"],
|
||||
"token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"],
|
||||
"id_token_signing_alg_values_supported": ["RS256"],
|
||||
"code_challenge_methods_supported": ["S256"],
|
||||
"scopes_supported": ["openid", "profile", "email"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.get("/jwks")
|
||||
def jwks() -> Response:
|
||||
return jsonify({"keys": [PUBLIC_JWK]})
|
||||
|
||||
|
||||
@app.get("/authorize")
|
||||
def authorize() -> Response:
|
||||
args = request.args
|
||||
missing = [
|
||||
name
|
||||
for name in ("client_id", "redirect_uri", "code_challenge", "state")
|
||||
if not args.get(name)
|
||||
]
|
||||
if missing:
|
||||
return Response(f"missing params: {', '.join(missing)}", status=400)
|
||||
if args.get("response_type") != "code":
|
||||
return Response("unsupported response_type", status=400)
|
||||
if args.get("code_challenge_method") != "S256":
|
||||
return Response("unsupported code_challenge_method", status=400)
|
||||
|
||||
code = secrets.token_urlsafe(24)
|
||||
_codes[code] = {
|
||||
"client_id": args["client_id"],
|
||||
"redirect_uri": args["redirect_uri"],
|
||||
"code_challenge": args["code_challenge"],
|
||||
"nonce": args.get("nonce"),
|
||||
"sub": args.get("sub") or DEFAULT_SUB,
|
||||
"email": args.get("email") or DEFAULT_EMAIL,
|
||||
"name": DEFAULT_NAME,
|
||||
"groups": list(DEFAULT_GROUPS),
|
||||
}
|
||||
_log(f"authorize: auto-approved sub={_codes[code]['sub']}")
|
||||
separator = "&" if "?" in args["redirect_uri"] else "?"
|
||||
query = urlencode({"code": code, "state": args["state"]})
|
||||
return redirect(f"{args['redirect_uri']}{separator}{query}", code=302)
|
||||
|
||||
|
||||
@app.post("/token")
|
||||
def token() -> Response:
|
||||
global _last_client_id
|
||||
form = request.form
|
||||
grant_type = form.get("grant_type")
|
||||
|
||||
if grant_type == "refresh_token":
|
||||
record = _refresh_tokens.pop(form.get("refresh_token", ""), None) # single-use
|
||||
if record is None:
|
||||
_log("token: unknown or reused refresh_token")
|
||||
return jsonify({"error": "invalid_grant"}), 400
|
||||
_last_client_id = record["client_id"]
|
||||
_log(f"token: refreshed tokens for sub={record['sub']}")
|
||||
return jsonify(_issue_tokens(record, nonce=None))
|
||||
|
||||
if grant_type != "authorization_code":
|
||||
return jsonify({"error": "unsupported_grant_type"}), 400
|
||||
record = _codes.pop(form.get("code", ""), None) # single-use
|
||||
if record is None:
|
||||
_log("token: unknown or replayed code")
|
||||
return jsonify({"error": "invalid_grant"}), 400
|
||||
if form.get("redirect_uri") != record["redirect_uri"]:
|
||||
return jsonify({"error": "invalid_grant", "error_description": "redirect_uri mismatch"}), 400
|
||||
if form.get("client_id") != record["client_id"]:
|
||||
return jsonify({"error": "invalid_client"}), 400
|
||||
verifier = form.get("code_verifier", "")
|
||||
if not verifier or _pkce_challenge(verifier) != record["code_challenge"]:
|
||||
_log("token: PKCE verification failed")
|
||||
return jsonify({"error": "invalid_grant", "error_description": "PKCE failed"}), 400
|
||||
|
||||
_last_client_id = record["client_id"]
|
||||
_log(f"token: issued id_token for sub={record['sub']}")
|
||||
return jsonify(_issue_tokens(record, nonce=record["nonce"]))
|
||||
|
||||
|
||||
@app.get("/userinfo")
|
||||
def userinfo() -> Response:
|
||||
header = request.headers.get("Authorization", "")
|
||||
access_token = header[len("Bearer "):] if header.startswith("Bearer ") else ""
|
||||
record = _access_tokens.get(access_token)
|
||||
if record is None:
|
||||
_log("userinfo: unknown access token")
|
||||
return jsonify({"error": "invalid_token"}), 401
|
||||
return jsonify(
|
||||
{
|
||||
"sub": record["sub"],
|
||||
"email": record["email"],
|
||||
"name": record["name"],
|
||||
"groups": record["groups"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.post("/trigger-backchannel-logout")
|
||||
def trigger_backchannel_logout() -> Response:
|
||||
"""Test hook: sign a back-channel logout token and POST it to ``url``."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
url = body.get("url")
|
||||
sub = body.get("sub")
|
||||
sid = body.get("sid")
|
||||
if not url or not (sub or sid):
|
||||
return jsonify({"error": "url and sub (or sid) required"}), 400
|
||||
|
||||
now = int(time.time())
|
||||
claims = {
|
||||
"iss": ISSUER,
|
||||
"aud": body.get("client_id") or _last_client_id or DEFAULT_CLIENT_ID,
|
||||
"iat": now,
|
||||
"exp": now + LOGOUT_TOKEN_TTL_SECONDS,
|
||||
"jti": secrets.token_urlsafe(16),
|
||||
"events": {BACKCHANNEL_LOGOUT_EVENT: {}},
|
||||
}
|
||||
if sub:
|
||||
claims["sub"] = sub
|
||||
if sid:
|
||||
claims["sid"] = sid
|
||||
logout_token = jose_jwt.encode(claims, PRIVATE_PEM, algorithm="RS256", headers={"kid": KID})
|
||||
try:
|
||||
downstream = requests.post(url, data={"logout_token": logout_token}, timeout=10)
|
||||
except requests.RequestException as exc:
|
||||
_log(f"trigger-backchannel-logout: delivery to {url} failed: {exc}")
|
||||
return jsonify({"error": "delivery_failed", "detail": str(exc)}), 502
|
||||
_log(f"trigger-backchannel-logout: {url} responded {downstream.status_code}")
|
||||
return jsonify({"status": downstream.status_code})
|
||||
|
||||
|
||||
@app.get("/end-session")
|
||||
def end_session() -> Response:
|
||||
target = request.args.get("post_logout_redirect_uri")
|
||||
_log(f"end-session: redirect={target or '<none>'}")
|
||||
if target:
|
||||
return redirect(target, code=302)
|
||||
return Response("Signed out of mock IdP.", status=200, mimetype="text/plain")
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz() -> Response:
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
def main() -> None:
|
||||
_log(f"listening on {ISSUER} (sub={DEFAULT_SUB}, groups={','.join(DEFAULT_GROUPS)})")
|
||||
app.run(host=HOST, port=PORT, debug=False, use_reloader=False, threaded=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Spawn/stop the mock OIDC IdP (scripts/e2e/mock_oidc_idp.py) for oidc specs.
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const MOCK_OIDC_PORT = Number(process.env.MOCK_OIDC_PORT ?? 7999);
|
||||
export const MOCK_OIDC_ISSUER = `http://127.0.0.1:${MOCK_OIDC_PORT}`;
|
||||
|
||||
/**
|
||||
* Start the mock IdP as a child process and wait for `/healthz`. The backend
|
||||
* must have been booted with `AUTH_TYPE=oidc` (see scripts/e2e/env.sh for the
|
||||
* matching OIDC_* defaults). Returns a `stop()` that SIGTERMs the child.
|
||||
*/
|
||||
export async function startMockIdp(): Promise<{ stop: () => void }> {
|
||||
const repoRoot = path.resolve(
|
||||
fileURLToPath(new URL('../../..', import.meta.url)),
|
||||
);
|
||||
const python =
|
||||
process.env.E2E_PYTHON ?? path.join(repoRoot, '.venv', 'bin', 'python');
|
||||
const child = spawn(
|
||||
python,
|
||||
[path.join(repoRoot, 'scripts', 'e2e', 'mock_oidc_idp.py')],
|
||||
{
|
||||
env: { ...process.env, MOCK_OIDC_PORT: String(MOCK_OIDC_PORT) },
|
||||
// stderr passes through so PKCE/code failures show up in test output.
|
||||
stdio: ['ignore', 'ignore', 'inherit'],
|
||||
},
|
||||
);
|
||||
const stop = () => {
|
||||
if (!child.killed) child.kill('SIGTERM');
|
||||
};
|
||||
|
||||
const deadline = Date.now() + 15_000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const res = await fetch(`${MOCK_OIDC_ISSUER}/healthz`);
|
||||
if (res.ok) return { stop };
|
||||
} catch {
|
||||
// not listening yet
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
stop();
|
||||
throw new Error(
|
||||
`mock OIDC IdP did not become healthy on ${MOCK_OIDC_ISSUER} within 15s`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* OIDC SSO auth flow (AUTH_TYPE=oidc).
|
||||
*
|
||||
* Covers the backend-driven Authorization Code + PKCE flow: the SPA
|
||||
* auto-redirects to `/api/auth/oidc/login`, the IdP (a local mock —
|
||||
* scripts/e2e/mock_oidc_idp.py — spawned by this spec) auto-approves, the
|
||||
* callback mints a local HS256 session JWT and hands it to the SPA via a
|
||||
* single-use `#oidc_code=` fragment, which the SPA exchanges and stores as
|
||||
* `localStorage.authToken`.
|
||||
*
|
||||
* HOW TO RUN THIS SPEC
|
||||
* --------------------
|
||||
* Only one backend auth mode can be live per suite run, so this spec
|
||||
* self-skips unless the harness is in oidc mode:
|
||||
*
|
||||
* 1. Boot the stack in oidc mode: `AUTH_TYPE=oidc scripts/e2e/up.sh`
|
||||
* (env.sh fills in OIDC_ISSUER/OIDC_CLIENT_ID/OIDC_FRONTEND_URL
|
||||
* defaults pointing at the mock IdP on :7999).
|
||||
* 2. Un-skip and run: `E2E_AUTH_MODE=oidc npx playwright test specs/auth/oidc.spec.ts`.
|
||||
* 3. The mock IdP itself is started/stopped by this spec's beforeAll /
|
||||
* afterAll — no extra terminal needed.
|
||||
*/
|
||||
|
||||
import * as playwright from '@playwright/test';
|
||||
const { expect, test } = playwright;
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
import { authedRequest } from '../../helpers/api.js';
|
||||
import { getUserRow } from '../../helpers/db.js';
|
||||
import { MOCK_OIDC_ISSUER, startMockIdp } from '../../helpers/oidc.js';
|
||||
import { resetDb } from '../../helpers/reset.js';
|
||||
|
||||
const OIDC_SUB = 'mock-oidc-user';
|
||||
const API_URL = process.env.API_URL ?? 'http://127.0.0.1:7099';
|
||||
|
||||
test.describe('auth · oidc', () => {
|
||||
test.skip(
|
||||
process.env.E2E_AUTH_MODE !== 'oidc',
|
||||
'Only runs when the backend is booted with AUTH_TYPE=oidc. '
|
||||
+ 'Re-run with `AUTH_TYPE=oidc scripts/e2e/up.sh` then '
|
||||
+ '`E2E_AUTH_MODE=oidc npx playwright test specs/auth/oidc.spec.ts`.',
|
||||
);
|
||||
|
||||
let idp: { stop: () => void };
|
||||
|
||||
test.beforeAll(async () => {
|
||||
idp = await startMockIdp();
|
||||
});
|
||||
|
||||
test.afterAll(() => {
|
||||
idp?.stop();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetDb();
|
||||
});
|
||||
|
||||
test('oidc: full browser login — redirect to IdP, back, session stored, API authed', async ({
|
||||
browser,
|
||||
}) => {
|
||||
// Plain context: no token injection — the whole point is the redirect flow.
|
||||
const context = await browser.newContext();
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
await page.goto('/');
|
||||
|
||||
// SPA discovers oidc mode, bounces through the IdP, and lands back
|
||||
// authed — the nav chrome appearing means the loop completed.
|
||||
await expect(
|
||||
page.getByRole('link', { name: /new chat/i }).first(),
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
const storedToken = await page.evaluate(() =>
|
||||
window.localStorage.getItem('authToken'),
|
||||
);
|
||||
expect(storedToken).toBeTruthy();
|
||||
const decoded = jwt.decode(storedToken as string) as Record<string, unknown>;
|
||||
expect(decoded.sub).toBe(OIDC_SUB);
|
||||
expect(decoded.email).toBe('mock-oidc-user@example.com');
|
||||
expect(typeof decoded.exp).toBe('number');
|
||||
|
||||
// The handoff fragment must not linger in the address bar.
|
||||
expect(page.url()).not.toContain('oidc_code');
|
||||
|
||||
// Minted session works against the API and upserts the users row.
|
||||
const api = await authedRequest(playwright, storedToken as string);
|
||||
try {
|
||||
const res = await api.get('/api/get_prompts');
|
||||
expect(res.status()).toBe(200);
|
||||
const userRow = await getUserRow(OIDC_SUB);
|
||||
expect(userRow).not.toBeNull();
|
||||
expect(userRow?.user_id).toBe(OIDC_SUB);
|
||||
} finally {
|
||||
await api.dispose();
|
||||
}
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('oidc: sign out hits the IdP end-session and re-login works', async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await browser.newContext();
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
|
||||
const endSessionRequests: string[] = [];
|
||||
page.on('request', (req) => {
|
||||
if (req.url().startsWith(`${MOCK_OIDC_ISSUER}/end-session`)) {
|
||||
endSessionRequests.push(req.url());
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await expect(
|
||||
page.getByRole('link', { name: /new chat/i }).first(),
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await page.getByTestId('oidc-signout').click();
|
||||
|
||||
// Logout chain: backend /logout → IdP end-session → back to the app,
|
||||
// which (mock IdP auto-approves) walks straight through a fresh login.
|
||||
await expect(
|
||||
page.getByRole('link', { name: /new chat/i }).first(),
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
expect(endSessionRequests.length).toBeGreaterThan(0);
|
||||
expect(endSessionRequests[0]).toContain('post_logout_redirect_uri');
|
||||
|
||||
const tokenAfter = await page.evaluate(() =>
|
||||
window.localStorage.getItem('authToken'),
|
||||
);
|
||||
expect(tokenAfter).toBeTruthy();
|
||||
expect((jwt.decode(tokenAfter as string) as Record<string, unknown>).sub).toBe(
|
||||
OIDC_SUB,
|
||||
);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('oidc: handoff code is single-use (API-level redirect walk)', async () => {
|
||||
const api = await playwright.request.newContext({ baseURL: API_URL });
|
||||
try {
|
||||
const login = await api.get('/api/auth/oidc/login', { maxRedirects: 0 });
|
||||
expect(login.status()).toBe(302);
|
||||
const authorizeUrl = login.headers()['location'];
|
||||
expect(authorizeUrl).toContain(`${MOCK_OIDC_ISSUER}/authorize`);
|
||||
expect(authorizeUrl).toContain('code_challenge_method=S256');
|
||||
|
||||
const authorize = await api.get(authorizeUrl, { maxRedirects: 0 });
|
||||
expect(authorize.status()).toBe(302);
|
||||
const callbackUrl = authorize.headers()['location'];
|
||||
expect(callbackUrl).toContain('/api/auth/oidc/callback');
|
||||
|
||||
const callback = await api.get(callbackUrl, { maxRedirects: 0 });
|
||||
expect(callback.status()).toBe(302);
|
||||
const frontendUrl = callback.headers()['location'];
|
||||
expect(frontendUrl).toContain('#oidc_code=');
|
||||
const code = frontendUrl.split('#oidc_code=')[1];
|
||||
|
||||
const first = await api.post('/api/auth/oidc/token', {
|
||||
data: { code },
|
||||
});
|
||||
expect(first.status()).toBe(200);
|
||||
const { token } = await first.json();
|
||||
expect((jwt.decode(token) as Record<string, unknown>).sub).toBe(OIDC_SUB);
|
||||
|
||||
const replay = await api.post('/api/auth/oidc/token', {
|
||||
data: { code },
|
||||
});
|
||||
expect(replay.status()).toBe(401);
|
||||
} finally {
|
||||
await api.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Integration tests for the SCIM 2.0 endpoints against a live Postgres.
|
||||
|
||||
These tests drive the real Flask app through its test client and verify
|
||||
row state in the database configured by ``POSTGRES_URI``. Redis is not
|
||||
required — the denylist functions are stubbed. They are skipped by the
|
||||
default ``pytest`` run (``--ignore=tests/integration`` in ``pytest.ini``)
|
||||
and marked ``@pytest.mark.integration``. Run them locally with::
|
||||
|
||||
.venv/bin/python -m pytest tests/integration/test_scim.py -q --no-cov \\
|
||||
-p no:cacheprovider --override-ini "addopts="
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
|
||||
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
|
||||
|
||||
SCIM_TOKEN = "scim-test-token"
|
||||
AUTH = {"Authorization": f"Bearer {SCIM_TOKEN}"}
|
||||
|
||||
ERROR_URN = "urn:ietf:params:scim:api:messages:2.0:Error"
|
||||
PATCH_OP_URN = "urn:ietf:params:scim:api:messages:2.0:PatchOp"
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.integration,
|
||||
pytest.mark.skipif(
|
||||
not settings.POSTGRES_URI,
|
||||
reason="POSTGRES_URI not set — skipping SCIM integration tests",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app():
|
||||
"""Real Flask app; /scim/ paths bypass JWT auth so no handle_auth patching is needed."""
|
||||
from application.app import app as flask_app
|
||||
|
||||
flask_app.config["TESTING"] = True
|
||||
return flask_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scim_env(monkeypatch):
|
||||
"""Enable SCIM on the settings singleton and stub the Redis denylist."""
|
||||
monkeypatch.setattr(settings, "SCIM_ENABLED", True)
|
||||
monkeypatch.setattr(settings, "SCIM_TOKEN", SCIM_TOKEN)
|
||||
with patch("application.api.scim.routes.deny_user") as deny_user_mock:
|
||||
yield SimpleNamespace(deny_user=deny_user_mock)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scim_user_name():
|
||||
"""Unique per-run userName; deletes the user + audit rows afterwards."""
|
||||
name = f"scim-it-{uuid.uuid4().hex[:10]}@example.com"
|
||||
yield name
|
||||
with db_session() as conn:
|
||||
conn.execute(text("DELETE FROM auth_events WHERE user_id = :user_id"), {"user_id": name})
|
||||
conn.execute(text("DELETE FROM users WHERE user_id = :user_id"), {"user_id": name})
|
||||
|
||||
|
||||
def _fetch_user(user_name: str):
|
||||
with db_readonly() as conn:
|
||||
return UsersRepository(conn).get(user_name)
|
||||
|
||||
|
||||
def _fetch_events(user_name: str):
|
||||
with db_readonly() as conn:
|
||||
return AuthEventsRepository(conn).list_recent(user_name)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestScimLifecycle:
|
||||
|
||||
def test_full_user_lifecycle(self, client, scim_env, scim_user_name):
|
||||
# Create
|
||||
response = client.post("/scim/v2/Users", headers=AUTH, json={"userName": scim_user_name})
|
||||
assert response.status_code == 201, response.get_data(as_text=True)
|
||||
created = response.get_json()
|
||||
pk = created["id"]
|
||||
assert created["userName"] == scim_user_name
|
||||
assert created["active"] is True
|
||||
assert created["emails"] == [{"value": scim_user_name, "primary": True}]
|
||||
assert response.headers["Location"].endswith(f"/scim/v2/Users/{pk}")
|
||||
|
||||
row = _fetch_user(scim_user_name)
|
||||
assert row is not None
|
||||
assert row["active"] is True
|
||||
assert [event["event"] for event in _fetch_events(scim_user_name)] == ["scim_created"]
|
||||
|
||||
# GET by id
|
||||
response = client.get(f"/scim/v2/Users/{pk}", headers=AUTH)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["id"] == pk
|
||||
|
||||
# List with exact userName filter
|
||||
response = client.get(
|
||||
"/scim/v2/Users",
|
||||
headers=AUTH,
|
||||
query_string={"filter": f'userName eq "{scim_user_name}"'},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.get_json()
|
||||
assert body["totalResults"] == 1
|
||||
assert body["itemsPerPage"] == 1
|
||||
assert body["Resources"][0]["id"] == pk
|
||||
|
||||
# Deactivate via PATCH (Okta no-path form with string value)
|
||||
response = client.patch(
|
||||
f"/scim/v2/Users/{pk}",
|
||||
headers=AUTH,
|
||||
json={
|
||||
"schemas": [PATCH_OP_URN],
|
||||
"Operations": [{"op": "replace", "value": {"active": "False"}}],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["active"] is False
|
||||
assert _fetch_user(scim_user_name)["active"] is False
|
||||
deactivations = [e for e in _fetch_events(scim_user_name) if e["event"] == "scim_deactivated"]
|
||||
assert len(deactivations) == 1
|
||||
assert deactivations[0]["metadata"] == {"via": "scim"}
|
||||
scim_env.deny_user.assert_called_once_with(scim_user_name)
|
||||
|
||||
# Reactivate via PUT (full replace; only active is honored)
|
||||
response = client.put(
|
||||
f"/scim/v2/Users/{pk}",
|
||||
headers=AUTH,
|
||||
json={"userName": scim_user_name, "active": True},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["active"] is True
|
||||
assert _fetch_user(scim_user_name)["active"] is True
|
||||
# Reactivation no longer clears the denylist (watermark model).
|
||||
scim_env.deny_user.assert_called_once_with(scim_user_name)
|
||||
assert any(e["event"] == "scim_reactivated" for e in _fetch_events(scim_user_name))
|
||||
|
||||
# Soft delete deactivates again
|
||||
response = client.delete(f"/scim/v2/Users/{pk}", headers=AUTH)
|
||||
assert response.status_code == 204
|
||||
assert response.data == b""
|
||||
assert _fetch_user(scim_user_name)["active"] is False
|
||||
assert scim_env.deny_user.call_count == 2
|
||||
|
||||
# Duplicate create conflicts (row still exists after soft delete)
|
||||
response = client.post("/scim/v2/Users", headers=AUTH, json={"userName": scim_user_name})
|
||||
assert response.status_code == 409
|
||||
body = response.get_json()
|
||||
assert body["schemas"] == [ERROR_URN]
|
||||
assert body["scimType"] == "uniqueness"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestScimAccess:
|
||||
|
||||
def test_wrong_bearer_token_rejected(self, client, scim_env):
|
||||
response = client.get("/scim/v2/Users", headers={"Authorization": "Bearer wrong"})
|
||||
assert response.status_code == 401
|
||||
assert response.get_json()["schemas"] == [ERROR_URN]
|
||||
|
||||
def test_get_user_with_malformed_uuid_returns_404(self, client, scim_env):
|
||||
response = client.get("/scim/v2/Users/not-a-uuid", headers=AUTH)
|
||||
assert response.status_code == 404
|
||||
assert response.get_json()["status"] == "404"
|
||||
|
||||
def test_service_provider_config_served(self, client, scim_env):
|
||||
response = client.get("/scim/v2/ServiceProviderConfig", headers=AUTH)
|
||||
assert response.status_code == 200
|
||||
assert response.content_type.startswith("application/scim+json")
|
||||
assert response.get_json()["patch"] == {"supported": True}
|
||||
@@ -43,11 +43,31 @@ class TestConfigRoute:
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_returns_auth_config(self, client):
|
||||
response = client.get("/api/config")
|
||||
# Pin AUTH_TYPE so the assertion doesn't depend on the dev .env.
|
||||
with patch("application.app.settings") as mock_settings:
|
||||
mock_settings.AUTH_TYPE = None
|
||||
response = client.get("/api/config")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert "auth_type" in data
|
||||
assert "requires_auth" in data
|
||||
assert "oidc" not in data
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_oidc_config_exposes_login_paths(self, client):
|
||||
with patch("application.app.settings") as mock_settings:
|
||||
mock_settings.AUTH_TYPE = "oidc"
|
||||
mock_settings.OIDC_PROVIDER_NAME = "Test SSO"
|
||||
response = client.get("/api/config")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data["auth_type"] == "oidc"
|
||||
assert data["requires_auth"] is True
|
||||
assert data["oidc"] == {
|
||||
"login_path": "/api/auth/oidc/login",
|
||||
"logout_path": "/api/auth/oidc/logout",
|
||||
"provider_name": "Test SSO",
|
||||
}
|
||||
|
||||
|
||||
class TestGenerateTokenRoute:
|
||||
@@ -104,6 +124,23 @@ class TestAuthenticateRequest:
|
||||
response = client.get("/api/health")
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_oidc_auth_paths_exempt_from_jwt_check(self, client, app):
|
||||
# A stale/expired Bearer header must never 401 the oidc login
|
||||
# endpoints — they are the only path back to a fresh session. The oidc
|
||||
# routes are only live under AUTH_TYPE=oidc, so pin it here.
|
||||
from application.core.settings import settings as _settings
|
||||
|
||||
with patch(
|
||||
"application.app.handle_auth", return_value={"error": "invalid_token"}
|
||||
), patch(
|
||||
"application.api.oidc.routes.get_redis_instance", return_value=None
|
||||
), patch.object(_settings, "AUTH_TYPE", "oidc"):
|
||||
response = client.get(
|
||||
"/api/auth/oidc/login", headers={"Authorization": "Bearer garbage"}
|
||||
)
|
||||
assert response.status_code == 503 # redis guard, not a 401
|
||||
|
||||
|
||||
class TestFlaskCors:
|
||||
|
||||
|
||||
+136
-1
@@ -46,7 +46,7 @@ class TestHandleAuth:
|
||||
"valid_token",
|
||||
"secret",
|
||||
algorithms=["HS256"],
|
||||
options={"verify_exp": False},
|
||||
options={"verify_exp": False, "require_exp": False},
|
||||
)
|
||||
|
||||
def test_returns_error_on_invalid_jwt(self):
|
||||
@@ -81,3 +81,138 @@ class TestHandleAuth:
|
||||
|
||||
mock_jwt.decode.assert_called_once()
|
||||
assert mock_jwt.decode.call_args[0][0] == "my_token"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestHandleAuthOidc:
|
||||
"""AUTH_TYPE=oidc: same local HS256 session tokens, but exp is verified."""
|
||||
|
||||
def test_returns_none_when_no_jwt_header(self):
|
||||
from application.auth import handle_auth
|
||||
|
||||
mock_request = Mock()
|
||||
mock_request.headers.get.return_value = None
|
||||
with patch("application.auth.settings") as mock_settings:
|
||||
mock_settings.AUTH_TYPE = "oidc"
|
||||
result = handle_auth(mock_request)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_decodes_valid_jwt_with_exp_verification(self):
|
||||
from application.auth import handle_auth
|
||||
|
||||
mock_request = Mock()
|
||||
mock_request.headers.get.return_value = "Bearer valid_token"
|
||||
|
||||
with patch("application.auth.settings") as mock_settings, patch(
|
||||
"application.auth.jwt"
|
||||
) as mock_jwt:
|
||||
mock_settings.AUTH_TYPE = "oidc"
|
||||
mock_settings.JWT_SECRET_KEY = "secret"
|
||||
mock_jwt.decode.return_value = {"sub": "user123", "email": "u@example.com"}
|
||||
result = handle_auth(mock_request)
|
||||
|
||||
assert result == {"sub": "user123", "email": "u@example.com"}
|
||||
mock_jwt.decode.assert_called_once_with(
|
||||
"valid_token",
|
||||
"secret",
|
||||
algorithms=["HS256"],
|
||||
options={"verify_exp": True, "require_exp": True},
|
||||
)
|
||||
|
||||
def test_expired_token_returns_token_expired(self):
|
||||
from jose.exceptions import ExpiredSignatureError
|
||||
|
||||
from application.auth import handle_auth
|
||||
|
||||
mock_request = Mock()
|
||||
mock_request.headers.get.return_value = "Bearer stale_token"
|
||||
|
||||
with patch("application.auth.settings") as mock_settings, patch(
|
||||
"application.auth.jwt"
|
||||
) as mock_jwt:
|
||||
mock_settings.AUTH_TYPE = "oidc"
|
||||
mock_settings.JWT_SECRET_KEY = "secret"
|
||||
mock_jwt.decode.side_effect = ExpiredSignatureError("expired")
|
||||
result = handle_auth(mock_request)
|
||||
|
||||
assert result["error"] == "token_expired"
|
||||
|
||||
def test_invalid_token_returns_invalid_token(self):
|
||||
from application.auth import handle_auth
|
||||
|
||||
mock_request = Mock()
|
||||
mock_request.headers.get.return_value = "Bearer bad_token"
|
||||
|
||||
with patch("application.auth.settings") as mock_settings, patch(
|
||||
"application.auth.jwt"
|
||||
) as mock_jwt:
|
||||
mock_settings.AUTH_TYPE = "oidc"
|
||||
mock_settings.JWT_SECRET_KEY = "secret"
|
||||
mock_jwt.decode.side_effect = Exception("bad")
|
||||
result = handle_auth(mock_request)
|
||||
|
||||
assert result["error"] == "invalid_token"
|
||||
|
||||
def test_token_without_exp_rejected_under_oidc(self):
|
||||
# Under oidc, exp is REQUIRED: an exp-less HS256 token signed with the
|
||||
# shared secret (e.g. a legacy simple_jwt/session_jwt token) must not
|
||||
# authenticate, or it would be valid forever and unrevocable.
|
||||
from jose import jwt as real_jwt
|
||||
|
||||
from application.auth import handle_auth
|
||||
|
||||
token = real_jwt.encode({"sub": "helper_user"}, "secret", algorithm="HS256")
|
||||
mock_request = Mock()
|
||||
mock_request.headers.get.return_value = f"Bearer {token}"
|
||||
|
||||
with patch("application.auth.settings") as mock_settings:
|
||||
mock_settings.AUTH_TYPE = "oidc"
|
||||
mock_settings.JWT_SECRET_KEY = "secret"
|
||||
result = handle_auth(mock_request)
|
||||
|
||||
assert result["error"] == "invalid_token"
|
||||
|
||||
def test_expired_token_real_jose(self):
|
||||
import time
|
||||
|
||||
from jose import jwt as real_jwt
|
||||
|
||||
from application.auth import handle_auth
|
||||
|
||||
token = real_jwt.encode(
|
||||
{"sub": "helper_user", "exp": int(time.time()) - 3600},
|
||||
"secret",
|
||||
algorithm="HS256",
|
||||
)
|
||||
mock_request = Mock()
|
||||
mock_request.headers.get.return_value = f"Bearer {token}"
|
||||
|
||||
with patch("application.auth.settings") as mock_settings:
|
||||
mock_settings.AUTH_TYPE = "oidc"
|
||||
mock_settings.JWT_SECRET_KEY = "secret"
|
||||
result = handle_auth(mock_request)
|
||||
|
||||
assert result["error"] == "token_expired"
|
||||
|
||||
def test_simple_jwt_still_skips_exp_verification(self):
|
||||
import time
|
||||
|
||||
from jose import jwt as real_jwt
|
||||
|
||||
from application.auth import handle_auth
|
||||
|
||||
token = real_jwt.encode(
|
||||
{"sub": "local", "exp": int(time.time()) - 3600},
|
||||
"secret",
|
||||
algorithm="HS256",
|
||||
)
|
||||
mock_request = Mock()
|
||||
mock_request.headers.get.return_value = f"Bearer {token}"
|
||||
|
||||
with patch("application.auth.settings") as mock_settings:
|
||||
mock_settings.AUTH_TYPE = "simple_jwt"
|
||||
mock_settings.JWT_SECRET_KEY = "secret"
|
||||
result = handle_auth(mock_request)
|
||||
|
||||
assert result["sub"] == "local"
|
||||
+1501
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,591 @@
|
||||
"""Unit tests for the SCIM 2.0 provisioning endpoints (application/api/scim/)."""
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from application.core.settings import settings
|
||||
|
||||
TOKEN = "test-scim-token"
|
||||
AUTH = {"Authorization": f"Bearer {TOKEN}"}
|
||||
USER_PK = "3f1f2bd6-8a87-4f7e-9c2b-2f76c1a4f0d3"
|
||||
|
||||
ERROR_URN = "urn:ietf:params:scim:api:messages:2.0:Error"
|
||||
LIST_RESPONSE_URN = "urn:ietf:params:scim:api:messages:2.0:ListResponse"
|
||||
PATCH_OP_URN = "urn:ietf:params:scim:api:messages:2.0:PatchOp"
|
||||
USER_URN = "urn:ietf:params:scim:schemas:core:2.0:User"
|
||||
|
||||
|
||||
def _user_row(pk=USER_PK, user_id="alice@example.com", active=True):
|
||||
return {
|
||||
"id": pk,
|
||||
"user_id": user_id,
|
||||
"active": active,
|
||||
"agent_preferences": {"pinned": [], "shared_with_me": []},
|
||||
"tool_preferences": {},
|
||||
"created_at": "2026-06-09T10:00:00+00:00",
|
||||
"updated_at": "2026-06-09T11:00:00+00:00",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app():
|
||||
"""Import the Flask app with auth mocked to avoid JWT setup issues."""
|
||||
with patch("application.app.handle_auth", return_value={"sub": "test_user"}):
|
||||
from application.app import app as flask_app
|
||||
|
||||
flask_app.config["TESTING"] = True
|
||||
yield flask_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scim_settings(monkeypatch):
|
||||
"""Enable SCIM with a known bearer token on the real settings singleton."""
|
||||
monkeypatch.setattr(settings, "SCIM_ENABLED", True)
|
||||
monkeypatch.setattr(settings, "SCIM_TOKEN", TOKEN)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scim_mocks():
|
||||
"""Patch DB plumbing, repositories, and denylist functions used by the routes."""
|
||||
with patch("application.api.scim.routes.db_session") as db_session_mock, patch(
|
||||
"application.api.scim.routes.db_readonly"
|
||||
) as db_readonly_mock, patch(
|
||||
"application.api.scim.routes.UsersRepository"
|
||||
) as users_cls, patch(
|
||||
"application.api.scim.routes.AuthEventsRepository"
|
||||
) as audit_cls, patch(
|
||||
"application.api.scim.routes.deny_user"
|
||||
) as deny_user_mock:
|
||||
conn = MagicMock(name="conn")
|
||||
db_session_mock.return_value.__enter__.return_value = conn
|
||||
db_readonly_mock.return_value.__enter__.return_value = conn
|
||||
# Don't let the mocked context manager swallow exceptions (a real
|
||||
# ``engine.begin()`` __exit__ returns None) — _RevocationUnavailable
|
||||
# must propagate to the blueprint error handler.
|
||||
db_session_mock.return_value.__exit__.return_value = False
|
||||
db_readonly_mock.return_value.__exit__.return_value = False
|
||||
yield SimpleNamespace(
|
||||
users=users_cls.return_value,
|
||||
audit=audit_cls.return_value,
|
||||
deny_user=deny_user_mock,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestScimGate:
|
||||
|
||||
def test_disabled_returns_scim_404_everywhere(self, client, scim_mocks, monkeypatch):
|
||||
monkeypatch.setattr(settings, "SCIM_ENABLED", False)
|
||||
monkeypatch.setattr(settings, "SCIM_TOKEN", TOKEN)
|
||||
for path in ("/scim/v2/Users", "/scim/v2/ServiceProviderConfig", "/scim/v2/Groups"):
|
||||
response = client.get(path, headers=AUTH)
|
||||
assert response.status_code == 404
|
||||
body = response.get_json()
|
||||
assert body["schemas"] == [ERROR_URN]
|
||||
assert body["status"] == "404"
|
||||
scim_mocks.users.list_paginated.assert_not_called()
|
||||
|
||||
def test_enabled_without_token_returns_503(self, client, scim_mocks, monkeypatch):
|
||||
monkeypatch.setattr(settings, "SCIM_ENABLED", True)
|
||||
monkeypatch.setattr(settings, "SCIM_TOKEN", None)
|
||||
response = client.get("/scim/v2/Users", headers=AUTH)
|
||||
assert response.status_code == 503
|
||||
body = response.get_json()
|
||||
assert body["schemas"] == [ERROR_URN]
|
||||
assert body["status"] == "503"
|
||||
|
||||
def test_enabled_with_empty_token_returns_503(self, client, scim_mocks, monkeypatch):
|
||||
monkeypatch.setattr(settings, "SCIM_ENABLED", True)
|
||||
monkeypatch.setattr(settings, "SCIM_TOKEN", "")
|
||||
response = client.get("/scim/v2/Users", headers=AUTH)
|
||||
assert response.status_code == 503
|
||||
|
||||
def test_wrong_bearer_returns_401(self, client, scim_settings, scim_mocks):
|
||||
response = client.get("/scim/v2/Users", headers={"Authorization": "Bearer wrong-token"})
|
||||
assert response.status_code == 401
|
||||
assert response.get_json()["schemas"] == [ERROR_URN]
|
||||
scim_mocks.users.list_paginated.assert_not_called()
|
||||
|
||||
def test_missing_authorization_returns_401(self, client, scim_settings, scim_mocks):
|
||||
response = client.get("/scim/v2/Users")
|
||||
assert response.status_code == 401
|
||||
assert response.get_json()["status"] == "401"
|
||||
|
||||
def test_non_bearer_scheme_returns_401(self, client, scim_settings, scim_mocks):
|
||||
response = client.get("/scim/v2/Users", headers={"Authorization": f"Basic {TOKEN}"})
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_errors_use_scim_content_type(self, client, scim_settings, scim_mocks):
|
||||
response = client.get("/scim/v2/Users", headers={"Authorization": "Bearer nope"})
|
||||
assert response.content_type.startswith("application/scim+json")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDiscoveryEndpoints:
|
||||
|
||||
def test_service_provider_config_shape(self, client, scim_settings, scim_mocks):
|
||||
response = client.get("/scim/v2/ServiceProviderConfig", headers=AUTH)
|
||||
assert response.status_code == 200
|
||||
assert response.content_type.startswith("application/scim+json")
|
||||
body = response.get_json()
|
||||
assert body["schemas"] == ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"]
|
||||
assert body["patch"] == {"supported": True}
|
||||
assert body["bulk"]["supported"] is False
|
||||
assert body["filter"] == {"supported": True, "maxResults": 200}
|
||||
assert body["changePassword"] == {"supported": False}
|
||||
assert body["sort"] == {"supported": False}
|
||||
assert body["etag"] == {"supported": False}
|
||||
scheme = body["authenticationSchemes"][0]
|
||||
assert scheme["type"] == "oauthbearertoken"
|
||||
assert scheme["name"] == "Bearer Token"
|
||||
|
||||
def test_resource_types_advertise_user_only(self, client, scim_settings, scim_mocks):
|
||||
response = client.get("/scim/v2/ResourceTypes", headers=AUTH)
|
||||
assert response.status_code == 200
|
||||
body = response.get_json()
|
||||
assert body["schemas"] == [LIST_RESPONSE_URN]
|
||||
assert body["totalResults"] == 1
|
||||
resource = body["Resources"][0]
|
||||
assert resource["name"] == "User"
|
||||
assert resource["endpoint"] == "/scim/v2/Users"
|
||||
assert resource["schema"] == USER_URN
|
||||
|
||||
def test_schemas_advertise_user_only(self, client, scim_settings, scim_mocks):
|
||||
response = client.get("/scim/v2/Schemas", headers=AUTH)
|
||||
assert response.status_code == 200
|
||||
body = response.get_json()
|
||||
assert body["schemas"] == [LIST_RESPONSE_URN]
|
||||
assert body["totalResults"] == 1
|
||||
assert body["Resources"][0]["id"] == USER_URN
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestListUsers:
|
||||
|
||||
def test_filter_username_eq_passes_value_to_repo(self, client, scim_settings, scim_mocks):
|
||||
scim_mocks.users.list_paginated.return_value = (1, [_user_row()])
|
||||
response = client.get(
|
||||
"/scim/v2/Users",
|
||||
headers=AUTH,
|
||||
query_string={"filter": 'userName eq "alice smith@example.com"'},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
scim_mocks.users.list_paginated.assert_called_once_with("alice smith@example.com", 0, 100)
|
||||
|
||||
def test_filter_keywords_are_case_insensitive(self, client, scim_settings, scim_mocks):
|
||||
scim_mocks.users.list_paginated.return_value = (0, [])
|
||||
response = client.get(
|
||||
"/scim/v2/Users", headers=AUTH, query_string={"filter": 'UserName EQ "bob@example.com"'}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
scim_mocks.users.list_paginated.assert_called_once_with("bob@example.com", 0, 100)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad_filter",
|
||||
[
|
||||
'userName co "alice"',
|
||||
'displayName eq "alice"',
|
||||
'userName eq "a" and active eq true',
|
||||
"userName eq alice",
|
||||
],
|
||||
)
|
||||
def test_unsupported_filter_rejected(self, client, scim_settings, scim_mocks, bad_filter):
|
||||
response = client.get("/scim/v2/Users", headers=AUTH, query_string={"filter": bad_filter})
|
||||
assert response.status_code == 400
|
||||
body = response.get_json()
|
||||
assert body["schemas"] == [ERROR_URN]
|
||||
assert body["scimType"] == "invalidFilter"
|
||||
scim_mocks.users.list_paginated.assert_not_called()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("query", "expected_offset", "expected_limit"),
|
||||
[
|
||||
({}, 0, 100),
|
||||
({"startIndex": "3", "count": "2"}, 2, 2),
|
||||
({"startIndex": "0", "count": "999"}, 0, 200),
|
||||
({"startIndex": "-4", "count": "-5"}, 0, 0),
|
||||
],
|
||||
)
|
||||
def test_pagination_offset_limit_math(
|
||||
self, client, scim_settings, scim_mocks, query, expected_offset, expected_limit
|
||||
):
|
||||
scim_mocks.users.list_paginated.return_value = (0, [])
|
||||
response = client.get("/scim/v2/Users", headers=AUTH, query_string=query)
|
||||
assert response.status_code == 200
|
||||
scim_mocks.users.list_paginated.assert_called_once_with(None, expected_offset, expected_limit)
|
||||
|
||||
def test_list_response_shape(self, client, scim_settings, scim_mocks):
|
||||
scim_mocks.users.list_paginated.return_value = (42, [_user_row()])
|
||||
response = client.get(
|
||||
"/scim/v2/Users", headers=AUTH, query_string={"startIndex": "5", "count": "1"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.content_type.startswith("application/scim+json")
|
||||
body = response.get_json()
|
||||
assert body["schemas"] == [LIST_RESPONSE_URN]
|
||||
assert body["totalResults"] == 42
|
||||
assert body["startIndex"] == 5
|
||||
assert body["itemsPerPage"] == 1
|
||||
resource = body["Resources"][0]
|
||||
assert resource["schemas"] == [USER_URN]
|
||||
assert resource["id"] == USER_PK
|
||||
assert resource["userName"] == "alice@example.com"
|
||||
assert resource["active"] is True
|
||||
assert resource["emails"] == [{"value": "alice@example.com", "primary": True}]
|
||||
assert resource["meta"]["resourceType"] == "User"
|
||||
assert resource["meta"]["location"] == f"/scim/v2/Users/{USER_PK}"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestCreateUser:
|
||||
|
||||
def test_missing_username_returns_400(self, client, scim_settings, scim_mocks):
|
||||
response = client.post("/scim/v2/Users", headers=AUTH, json={"active": True})
|
||||
assert response.status_code == 400
|
||||
assert response.get_json()["scimType"] == "invalidValue"
|
||||
scim_mocks.users.create.assert_not_called()
|
||||
|
||||
def test_blank_username_returns_400(self, client, scim_settings, scim_mocks):
|
||||
response = client.post("/scim/v2/Users", headers=AUTH, json={"userName": " "})
|
||||
assert response.status_code == 400
|
||||
assert response.get_json()["scimType"] == "invalidValue"
|
||||
|
||||
def test_conflict_returns_409(self, client, scim_settings, scim_mocks):
|
||||
scim_mocks.users.create.return_value = None
|
||||
response = client.post("/scim/v2/Users", headers=AUTH, json={"userName": "alice@example.com"})
|
||||
assert response.status_code == 409
|
||||
body = response.get_json()
|
||||
assert body["schemas"] == [ERROR_URN]
|
||||
assert body["scimType"] == "uniqueness"
|
||||
scim_mocks.audit.insert.assert_not_called()
|
||||
|
||||
def test_create_happy_path(self, client, scim_settings, scim_mocks):
|
||||
scim_mocks.users.create.return_value = _user_row()
|
||||
response = client.post("/scim/v2/Users", headers=AUTH, json={"userName": "alice@example.com"})
|
||||
assert response.status_code == 201
|
||||
assert response.content_type.startswith("application/scim+json")
|
||||
assert response.headers["Location"].endswith(f"/scim/v2/Users/{USER_PK}")
|
||||
scim_mocks.users.create.assert_called_once_with("alice@example.com", active=True)
|
||||
scim_mocks.audit.insert.assert_called_once_with(
|
||||
"alice@example.com", "scim_created", metadata={"via": "scim"}
|
||||
)
|
||||
body = response.get_json()
|
||||
assert body["id"] == USER_PK
|
||||
assert body["userName"] == "alice@example.com"
|
||||
assert body["active"] is True
|
||||
assert body["emails"] == [{"value": "alice@example.com", "primary": True}]
|
||||
assert body["meta"]["resourceType"] == "User"
|
||||
|
||||
def test_create_honors_active_false(self, client, scim_settings, scim_mocks):
|
||||
scim_mocks.users.create.return_value = _user_row(active=False)
|
||||
response = client.post(
|
||||
"/scim/v2/Users", headers=AUTH, json={"userName": "alice@example.com", "active": False}
|
||||
)
|
||||
assert response.status_code == 201
|
||||
scim_mocks.users.create.assert_called_once_with("alice@example.com", active=False)
|
||||
assert response.get_json()["active"] is False
|
||||
|
||||
def test_create_accepts_scim_content_type(self, client, scim_settings, scim_mocks):
|
||||
scim_mocks.users.create.return_value = _user_row()
|
||||
response = client.post(
|
||||
"/scim/v2/Users",
|
||||
headers=AUTH,
|
||||
data=json.dumps({"userName": "alice@example.com"}),
|
||||
content_type="application/scim+json",
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
def test_create_without_email_username_has_no_emails(self, client, scim_settings, scim_mocks):
|
||||
scim_mocks.users.create.return_value = _user_row(user_id="ldap-user-1")
|
||||
response = client.post("/scim/v2/Users", headers=AUTH, json={"userName": "ldap-user-1"})
|
||||
assert response.status_code == 201
|
||||
assert "emails" not in response.get_json()
|
||||
|
||||
def test_audit_failure_does_not_fail_request(self, client, scim_settings, scim_mocks):
|
||||
scim_mocks.users.create.return_value = _user_row()
|
||||
scim_mocks.audit.insert.side_effect = RuntimeError("audit down")
|
||||
response = client.post("/scim/v2/Users", headers=AUTH, json={"userName": "alice@example.com"})
|
||||
assert response.status_code == 201
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGetUser:
|
||||
|
||||
def test_get_user_found(self, client, scim_settings, scim_mocks):
|
||||
scim_mocks.users.get_by_pk.return_value = _user_row()
|
||||
response = client.get(f"/scim/v2/Users/{USER_PK}", headers=AUTH)
|
||||
assert response.status_code == 200
|
||||
scim_mocks.users.get_by_pk.assert_called_once_with(USER_PK)
|
||||
body = response.get_json()
|
||||
assert body["id"] == USER_PK
|
||||
assert body["userName"] == "alice@example.com"
|
||||
assert body["meta"]["location"] == f"/scim/v2/Users/{USER_PK}"
|
||||
|
||||
def test_get_user_missing_returns_404(self, client, scim_settings, scim_mocks):
|
||||
scim_mocks.users.get_by_pk.return_value = None
|
||||
response = client.get(f"/scim/v2/Users/{USER_PK}", headers=AUTH)
|
||||
assert response.status_code == 404
|
||||
assert response.get_json()["schemas"] == [ERROR_URN]
|
||||
|
||||
def test_get_user_malformed_uuid_returns_404(self, client, scim_settings, scim_mocks):
|
||||
scim_mocks.users.get_by_pk.return_value = None
|
||||
response = client.get("/scim/v2/Users/not-a-uuid", headers=AUTH)
|
||||
assert response.status_code == 404
|
||||
assert response.get_json()["status"] == "404"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestReplaceUser:
|
||||
|
||||
def test_username_change_rejected_as_mutability(self, client, scim_settings, scim_mocks):
|
||||
scim_mocks.users.get_by_pk.return_value = _user_row()
|
||||
response = client.put(
|
||||
f"/scim/v2/Users/{USER_PK}",
|
||||
headers=AUTH,
|
||||
json={"userName": "bob@example.com", "active": True},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.get_json()["scimType"] == "mutability"
|
||||
scim_mocks.users.set_active.assert_not_called()
|
||||
|
||||
def test_put_active_true_reactivates(self, client, scim_settings, scim_mocks):
|
||||
row = _user_row(active=False)
|
||||
scim_mocks.users.get_by_pk.return_value = row
|
||||
scim_mocks.users.set_active.return_value = {**row, "active": True}
|
||||
response = client.put(
|
||||
f"/scim/v2/Users/{USER_PK}",
|
||||
headers=AUTH,
|
||||
json={"userName": "alice@example.com", "active": True},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["active"] is True
|
||||
scim_mocks.users.set_active.assert_called_once_with(USER_PK, True)
|
||||
# Reactivation does not touch the denylist (the deactivation watermark
|
||||
# already lets a fresh login through on a newer iat).
|
||||
scim_mocks.deny_user.assert_not_called()
|
||||
scim_mocks.audit.insert.assert_called_once_with(
|
||||
"alice@example.com", "scim_reactivated", metadata={"via": "scim"}
|
||||
)
|
||||
|
||||
def test_put_active_false_triggers_deny(self, client, scim_settings, scim_mocks):
|
||||
row = _user_row(active=True)
|
||||
scim_mocks.users.get_by_pk.return_value = row
|
||||
scim_mocks.users.set_active.return_value = {**row, "active": False}
|
||||
response = client.put(
|
||||
f"/scim/v2/Users/{USER_PK}",
|
||||
headers=AUTH,
|
||||
json={"userName": "alice@example.com", "active": False},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["active"] is False
|
||||
scim_mocks.users.set_active.assert_called_once_with(USER_PK, False)
|
||||
scim_mocks.deny_user.assert_called_once_with("alice@example.com")
|
||||
scim_mocks.audit.insert.assert_called_once_with(
|
||||
"alice@example.com", "scim_deactivated", metadata={"via": "scim"}
|
||||
)
|
||||
|
||||
def test_put_differently_cased_username_deprovisions(self, client, scim_settings, scim_mocks):
|
||||
# userName is case-insensitive: a PUT echoing the stored name under
|
||||
# different casing must deactivate, not 400 "userName is immutable".
|
||||
row = _user_row(active=True) # user_id == "alice@example.com"
|
||||
scim_mocks.users.get_by_pk.return_value = row
|
||||
scim_mocks.users.set_active.return_value = {**row, "active": False}
|
||||
response = client.put(
|
||||
f"/scim/v2/Users/{USER_PK}",
|
||||
headers=AUTH,
|
||||
json={"userName": "Alice@Example.com", "active": False},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["active"] is False
|
||||
scim_mocks.users.set_active.assert_called_once_with(USER_PK, False)
|
||||
scim_mocks.deny_user.assert_called_once_with("alice@example.com")
|
||||
|
||||
def test_put_unchanged_active_has_no_side_effects(self, client, scim_settings, scim_mocks):
|
||||
scim_mocks.users.get_by_pk.return_value = _user_row(active=True)
|
||||
response = client.put(
|
||||
f"/scim/v2/Users/{USER_PK}",
|
||||
headers=AUTH,
|
||||
json={"userName": "alice@example.com", "active": True},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
scim_mocks.users.set_active.assert_not_called()
|
||||
scim_mocks.deny_user.assert_not_called()
|
||||
scim_mocks.audit.insert.assert_not_called()
|
||||
|
||||
def test_put_missing_user_returns_404(self, client, scim_settings, scim_mocks):
|
||||
scim_mocks.users.get_by_pk.return_value = None
|
||||
response = client.put(
|
||||
f"/scim/v2/Users/{USER_PK}", headers=AUTH, json={"userName": "x", "active": True}
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestPatchUser:
|
||||
|
||||
def _patch(self, client, operations):
|
||||
return client.patch(
|
||||
f"/scim/v2/Users/{USER_PK}",
|
||||
headers=AUTH,
|
||||
json={"schemas": [PATCH_OP_URN], "Operations": operations},
|
||||
)
|
||||
|
||||
def _expect_deactivated(self, scim_mocks, response):
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["active"] is False
|
||||
scim_mocks.users.set_active.assert_called_once_with(USER_PK, False)
|
||||
scim_mocks.deny_user.assert_called_once_with("alice@example.com")
|
||||
scim_mocks.audit.insert.assert_called_once_with(
|
||||
"alice@example.com", "scim_deactivated", metadata={"via": "scim"}
|
||||
)
|
||||
|
||||
def test_replace_with_path_deactivates(self, client, scim_settings, scim_mocks):
|
||||
row = _user_row(active=True)
|
||||
scim_mocks.users.get_by_pk.return_value = row
|
||||
scim_mocks.users.set_active.return_value = {**row, "active": False}
|
||||
response = self._patch(client, [{"op": "replace", "path": "active", "value": False}])
|
||||
self._expect_deactivated(scim_mocks, response)
|
||||
|
||||
def test_replace_without_path_object_value_deactivates(self, client, scim_settings, scim_mocks):
|
||||
row = _user_row(active=True)
|
||||
scim_mocks.users.get_by_pk.return_value = row
|
||||
scim_mocks.users.set_active.return_value = {**row, "active": False}
|
||||
response = self._patch(client, [{"op": "replace", "value": {"active": False}}])
|
||||
self._expect_deactivated(scim_mocks, response)
|
||||
|
||||
def test_replace_with_string_false_deactivates(self, client, scim_settings, scim_mocks):
|
||||
row = _user_row(active=True)
|
||||
scim_mocks.users.get_by_pk.return_value = row
|
||||
scim_mocks.users.set_active.return_value = {**row, "active": False}
|
||||
response = self._patch(client, [{"op": "Replace", "path": "active", "value": "False"}])
|
||||
self._expect_deactivated(scim_mocks, response)
|
||||
|
||||
def test_replace_with_string_true_reactivates(self, client, scim_settings, scim_mocks):
|
||||
row = _user_row(active=False)
|
||||
scim_mocks.users.get_by_pk.return_value = row
|
||||
scim_mocks.users.set_active.return_value = {**row, "active": True}
|
||||
response = self._patch(client, [{"op": "replace", "path": "active", "value": "true"}])
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["active"] is True
|
||||
scim_mocks.users.set_active.assert_called_once_with(USER_PK, True)
|
||||
scim_mocks.deny_user.assert_not_called()
|
||||
scim_mocks.audit.insert.assert_called_once_with(
|
||||
"alice@example.com", "scim_reactivated", metadata={"via": "scim"}
|
||||
)
|
||||
|
||||
def test_bogus_path_rejected(self, client, scim_settings, scim_mocks):
|
||||
scim_mocks.users.get_by_pk.return_value = _user_row()
|
||||
response = self._patch(client, [{"op": "replace", "path": "displayName", "value": "X"}])
|
||||
assert response.status_code == 400
|
||||
assert response.get_json()["scimType"] == "invalidPath"
|
||||
scim_mocks.users.set_active.assert_not_called()
|
||||
|
||||
def test_bogus_op_rejected(self, client, scim_settings, scim_mocks):
|
||||
scim_mocks.users.get_by_pk.return_value = _user_row()
|
||||
response = self._patch(client, [{"op": "add", "path": "active", "value": True}])
|
||||
assert response.status_code == 400
|
||||
assert response.get_json()["scimType"] == "invalidPath"
|
||||
|
||||
def test_invalid_active_value_rejected(self, client, scim_settings, scim_mocks):
|
||||
scim_mocks.users.get_by_pk.return_value = _user_row()
|
||||
response = self._patch(client, [{"op": "replace", "path": "active", "value": "maybe"}])
|
||||
assert response.status_code == 400
|
||||
assert response.get_json()["scimType"] == "invalidValue"
|
||||
|
||||
def test_unchanged_active_is_noop(self, client, scim_settings, scim_mocks):
|
||||
scim_mocks.users.get_by_pk.return_value = _user_row(active=True)
|
||||
response = self._patch(client, [{"op": "replace", "path": "active", "value": True}])
|
||||
assert response.status_code == 200
|
||||
scim_mocks.users.set_active.assert_not_called()
|
||||
scim_mocks.deny_user.assert_not_called()
|
||||
scim_mocks.audit.insert.assert_not_called()
|
||||
|
||||
def test_no_path_value_without_active_is_noop(self, client, scim_settings, scim_mocks):
|
||||
scim_mocks.users.get_by_pk.return_value = _user_row(active=True)
|
||||
response = self._patch(client, [{"op": "replace", "value": {"displayName": "X"}}])
|
||||
assert response.status_code == 200
|
||||
scim_mocks.users.set_active.assert_not_called()
|
||||
|
||||
def test_missing_operations_rejected(self, client, scim_settings, scim_mocks):
|
||||
response = client.patch(
|
||||
f"/scim/v2/Users/{USER_PK}", headers=AUTH, json={"schemas": [PATCH_OP_URN]}
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_patch_missing_user_returns_404(self, client, scim_settings, scim_mocks):
|
||||
scim_mocks.users.get_by_pk.return_value = None
|
||||
response = self._patch(client, [{"op": "replace", "path": "active", "value": False}])
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDeleteUser:
|
||||
|
||||
def test_delete_deactivates_and_returns_204(self, client, scim_settings, scim_mocks):
|
||||
row = _user_row(active=True)
|
||||
scim_mocks.users.get_by_pk.return_value = row
|
||||
scim_mocks.users.set_active.return_value = {**row, "active": False}
|
||||
response = client.delete(f"/scim/v2/Users/{USER_PK}", headers=AUTH)
|
||||
assert response.status_code == 204
|
||||
assert response.data == b""
|
||||
scim_mocks.users.set_active.assert_called_once_with(USER_PK, False)
|
||||
scim_mocks.deny_user.assert_called_once_with("alice@example.com")
|
||||
scim_mocks.audit.insert.assert_called_once_with(
|
||||
"alice@example.com", "scim_deactivated", metadata={"via": "scim"}
|
||||
)
|
||||
|
||||
def test_delete_already_inactive_has_no_side_effects(self, client, scim_settings, scim_mocks):
|
||||
scim_mocks.users.get_by_pk.return_value = _user_row(active=False)
|
||||
response = client.delete(f"/scim/v2/Users/{USER_PK}", headers=AUTH)
|
||||
assert response.status_code == 204
|
||||
scim_mocks.users.set_active.assert_not_called()
|
||||
scim_mocks.deny_user.assert_not_called()
|
||||
|
||||
def test_delete_missing_user_returns_404(self, client, scim_settings, scim_mocks):
|
||||
scim_mocks.users.get_by_pk.return_value = None
|
||||
response = client.delete(f"/scim/v2/Users/{USER_PK}", headers=AUTH)
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_deactivation_revocation_failure_returns_503(self, client, scim_settings, scim_mocks):
|
||||
# Redis down: deny_user returns False, so the deactivation rolls back and
|
||||
# the IdP gets a 503 to retry instead of a false deprovision success.
|
||||
row = _user_row(active=True)
|
||||
scim_mocks.users.get_by_pk.return_value = row
|
||||
scim_mocks.users.set_active.return_value = {**row, "active": False}
|
||||
scim_mocks.deny_user.return_value = False
|
||||
response = client.delete(f"/scim/v2/Users/{USER_PK}", headers=AUTH)
|
||||
assert response.status_code == 503
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGroups:
|
||||
|
||||
def test_get_groups_returns_empty_list(self, client, scim_settings, scim_mocks):
|
||||
response = client.get("/scim/v2/Groups", headers=AUTH)
|
||||
assert response.status_code == 200
|
||||
body = response.get_json()
|
||||
assert body["schemas"] == [LIST_RESPONSE_URN]
|
||||
assert body["totalResults"] == 0
|
||||
assert body["Resources"] == []
|
||||
|
||||
def test_post_groups_not_implemented(self, client, scim_settings, scim_mocks):
|
||||
response = client.post("/scim/v2/Groups", headers=AUTH, json={"displayName": "Team"})
|
||||
assert response.status_code == 501
|
||||
body = response.get_json()
|
||||
assert body["schemas"] == [ERROR_URN]
|
||||
assert body["detail"] == "Group provisioning is not supported"
|
||||
|
||||
@pytest.mark.parametrize("method", ["put", "patch", "delete"])
|
||||
def test_group_mutations_not_implemented(self, client, scim_settings, scim_mocks, method):
|
||||
response = getattr(client, method)("/scim/v2/Groups/some-group", headers=AUTH)
|
||||
assert response.status_code == 501
|
||||
assert response.get_json()["detail"] == "Group provisioning is not supported"
|
||||
Reference in new issue
Block a user