Actor: Use shared secret for Actor auth (#40)

Actor side of https://github.com/ArcadeAI/Engine/pull/78 (see comments
there)
This commit is contained in:
Nate Barbettini 2024-09-17 17:03:40 -07:00 committed by GitHub
parent f4fe8c7892
commit e4839195d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 70 additions and 22 deletions

View file

@ -1,12 +1,13 @@
import logging
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
import jwt import jwt
from arcade.core.config import config
SUPPORTED_TOKEN_VER = "1" # noqa: S105 Possible hardcoded password assigned (false positive) SUPPORTED_TOKEN_VER = "1" # noqa: S105 Possible hardcoded password assigned (false positive)
logger = logging.getLogger(__name__)
@dataclass @dataclass
class TokenValidationResult: class TokenValidationResult:
@ -18,16 +19,23 @@ class SigningAlgorithm(str, Enum):
HS256 = "HS256" HS256 = "HS256"
def validate_engine_token(token: str) -> TokenValidationResult: def validate_engine_token(actor_secret: str, token: str) -> TokenValidationResult:
try: try:
payload = jwt.decode( payload = jwt.decode(
token, token,
config.api.key, actor_secret,
algorithms=[SigningAlgorithm.HS256], algorithms=[SigningAlgorithm.HS256],
verify=True, verify=True,
audience="actor", audience="actor",
) )
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError) as e: except jwt.InvalidSignatureError as e:
logger.warning(
"Invalid signature. Is the Arcade Engine configured with the Actor secret '%s'?",
actor_secret,
)
return TokenValidationResult(valid=False, error=str(e))
except jwt.InvalidTokenError as e:
return TokenValidationResult(valid=False, error=str(e)) return TokenValidationResult(valid=False, error=str(e))
token_ver = payload.get("ver") token_ver = payload.get("ver")

View file

@ -32,12 +32,13 @@ class BaseActor(Actor):
HealthCheckComponent, HealthCheckComponent,
) )
def __init__(self, disable_auth: bool = False) -> None: def __init__(self, secret: str, disable_auth: bool = False) -> None:
""" """
Initialize the BaseActor with an empty ToolCatalog. Initialize the BaseActor with an empty ToolCatalog.
""" """
self.catalog = ToolCatalog() self.catalog = ToolCatalog()
self.disable_auth = disable_auth self.disable_auth = disable_auth
self.secret = secret
def get_catalog(self) -> list[ToolDefinition]: def get_catalog(self) -> list[ToolDefinition]:
""" """

View file

@ -2,6 +2,7 @@ import json
from typing import Any, Callable from typing import Any, Callable
from fastapi import Depends, FastAPI, Request from fastapi import Depends, FastAPI, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from arcade.actor.core.base import ( from arcade.actor.core.base import (
BaseActor, BaseActor,
@ -17,17 +18,20 @@ class FastAPIActor(BaseActor):
An Arcade Actor that is hosted inside a FastAPI app. An Arcade Actor that is hosted inside a FastAPI app.
""" """
def __init__(self, app: FastAPI, *, disable_auth: bool = False) -> None: def __init__(self, app: FastAPI, *, secret: str, disable_auth: bool = False) -> None:
""" """
Initialize the FastAPIActor with a FastAPI app Initialize the FastAPIActor with a FastAPI app
instance and an empty ToolCatalog. instance and an empty ToolCatalog.
""" """
super().__init__(disable_auth) super().__init__(secret, disable_auth)
self.app = app self.app = app
self.router = FastAPIRouter(app, self) self.router = FastAPIRouter(app, self)
self.register_routes(self.router) self.register_routes(self.router)
security = HTTPBearer() # Authorization: Bearer <xxx>
class FastAPIRouter(Router): class FastAPIRouter(Router):
def __init__(self, app: FastAPI, actor: BaseActor) -> None: def __init__(self, app: FastAPI, actor: BaseActor) -> None:
self.app = app self.app = app
@ -40,9 +44,19 @@ class FastAPIRouter(Router):
use_auth_for_route = not self.actor.disable_auth and require_auth use_auth_for_route = not self.actor.disable_auth and require_auth
def call_validate_engine_request(actor_secret: str) -> Callable:
async def dependency(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> None:
await validate_engine_request(actor_secret, credentials)
return dependency
async def wrapped_handler( async def wrapped_handler(
request: Request, request: Request,
_: None = Depends(validate_engine_request) if use_auth_for_route else None, _: None = Depends(call_validate_engine_request(self.actor.secret))
if use_auth_for_route
else None,
) -> Any: ) -> Any:
body_str = await request.body() body_str = await request.body()
body_json = json.loads(body_str) if body_str else {} body_json = json.loads(body_str) if body_str else {}

View file

@ -1,17 +1,16 @@
from fastapi import Depends, HTTPException from fastapi import HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials
from arcade.actor.core.auth import validate_engine_token from arcade.actor.core.auth import validate_engine_token
security = HTTPBearer() # Authorization: Bearer <xxx>
# Dependency function to validate JWT # Dependency function to validate JWT
async def validate_engine_request( async def validate_engine_request(
credentials: HTTPAuthorizationCredentials = Depends(security), actor_secret: str,
credentials: HTTPAuthorizationCredentials,
) -> None: ) -> None:
jwt: str = credentials.credentials jwt: str = credentials.credentials
validation_result = validate_engine_token(jwt) validation_result = validate_engine_token(actor_secret, jwt)
if not validation_result.valid: if not validation_result.valid:
raise HTTPException( raise HTTPException(

View file

@ -14,12 +14,12 @@ class FlaskActor(BaseActor):
An Arcade Actor that is hosted inside a Flask app. An Arcade Actor that is hosted inside a Flask app.
""" """
def __init__(self, app: Flask) -> None: def __init__(self, app: Flask, *, secret: str, disable_auth: bool = False) -> None:
""" """
Initialize the FlaskActor with a Flask app Initialize the FlaskActor with a Flask app
instance and an empty ToolCatalog. instance and an empty ToolCatalog.
""" """
super().__init__() super().__init__(secret, disable_auth)
self.app = app self.app = app
self.router = FlaskRouter(app, self) self.router = FlaskRouter(app, self)
self.register_routes(self.router) self.register_routes(self.router)

View file

@ -1,3 +1,6 @@
import logging
import os
from rich.console import Console from rich.console import Console
try: try:
@ -15,6 +18,9 @@ except ImportError:
from arcade.actor.fastapi.actor import FastAPIActor from arcade.actor.fastapi.actor import FastAPIActor
from arcade.core.toolkit import Toolkit from arcade.core.toolkit import Toolkit
DEVELOPMENT_SECRET = "dev" # noqa: S105
logger = logging.getLogger(__name__)
console = Console() console = Console()
@ -24,28 +30,48 @@ def serve_default_actor(
""" """
Get an instance of a FastAPI server with the Arcade Actor. Get an instance of a FastAPI server with the Arcade Actor.
""" """
# Use Uvicorn's default log config for Arcade logging,
# to ensure a nice consistent style for all logs.
logging_config = uvicorn.config.LOGGING_CONFIG
logging_config["loggers"]["arcade"] = {
"handlers": ["default"],
"level": "INFO",
"propagate": False,
}
# TODO: Pass in a logging config from the CLI, to set the log level.
logging.config.dictConfig(logging_config)
toolkits = Toolkit.find_all_arcade_toolkits() toolkits = Toolkit.find_all_arcade_toolkits()
if not toolkits: if not toolkits:
console.print("No toolkits found in Python environment. Exiting...", style="bold red") logger.error("No toolkits found in Python environment. Exiting...")
return return
else: else:
console.print("Serving the following toolkits:", style="bold blue") logger.info("Serving the following toolkits:")
for toolkit in toolkits: for toolkit in toolkits:
console.print(f" - {toolkit.name} ({toolkit.package_name})") logger.info(f" - {toolkit.name} ({toolkit.package_name})")
actor_secret = os.environ.get("ARCADE_ACTOR_SECRET")
if not actor_secret:
logger.warning(
"Warning: ARCADE_ACTOR_SECRET environment variable is not set. Using 'dev' as the actor secret.",
)
actor_secret = DEVELOPMENT_SECRET
app = fastapi.FastAPI( app = fastapi.FastAPI(
title="Arcade AI Actor", title="Arcade AI Actor",
description="Arcade AI default Actor implementation using FastAPI.", description="Arcade AI default Actor implementation using FastAPI.",
version="0.1.0", version="0.1.0",
) )
actor = FastAPIActor(app, disable_auth=disable_auth) actor = FastAPIActor(app, secret=actor_secret, disable_auth=disable_auth)
for toolkit in toolkits: for toolkit in toolkits:
actor.register_toolkit(toolkit) actor.register_toolkit(toolkit)
console.print("Starting FastAPI server...", style="bold blue") logger.info("Starting FastAPI server...")
uvicorn.run( uvicorn.run(
app=app, app=app,
host=host, host=host,
port=port, port=port,
log_config=logging_config,
) )