diff --git a/scanner/.env.example b/scanner/.env.example index cd43a51c..4d2b6f35 100644 --- a/scanner/.env.example +++ b/scanner/.env.example @@ -13,3 +13,13 @@ THEMOVIEDB_API_ACCESS_TOKEN="" KYOO_URL="http://api:3567/api" KYOO_APIKEY="" +JWKS_URL="http://auth:4568/.well-known/jwks.json" +JWT_ISSUER=$PUBLIC_URL + +# The behavior of the below variables match what is documented here: +# https://www.postgresql.org/docs/current/libpq-envars.html +PGUSER=kyoo +PGPASSWORD=password +PGDATABASE=kyooDB +PGHOST=postgres +PGPORT=5432 diff --git a/scanner/requirements.txt b/scanner/requirements.txt index 0575ee6c..bf7ed2e4 100644 --- a/scanner/requirements.txt +++ b/scanner/requirements.txt @@ -5,3 +5,4 @@ aiohttp watchfiles langcodes asyncpg +pyjwt[crypto] diff --git a/scanner/scanner/__init__.py b/scanner/scanner/__init__.py index 4ed5ceca..ad2d3b08 100644 --- a/scanner/scanner/__init__.py +++ b/scanner/scanner/__init__.py @@ -1,12 +1,14 @@ import asyncio import logging from contextlib import asynccontextmanager +from typing import Annotated import asyncpg -from fastapi import BackgroundTasks, FastAPI +from fastapi import BackgroundTasks, FastAPI, Security from .client import KyooClient from .fsscan import Scanner +from .jwt import validate_bearer from .providers.composite import CompositeProvider from .providers.themoviedatabase import TheMovieDatabase from .requests import RequestCreator, RequestProcessor @@ -71,8 +73,13 @@ app = FastAPI( @app.put( "/scan", status_code=204, - description="Trigger a full scan of the filesystem, trying to find new videos & deleting old ones.", response_description="Scan started.", ) -async def trigger_scan(tasks: BackgroundTasks): +async def trigger_scan( + tasks: BackgroundTasks, + _: Annotated[None, Security(validate_bearer, scopes=["scanner."])], +): + """ + Trigger a full scan of the filesystem, trying to find new videos & deleting old ones. + """ tasks.add_task(scanner.scan) diff --git a/scanner/scanner/jwt.py b/scanner/scanner/jwt.py new file mode 100644 index 00000000..7d070cf4 --- /dev/null +++ b/scanner/scanner/jwt.py @@ -0,0 +1,41 @@ +import os +from typing import Annotated + +import jwt +from fastapi import Depends, HTTPException +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, SecurityScopes +from jwt import PyJWKClient + +jwks_client = PyJWKClient( + os.environ.get("JWKS_URL", "http://auth:4568/.well-known/jwks.json") +) + +security = HTTPBearer(scheme_name="Bearer") + + +def validate_bearer( + token: Annotated[HTTPAuthorizationCredentials, Depends(security)], + perms: SecurityScopes, +): + try: + payload = jwt.decode( + token.credentials, + jwks_client.get_signing_key_from_jwt(token.credentials).key, + issuer=os.environ.get("JWT_ISSUER"), + ) + for scope in perms.scopes: + if scope not in payload["permissions"]: + raise HTTPException( + status_code=403, + detail=f"Missing permissions {', '.join(perms.scopes)}", + headers={ + "WWW-Authenticate": f'Bearer permissions="{",".join(perms.scopes)}"' + }, + ) + return payload + except Exception as e: + raise HTTPException( + status_code=403, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) from e diff --git a/scanner/shell.nix b/scanner/shell.nix index 816ec910..b5e442da 100644 --- a/scanner/shell.nix +++ b/scanner/shell.nix @@ -8,6 +8,7 @@ watchfiles langcodes asyncpg + pyjwt ]); in pkgs.mkShell {