diff --git a/server/pyproject.toml b/server/pyproject.toml index 49648d6..d07f9a9 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -7,4 +7,5 @@ dependencies = [ "uvicorn[standard]>=0.29", "pydantic>=2.0", "PyJWT>=2.8", + "cryptography>=42", ] diff --git a/server/server.py b/server/server.py index 815a00a..2619f57 100644 --- a/server/server.py +++ b/server/server.py @@ -1,13 +1,15 @@ """BincioPlanner API — save/load route plans. -Auth: validates bincio_session JWT issued by bincio-auth (HS256, shared secret). +Auth: validates bincio_session JWT from bincio-auth. + Accepts RS256 OIDC tokens (BINCIO_OIDC_ISSUER) and HS256 session tokens (BINCIO_AUTH_JWT_SECRET). Storage: JSON files at $PLANS_DIR/{handle}/{plan_id}.json Collections: $PLANS_DIR/{handle}/_collections.json Shared plans: $PLANS_DIR/_shared/{plan_id}.json (any authed user can read/write/delete) Run: uvicorn server:app --host 127.0.0.1 --port 8002 Env vars: - BINCIO_AUTH_JWT_SECRET HS256 secret shared with bincio-auth (required in production) + BINCIO_OIDC_ISSUER OIDC issuer URL — JWKS fetched from {issuer}/.well-known/jwks.json + BINCIO_AUTH_JWT_SECRET HS256 fallback secret PLANS_DIR root dir for plan JSON files (default: /var/bincio_planner) DEV_HANDLE bypass auth in local dev """ @@ -18,23 +20,39 @@ import os import re import secrets import time +import urllib.request from dataclasses import dataclass from pathlib import Path from typing import Optional import jwt as _jwt +from jwt.algorithms import RSAAlgorithm as _RSAAlgorithm from fastapi import Cookie, Depends, FastAPI, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware from fastapi.responses import JSONResponse from pydantic import BaseModel -_JWT_SECRET = os.environ.get("BINCIO_AUTH_JWT_SECRET", "") -_PLANS_DIR = Path(os.environ.get("PLANS_DIR", "/var/bincio_planner")) -_DEV_HANDLE = os.environ.get("DEV_HANDLE", "") +_JWT_SECRET = os.environ.get("BINCIO_AUTH_JWT_SECRET", "") +_OIDC_ISSUER = os.environ.get("BINCIO_OIDC_ISSUER", "") +_PLANS_DIR = Path(os.environ.get("PLANS_DIR", "/var/bincio_planner")) +_DEV_HANDLE = os.environ.get("DEV_HANDLE", "") _SAFE_ID = re.compile(r"^[A-Za-z0-9_-]{1,32}$") +# Load RS256 public key from JWKS at startup. +_rs256_key = None +if _OIDC_ISSUER: + try: + with urllib.request.urlopen(f"{_OIDC_ISSUER}/.well-known/jwks.json", timeout=5) as _r: + _jwks = json.loads(_r.read()) + for _k in _jwks.get("keys", []): + if _k.get("kty") == "RSA": + _rs256_key = _RSAAlgorithm.from_jwk(json.dumps(_k)) + break + except Exception as _e: + print(f"Warning: could not load JWKS from {_OIDC_ISSUER}: {_e}") + # ── Auth ─────────────────────────────────────────────────────────────────────── @@ -45,6 +63,19 @@ class User: def _decode_session(token: str) -> Optional[User]: + # Try RS256 (OIDC id_token from bincio-auth) + if _rs256_key: + try: + payload = _jwt.decode(token, _rs256_key, algorithms=["RS256"], + options={"verify_aud": False}) + handle = payload.get("sub") + if handle and payload.get("activity_access"): + return User(handle=handle, + display_name=payload.get("name") or payload.get("display_name") or handle) + except _jwt.PyJWTError: + pass + + # Fall back to HS256 session token if not _JWT_SECRET: return None try: @@ -52,9 +83,7 @@ def _decode_session(token: str) -> Optional[User]: except _jwt.PyJWTError: return None handle = payload.get("sub") - if not handle: - return None - if not payload.get("activity_access"): + if not handle or not payload.get("activity_access"): return None return User(handle=handle, display_name=payload.get("display_name", handle))