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.
This commit is contained in:
+9
-3
@@ -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("--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("--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("--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,
|
def serve(data_dir: str, site_dir: str | None, host: str, port: int,
|
||||||
strava_client_id: str | None, strava_client_secret: str | None,
|
strava_client_id: str | None, strava_client_secret: str | None,
|
||||||
max_users: int | None, public_url: str | None,
|
max_users: int | None, public_url: str | None,
|
||||||
webroot: str | None, dem_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.
|
"""Start the bincio multi-user application server.
|
||||||
|
|
||||||
Handles auth, user management, and write operations.
|
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
|
deps.jwt_secret = jwt_secret
|
||||||
if auth_api:
|
if auth_api:
|
||||||
deps.auth_api = auth_api.rstrip("/")
|
deps.auth_api = auth_api.rstrip("/")
|
||||||
|
if oidc_issuer:
|
||||||
|
deps.oidc_issuer = oidc_issuer
|
||||||
|
|
||||||
db = open_db(dd)
|
db = open_db(dd)
|
||||||
current_limit = get_setting(db, "max_users")
|
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:
|
else:
|
||||||
console.print(" Users: [dim]unlimited[/dim]")
|
console.print(" Users: [dim]unlimited[/dim]")
|
||||||
console.print(f" DEM: [cyan]{deps.dem_url}[/cyan]")
|
console.print(f" DEM: [cyan]{deps.dem_url}[/cyan]")
|
||||||
if deps.jwt_secret:
|
if deps.oidc_issuer:
|
||||||
console.print(" Auth: [green]JWT (bincio-auth)[/green]")
|
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:
|
else:
|
||||||
console.print(" Auth: [dim]local DB sessions[/dim]")
|
console.print(" Auth: [dim]local DB sessions[/dim]")
|
||||||
console.print()
|
console.print()
|
||||||
|
|||||||
+74
-6
@@ -38,12 +38,20 @@ dem_url: str = "https://api.open-elevation.com"
|
|||||||
sync_secret: str = ""
|
sync_secret: str = ""
|
||||||
jwt_secret: str = "" # when set, validates JWTs from bincio-auth instead of DB session lookup
|
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)
|
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
|
_db = None
|
||||||
_strava_sync_running = False
|
_strava_sync_running = False
|
||||||
_strava_sync_lock = threading.Lock()
|
_strava_sync_lock = threading.Lock()
|
||||||
_garmin_sync_running = False
|
_garmin_sync_running = False
|
||||||
_garmin_sync_lock = threading.Lock()
|
_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 ─────────────────────────────────────────────────────────────────
|
# ── Constants ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
_VALID_HANDLE = re.compile(r'^[a-z0-9][a-z0-9_-]{0,29}$')
|
_VALID_HANDLE = re.compile(r'^[a-z0-9][a-z0-9_-]{0,29}$')
|
||||||
@@ -116,8 +124,59 @@ def _check_rate_limit(
|
|||||||
|
|
||||||
# ── Auth dependency functions ─────────────────────────────────────────────────
|
# ── Auth dependency functions ─────────────────────────────────────────────────
|
||||||
|
|
||||||
def _decode_jwt(token: str) -> User | None:
|
def _get_jwks_public_key() -> object:
|
||||||
"""Decode a bincio-auth JWT and return a User. Returns None on any failure."""
|
"""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:
|
try:
|
||||||
payload = _jwt.decode(token, jwt_secret, algorithms=["HS256"])
|
payload = _jwt.decode(token, jwt_secret, algorithms=["HS256"])
|
||||||
except _jwt.PyJWTError:
|
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:
|
def _current_user(bincio_session: str | None = Cookie(default=None)) -> User | None:
|
||||||
if not bincio_session:
|
if not bincio_session:
|
||||||
return None
|
return None
|
||||||
if jwt_secret:
|
return _decode_token(bincio_session)
|
||||||
return _decode_jwt(bincio_session)
|
|
||||||
return get_session(_get_db(), bincio_session)
|
|
||||||
|
|
||||||
|
|
||||||
def _require_user(bincio_session: str | None = Cookie(default=None)) -> User:
|
def _require_user(bincio_session: str | None = Cookie(default=None)) -> User:
|
||||||
@@ -170,7 +238,7 @@ def _require_auth(
|
|||||||
token = auth[7:]
|
token = auth[7:]
|
||||||
if not token:
|
if not token:
|
||||||
raise HTTPException(401, "Not authenticated")
|
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:
|
if not user:
|
||||||
raise HTTPException(401, "Invalid or expired session")
|
raise HTTPException(401, "Invalid or expired session")
|
||||||
return user
|
return user
|
||||||
|
|||||||
Reference in New Issue
Block a user