diff --git a/bincio/serve/cli.py b/bincio/serve/cli.py index 2c81234..ef4a4fb 100644 --- a/bincio/serve/cli.py +++ b/bincio/serve/cli.py @@ -3,7 +3,6 @@ from __future__ import annotations from pathlib import Path -from typing import Optional import click from rich.console import Console @@ -23,11 +22,12 @@ console = Console() @click.option("--webroot", default=None, type=click.Path(), help="Nginx webroot (e.g. /var/www/bincio). When set, uploads trigger a full Astro build + rsync so new activity pages are immediately accessible without a git push.") @click.option("--dem-url", default=None, envvar="DEM_URL", help="Base URL of an Open-Elevation-compatible API (default: https://api.open-elevation.com).") @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).") -def serve(data_dir: str, site_dir: Optional[str], host: str, port: int, - strava_client_id: Optional[str], strava_client_secret: Optional[str], - max_users: Optional[int], public_url: Optional[str], - webroot: Optional[str], dem_url: Optional[str], - sync_secret: Optional[str]) -> None: +@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.") +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) -> None: """Start the bincio multi-user application server. Handles auth, user management, and write operations. @@ -36,9 +36,10 @@ def serve(data_dir: str, site_dir: Optional[str], host: str, port: int, Requires a data directory initialised with `bincio init`. """ import uvicorn + import bincio.serve.server as srv from bincio.serve import deps - from bincio.serve.db import open_db, set_setting, get_setting + from bincio.serve.db import get_setting, open_db, set_setting dd = Path(data_dir).expanduser().resolve() if not (dd / "instance.db").exists(): @@ -66,12 +67,14 @@ def serve(data_dir: str, site_dir: Optional[str], host: str, port: int, deps.dem_url = dem_url if sync_secret: deps.sync_secret = sync_secret + if jwt_secret: + deps.jwt_secret = jwt_secret db = open_db(dd) current_limit = get_setting(db, "max_users") db.close() - console.print(f"[bold]bincio serve[/bold]") + console.print("[bold]bincio serve[/bold]") console.print(f" Data: [cyan]{dd}[/cyan]") if deps.site_dir: console.print(f" Site: [cyan]{deps.site_dir}[/cyan]") @@ -81,8 +84,12 @@ def serve(data_dir: str, site_dir: Optional[str], host: str, port: int, if current_limit and int(current_limit) > 0: console.print(f" Users: [yellow]max {current_limit}[/yellow]") else: - console.print(f" Users: [dim]unlimited[/dim]") + 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]") + else: + console.print(" Auth: [dim]local DB sessions[/dim]") console.print() log_config = uvicorn.config.LOGGING_CONFIG.copy() diff --git a/bincio/serve/deps.py b/bincio/serve/deps.py index f648db9..bb28dfb 100644 --- a/bincio/serve/deps.py +++ b/bincio/serve/deps.py @@ -12,20 +12,16 @@ import re import threading import time from pathlib import Path -from typing import Optional +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, - authenticate, - create_session, - delete_session, get_session, - get_user, open_db, ) -from bincio.edit.ops import VALID_ACTIVITY_ID as _VALID_ACTIVITY_ID 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 @@ -40,6 +36,7 @@ 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 _db = None _strava_sync_running = False _strava_sync_lock = threading.Lock() @@ -118,20 +115,42 @@ def _check_rate_limit( # ── Auth dependency functions ───────────────────────────────────────────────── -def _current_user(bincio_session: Optional[str] = Cookie(default=None)) -> Optional[User]: +def _decode_jwt(token: str) -> User | None: + """Decode a bincio-auth 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 _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) -def _require_user(bincio_session: Optional[str] = Cookie(default=None)) -> User: +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: Optional[str] = Cookie(default=None)) -> 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") @@ -140,7 +159,7 @@ def _require_admin(bincio_session: Optional[str] = Cookie(default=None)) -> User def _require_auth( request: Request, - bincio_session: Optional[str] = Cookie(default=None), + bincio_session: str | None = Cookie(default=None), ) -> User: """Accept session cookie (web) OR Authorization: Bearer token (mobile).""" token = bincio_session @@ -150,7 +169,7 @@ def _require_auth( token = auth[7:] if not token: raise HTTPException(401, "Not authenticated") - user = get_session(_get_db(), token) + user = _decode_jwt(token) if jwt_secret else get_session(_get_db(), token) if not user: raise HTTPException(401, "Invalid or expired session") return user diff --git a/pyproject.toml b/pyproject.toml index ec0cacf..9520592 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ serve = [ "uvicorn[standard]>=0.29", "python-multipart>=0.0.9", "bcrypt>=4.1", + "PyJWT>=2.8", ] strava = [ "requests>=2.32", @@ -79,6 +80,7 @@ dev = [ "uvicorn[standard]>=0.29", "python-multipart>=0.0.9", "bcrypt>=4.1", + "PyJWT>=2.8", "httpx>=0.28.1", ]