Skip to content

Commit c891935

Browse files
committed
fix(github): auth /app with App JWT and serialize bot-id detection
Addresses two PR #128 review findings on bot-user-ID auto-detection: HIGH (Gemini): GET /app must use an App JWT, not an installation token. _detect_bot_user_id's fallback calls apps.getAuthenticated (GET /app), but _github_api_request unconditionally exchanged the App JWT for an installation token whenever _app_credentials was set. GitHub's /app endpoint rejects installation tokens (401/403), so bot-id detection was broken for the GitHub-App/installation case (the primary /user path also 403s on installation tokens, so the /app fallback is what must work). Special-case path == "/app" in the auth-selection branch to authenticate with self._generate_app_jwt() directly; all other App-auth requests keep the installation-token exchange (and its RuntimeError when no installation id is resolvable). /users/{slug}[bot] is a public lookup and works fine with the installation token. MEDIUM (Gemini): race condition + await-in-try in _detect_bot_user_id. Concurrent webhooks could all trigger redundant detection before the first cached the result. Add an asyncio.Lock (_detect_lock, created in __init__) and double-checked locking: fast-path early-return on cache hit, then acquire the lock and re-check inside it before running the detection sequence with each await inside its try block. Best-effort semantics (errors logged, not raised) preserved. Tests (tests/test_github_dispatch.py): - TestAppEndpointAuthSelection.test_app_endpoint_uses_jwt_not_installation_token: fakes the HTTP layer so the real auth-selection logic runs; asserts /app is sent "Bearer APP_JWT" (not the installation token) while /user and /users/{slug}[bot] use the installation token. Fails against the broken code (where /app went through installation-token exchange). - TestConcurrentDetectionFetchesOnce.test_concurrent_detect_fetches_once: fires 8 concurrent _detect_bot_user_id calls with a slow mocked /user; asserts the API is hit exactly once. Fails without the lock (8 calls). https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj
1 parent cc59902 commit c891935

2 files changed

Lines changed: 194 additions & 39 deletions

File tree

src/chat_sdk/adapters/github/adapter.py

Lines changed: 63 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,10 @@ def __init__(self, config: GitHubAdapterConfig | None = None) -> None:
120120
self._installation_id: int | None = None
121121
self._installation_token_cache: dict[int, tuple[str, float]] = {}
122122
self._token_lock = asyncio.Lock()
123+
# Serializes concurrent bot-user-ID detection so concurrent webhooks
124+
# don't all race to fetch the (identical) bot identity (see
125+
# ``_detect_bot_user_id``).
126+
self._detect_lock = asyncio.Lock()
123127

124128
# Shared aiohttp session for connection pooling
125129
self._http_session: Any | None = None
@@ -224,45 +228,54 @@ async def _detect_bot_user_id(self, installation_id: int | None = None) -> None:
224228
failure, fall back to ``apps.getAuthenticated`` (``GET /app``) and
225229
resolve the bot user via ``users.getByUsername`` (``GET /users/{slug}[bot]``).
226230
"""
227-
# Cache hit: already detected once, don't re-fetch per webhook.
231+
# Cache hit: already detected once, don't re-fetch per webhook. This is
232+
# the fast path that avoids taking the lock once detection has succeeded.
228233
if self._bot_user_id is not None:
229234
return
230235

231-
try:
232-
user = await self._github_api_request("GET", "/user", installation_id=installation_id)
233-
self._bot_user_id = user.get("id")
234-
self._logger.info(
235-
"GitHub bot user ID auto-detected",
236-
{
237-
"botUserId": self._bot_user_id,
238-
"login": user.get("login"),
239-
},
240-
)
241-
return
242-
except Exception as error:
243-
self._logger.debug(
244-
"users.getAuthenticated failed; falling back to apps.getAuthenticated",
245-
{"error": str(error)},
246-
)
236+
# Double-checked locking: concurrent webhooks must not all race to fetch
237+
# the (identical) bot identity. Serialize detection and re-check the
238+
# cache inside the lock so only the first caller hits the API.
239+
async with self._detect_lock:
240+
if self._bot_user_id is not None:
241+
return
247242

248-
try:
249-
# For App-authenticated installation tokens, /user is not available;
250-
# use apps.getAuthenticated and resolve the bot user via
251-
# /users/{login}[bot].
252-
app = await self._github_api_request("GET", "/app", installation_id=installation_id)
253-
if app:
254-
login = f"{app['slug']}[bot]"
255-
bot_user = await self._github_api_request("GET", f"/users/{login}", installation_id=installation_id)
256-
self._bot_user_id = bot_user.get("id")
243+
try:
244+
user = await self._github_api_request("GET", "/user", installation_id=installation_id)
245+
self._bot_user_id = user.get("id")
257246
self._logger.info(
258-
"GitHub bot user ID auto-detected via app slug",
247+
"GitHub bot user ID auto-detected",
259248
{
260249
"botUserId": self._bot_user_id,
261-
"login": login,
250+
"login": user.get("login"),
262251
},
263252
)
264-
except Exception as error:
265-
self._logger.warn("Could not auto-detect GitHub bot user ID", {"error": str(error)})
253+
return
254+
except Exception as error:
255+
self._logger.debug(
256+
"users.getAuthenticated failed; falling back to apps.getAuthenticated",
257+
{"error": str(error)},
258+
)
259+
260+
try:
261+
# For App-authenticated installation tokens, /user is not
262+
# available; use apps.getAuthenticated (GET /app, authenticated
263+
# with the App JWT) and resolve the bot user via
264+
# /users/{login}[bot].
265+
app = await self._github_api_request("GET", "/app", installation_id=installation_id)
266+
if app:
267+
login = f"{app['slug']}[bot]"
268+
bot_user = await self._github_api_request("GET", f"/users/{login}", installation_id=installation_id)
269+
self._bot_user_id = bot_user.get("id")
270+
self._logger.info(
271+
"GitHub bot user ID auto-detected via app slug",
272+
{
273+
"botUserId": self._bot_user_id,
274+
"login": login,
275+
},
276+
)
277+
except Exception as error:
278+
self._logger.warn("Could not auto-detect GitHub bot user ID", {"error": str(error)})
266279

267280
async def get_user(self, user_id: str) -> UserInfo | None:
268281
"""Look up a GitHub user by numeric account ID.
@@ -1126,16 +1139,27 @@ async def _github_api_request(
11261139
"""
11271140
auth_token = self._auth_token
11281141

1129-
# GitHub App auth: exchange JWT for installation token
1142+
# GitHub App auth.
11301143
if not auth_token and self._app_credentials:
1131-
resolved_installation_id = installation_id if installation_id is not None else self._installation_id
1132-
if not resolved_installation_id:
1133-
raise RuntimeError(
1134-
"Installation ID required for GitHub App authentication. "
1135-
"This usually means you're trying to make an API call outside of a webhook context. "
1136-
"For proactive messages, use thread IDs from previous webhook interactions."
1137-
)
1138-
auth_token = await self._get_installation_token(resolved_installation_id)
1144+
if path == "/app":
1145+
# App-level endpoints (apps.getAuthenticated) REQUIRE app-JWT
1146+
# auth; installation tokens get 401/403 here. Authenticate with
1147+
# the App JWT directly instead of exchanging for an installation
1148+
# token. This is what makes bot-user-ID detection work in the
1149+
# GitHub-App/installation case (the /user path 403s on
1150+
# installation tokens, so this fallback must succeed).
1151+
auth_token = self._generate_app_jwt()
1152+
else:
1153+
# Installation-scoped endpoints: exchange the JWT for an
1154+
# installation token.
1155+
resolved_installation_id = installation_id if installation_id is not None else self._installation_id
1156+
if not resolved_installation_id:
1157+
raise RuntimeError(
1158+
"Installation ID required for GitHub App authentication. "
1159+
"This usually means you're trying to make an API call outside of a webhook context. "
1160+
"For proactive messages, use thread IDs from previous webhook interactions."
1161+
)
1162+
auth_token = await self._get_installation_token(resolved_installation_id)
11391163

11401164
headers: dict[str, str] = {
11411165
"Accept": "application/vnd.github+json",

tests/test_github_dispatch.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from __future__ import annotations
99

10+
import asyncio
1011
import hashlib
1112
import hmac
1213
import json
@@ -466,3 +467,133 @@ async def fake_api(method: str, path: str, body: Any = None, *, installation_id:
466467
# No additional detection API calls on the second webhook (cache hit).
467468
assert len(api.await_args_list) == detection_calls_after_first
468469
assert chat.process_message.call_count == 2
470+
471+
472+
# =============================================================================
473+
# Fake aiohttp session (exercises the real auth-selection logic in
474+
# _github_api_request: PAT vs App-JWT vs installation-token)
475+
# =============================================================================
476+
477+
478+
class _FakeResponse:
479+
"""Minimal aiohttp-style response usable as an async context manager."""
480+
481+
def __init__(self, status: int, payload: Any) -> None:
482+
self.status = status
483+
self._payload = payload
484+
485+
async def __aenter__(self) -> _FakeResponse:
486+
return self
487+
488+
async def __aexit__(self, *exc: Any) -> None:
489+
return None
490+
491+
async def json(self) -> Any:
492+
return self._payload
493+
494+
async def text(self) -> str:
495+
return json.dumps(self._payload)
496+
497+
498+
class _FakeSession:
499+
"""Routes requests by path, recording the Authorization header used.
500+
501+
Lets a test assert *which* credential (app JWT vs installation token) was
502+
sent to each GitHub endpoint, which is the load-bearing behaviour for the
503+
/app-needs-JWT fix.
504+
"""
505+
506+
def __init__(self, handler: Any) -> None:
507+
self._handler = handler
508+
self.closed = False
509+
# path -> Authorization header value used for that path
510+
self.auth_by_path: dict[str, str] = {}
511+
512+
def request(self, method: str, url: str, **kwargs: Any) -> _FakeResponse:
513+
path = url.replace("https://api.github.com", "")
514+
headers = kwargs.get("headers", {})
515+
self.auth_by_path[path] = headers.get("Authorization", "")
516+
status, payload = self._handler(method, path)
517+
return _FakeResponse(status, payload)
518+
519+
520+
class TestAppEndpointAuthSelection:
521+
"""GET /app must authenticate with the App JWT, not an installation token.
522+
523+
GitHub's apps.getAuthenticated (GET /app) endpoint rejects installation
524+
tokens (401/403). For the GitHub-App/installation case the /user path also
525+
fails on installation tokens, so the /app fallback is what must work --
526+
and it only works when authenticated with the App JWT directly.
527+
528+
This test exercises the real auth-selection branch in _github_api_request
529+
(the HTTP layer is faked, not _github_api_request itself).
530+
"""
531+
532+
@pytest.mark.asyncio
533+
async def test_app_endpoint_uses_jwt_not_installation_token(self):
534+
adapter = _make_multi_tenant_adapter()
535+
adapter._installation_id = 54321 # single resolvable installation
536+
537+
# Spy the credential minting so we can assert which path used which.
538+
adapter._generate_app_jwt = MagicMock(return_value="APP_JWT") # type: ignore[method-assign]
539+
adapter._get_installation_token = AsyncMock(return_value="INSTALL_TOKEN") # type: ignore[method-assign]
540+
541+
def handler(method: str, path: str) -> tuple[int, Any]:
542+
if path == "/user":
543+
# Installation token cannot use /user -> 403.
544+
return 403, {"message": "Resource not accessible by integration"}
545+
if path == "/app":
546+
return 200, {"slug": "my-app", "id": 1}
547+
if path == "/users/my-app[bot]":
548+
return 200, {"id": 12345, "login": "my-app[bot]"}
549+
raise AssertionError(f"unexpected path {path}")
550+
551+
session = _FakeSession(handler)
552+
adapter._get_http_session = AsyncMock(return_value=session) # type: ignore[method-assign]
553+
554+
await adapter._detect_bot_user_id(installation_id=54321)
555+
556+
# Detection resolved the bot id via /app -> /users/{slug}[bot].
557+
assert adapter._bot_user_id == 12345
558+
559+
# The /app call was authenticated with the App JWT (not an install token).
560+
assert session.auth_by_path["/app"] == "Bearer APP_JWT"
561+
assert adapter._generate_app_jwt.called
562+
# And /app was NOT sent the installation token.
563+
assert session.auth_by_path["/app"] != "Bearer INSTALL_TOKEN"
564+
565+
# The /user attempt (which 403s) and the public /users/{slug}[bot] lookup
566+
# both go through the installation-token exchange, as expected.
567+
assert session.auth_by_path["/user"] == "Bearer INSTALL_TOKEN"
568+
assert session.auth_by_path["/users/my-app[bot]"] == "Bearer INSTALL_TOKEN"
569+
570+
571+
class TestConcurrentDetectionFetchesOnce:
572+
"""Concurrent detection must fetch the bot identity only once (lock works)."""
573+
574+
@pytest.mark.asyncio
575+
async def test_concurrent_detect_fetches_once(self):
576+
adapter = _make_multi_tenant_adapter()
577+
578+
call_counts: dict[str, int] = {"/user": 0}
579+
started = asyncio.Event()
580+
581+
async def fake_api(method: str, path: str, body: Any = None, *, installation_id: int | None = None) -> Any:
582+
if path == "/user":
583+
call_counts["/user"] += 1
584+
started.set()
585+
# Slow PAT-style success so concurrent callers pile up behind
586+
# the first one if locking is absent.
587+
await asyncio.sleep(0.05)
588+
return {"id": 4242, "login": "the-bot"}
589+
raise AssertionError(f"unexpected path {path}")
590+
591+
adapter._github_api_request = AsyncMock(side_effect=fake_api)
592+
593+
# Fire N concurrent detections; only the first should hit the API.
594+
await asyncio.gather(*[adapter._detect_bot_user_id(installation_id=54321) for _ in range(8)])
595+
596+
assert adapter._bot_user_id == 4242
597+
# The lock + double-checked cache means /user is fetched exactly once.
598+
assert call_counts["/user"] == 1
599+
assert adapter._github_api_request.await_count == 1

0 commit comments

Comments
 (0)