mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-12 20:15:26 +00:00
feat: oidc renewal, groups, logout, scim
This commit is contained in:
1 parent
8bc777a428
commit
054f0f1b8b
29 files changed
+3392
-155
No files matched your search
+13
-1
@@ -52,11 +52,23 @@ 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.
|
||||
# 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
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
+136
-25
@@ -4,17 +4,23 @@ 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``)
|
||||
* ``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).
|
||||
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) are all in ``application/requirements.txt``.
|
||||
(flask, python-jose, cryptography, requests) are all in
|
||||
``application/requirements.txt``.
|
||||
|
||||
Usage::
|
||||
|
||||
@@ -34,6 +40,7 @@ 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
|
||||
@@ -46,8 +53,19 @@ 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
|
||||
KID = "mock-oidc-key-1"
|
||||
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__)
|
||||
|
||||
@@ -63,8 +81,13 @@ PUBLIC_JWK = {
|
||||
"use": "sig",
|
||||
}
|
||||
|
||||
# code -> {client_id, redirect_uri, code_challenge, nonce, sub, email, name}
|
||||
# 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:
|
||||
@@ -77,6 +100,46 @@ def _pkce_challenge(verifier: str) -> str:
|
||||
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(
|
||||
@@ -85,9 +148,12 @@ def discovery() -> Response:
|
||||
"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"],
|
||||
"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"],
|
||||
@@ -124,6 +190,7 @@ def authorize() -> Response:
|
||||
"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 "?"
|
||||
@@ -133,8 +200,20 @@ def authorize() -> Response:
|
||||
|
||||
@app.post("/token")
|
||||
def token() -> Response:
|
||||
global _last_client_id
|
||||
form = request.form
|
||||
if form.get("grant_type") != "authorization_code":
|
||||
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:
|
||||
@@ -149,28 +228,60 @@ def token() -> Response:
|
||||
_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": record["client_id"],
|
||||
"sub": record["sub"],
|
||||
"email": record["email"],
|
||||
"name": record["name"],
|
||||
"aud": body.get("client_id") or _last_client_id or DEFAULT_CLIENT_ID,
|
||||
"iat": now,
|
||||
"exp": now + ID_TOKEN_TTL_SECONDS,
|
||||
"exp": now + LOGOUT_TOKEN_TTL_SECONDS,
|
||||
"jti": secrets.token_urlsafe(16),
|
||||
"events": {BACKCHANNEL_LOGOUT_EVENT: {}},
|
||||
}
|
||||
if record["nonce"]:
|
||||
claims["nonce"] = record["nonce"]
|
||||
id_token = jose_jwt.encode(claims, PRIVATE_PEM, algorithm="RS256", headers={"kid": KID})
|
||||
_log(f"token: issued id_token for sub={record['sub']}")
|
||||
return jsonify(
|
||||
{
|
||||
"access_token": secrets.token_urlsafe(24),
|
||||
"token_type": "Bearer",
|
||||
"expires_in": ID_TOKEN_TTL_SECONDS,
|
||||
"id_token": id_token,
|
||||
}
|
||||
)
|
||||
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")
|
||||
@@ -188,7 +299,7 @@ def healthz() -> Response:
|
||||
|
||||
|
||||
def main() -> None:
|
||||
_log(f"listening on {ISSUER} (sub={DEFAULT_SUB})")
|
||||
_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)
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user