From 42bc4768825ababa81e20225482a597b59101d22 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Wed, 3 Jun 2026 15:11:43 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20OIDC=20Identity=20Provider=20=E2=80=94?= =?UTF-8?q?=20Phase=201=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add OIDC/OAuth2 endpoints to bincio-auth so it acts as a full IdP: GET /.well-known/openid-configuration GET /.well-known/jwks.json GET /oauth2/authorize (auth-code flow, redirects to /login/ if no session) POST /oauth2/token (exchanges code for RS256 id_token; PKCE supported) GET /oauth2/userinfo (Bearer token → profile claims) Infrastructure: - oauth2_clients + oauth2_codes tables in db.py with CRUD helpers - RS256 sign/verify helpers in tokens.py (create_id_token, get_jwks) - oidc_private_key_pem / oidc_issuer state + _issue_id_token in deps.py - serve_cmd reads BINCIO_OIDC_PRIVATE_KEY_FILE / BINCIO_OIDC_ISSUER env vars - `bincio-auth client add/list` commands for managing OAuth2 clients --- bincio/auth/cli.py | 90 ++++++++++++- bincio/auth/db.py | 104 +++++++++++++++ bincio/auth/deps.py | 25 +++- bincio/auth/routers/oidc.py | 257 ++++++++++++++++++++++++++++++++++++ bincio/auth/server.py | 4 +- bincio/auth/tokens.py | 57 ++++++-- pyproject.toml | 3 +- 7 files changed, 525 insertions(+), 15 deletions(-) create mode 100644 bincio/auth/routers/oidc.py diff --git a/bincio/auth/cli.py b/bincio/auth/cli.py index 9c9f2a5..346a3a5 100644 --- a/bincio/auth/cli.py +++ b/bincio/auth/cli.py @@ -94,7 +94,18 @@ def show_secret_cmd(data_dir: str) -> None: @click.option("--port", default=4040, type=int) @click.option("--jwt-secret", default=None, envvar="BINCIO_AUTH_JWT_SECRET", help="HS256 signing secret. Defaults to the value stored in the DB during `init`.") -def serve_cmd(data_dir: str, host: str, port: int, jwt_secret: str | None) -> None: +@click.option("--oidc-private-key-file", default=None, envvar="BINCIO_OIDC_PRIVATE_KEY_FILE", + type=click.Path(), help="Path to RS256 PEM private key. Enables OIDC endpoints.") +@click.option("--oidc-issuer", default=None, envvar="BINCIO_OIDC_ISSUER", + help="OIDC issuer URL (e.g. https://bincio.org). Required when --oidc-private-key-file is set.") +def serve_cmd( + data_dir: str, + host: str, + port: int, + jwt_secret: str | None, + oidc_private_key_file: str | None, + oidc_issuer: str | None, +) -> None: """Start the bincio-auth API server.""" import uvicorn @@ -120,9 +131,86 @@ def serve_cmd(data_dir: str, host: str, port: int, jwt_secret: str | None) -> No deps.data_dir = dd deps.jwt_secret = secret + if oidc_private_key_file: + key_path = Path(oidc_private_key_file).expanduser().resolve() + if not key_path.exists(): + raise click.UsageError(f"OIDC private key file not found: {key_path}") + deps.oidc_private_key_pem = key_path.read_text() + deps.oidc_issuer = oidc_issuer or "" + if not deps.oidc_issuer: + raise click.UsageError("--oidc-issuer is required when --oidc-private-key-file is set") + console.print("[bold]bincio-auth[/bold]") console.print(f" Data: [cyan]{dd}[/cyan]") console.print(f" URL: [cyan]http://{host}:{port}[/cyan]") + if deps.oidc_issuer: + console.print(f" OIDC: [cyan]{deps.oidc_issuer}[/cyan]") console.print() uvicorn.run(srv.app, host=host, port=port, log_level="info") + + +# ── OAuth2 client management ────────────────────────────────────────────────── + +@main.group("client") +def client_group() -> None: + """Manage OAuth2 clients (OIDC consumers: Gitea, mobile app, etc.).""" + + +@client_group.command("add") +@click.option("--data-dir", required=True, type=click.Path()) +@click.option("--client-id", required=True) +@click.option("--name", required=True, help="Human-readable name") +@click.option("--redirect-uris", required=True, multiple=True, help="Allowed redirect URIs (repeat for multiple)") +@click.option("--client-secret", default=None, + help="Client secret (omit for public/PKCE-only clients)") +@click.option("--scopes", default="openid profile", show_default=True) +def client_add_cmd( + data_dir: str, + client_id: str, + name: str, + redirect_uris: tuple[str, ...], + client_secret: str | None, + scopes: str, +) -> None: + """Register or update an OAuth2 client.""" + from bincio.auth.db import open_db, upsert_oauth2_client + + dd = Path(data_dir).expanduser().resolve() + db = open_db(dd) + upsert_oauth2_client( + db, + client_id=client_id, + client_secret=client_secret, + name=name, + redirect_uris=list(redirect_uris), + scopes=scopes, + ) + db.close() + console.print(f" [green]✓[/green] client [cyan]{client_id}[/cyan] saved") + if not client_secret: + console.print(" [yellow]![/yellow] No secret — this client must use PKCE (public client)") + + +@client_group.command("list") +@click.option("--data-dir", required=True, type=click.Path()) +def client_list_cmd(data_dir: str) -> None: + """List registered OAuth2 clients.""" + from bincio.auth.db import open_db + + dd = Path(data_dir).expanduser().resolve() + db = open_db(dd) + rows = db.execute( + "SELECT client_id, name, redirect_uris, scopes, client_secret IS NOT NULL AS has_secret FROM oauth2_clients ORDER BY client_id" + ).fetchall() + db.close() + if not rows: + console.print("[yellow]No clients registered.[/yellow]") + return + import json + for r in rows: + secret_label = "[green]confidential[/green]" if r["has_secret"] else "[yellow]public/PKCE[/yellow]" + uris = ", ".join(json.loads(r["redirect_uris"])) + console.print(f" [bold]{r['client_id']}[/bold] ({r['name']}) — {secret_label}") + console.print(f" scopes: {r['scopes']}") + console.print(f" redirects: {uris}") diff --git a/bincio/auth/db.py b/bincio/auth/db.py index 8b2f4db..9f46b8b 100644 --- a/bincio/auth/db.py +++ b/bincio/auth/db.py @@ -74,9 +74,33 @@ CREATE TABLE IF NOT EXISTS user_prefs ( PRIMARY KEY (handle, key) ); +CREATE TABLE IF NOT EXISTS oauth2_clients ( + client_id TEXT PRIMARY KEY, + client_secret TEXT, + name TEXT NOT NULL, + redirect_uris TEXT NOT NULL, + scopes TEXT NOT NULL DEFAULT 'openid profile', + created_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS oauth2_codes ( + code TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + handle TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + scope TEXT NOT NULL, + nonce TEXT, + code_challenge TEXT, + code_challenge_method TEXT, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + used_at INTEGER +); + CREATE INDEX IF NOT EXISTS sessions_handle ON sessions(handle); CREATE INDEX IF NOT EXISTS invites_created_by ON invites(created_by); CREATE INDEX IF NOT EXISTS reset_codes_handle ON reset_codes(handle); +CREATE INDEX IF NOT EXISTS oauth2_codes_client ON oauth2_codes(client_id); CREATE INDEX IF NOT EXISTS user_prefs_handle ON user_prefs(handle); """ @@ -488,3 +512,83 @@ def use_reset_code(db: sqlite3.Connection, code: str, handle: str) -> bool: ) db.commit() return True + + +# ── OAuth2 clients ──────────────────────────────────────────────────────────── + +import json as _json + + +def get_oauth2_client(db: sqlite3.Connection, client_id: str) -> dict | None: + row = db.execute( + "SELECT client_id, client_secret, name, redirect_uris, scopes FROM oauth2_clients WHERE client_id = ?", + (client_id,), + ).fetchone() + if not row: + return None + return { + "client_id": row["client_id"], + "client_secret": row["client_secret"], + "name": row["name"], + "redirect_uris": _json.loads(row["redirect_uris"]), + "scopes": row["scopes"], + } + + +def upsert_oauth2_client( + db: sqlite3.Connection, + client_id: str, + client_secret: str | None, + name: str, + redirect_uris: list[str], + scopes: str = "openid profile", +) -> None: + db.execute( + "INSERT INTO oauth2_clients (client_id, client_secret, name, redirect_uris, scopes, created_at) " + "VALUES (?, ?, ?, ?, ?, ?) " + "ON CONFLICT(client_id) DO UPDATE SET " + "client_secret=excluded.client_secret, name=excluded.name, " + "redirect_uris=excluded.redirect_uris, scopes=excluded.scopes", + (client_id, client_secret, name, _json.dumps(redirect_uris), scopes, int(time.time())), + ) + db.commit() + + +# ── OAuth2 authorization codes ──────────────────────────────────────────────── + +_CODE_TTL = 300 # 5 minutes + + +def create_oauth2_code( + db: sqlite3.Connection, + client_id: str, + handle: str, + redirect_uri: str, + scope: str, + nonce: str | None = None, + code_challenge: str | None = None, + code_challenge_method: str | None = None, +) -> str: + code = secrets.token_urlsafe(32) + now = int(time.time()) + db.execute( + "INSERT INTO oauth2_codes " + "(code, client_id, handle, redirect_uri, scope, nonce, code_challenge, code_challenge_method, created_at, expires_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (code, client_id, handle, redirect_uri, scope, nonce, + code_challenge, code_challenge_method, now, now + _CODE_TTL), + ) + db.commit() + return code + + +def get_oauth2_code(db: sqlite3.Connection, code: str) -> dict | None: + row = db.execute("SELECT * FROM oauth2_codes WHERE code = ?", (code,)).fetchone() + if not row: + return None + return dict(row) + + +def use_oauth2_code(db: sqlite3.Connection, code: str) -> None: + db.execute("UPDATE oauth2_codes SET used_at = ? WHERE code = ?", (int(time.time()), code)) + db.commit() diff --git a/bincio/auth/deps.py b/bincio/auth/deps.py index 3dafa1e..b4d5d9a 100644 --- a/bincio/auth/deps.py +++ b/bincio/auth/deps.py @@ -11,12 +11,14 @@ import jwt as _jwt from fastapi import Cookie, HTTPException, Request, Response from bincio.auth.db import User, get_user, open_db -from bincio.auth.tokens import create_token, decode_token +from bincio.auth.tokens import create_id_token, create_token, decode_token # ── Module-level state (set by CLI before uvicorn starts) ───────────────────── data_dir: Path | None = None jwt_secret: str = "" +oidc_private_key_pem: str = "" # RS256 private key PEM (loaded from file at startup) +oidc_issuer: str = "" # e.g. "https://bincio.org" _db = None # ── Constants ───────────────────────────────────────────────────────────────── @@ -63,6 +65,27 @@ def _issue_jwt(user: User) -> str: ) +_ID_TOKEN_TTL = 3600 # 1 hour — short-lived; clients use refresh or re-auth + + +def _issue_id_token(user: User, client_id: str, nonce: str | None = None) -> str: + """Issue an RS256 OIDC id_token for the given user and client.""" + claims: dict = { + "iss": oidc_issuer, + "sub": user.handle, + "aud": client_id, + "name": user.display_name, + "preferred_username": user.handle, + # bincio-specific claims (used by bincio-activity for authz) + "is_admin": user.is_admin, + "wiki_access": user.wiki_access, + "activity_access": user.activity_access, + } + if nonce: + claims["nonce"] = nonce + return create_id_token(claims, oidc_private_key_pem, _ID_TOKEN_TTL) + + # ── Rate limiting ───────────────────────────────────────────────────────────── def _check_rate_limit( diff --git a/bincio/auth/routers/oidc.py b/bincio/auth/routers/oidc.py new file mode 100644 index 0000000..7eeb8e9 --- /dev/null +++ b/bincio/auth/routers/oidc.py @@ -0,0 +1,257 @@ +"""OIDC / OAuth2 endpoints for bincio-auth acting as an Identity Provider. + +Implements the authorization code flow (+ PKCE for public clients). +Endpoints are at standard paths: + GET /.well-known/openid-configuration + GET /.well-known/jwks.json + GET /oauth2/authorize + POST /oauth2/token + GET /oauth2/userinfo +""" + +from __future__ import annotations + +import base64 +import hashlib +import time +from urllib.parse import urlencode + +from fastapi import APIRouter, Cookie, HTTPException, Request +from fastapi.responses import JSONResponse, RedirectResponse + +from bincio.auth import deps +from bincio.auth.db import ( + create_oauth2_code, + get_oauth2_client, + get_oauth2_code, + get_user, + use_oauth2_code, +) +from bincio.auth.tokens import decode_id_token, get_jwks + +router = APIRouter() + + +# ── Discovery ───────────────────────────────────────────────────────────────── + +@router.get("/.well-known/openid-configuration") +async def oidc_discovery() -> JSONResponse: + if not deps.oidc_issuer: + raise HTTPException(503, "OIDC not configured") + iss = deps.oidc_issuer.rstrip("/") + return JSONResponse({ + "issuer": iss, + "authorization_endpoint": f"{iss}/oauth2/authorize", + "token_endpoint": f"{iss}/oauth2/token", + "userinfo_endpoint": f"{iss}/oauth2/userinfo", + "jwks_uri": f"{iss}/.well-known/jwks.json", + "response_types_supported": ["code"], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256"], + "scopes_supported": ["openid", "profile"], + "token_endpoint_auth_methods_supported": ["client_secret_post", "none"], + "code_challenge_methods_supported": ["S256"], + "claims_supported": [ + "iss", "sub", "aud", "exp", "iat", "nonce", + "name", "preferred_username", + ], + }) + + +@router.get("/.well-known/jwks.json") +async def jwks() -> JSONResponse: + if not deps.oidc_private_key_pem: + raise HTTPException(503, "OIDC not configured") + return JSONResponse(get_jwks(deps.oidc_private_key_pem)) + + +# ── Authorization endpoint ──────────────────────────────────────────────────── + +@router.get("/oauth2/authorize") +async def authorize( + request: Request, + bincio_session: str | None = Cookie(default=None), +) -> RedirectResponse: + if not deps.oidc_issuer: + raise HTTPException(503, "OIDC not configured") + + p = request.query_params + client_id = p.get("client_id", "") + redirect_uri = p.get("redirect_uri", "") + response_type = p.get("response_type", "") + scope = p.get("scope", "openid") + state = p.get("state", "") + nonce = p.get("nonce") or None + code_challenge = p.get("code_challenge") or None + code_challenge_method = p.get("code_challenge_method") or None + + def _err(error: str, description: str) -> RedirectResponse: + qs = urlencode({"error": error, "error_description": description, "state": state}) + return RedirectResponse(f"{redirect_uri}?{qs}", status_code=302) + + # Validate client and redirect_uri before anything else + if not client_id or not redirect_uri: + raise HTTPException(400, "client_id and redirect_uri are required") + + client = get_oauth2_client(deps._get_db(), client_id) + if not client: + raise HTTPException(400, "Unknown client_id") + if redirect_uri not in client["redirect_uris"]: + raise HTTPException(400, "redirect_uri not registered for this client") + + if response_type != "code": + return _err("unsupported_response_type", "Only response_type=code is supported") + + # Check session — redirect to login if not authenticated + user = deps._current_user(bincio_session) + if not user: + login_url = f"{deps.oidc_issuer.rstrip('/')}/login/" + next_url = str(request.url) + return RedirectResponse( + f"{login_url}?next={_pct_encode(next_url)}", + status_code=302, + ) + + # Issue authorization code + code = create_oauth2_code( + deps._get_db(), + client_id=client_id, + handle=user.handle, + redirect_uri=redirect_uri, + scope=scope, + nonce=nonce, + code_challenge=code_challenge, + code_challenge_method=code_challenge_method, + ) + + qs_parts: dict[str, str] = {"code": code} + if state: + qs_parts["state"] = state + return RedirectResponse(f"{redirect_uri}?{urlencode(qs_parts)}", status_code=302) + + +# ── Token endpoint ──────────────────────────────────────────────────────────── + +@router.post("/oauth2/token") +async def token(request: Request) -> JSONResponse: + if not deps.oidc_issuer: + raise HTTPException(503, "OIDC not configured") + + form = await request.form() + grant_type = str(form.get("grant_type", "")) + code = str(form.get("code", "")) + redirect_uri = str(form.get("redirect_uri", "")) + client_id = str(form.get("client_id", "")) + client_secret = str(form.get("client_secret", "")) + code_verifier = str(form.get("code_verifier", "")) + + if grant_type != "authorization_code": + raise HTTPException(400, "unsupported_grant_type") + if not code or not redirect_uri or not client_id: + raise HTTPException(400, "code, redirect_uri, client_id are required") + + client = get_oauth2_client(deps._get_db(), client_id) + if not client: + raise HTTPException(401, "invalid_client") + + # Authenticate client: secret (confidential) or PKCE verifier (public) + if client["client_secret"] is not None: + if client_secret != client["client_secret"]: + raise HTTPException(401, "invalid_client") + # Public clients (no secret) rely on PKCE — verified below against the code record + + rec = get_oauth2_code(deps._get_db(), code) + if not rec: + raise HTTPException(400, "invalid_grant") + if rec["used_at"] is not None: + raise HTTPException(400, "invalid_grant") + if rec["expires_at"] < int(time.time()): + raise HTTPException(400, "invalid_grant") + if rec["client_id"] != client_id: + raise HTTPException(400, "invalid_grant") + if rec["redirect_uri"] != redirect_uri: + raise HTTPException(400, "invalid_grant") + + # PKCE verification for public clients + if rec["code_challenge"]: + if not code_verifier: + raise HTTPException(400, "code_verifier required") + if not _verify_pkce(code_verifier, rec["code_challenge"], rec["code_challenge_method"] or "S256"): + raise HTTPException(400, "invalid_grant") + + use_oauth2_code(deps._get_db(), code) + + user = get_user(deps._get_db(), rec["handle"]) + if not user or user.suspended: + raise HTTPException(400, "invalid_grant") + + id_token = deps._issue_id_token(user, client_id, nonce=rec["nonce"]) + + return JSONResponse({ + "access_token": id_token, # access_token = id_token (validated by userinfo) + "token_type": "Bearer", + "expires_in": deps._ID_TOKEN_TTL, + "id_token": id_token, + }) + + +# ── Userinfo endpoint ───────────────────────────────────────────────────────── + +@router.get("/oauth2/userinfo") +async def userinfo(request: Request) -> JSONResponse: + if not deps.oidc_issuer: + raise HTTPException(503, "OIDC not configured") + + auth = request.headers.get("Authorization", "") + if not auth.startswith("Bearer "): + raise HTTPException(401, "Bearer token required") + token_str = auth[7:] + + # We need the client_id (audience) to validate — try all registered clients + # by decoding without aud check first, then validate aud matches a real client + try: + import jwt as _jwt + from cryptography.hazmat.primitives.serialization import load_pem_private_key + priv = load_pem_private_key(deps.oidc_private_key_pem.encode(), password=None) + payload = _jwt.decode( + token_str, + priv.public_key(), + algorithms=["RS256"], + options={"verify_aud": False}, + ) + except Exception: + raise HTTPException(401, "Invalid token") + + handle = payload.get("sub") + if not handle: + raise HTTPException(401, "Invalid token") + + user = get_user(deps._get_db(), handle) + if not user or user.suspended: + raise HTTPException(401, "User not found or suspended") + + return JSONResponse({ + "sub": user.handle, + "name": user.display_name, + "preferred_username": user.handle, + "is_admin": user.is_admin, + "wiki_access": user.wiki_access, + "activity_access": user.activity_access, + }) + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _pct_encode(url: str) -> str: + from urllib.parse import quote + return quote(url, safe="") + + +def _verify_pkce(verifier: str, challenge: str, method: str) -> bool: + if method == "S256": + digest = hashlib.sha256(verifier.encode()).digest() + computed = base64.urlsafe_b64encode(digest).rstrip(b"=").decode() + return computed == challenge + if method == "plain": + return verifier == challenge + return False diff --git a/bincio/auth/server.py b/bincio/auth/server.py index 5661637..16621c5 100644 --- a/bincio/auth/server.py +++ b/bincio/auth/server.py @@ -6,7 +6,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware -from bincio.auth.routers import admin, auth, invites +from bincio.auth.routers import admin, auth, invites, oidc app = FastAPI(title="bincio-auth") @@ -19,5 +19,5 @@ app.add_middleware( allow_headers=["Content-Type", "Authorization"], ) -for _router in [auth.router, invites.router, admin.router]: +for _router in [auth.router, invites.router, admin.router, oidc.router]: app.include_router(_router) diff --git a/bincio/auth/tokens.py b/bincio/auth/tokens.py index 93e1bee..26e0193 100644 --- a/bincio/auth/tokens.py +++ b/bincio/auth/tokens.py @@ -1,28 +1,65 @@ """JWT helpers for bincio-auth. -Tokens are HS256-signed JWTs. Consumers validate locally using the shared -secret — no round-trip to the auth service per request. +HS256 tokens: used for session cookies (existing behaviour, shared secret). +RS256 tokens: used for OIDC id_tokens (asymmetric, public key via JWKS). """ from __future__ import annotations +import base64 import time import jwt +from cryptography.hazmat.primitives.serialization import load_pem_private_key +_KID = "bincio-oidc-1" + + +# ── HS256 (session cookies) ─────────────────────────────────────────────────── def create_token(payload: dict, secret: str, expires_in: int) -> str: - """Return a signed JWT. - - Args: - payload: Claims to embed (will be shallow-copied; 'exp' is added). - secret: HS256 signing key. - expires_in: Validity window in seconds from now. - """ claims = {**payload, "exp": int(time.time()) + expires_in} return jwt.encode(claims, secret, algorithm="HS256") def decode_token(token: str, secret: str) -> dict: - """Decode and verify a JWT. Raises jwt.PyJWTError on any failure.""" + """Decode and verify an HS256 JWT. Raises jwt.PyJWTError on any failure.""" return jwt.decode(token, secret, algorithms=["HS256"]) + + +# ── RS256 (OIDC id_tokens) ──────────────────────────────────────────────────── + +def create_id_token(payload: dict, private_key_pem: str, expires_in: int) -> str: + """Sign an OIDC id_token with RS256. payload should include iss, sub, aud.""" + now = int(time.time()) + claims = {**payload, "iat": now, "exp": now + expires_in} + private_key = load_pem_private_key(private_key_pem.encode(), password=None) + return jwt.encode(claims, private_key, algorithm="RS256", headers={"kid": _KID}) + + +def get_jwks(private_key_pem: str) -> dict: + """Return the JWKS document for the given RSA private key.""" + private_key = load_pem_private_key(private_key_pem.encode(), password=None) + pub = private_key.public_key().public_numbers() + + def b64url(n: int) -> str: + length = (n.bit_length() + 7) // 8 + return base64.urlsafe_b64encode(n.to_bytes(length, "big")).rstrip(b"=").decode() + + return { + "keys": [{ + "kty": "RSA", + "use": "sig", + "alg": "RS256", + "kid": _KID, + "n": b64url(pub.n), + "e": b64url(pub.e), + }] + } + + +def decode_id_token(token: str, private_key_pem: str, audience: str) -> dict: + """Decode and verify an RS256 id_token (used by userinfo endpoint).""" + private_key = load_pem_private_key(private_key_pem.encode(), password=None) + public_key = private_key.public_key() + return jwt.decode(token, public_key, algorithms=["RS256"], audience=audience) diff --git a/pyproject.toml b/pyproject.toml index 983e5b1..e9f4d92 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,8 @@ dependencies = [ "uvicorn[standard]>=0.29", "python-multipart>=0.0.9", "bcrypt>=4.1", - "PyJWT>=2.8", + "PyJWT[crypto]>=2.8", + "cryptography>=42.0", "click>=8.1", "rich>=13.0", ]