feat: oidc renewal, groups, logout, scim

This commit is contained in:
Alex committed 2026-06-10 00:57:18 +01:00
1 parent 8bc777a428
commit 054f0f1b8b
29 files changed
+3392 -155

No files matched your search

+13 -1
View File
@@ -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
View File
@@ -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)