API key auth for Actors (#23)
Protects Actors by requiring the Engine to send a token signed with the Arcade API key. Also, updates the FastAPI example so the Arcade API key is sent to the Engine in a chat request.
This commit is contained in:
parent
d37303de6a
commit
07d9cca2ab
7 changed files with 44 additions and 36 deletions
|
|
@ -5,13 +5,12 @@ import jwt
|
||||||
|
|
||||||
from arcade.core.config import config
|
from arcade.core.config import config
|
||||||
|
|
||||||
TOKEN_VER = "1" # noqa: S105 Possible hardcoded password assigned (false positive)
|
SUPPORTED_TOKEN_VER = "1" # noqa: S105 Possible hardcoded password assigned (false positive)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class TokenValidationResult:
|
class TokenValidationResult:
|
||||||
valid: bool
|
valid: bool
|
||||||
api_key: str | None = None
|
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -19,25 +18,20 @@ class SigningAlgorithm(str, Enum):
|
||||||
HS256 = "HS256"
|
HS256 = "HS256"
|
||||||
|
|
||||||
|
|
||||||
def validate_token(token: str) -> TokenValidationResult:
|
def validate_engine_token(token: str) -> TokenValidationResult:
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(
|
payload = jwt.decode(
|
||||||
token,
|
token,
|
||||||
config.api.secret,
|
config.api.key,
|
||||||
algorithms=[SigningAlgorithm.HS256],
|
algorithms=[SigningAlgorithm.HS256],
|
||||||
verify=True,
|
verify=True,
|
||||||
issuer=config.engine_url,
|
|
||||||
audience="actor",
|
audience="actor",
|
||||||
)
|
)
|
||||||
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError) as e:
|
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError) as e:
|
||||||
return TokenValidationResult(valid=False, error=str(e))
|
return TokenValidationResult(valid=False, error=str(e))
|
||||||
|
|
||||||
api_key = payload.get("api_key")
|
|
||||||
if api_key != config.api.key:
|
|
||||||
return TokenValidationResult(valid=False, error="Invalid API key")
|
|
||||||
|
|
||||||
token_ver = payload.get("ver")
|
token_ver = payload.get("ver")
|
||||||
if token_ver != TOKEN_VER:
|
if token_ver != SUPPORTED_TOKEN_VER:
|
||||||
return TokenValidationResult(valid=False, error=f"Unknown token version: {token_ver}")
|
return TokenValidationResult(valid=False, error=f"Unsupported token version: {token_ver}")
|
||||||
|
|
||||||
return TokenValidationResult(valid=True, api_key=api_key)
|
return TokenValidationResult(valid=True)
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
import json
|
import json
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import Depends, FastAPI, Request
|
||||||
|
|
||||||
from arcade.actor.core.base import (
|
from arcade.actor.core.base import (
|
||||||
BaseActor,
|
BaseActor,
|
||||||
Router,
|
Router,
|
||||||
)
|
)
|
||||||
from arcade.actor.core.common import RequestData
|
from arcade.actor.core.common import RequestData
|
||||||
|
from arcade.actor.fastapi.auth import validate_engine_request
|
||||||
from arcade.actor.utils import is_async_callable
|
from arcade.actor.utils import is_async_callable
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -39,7 +40,7 @@ class FastAPIRouter(Router):
|
||||||
|
|
||||||
async def wrapped_handler(
|
async def wrapped_handler(
|
||||||
request: Request,
|
request: Request,
|
||||||
# api_key: str = Depends(get_api_key), # TODO re-enable when Engine supports auth
|
_: None = Depends(validate_engine_request),
|
||||||
) -> 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 {}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,17 @@
|
||||||
from typing import cast
|
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException
|
from fastapi import Depends, HTTPException
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
from arcade.actor.core.auth import validate_token
|
from arcade.actor.core.auth import validate_engine_token
|
||||||
|
|
||||||
security = HTTPBearer() # Authorization: Bearer <xxx>
|
security = HTTPBearer() # Authorization: Bearer <xxx>
|
||||||
|
|
||||||
|
|
||||||
# Dependency function to validate JWT and extract API key
|
# Dependency function to validate JWT
|
||||||
async def get_api_key(
|
async def validate_engine_request(
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
) -> str:
|
) -> None:
|
||||||
jwt: str = credentials.credentials
|
jwt: str = credentials.credentials
|
||||||
validation_result = validate_token(jwt)
|
validation_result = validate_engine_token(jwt)
|
||||||
|
|
||||||
if not validation_result.valid:
|
if not validation_result.valid:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -21,5 +19,3 @@ async def get_api_key(
|
||||||
detail=f"Invalid token. Error: {validation_result.error}",
|
detail=f"Invalid token. Error: {validation_result.error}",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
return cast(str, validation_result.api_key)
|
|
||||||
|
|
|
||||||
|
|
@ -126,8 +126,18 @@ def run(
|
||||||
|
|
||||||
tools = [catalog[tool]] if tool else list(catalog)
|
tools = [catalog[tool]] if tool else list(catalog)
|
||||||
|
|
||||||
# allow user to specify the engine url?
|
config = Config.load_from_file()
|
||||||
client = EngineClient()
|
if not config.engine or not config.engine_url:
|
||||||
|
console.print("❌ Engine configuration not found or URL is missing.", style="bold red")
|
||||||
|
typer.Exit(code=1)
|
||||||
|
|
||||||
|
if not config.api or not config.api.key:
|
||||||
|
console.print(
|
||||||
|
"❌ API configuration not found or key is missing. Please run `arcade login`.",
|
||||||
|
style="bold red",
|
||||||
|
)
|
||||||
|
typer.Exit(code=1)
|
||||||
|
client = EngineClient(api_key=config.api.key, base_url=config.engine_url)
|
||||||
|
|
||||||
# TODO better way of doing this
|
# TODO better way of doing this
|
||||||
tool_choice = "auto" if choice in ["execute", "generate"] else choice
|
tool_choice = "auto" if choice in ["execute", "generate"] else choice
|
||||||
|
|
@ -202,7 +212,14 @@ def chat(
|
||||||
console.print("❌ Engine configuration not found or URL is missing.", style="bold red")
|
console.print("❌ Engine configuration not found or URL is missing.", style="bold red")
|
||||||
typer.Exit(code=1)
|
typer.Exit(code=1)
|
||||||
|
|
||||||
client = EngineClient(base_url=config.engine_url)
|
if not config.api or not config.api.key:
|
||||||
|
console.print(
|
||||||
|
"❌ API configuration not found or key is missing. Please run `arcade login`.",
|
||||||
|
style="bold red",
|
||||||
|
)
|
||||||
|
typer.Exit(code=1)
|
||||||
|
|
||||||
|
client = EngineClient(api_key=config.api.key, base_url=config.engine_url)
|
||||||
|
|
||||||
if config.user and config.user.email:
|
if config.user and config.user.email:
|
||||||
user_email = config.user.email
|
user_email = config.user.email
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
|
@ -117,8 +116,7 @@ def get_tool_args(chat_completion: ChatCompletion) -> list[tuple[str, dict[str,
|
||||||
|
|
||||||
|
|
||||||
class EngineClient:
|
class EngineClient:
|
||||||
def __init__(self, api_key: str | None = None, base_url: str | None = None):
|
def __init__(self, api_key: str, base_url: str | None = None):
|
||||||
api_key = os.environ["OPENAI_API_KEY"] if api_key is None else api_key
|
|
||||||
self.client = OpenAI(api_key=api_key, base_url=base_url)
|
self.client = OpenAI(api_key=api_key, base_url=base_url)
|
||||||
|
|
||||||
def __getattr__(self, name: str) -> Any:
|
def __getattr__(self, name: str) -> Any:
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,6 @@ class ApiConfig(BaseModel):
|
||||||
"""
|
"""
|
||||||
Arcade API key.
|
Arcade API key.
|
||||||
"""
|
"""
|
||||||
secret: str
|
|
||||||
"""
|
|
||||||
Arcade API secret.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class UserConfig(BaseModel):
|
class UserConfig(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,17 @@ from pydantic import BaseModel
|
||||||
from arcade_gmail.tools import gmail
|
from arcade_gmail.tools import gmail
|
||||||
from arcade_github.tools import repo, user
|
from arcade_github.tools import repo, user
|
||||||
from arcade_slack.tools import chat
|
from arcade_slack.tools import chat
|
||||||
|
from arcade.core.config import config
|
||||||
|
|
||||||
from arcade.actor.fastapi.actor import FastAPIActor
|
from arcade.actor.fastapi.actor import FastAPIActor
|
||||||
|
|
||||||
client = AsyncOpenAI(base_url="http://localhost:9099/v1")
|
if not config.api or not config.api.key:
|
||||||
|
raise ValueError("Arcade API key not set. Please run `arcade login`.")
|
||||||
|
|
||||||
|
client = AsyncOpenAI(
|
||||||
|
api_key=config.api.key,
|
||||||
|
base_url="http://localhost:9099/v1",
|
||||||
|
)
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
|
|
@ -40,7 +47,6 @@ async def postChat(request: ChatRequest, tool_choice: str = "execute"):
|
||||||
],
|
],
|
||||||
model="gpt-4o-mini",
|
model="gpt-4o-mini",
|
||||||
max_tokens=500,
|
max_tokens=500,
|
||||||
# TODO tests for tool choice
|
|
||||||
tools=[
|
tools=[
|
||||||
"GetEmails",
|
"GetEmails",
|
||||||
"WriteDraft",
|
"WriteDraft",
|
||||||
|
|
@ -51,7 +57,7 @@ async def postChat(request: ChatRequest, tool_choice: str = "execute"):
|
||||||
"SendMessageToChannel",
|
"SendMessageToChannel",
|
||||||
],
|
],
|
||||||
tool_choice=tool_choice,
|
tool_choice=tool_choice,
|
||||||
user="sam",
|
user=config.user.email if config.user else None,
|
||||||
)
|
)
|
||||||
return raw_response.choices
|
return raw_response.choices
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue