Skip to content

Commit 7f19aa4

Browse files
authored
Merge pull request #57 from maia-iyer/agent_jwks_validation
✨ Implement JWKS validation for the Slack Research Agent
2 parents 76ec7c3 + 16ff4ee commit 7f19aa4

5 files changed

Lines changed: 145 additions & 31 deletions

File tree

a2a/slack_researcher/README.md

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,6 @@ The Slack Researcher Agent is designed to perform research tasks across Slack ch
1010

1111
### Configuration Variables
1212

13-
## Running in Kagenti
14-
When deploying in the Kagenti UI - You will need to attach 3 environments to the agent deployment:
15-
16-
![alt text](docs/environments.png)
17-
18-
1. `ollama` or `openai` (Slack Researcher is looking for the values of `LLM_API_BASE` and `LLM_API_KEY` - Either ollama or any other OpenAI-compatible API will work)
19-
2. `mcp-slack` - This provides the value of the slack MCP server (`MCP_URL`)
20-
3. `slack-researcher` - This provides the remainder of the necessary configuration settings
21-
2213
| Variable Name | Description | Required? | Default Value |
2314
|---------------|-------------|-----------|---------------|
2415
| LLM_API_BASE | The URL for OpenAI compatible API | Yes | `http://localhost:11434/v1` |
@@ -30,3 +21,22 @@ When deploying in the Kagenti UI - You will need to attach 3 environments to the
3021
| MCP_URL | Endpoint where the Slack MCP server can be found | No | "" |
3122
| SERVICE_PORT | Port on which the service will run | Yes | `8000` |
3223
| LOG_LEVEL | Application log level | No | DEBUG |
24+
| JWKS_URL | Endpoint to obtain JWKS for token validation. Enables token validation | No | - |
25+
| ISSUER | Expected `iss` value of incoming bearer tokens | No | - |
26+
| AUDIENCE | Expected `aud` value of incoming bearer tokens | No | - |
27+
28+
> **Note on Authorization configuration**
29+
> By default, no token validation is performed. To enable token validation, set `JWKS_URL`.
30+
> If `ISSUER` is additionally set, the `iss` claim will be checked to equal this value.
31+
> If `AUDIENCE` is additionally set, the `aud` claim will be checked to equal this value.
32+
33+
## Running in Kagenti
34+
When deploying in the Kagenti UI - You will need to attach 3 environments to the agent deployment:
35+
36+
![alt text](docs/environments.png)
37+
38+
1. `ollama` or `openai` (Slack Researcher is looking for the values of `LLM_API_BASE` and `LLM_API_KEY` - Either ollama or any other OpenAI-compatible API will work)
39+
2. `mcp-slack` - This provides the value of the slack MCP server (`MCP_URL`)
40+
3. `slack-researcher` - This provides the remainder of the necessary configuration settings
41+
42+
NOTE if you are connecting to a tool that is OAuth-secured, you MUST set the `JWKS_URL` variable or the token will not be passed to the tool.

a2a/slack_researcher/a2a_agent.py

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -20,35 +20,16 @@
2020
from a2a.types import AgentCapabilities, AgentCard, AgentSkill, TaskState, TextPart, SecurityScheme, HTTPAuthSecurityScheme
2121
from a2a.utils import new_agent_text_message, new_task
2222

23-
from starlette.authentication import AuthCredentials, SimpleUser, AuthenticationBackend
2423
from starlette.middleware.authentication import AuthenticationMiddleware
2524

2625
from slack_researcher.config import settings, Settings
2726
from slack_researcher.event import Event
2827
from slack_researcher.main import SlackAgent
28+
from slack_researcher.auth import on_auth_error, BearerAuthBackend
2929

3030
logger = logging.getLogger(__name__)
3131
logging.basicConfig(level=logging.DEBUG, stream=sys.stdout, format='%(levelname)s: %(message)s')
3232

33-
class BearerAuthBackend(AuthenticationBackend):
34-
""" Very temporary demo to grab auth token and print it"""
35-
async def authenticate(self, conn):
36-
try:
37-
auth = conn.headers.get("authorization")
38-
if not auth or not auth.lower().startswith("bearer "):
39-
print("No bearer token provided")
40-
return
41-
token = auth.split(" ", 1)[1]
42-
print(f"TOKEN: {token}")
43-
44-
# Storing the token as the username - not a real life scenario - just demo-ing the passing of creds
45-
user = SimpleUser(token)
46-
return AuthCredentials(["authenticated"]), user
47-
except Exception as e:
48-
logger.error("Exception when attempting to obtain user token")
49-
logger.error(e)
50-
51-
5233
def get_agent_card(host: str, port: int):
5334
"""Returns the Agent Card for the AG2 Agent."""
5435
capabilities = AgentCapabilities(streaming=True)
@@ -237,6 +218,10 @@ def run():
237218
)
238219

239220
app = server.build() # this returns a Starlette app
240-
app.add_middleware(AuthenticationMiddleware, backend=BearerAuthBackend())
221+
# if one of the auth variables is set, create middleware
222+
# if none of them are set, ignore all authorization headers. No token validation will be performed
223+
if not settings.JWKS_URI is None:
224+
logging.info("JWKS_URI is set - using JWT Validation middleware")
225+
app.add_middleware(AuthenticationMiddleware, backend=BearerAuthBackend(), on_error=on_auth_error)
241226

242227
uvicorn.run(app, host="0.0.0.0", port=settings.SERVICE_PORT)

a2a/slack_researcher/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ dependencies = [
99
"python-dotenv>=1.1.0",
1010
"a2a-sdk>=0.2.16",
1111
"python-keycloak>=5.5.1",
12+
"authlib>=1.6.0",
1213
]
1314

1415
[tool.ruff]
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import httpx
2+
import logging
3+
import sys
4+
5+
from starlette.responses import JSONResponse
6+
from starlette.requests import Request
7+
from starlette.authentication import AuthCredentials, SimpleUser, AuthenticationBackend
8+
from starlette.authentication import AuthenticationError as StarletteAuthenticationError
9+
10+
from authlib.jose import jwt
11+
from authlib.common.errors import AuthlibBaseError
12+
13+
from slack_researcher.config import settings
14+
15+
logger = logging.getLogger(__name__)
16+
logging.basicConfig(level=logging.DEBUG, stream=sys.stdout, format='%(levelname)s: %(message)s')
17+
18+
def on_auth_error(request: Request, e: Exception):
19+
status_code = 401
20+
message = "Authentication failed"
21+
headers = {"WWW-Authenticate": "Bearer"}
22+
23+
if isinstance(e, AuthenticationError):
24+
status_code = e.status_code
25+
message = str(e)
26+
27+
return JSONResponse(
28+
{"error": message},
29+
status_code = status_code,
30+
headers=headers if status_code == 401 else None
31+
)
32+
33+
class AuthenticationError(StarletteAuthenticationError):
34+
def __init__(self, message: str, status_code: int = 401):
35+
self.status_code = status_code
36+
super().__init__(message)
37+
38+
class BearerAuthBackend(AuthenticationBackend):
39+
def __init__(self):
40+
if settings.JWKS_URI is None: # TODO implement oidc discovery in this case
41+
raise Exception("JWKS_URI env var not set. ")
42+
self.jwks_url = settings.JWKS_URI
43+
44+
self.claims_options = {}
45+
if settings.AUDIENCE is None:
46+
logger.debug(f"AUDIENCE env var not set. No audience check will be performed. ")
47+
else:
48+
self.claims_options["aud"] = {"essential": True, "value": settings.AUDIENCE}
49+
if settings.ISSUER is None:
50+
logger.debug(f"ISSUER env var no set. No issuer check will be performed")
51+
else:
52+
self.claims_options["iss"] = {"essential": True, "value": settings.ISSUER}
53+
54+
async def get_jwks(self):
55+
logger.debug(f"Fetching JWKS from {self.jwks_url}")
56+
jwks = None
57+
try:
58+
async with httpx.AsyncClient() as client:
59+
response = await client.get(self.jwks_url)
60+
response.raise_for_status()
61+
jwks = response.json() # TODO should probably use caching
62+
return jwks
63+
except httpx.HTTPStatusError as e:
64+
logger.debug(f"Could not retrieve JWKS from {self.jwks_url}: {e}")
65+
return None
66+
67+
async def get_token(self, conn):
68+
logger.debug("Obtaining bearer token...")
69+
headers = conn.headers
70+
logger.debug(f"Headers obtained: {headers}")
71+
auth = headers.get("authorization")
72+
if not auth or not auth.lower().startswith("bearer "):
73+
logger.error("Expected `Authorization: Bearer` access token; None provided. ")
74+
return None
75+
token = auth.split(" ", 1)[1]
76+
return token
77+
78+
"""This is run upon every received request"""
79+
async def authenticate(self, conn):
80+
# bypass authentication for agent card
81+
if conn.scope.get("path") == "/.well-known/agent.json":
82+
logger.debug("Bypassing authentication for public agent card path")
83+
return None
84+
85+
# extract token
86+
token = await self.get_token(conn)
87+
if token is None:
88+
raise AuthenticationError(message = "Bearer token not found in Authorization header.")
89+
90+
# fetch jwks
91+
jwks = await self.get_jwks()
92+
93+
try:
94+
# decode and validate claims
95+
claims = jwt.decode(s=token, key=jwks, claims_options=self.claims_options)
96+
claims.validate()
97+
logger.debug("Token successfully validated.")
98+
99+
# return user
100+
user = SimpleUser(token)
101+
return AuthCredentials(["authenticated"]), user
102+
except AuthlibBaseError as e:
103+
logger.error(f"Token validation failed: {e}")
104+
raise AuthenticationError(f"Invalid token: {e}, status_code=401")

a2a/slack_researcher/slack_researcher/config.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from pydantic_settings import BaseSettings
66
from pydantic import model_validator
77
from pydantic import Field
8-
from typing import Literal
8+
from typing import Literal, Optional
99

1010

1111
class Settings(BaseSettings):
@@ -36,6 +36,20 @@ class Settings(BaseSettings):
3636
MCP_URL: str = Field(os.getenv("MCP_URL", "http://slack-tool:8000"), description="Endpoint for an option MCP server")
3737
SERVICE_PORT: int = Field(os.getenv("SERVICE_URL", 8000), description="Port on which the service will run.")
3838

39+
# auth variables
40+
ISSUER: Optional[str] = Field(
41+
os.getenv("ISSUER", None),
42+
description="The issuer for incoming JWT tokens"
43+
)
44+
JWKS_URI: Optional[str] = Field(
45+
os.getenv("JWKS_URI", None),
46+
description="Endpoint to obtain JWKS from auth server"
47+
)
48+
AUDIENCE: Optional[str] = Field(
49+
os.getenv("AUDIENCE", None),
50+
description="Expected audience value during resource validation"
51+
)
52+
3953
class Config:
4054
env_file = ".env"
4155
env_file_encoding = "utf-8"

0 commit comments

Comments
 (0)