3394be4ee9
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.
259 lines
8.8 KiB
Python
259 lines
8.8 KiB
Python
"""Shared state and FastAPI dependency functions for bincio.serve.
|
|
|
|
All module-level globals live here so routers can import them without
|
|
creating circular dependencies through server.py.
|
|
The CLI sets these before uvicorn starts.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import jwt as _jwt
|
|
from fastapi import Cookie, HTTPException, Request, Response
|
|
|
|
from bincio.edit.ops import VALID_ACTIVITY_ID as _VALID_ACTIVITY_ID
|
|
from bincio.serve.db import (
|
|
User,
|
|
get_session,
|
|
open_db,
|
|
)
|
|
from bincio.shared.images import ALLOWED_IMAGE_TYPES as _ALLOWED_IMAGE_TYPES # noqa: F401
|
|
from bincio.shared.images import MAX_IMAGE_BYTES as _MAX_IMAGE_BYTES # noqa: F401
|
|
from bincio.shared.images import unique_image_name as _unique_image_name # noqa: F401
|
|
|
|
# ── Module-level state (set by CLI before uvicorn starts) ─────────────────────
|
|
|
|
data_dir: Path | None = None
|
|
site_dir: Path | None = None
|
|
webroot: Path | None = None
|
|
strava_client_id: str = ""
|
|
strava_client_secret: str = ""
|
|
public_url: str = ""
|
|
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}$')
|
|
_SESSION_COOKIE = "bincio_session"
|
|
_COOKIE_MAX_AGE = 30 * 86400 # 30 days
|
|
_SESSION_DOMAIN = os.environ.get("SESSION_DOMAIN") or None
|
|
|
|
_STRAVA_CREDS_FILE = "strava_credentials.json"
|
|
|
|
_login_attempts: dict[str, list[float]] = {}
|
|
_register_attempts: dict[str, list[float]] = {}
|
|
_RATE_WINDOW = 900 # 15 minutes
|
|
_LOGIN_RATE_LIMIT = 10
|
|
_REGISTER_RATE_LIMIT = 5
|
|
|
|
# ── Core helpers ──────────────────────────────────────────────────────────────
|
|
|
|
def _get_data_dir() -> Path:
|
|
if data_dir is None:
|
|
raise HTTPException(500, "Server not configured")
|
|
return data_dir
|
|
|
|
|
|
def _get_db():
|
|
global _db
|
|
if _db is None:
|
|
_db = open_db(_get_data_dir())
|
|
return _db
|
|
|
|
|
|
def _strava_creds(handle: str) -> tuple[str, str]:
|
|
"""Return (client_id, client_secret) for a user.
|
|
|
|
Per-user credentials take precedence over the instance-level globals.
|
|
Returns ("", "") when neither is configured.
|
|
"""
|
|
creds_path = _get_data_dir() / handle / _STRAVA_CREDS_FILE
|
|
if creds_path.exists():
|
|
try:
|
|
d = json.loads(creds_path.read_text(encoding="utf-8"))
|
|
cid = str(d.get("client_id", "")).strip()
|
|
csec = str(d.get("client_secret", "")).strip()
|
|
if cid and csec:
|
|
return cid, csec
|
|
except (OSError, json.JSONDecodeError, KeyError, ValueError):
|
|
pass
|
|
return strava_client_id, strava_client_secret
|
|
|
|
|
|
def _check_id(activity_id: str) -> str:
|
|
if not _VALID_ACTIVITY_ID.match(activity_id):
|
|
raise HTTPException(400, "Invalid activity ID")
|
|
return activity_id
|
|
|
|
# ── Rate limiting ─────────────────────────────────────────────────────────────
|
|
|
|
def _check_rate_limit(
|
|
ip: str,
|
|
store: dict[str, list[float]],
|
|
limit: int,
|
|
msg: str = "Too many attempts. Try again later.",
|
|
) -> None:
|
|
now = time.time()
|
|
attempts = [t for t in store.get(ip, []) if now - t < _RATE_WINDOW]
|
|
store[ip] = attempts
|
|
if len(attempts) >= limit:
|
|
raise HTTPException(429, msg)
|
|
attempts.append(now)
|
|
store[ip] = attempts
|
|
|
|
# ── Auth dependency functions ─────────────────────────────────────────────────
|
|
|
|
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:
|
|
return None
|
|
handle = payload.get("sub")
|
|
if not handle:
|
|
return None
|
|
return User(
|
|
handle=handle,
|
|
display_name=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_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
|
|
return _decode_token(bincio_session)
|
|
|
|
|
|
def _require_user(bincio_session: str | None = Cookie(default=None)) -> User:
|
|
user = _current_user(bincio_session)
|
|
if not user:
|
|
raise HTTPException(401, "Not authenticated")
|
|
return user
|
|
|
|
|
|
def _require_admin(bincio_session: str | None = Cookie(default=None)) -> User:
|
|
user = _require_user(bincio_session)
|
|
if not user.is_admin:
|
|
raise HTTPException(403, "Admin required")
|
|
return user
|
|
|
|
|
|
def _require_auth(
|
|
request: Request,
|
|
bincio_session: str | None = Cookie(default=None),
|
|
) -> User:
|
|
"""Accept session cookie (web) OR Authorization: Bearer token (mobile)."""
|
|
token = bincio_session
|
|
if not token:
|
|
auth = request.headers.get("Authorization", "")
|
|
if auth.startswith("Bearer "):
|
|
token = auth[7:]
|
|
if not token:
|
|
raise HTTPException(401, "Not authenticated")
|
|
user = _decode_token(token)
|
|
if not user:
|
|
raise HTTPException(401, "Invalid or expired session")
|
|
return user
|
|
|
|
|
|
def _set_session_cookie(response: Response, token: str) -> None:
|
|
kwargs: dict = dict(
|
|
key=_SESSION_COOKIE,
|
|
value=token,
|
|
max_age=_COOKIE_MAX_AGE,
|
|
httponly=True,
|
|
samesite="lax",
|
|
secure=False,
|
|
)
|
|
if _SESSION_DOMAIN:
|
|
kwargs["domain"] = _SESSION_DOMAIN
|
|
response.set_cookie(**kwargs)
|