From 3394be4ee9993faba9db1724e71525c7da37affc Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Wed, 3 Jun 2026 15:43:44 +0200 Subject: [PATCH] auth: add RS256 validation via JWKS (Phase 3) When --oidc-issuer is set, validate tokens as RS256 id_tokens fetched against bincio-auth's JWKS endpoint (cached for 1h). Falls back to HS256 if the RS256 check fails, so existing sessions keep working during the transition. DB session lookup is the final fallback. New --oidc-issuer flag reads BINCIO_OIDC_ISSUER env var. --- bincio/serve/cli.py | 12 +++++-- bincio/serve/deps.py | 80 ++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 83 insertions(+), 9 deletions(-) diff --git a/bincio/serve/cli.py b/bincio/serve/cli.py index 892a920..05905cd 100644 --- a/bincio/serve/cli.py +++ b/bincio/serve/cli.py @@ -24,11 +24,13 @@ console = Console() @click.option("--sync-secret", default=None, envvar="BINCIO_SYNC_SECRET", help="Shared secret for POST /api/internal/rebuild (used by the sync-strava systemd timer).") @click.option("--jwt-secret", default=None, envvar="BINCIO_AUTH_JWT_SECRET", help="Shared JWT secret from bincio-auth. When set, validates JWTs locally instead of DB session lookup.") @click.option("--auth-api", default=None, envvar="BINCIO_AUTH_API", help="Internal URL of the bincio-auth API (e.g. http://127.0.0.1:4040). When set, admin user-state operations are proxied to bincio-auth.") +@click.option("--oidc-issuer", default=None, envvar="BINCIO_OIDC_ISSUER", help="OIDC issuer URL (e.g. https://bincio.org). When set, validates RS256 id_tokens via JWKS (preferred over HS256).") def serve(data_dir: str, site_dir: str | None, host: str, port: int, strava_client_id: str | None, strava_client_secret: str | None, max_users: int | None, public_url: str | None, webroot: str | None, dem_url: str | None, - sync_secret: str | None, jwt_secret: str | None, auth_api: str | None) -> None: + sync_secret: str | None, jwt_secret: str | None, auth_api: str | None, + oidc_issuer: str | None) -> None: """Start the bincio multi-user application server. Handles auth, user management, and write operations. @@ -72,6 +74,8 @@ def serve(data_dir: str, site_dir: str | None, host: str, port: int, deps.jwt_secret = jwt_secret if auth_api: deps.auth_api = auth_api.rstrip("/") + if oidc_issuer: + deps.oidc_issuer = oidc_issuer db = open_db(dd) current_limit = get_setting(db, "max_users") @@ -89,8 +93,10 @@ def serve(data_dir: str, site_dir: str | None, host: str, port: int, else: console.print(" Users: [dim]unlimited[/dim]") console.print(f" DEM: [cyan]{deps.dem_url}[/cyan]") - if deps.jwt_secret: - console.print(" Auth: [green]JWT (bincio-auth)[/green]") + if deps.oidc_issuer: + console.print(f" Auth: [green]RS256 via {deps.oidc_issuer}[/green]" + (" + HS256 fallback" if deps.jwt_secret else "")) + elif deps.jwt_secret: + console.print(" Auth: [green]JWT HS256 (bincio-auth)[/green]") else: console.print(" Auth: [dim]local DB sessions[/dim]") console.print() diff --git a/bincio/serve/deps.py b/bincio/serve/deps.py index c9a1cf2..8fdb12a 100644 --- a/bincio/serve/deps.py +++ b/bincio/serve/deps.py @@ -38,12 +38,20 @@ dem_url: str = "https://api.open-elevation.com" sync_secret: str = "" jwt_secret: str = "" # when set, validates JWTs from bincio-auth instead of DB session lookup auth_api: str = "" # when set, proxies user-state admin ops to bincio-auth (e.g. http://127.0.0.1:4040) +oidc_issuer: str = "" # when set, validates RS256 id_tokens via bincio-auth JWKS _db = None _strava_sync_running = False _strava_sync_lock = threading.Lock() _garmin_sync_running = False _garmin_sync_lock = threading.Lock() +# ── JWKS cache ──────────────────────────────────────────────────────────────── + +_jwks_public_key: object = None +_jwks_fetched_at: float = 0.0 +_jwks_lock = threading.Lock() +_JWKS_TTL = 3600 + # ── Constants ───────────────────────────────────────────────────────────────── _VALID_HANDLE = re.compile(r'^[a-z0-9][a-z0-9_-]{0,29}$') @@ -116,8 +124,59 @@ def _check_rate_limit( # ── Auth dependency functions ───────────────────────────────────────────────── -def _decode_jwt(token: str) -> User | None: - """Decode a bincio-auth JWT and return a User. Returns None on any failure.""" +def _get_jwks_public_key() -> object: + """Fetch and cache the RSA public key from bincio-auth's JWKS endpoint.""" + global _jwks_public_key, _jwks_fetched_at + now = time.time() + with _jwks_lock: + if _jwks_public_key is not None and now - _jwks_fetched_at < _JWKS_TTL: + return _jwks_public_key + import base64 + import urllib.request + from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers + from cryptography.hazmat.backends import default_backend + url = f"{oidc_issuer.rstrip('/')}/.well-known/jwks.json" + with urllib.request.urlopen(url, timeout=5) as r: + jwks = json.loads(r.read()) + k = jwks["keys"][0] + + def _b64i(s: str) -> int: + s += "=" * (-len(s) % 4) + return int.from_bytes(base64.urlsafe_b64decode(s), "big") + + pub = RSAPublicNumbers(_b64i(k["e"]), _b64i(k["n"])).public_key(default_backend()) + _jwks_public_key = pub + _jwks_fetched_at = now + return pub + + +def _decode_rs256(token: str) -> User | None: + """Decode an RS256 id_token from bincio-auth. Returns None on any failure.""" + try: + pub = _get_jwks_public_key() + payload = _jwt.decode( + token, pub, algorithms=["RS256"], + options={"verify_aud": False}, + issuer=oidc_issuer, + ) + except Exception: + return None + handle = payload.get("sub") + if not handle: + return None + return User( + handle=handle, + display_name=payload.get("name") or payload.get("display_name", ""), + is_admin=bool(payload.get("is_admin", False)), + wiki_access=bool(payload.get("wiki_access", True)), + activity_access=bool(payload.get("activity_access", False)), + suspended=False, + created_at=0, + ) + + +def _decode_hs256(token: str) -> User | None: + """Decode a bincio-auth HS256 JWT and return a User. Returns None on any failure.""" try: payload = _jwt.decode(token, jwt_secret, algorithms=["HS256"]) except _jwt.PyJWTError: @@ -136,12 +195,21 @@ def _decode_jwt(token: str) -> User | None: ) +def _decode_token(token: str) -> User | None: + """Try RS256 first (if oidc_issuer set), then HS256, then DB session.""" + if oidc_issuer: + user = _decode_rs256(token) + if user: + return user + if jwt_secret: + return _decode_hs256(token) + return get_session(_get_db(), token) + + def _current_user(bincio_session: str | None = Cookie(default=None)) -> User | None: if not bincio_session: return None - if jwt_secret: - return _decode_jwt(bincio_session) - return get_session(_get_db(), bincio_session) + return _decode_token(bincio_session) def _require_user(bincio_session: str | None = Cookie(default=None)) -> User: @@ -170,7 +238,7 @@ def _require_auth( token = auth[7:] if not token: raise HTTPException(401, "Not authenticated") - user = _decode_jwt(token) if jwt_secret else get_session(_get_db(), token) + user = _decode_token(token) if not user: raise HTTPException(401, "Invalid or expired session") return user