Skip to content

Commit 191882a

Browse files
authored
Merge pull request #81 from Alan-Cha/main
Fetch SVID as CLIENT_ID
2 parents f7d233e + 7882a10 commit 191882a

10 files changed

Lines changed: 1154 additions & 1087 deletions

File tree

a2a/slack_researcher/README.md

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,33 +10,29 @@ The Slack Researcher Agent is designed to perform research tasks across Slack ch
1010

1111
### Configuration Variables
1212

13-
| Variable Name | Description | Required? | Default Value |
14-
|---------------|-------------|-----------|---------------|
15-
| LLM_API_BASE | The URL for OpenAI compatible API | Yes | `http://localhost:11434/v1` |
13+
| Variable Name | Description | Required? | Default Value |
14+
|---------------|-------------|-----------|---------------|
15+
| LLM_API_BASE | The URL for OpenAI compatible API | Yes | `http://localhost:11434/v1` |
1616
| LLM_API_KEY | The API key for OpenAI compatible API | No | `my_api_key` |
17-
| TASK_MODEL_ID | The ID of the LLM | Yes | `granite3.3:8b` |
18-
| EXTRA_HEADERS | Extra headers for the OpenAI API, e.g. {"MY_HEADER": "my_value"} | No | `{}` |
19-
| MODEL_TEMPERATURE | The temperature for the model | Yes | `0` |
20-
| MAX_PLAN_STEPS | The maximum number of plan steps | Yes | `6` |
21-
| MCP_URL | Endpoint where the Slack MCP server can be found | No | "" |
17+
| TASK_MODEL_ID | The ID of the LLM | Yes | `granite3.3:8b` |
18+
| EXTRA_HEADERS | Extra headers for the OpenAI API, e.g. {"MY_HEADER": "my_value"} | No | `{}` |
19+
| MODEL_TEMPERATURE | The temperature for the model | Yes | `0` |
20+
| MAX_PLAN_STEPS | The maximum number of plan steps | Yes | `6` |
21+
| MCP_URL | Endpoint where the Slack MCP server can be found | No | "" |
2222
| SERVICE_PORT | Port on which the service will run | Yes | `8000` |
2323
| LOG_LEVEL | Application log level | No | DEBUG |
2424
| JWKS_URL | Endpoint to obtain JWKS for token validation. Enables token validation | No | - |
2525
| ISSUER | Expected `iss` value of incoming bearer tokens | No | - |
26-
| AUDIENCE | Expected `aud` value of incoming bearer tokens | No | - |
2726
| TOKEN_URL | Endpoint to perform token exchange. Required for token exchange. | No | - |
28-
| CLIENT_ID | Client ID to authenticate to auth server with. Required for token exchange. | No | - |
27+
| CLIENT_ID | Client ID to authenticate to auth server with. Required for token exchange. Expected `aud` value of incoming bearer tokens | No | - |
2928
| CLIENT_SECRET | Client secret to authenticate to auth server with. Required for token exchange. | No | - |
30-
| TARGET_AUDIENCE | Audience of token exchanged token | No | - |
3129
| TARGET_SCOPES | Requested scopes of token exchanged token | No | - |
3230

3331
> **Note on Authorization configuration**
34-
> By default, no token validation is performed. To enable token validation, set `JWKS_URL`.
35-
> If `ISSUER` is additionally set, the `iss` claim will be checked to equal this value.
36-
> If `AUDIENCE` is additionally set, the `aud` claim will be checked to equal this value.
37-
> If all of `TOKEN_URL`, `CLIENT_ID`, and `CLIENT_SECRET` are set in addition, token exchange will be performed using Bearer tokens from incoming requests, to send to the MCP endpoint.
38-
> In addition to `TOKEN_URL`, `CLIENT_ID`, `CLIENT_SECRET`, which trigger token exchange, `TARGET_AUDIENCE` and `TARGET_SCOPES` can be optionally configured as the values of `audience` and `scope` in the token exchange request, respectively.
39-
32+
> By default, no token validation is performed. To enable token validation, set `JWKS_URL`.
33+
> If `ISSUER` is additionally set, the `iss` claim will be checked to equal this value.
34+
> If all of `TOKEN_URL`, `CLIENT_ID`, and `CLIENT_SECRET` are set in addition, token exchange will be performed using Bearer tokens from incoming requests, to send to the MCP endpoint.
35+
> In addition to `TOKEN_URL`, `CLIENT_ID`, `CLIENT_SECRET`, which trigger token exchange, `TARGET_SCOPES` can be optionally configured to be the `scope` in the token exchange request.
4036
4137
## Running in Kagenti
4238
When deploying in the Kagenti UI - You will need to attach 3 environments to the agent deployment:
@@ -47,4 +43,4 @@ When deploying in the Kagenti UI - You will need to attach 3 environments to the
4743
2. `mcp-slack` - This provides the value of the slack MCP server (`MCP_URL`)
4844
3. `slack-researcher` - This provides the remainder of the necessary configuration settings
4945

50-
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.
46+
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: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,6 @@ async def execute(self, context: RequestContext, event_queue: EventQueue):
166166

167167
headers = await auth_headers(
168168
user_token,
169-
target_audience=settings.TARGET_AUDIENCE,
170169
target_scopes=settings.TARGET_SCOPES
171170
)
172171

a2a/slack_researcher/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ dependencies = [
1010
"a2a-sdk>=0.2.16",
1111
"python-keycloak>=5.5.1",
1212
"authlib>=1.6.0",
13+
"PyJWT>=2.10.1",
1314
]
1415

1516
[tool.ruff]

a2a/slack_researcher/slack_researcher/auth.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,19 +42,20 @@ def __init__(self):
4242
self.jwks_url = settings.JWKS_URI
4343

4444
self.claims_options = {}
45-
if settings.AUDIENCE is None:
46-
logger.debug(f"AUDIENCE env var not set. No audience check will be performed. ")
45+
46+
if settings.CLIENT_ID is None:
47+
logger.debug(f"CLIENT_ID is not set. No audience check will be performed. ")
4748
else:
48-
self.claims_options["aud"] = {"essential": True, "value": settings.AUDIENCE}
49+
self.claims_options["aud"] = {"essential": True, "value": settings.CLIENT_ID}
4950
if settings.ISSUER is None:
50-
logger.debug(f"ISSUER env var no set. No issuer check will be performed")
51+
logger.debug(f"ISSUER env var not set. No issuer check will be performed")
5152
else:
5253
self.claims_options["iss"] = {"essential": True, "value": settings.ISSUER}
5354

5455
async def get_jwks(self):
5556
logger.debug(f"Fetching JWKS from {self.jwks_url}")
5657
jwks = None
57-
try:
58+
try:
5859
async with httpx.AsyncClient() as client:
5960
response = await client.get(self.jwks_url)
6061
response.raise_for_status()
@@ -81,7 +82,7 @@ async def authenticate(self, conn):
8182
if conn.scope.get("path") == "/.well-known/agent.json":
8283
logger.debug("Bypassing authentication for public agent card path")
8384
return None
84-
85+
8586
# extract token
8687
token = await self.get_token(conn)
8788
if token is None:
@@ -90,7 +91,7 @@ async def authenticate(self, conn):
9091
# fetch jwks
9192
jwks = await self.get_jwks()
9293

93-
try:
94+
try:
9495
# decode and validate claims
9596
claims = jwt.decode(s=token, key=jwks, claims_options=self.claims_options)
9697
claims.validate()
@@ -140,7 +141,7 @@ async def exchange(self, subject_token: str, audience: str = None, scope: str =
140141
# make token endpoint call
141142
logger.debug('Performing token exchange')
142143
async with httpx.AsyncClient() as client:
143-
try:
144+
try:
144145
response = await client.post(self.token_url, data=data, headers=headers)
145146
response.raise_for_status() # raise exception if Http status error
146147
token_data = response.json()
@@ -163,7 +164,7 @@ async def auth_headers(access_token, target_audience = None, target_scopes = Non
163164
access_token = await token_exchanger.exchange(access_token, audience=target_audience, scope=target_scopes)
164165
except AuthenticationError as e:
165166
logging.error(f"Error performing token exchange - returning empty headers: {e}")
166-
return headers #
167+
return headers #
167168
except Exception as e:
168169
logging.debug(f"Error creating token exchanger - will passthrough token")
169170

a2a/slack_researcher/slack_researcher/config.py

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,46 @@
11
import json
22
import logging
33
import os
4-
import sys
4+
import jwt
55
from pydantic_settings import BaseSettings
66
from pydantic import model_validator
77
from pydantic import Field
88
from typing import Literal, Optional
99

10+
def get_client_id() -> str:
11+
"""
12+
Read the SVID JWT from file and extract the client ID from the "sub" claim.
13+
"""
14+
# Read SVID JWT from file to get client ID
15+
jwt_file_path = "/opt/jwt_svid.token"
16+
17+
content = None
18+
try:
19+
with open(jwt_file_path, "r") as file:
20+
content = file.read()
21+
except FileNotFoundError:
22+
raise Exception(f"SVID JWT file {jwt_file_path} not found.")
23+
24+
if content is None or content.strip() == "":
25+
raise Exception(f"No content in SVID JWT file {jwt_file_path}.")
26+
27+
try:
28+
decoded = jwt.decode(content, options={"verify_signature": False})
29+
except jwt.DecodeError:
30+
raise ValueError(f"Failed to decode SVID JWT file {jwt_file_path}.")
31+
32+
try:
33+
return decoded["sub"]
34+
except KeyError:
35+
raise KeyError('SVID JWT is missing required "sub" claim.')
1036

1137
class Settings(BaseSettings):
1238
# static path for client secret file
1339
secret_file_path: str = "/shared/secret.txt"
1440

1541
LOG_LEVEL: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = Field(
1642
os.getenv("LOG_LEVEL", "DEBUG"),
17-
description="Application log level",
43+
description="Application log level",
1844
)
1945
TASK_MODEL_ID: str = Field(
2046
os.getenv("TASK_MODEL_ID", "granite3.3:8b"),
@@ -48,28 +74,20 @@ class Settings(BaseSettings):
4874
os.getenv("JWKS_URI", None),
4975
description="Endpoint to obtain JWKS from auth server"
5076
)
51-
AUDIENCE: Optional[str] = Field(
52-
os.getenv("AUDIENCE", None),
53-
description="Expected audience value during resource validation"
54-
)
5577

5678
# auth variables for token exchange
5779
TOKEN_URL: Optional[str] = Field(
5880
os.getenv("TOKEN_URL", None),
5981
description="Token endpoint to obtain new access tokens"
6082
)
6183
CLIENT_ID: Optional[str] = Field(
62-
os.getenv("CLIENT_ID", None),
84+
get_client_id(),
6385
description="Client ID to authenticate to OAuth server"
6486
)
6587
CLIENT_SECRET: Optional[str] = Field(
6688
None,
6789
description="Client secret to authenticate to OAuth server"
6890
)
69-
TARGET_AUDIENCE: Optional[str] = Field(
70-
os.getenv("TARGET_AUDIENCE", None),
71-
description="Target audience to request during token exchange"
72-
)
7391
TARGET_SCOPES: Optional[str] = Field(
7492
os.getenv("TARGET_SCOPES", None),
7593
description="Target scopes to request during token exchange"
@@ -98,4 +116,4 @@ def validate_extra_headers(self) -> "Settings":
98116

99117
return self
100118

101-
settings = Settings() # type: ignore[call-arg]
119+
settings = Settings() # type: ignore[call-arg]

0 commit comments

Comments
 (0)