Skip to content

Commit 6eccebf

Browse files
committed
Add option to provide github token
Signed-off-by: Kelly Abuelsaad <kaymar@gmail.com>
1 parent 7431d11 commit 6eccebf

4 files changed

Lines changed: 182 additions & 6 deletions

File tree

a2a/git_issue_agent/.env.template

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
TASK_MODEL_ID = "ollama/granite3.3:8b"
1+
TASK_MODEL_ID = "ollama/gpt-oss:20b"
22
LLM_API_BASE = "http://localhost:11434"
33
LLM_API_KEY = "ollama"
44
MODEL_TEMPERATURE = 0
55
MCP_URL = "https://api.githubcopilot.com/mcp/"
66
SERVICE_PORT = 8008
7-
LOG_LEVEL = "INFO"
7+
LOG_LEVEL = "INFO"
8+
GITHUB_TOKEN = ""

a2a/git_issue_agent/a2a_agent.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
from git_issue_agent.main import GitIssueAgent
3232

3333
logger = logging.getLogger(__name__)
34-
logging.basicConfig(level=logging.DEBUG, stream=sys.stdout, format='%(levelname)s: %(message)s')
34+
logging.basicConfig(level=settings.LOG_LEVEL, stream=sys.stdout, format='%(levelname)s: %(message)s')
3535

3636
class BearerAuthBackend(AuthenticationBackend):
3737
""" Very temporary demo to grab auth token and print it"""
@@ -158,7 +158,10 @@ async def execute(self, context: RequestContext, event_queue: EventQueue):
158158
Returns:
159159
None
160160
"""
161-
user_token = context.call_context.user._user.access_token
161+
if settings.JWKS_URI:
162+
user_token = context.call_context.user._user.access_token
163+
else:
164+
user_token = settings.GITHUB_TOKEN
162165
user_input = [context.get_user_input()]
163166
task = context.current_task
164167
if not task:
@@ -240,6 +243,6 @@ def run():
240243
app = server.build() # this returns a Starlette app
241244
if not settings.JWKS_URI is None:
242245
logging.info("JWKS_URI is set - using JWT Validation middleware")
243-
app.add_middleware(AuthenticationMiddleware, backend=BearerAuthBackend(), on_error=on_auth_error)
246+
app.add_middleware(AuthenticationMiddleware, backend=BearerAuthBackend(), on_error=on_auth_error)
244247

245248
uvicorn.run(app, host="0.0.0.0", port=settings.SERVICE_PORT)
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
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 git_issue_agent.config import settings
14+
15+
logger = logging.getLogger(__name__)
16+
logging.basicConfig(level=settings.LOG_LEVEL, 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 = AgentUser(token=token, claims=claims)
101+
return AuthCredentials(user.scopes()), user
102+
except AuthlibBaseError as e:
103+
logger.error(f"Token validation failed: {e}")
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/git_issue_agent/git_issue_agent/config.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
class Settings(BaseSettings):
1010
LOG_LEVEL: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = Field(
11-
os.getenv("LOG_LEVEL", "DEBUG"),
11+
os.getenv("LOG_LEVEL", "INFO"),
1212
description="Application log level",
1313
)
1414
TASK_MODEL_ID: str = Field(
@@ -28,6 +28,7 @@ class Settings(BaseSettings):
2828
)
2929
MCP_URL: str = Field(os.getenv("MCP_URL", "https://api.githubcopilot.com/mcp/"), description="Endpoint for an option MCP server")
3030
SERVICE_PORT: int = Field(os.getenv("SERVICE_URL", 8000), description="Port on which the service will run.")
31+
GITHUB_TOKEN: str = Field(os.getenv("GITHUB_TOKEN", None), description="If not using agent with authorization, the default Github token to use")
3132

3233
# auth variables for token validation
3334
ISSUER: Optional[str] = Field(

0 commit comments

Comments
 (0)