Skip to content

Commit 1747575

Browse files
authored
Merge pull request #62 from maia-iyer/slack_agent_token_exchange
✨ Implement token exchange in Slack researcher agent
2 parents f02063e + e5e0105 commit 1747575

4 files changed

Lines changed: 109 additions & 10 deletions

File tree

a2a/slack_researcher/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,19 @@ The Slack Researcher Agent is designed to perform research tasks across Slack ch
2424
| JWKS_URL | Endpoint to obtain JWKS for token validation. Enables token validation | No | - |
2525
| ISSUER | Expected `iss` value of incoming bearer tokens | No | - |
2626
| AUDIENCE | Expected `aud` value of incoming bearer tokens | No | - |
27+
| 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 | - |
29+
| CLIENT_SECRET | Client secret to authenticate to auth server with. Required for token exchange. | No | - |
30+
| TARGET_AUDIENCE | Audience of token exchanged token | No | - |
31+
| TARGET_SCOPES | Requested scopes of token exchanged token | No | - |
2732

2833
> **Note on Authorization configuration**
2934
> By default, no token validation is performed. To enable token validation, set `JWKS_URL`.
3035
> If `ISSUER` is additionally set, the `iss` claim will be checked to equal this value.
3136
> 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+
3240

3341
## Running in Kagenti
3442
When deploying in the Kagenti UI - You will need to attach 3 environments to the agent deployment:

a2a/slack_researcher/a2a_agent.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from slack_researcher.config import settings, Settings
2626
from slack_researcher.event import Event
2727
from slack_researcher.main import SlackAgent
28-
from slack_researcher.auth import on_auth_error, BearerAuthBackend
28+
from slack_researcher.auth import on_auth_error, BearerAuthBackend, auth_headers
2929

3030
logger = logging.getLogger(__name__)
3131
logging.basicConfig(level=logging.DEBUG, stream=sys.stdout, format='%(levelname)s: %(message)s')
@@ -138,7 +138,7 @@ async def execute(self, context: RequestContext, event_queue: EventQueue):
138138
Returns:
139139
None
140140
"""
141-
user_token = context.call_context.user.user_name
141+
user_token = context.call_context.user._user.access_token
142142
user_input = [context.get_user_input()]
143143
task = context.current_task
144144
if not task:
@@ -164,9 +164,11 @@ async def execute(self, context: RequestContext, event_queue: EventQueue):
164164
if settings.MCP_URL:
165165
logging.info("Connecting to MCP server at %s", settings.MCP_URL)
166166

167-
headers={}
168-
if user_token:
169-
headers={"Authorization": f"Bearer {user_token}"}
167+
headers = await auth_headers(
168+
user_token,
169+
target_audience=settings.TARGET_AUDIENCE,
170+
target_scopes=settings.TARGET_SCOPES
171+
)
170172

171173
async with streamablehttp_client(
172174
url=settings.MCP_URL,

a2a/slack_researcher/slack_researcher/auth.py

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,75 @@ async def authenticate(self, conn):
9797
logger.debug("Token successfully validated.")
9898

9999
# return user
100-
user = SimpleUser(token)
101-
return AuthCredentials(["authenticated"]), user
100+
user = AgentUser(token=token, claims=claims)
101+
return AuthCredentials(user.scopes()), user
102102
except AuthlibBaseError as e:
103103
logger.error(f"Token validation failed: {e}")
104-
raise AuthenticationError(f"Invalid token: {e}, status_code=401")
104+
raise AuthenticationError(f"Invalid token: {e}, status_code=401")
105+
106+
class AgentUser(SimpleUser):
107+
def __init__(self, token, claims) -> None:
108+
super().__init__(username=claims.get("sub"))
109+
self.access_token = token
110+
self.claims = claims
111+
112+
def scopes(self) -> list[str]:
113+
scope = self.claims.get("scope", "")
114+
return scope.split()
115+
116+
class TokenExchanger:
117+
def __init__(self):
118+
if None in [settings.TOKEN_URL, settings.CLIENT_ID, settings.CLIENT_SECRET]:
119+
raise Exception("One of TOKEN_URL, CLIENT_ID, CLIENT_SECRET env vars not set - token exchange will not be performed")
120+
self.token_url = settings.TOKEN_URL
121+
self.client_id = settings.CLIENT_ID
122+
self.client_secret = settings.CLIENT_SECRET
123+
124+
async def exchange(self, subject_token: str, audience: str = None, scope: str = None) -> str:
125+
# headers
126+
headers = {'Content-Type': 'application/x-www-form-urlencoded'}
127+
# data
128+
data = {
129+
'grant_type': 'urn:ietf:params:oauth:grant-type:token-exchange',
130+
'subject_token_type': 'urn:ietf:params:oauth:token-type:access_token',
131+
'requested_token_type': 'urn:ietf:params:oauth:token-type:access_token',
132+
'client_id': self.client_id,
133+
'client_secret': self.client_secret,
134+
'subject_token': subject_token,
135+
}
136+
if not audience is None:
137+
data['audience'] = audience
138+
if not scope is None:
139+
data['scope'] = scope
140+
# make token endpoint call
141+
logger.debug('Performing token exchange')
142+
async with httpx.AsyncClient() as client:
143+
try:
144+
response = await client.post(self.token_url, data=data, headers=headers)
145+
response.raise_for_status() # raise exception if Http status error
146+
token_data = response.json()
147+
if "access_token" in token_data:
148+
new_token = token_data["access_token"]
149+
logger.debug(f"Successful token exchange. Using token: {new_token}")
150+
return new_token
151+
logger.error("Token exchange failed.")
152+
raise AuthenticationError("Token exchange failed. Identity provider response did not include 'access_token'")
153+
except httpx.HTTPStatusError as e:
154+
logger.error(f"Token exchange failed with status {e.response.status_code}: {e}")
155+
raise AuthenticationError("Token endpoint call failed.")
156+
157+
async def auth_headers(access_token, target_audience = None, target_scopes = None):
158+
headers = {}
159+
if not access_token:
160+
return headers
161+
try:
162+
token_exchanger = TokenExchanger()
163+
access_token = await token_exchanger.exchange(access_token, audience=target_audience, scope=target_scopes)
164+
except AuthenticationError as e:
165+
logging.error(f"Error performing token exchange - returning empty headers: {e}")
166+
return headers #
167+
except Exception as e:
168+
logging.debug(f"Error creating token exchanger - will passthrough token")
169+
170+
headers["Authorization"] = f"Bearer {access_token}"
171+
return headers

a2a/slack_researcher/slack_researcher/config.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,9 @@ 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
39+
# auth variables for token validation
4040
ISSUER: Optional[str] = Field(
41-
os.getenv("ISSUER", None),
41+
os.getenv("ISSUER", None),
4242
description="The issuer for incoming JWT tokens"
4343
)
4444
JWKS_URI: Optional[str] = Field(
@@ -50,6 +50,28 @@ class Settings(BaseSettings):
5050
description="Expected audience value during resource validation"
5151
)
5252

53+
# auth variables for token exchange
54+
TOKEN_URL: Optional[str] = Field(
55+
os.getenv("TOKEN_URL", None),
56+
description="Token endpoint to obtain new access tokens"
57+
)
58+
CLIENT_ID: Optional[str] = Field(
59+
os.getenv("CLIENT_ID", None),
60+
description="Client ID to authenticate to OAuth server"
61+
)
62+
CLIENT_SECRET: Optional[str] = Field(
63+
os.getenv("CLIENT_SECRET", None),
64+
description="Client secret to authenticate to OAuth server"
65+
)
66+
TARGET_AUDIENCE: Optional[str] = Field(
67+
os.getenv("TARGET_AUDIENCE", None),
68+
description="Target audience to request during token exchange"
69+
)
70+
TARGET_SCOPES: Optional[str] = Field(
71+
os.getenv("TARGET_SCOPES", None),
72+
description="Target scopes to request during token exchange"
73+
)
74+
5375
class Config:
5476
env_file = ".env"
5577
env_file_encoding = "utf-8"

0 commit comments

Comments
 (0)