Skip to content

Commit 43ffae7

Browse files
committed
Added OAuth2 and migrated to httpx from requests
1 parent 1358f36 commit 43ffae7

File tree

9 files changed

+1272
-894
lines changed

9 files changed

+1272
-894
lines changed

.env.example

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,27 @@
11
# GitHub Settings
22
GITHUBAPP_ID=12345
3-
GITHUBAPP_CLIENT_ID=Iv1.abcdef123456
4-
GITHUBAPP_CLIENT_SECRET=super_duper_client_secret
53
GITHUBAPP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nsome-super-encrypted-key-data\n-----END RSA PRIVATE KEY-----"
64
GITHUBAPP_WEBHOOK_SECRET=super-duper-webhook-secret
75
GITHUBAPP_WEBHOOK_PATH=/webhooks/github/
6+
7+
# OAuth2 (optional - auto-mounts routes only when fully configured)
8+
# Enable/disable OAuth2 routing globally (defaults to true when fully configured)
9+
GITHUBAPP_ENABLE_OAUTH=true
10+
# OAuth2 application credentials (from GitHub OAuth App)
11+
GITHUBAPP_OAUTH_CLIENT_ID=Iv1.abcdef123456
12+
GITHUBAPP_OAUTH_CLIENT_SECRET=super_duper_client_secret
13+
# Redirect URI registered in your GitHub OAuth App settings
14+
GITHUBAPP_OAUTH_REDIRECT_URI=http://localhost:8000/auth/github/callback
15+
# Comma-separated scopes; defaults to "user:email,read:user" if unset
16+
GITHUBAPP_OAUTH_SCOPES="user:email,read:user"
17+
# Secret used to sign session JWTs (required to enable OAuth2 routes)
18+
GITHUBAPP_OAUTH_SESSION_SECRET=change_me_session_secret
19+
# Optional: Override OAuth2 routes prefix (default: /auth/github)
20+
# GITHUBAPP_OAUTH_ROUTES_PREFIX=/auth/github
21+
22+
# OIDC (future feature; leave commented until enabled)
23+
# GITHUBAPP_OIDC_AUDIENCE=myapp.example.com
24+
# GITHUBAPP_OIDC_ALLOWED_REPOSITORIES=owner/repo1,owner/repo2
25+
# GITHUBAPP_OIDC_ALLOWED_ACTORS=user1,user2
26+
# GITHUBAPP_OIDC_ALLOWED_ENVIRONMENTS=production,staging
27+
# GITHUBAPP_OIDC_REQUIRE_PROTECTED_REF=true

poetry.lock

Lines changed: 724 additions & 868 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,23 @@ build-backend = "poetry.core.masonry.api"
55

66
[tool.poetry]
77
name = "fastapi-githubapp"
8-
version = "0.1.3"
8+
version = "0.2.0"
99
description = "FastAPI extension for rapid GitHub App development"
1010
readme = "README.md"
1111
authors = ["primetheus <865381+primetheus@users.noreply.github.com>"]
1212
license = "MIT"
1313
homepage = "https://github.com/primetheus/fastapi-githubapp"
1414
repository = "https://github.com/primetheus/fastapi-githubapp"
15-
keywords = ["fastapi", "github", "app"]
15+
keywords = [
16+
"fastapi",
17+
"github",
18+
"app",
19+
"oauth2",
20+
"oauth",
21+
"probot",
22+
"githubapp",
23+
"github-app"
24+
]
1625
classifiers = [
1726
"Programming Language :: Python :: 3",
1827
"Framework :: FastAPI",
@@ -34,7 +43,7 @@ fastapi = ">=0.95.0"
3443
uvicorn = { version = ">=0.22.0", extras = ["standard"] }
3544
ghapi = ">=1.0.0"
3645
pyjwt = { version = ">=2.8.0", extras = ["crypto"] }
37-
requests = "*"
46+
httpx = ">=0.28.1"
3847

3948
[tool.poetry.group.dev.dependencies]
4049
pytest = "*"
@@ -47,8 +56,7 @@ seed-isort-config = "*"
4756
green = "*"
4857
tox = "*"
4958
tox-gh-actions = "*"
50-
responses = "*"
51-
httpx = "*"
59+
respx = ">=0.21.0"
5260
python-semantic-release = "^10.0.2"
5361

5462
[tool.semantic_release]

src/githubapp/core.py

Lines changed: 162 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@
44
import time
55
import hmac
66
import hashlib
7-
import requests
7+
import httpx
88
import inspect
9-
from fastapi import FastAPI, APIRouter, Request, HTTPException, status
9+
from fastapi import FastAPI, APIRouter, Request, HTTPException, status, Depends
1010
from fastapi.responses import JSONResponse
1111
from ghapi.all import GhApi
1212
from os import environ
1313
import jwt
14+
from .oauth import GitHubOAuth2
15+
from .session import SessionManager
16+
from contextlib import asynccontextmanager
1417

1518

1619
LOG = logging.getLogger(__name__)
@@ -95,6 +98,14 @@ def __init__(
9598
github_app_secret: bytes = None,
9699
github_app_url: str = None,
97100
github_app_route: str = "/",
101+
# OAuth2 (optional)
102+
oauth_client_id: str = None,
103+
oauth_client_secret: str = None,
104+
oauth_redirect_uri: str = None,
105+
oauth_scopes: list = None,
106+
oauth_routes_prefix: str = "/auth/github",
107+
enable_oauth: bool = None,
108+
oauth_session_secret: str = None,
98109
):
99110
self._hook_mappings = {}
100111
self._access_token = None
@@ -103,7 +114,26 @@ def __init__(
103114
self.key = github_app_key
104115
self.secret = github_app_secret
105116
self.router = APIRouter()
117+
self._initialized = False
118+
self._webhook_route = github_app_route or "/"
119+
# OAuth2 setup (gated)
120+
self.oauth = None
121+
self._enable_oauth = False
122+
self._oauth_routes_prefix = oauth_routes_prefix
123+
self._session_mgr = None
124+
if oauth_client_id and oauth_client_secret:
125+
self.oauth = GitHubOAuth2(
126+
client_id=oauth_client_id,
127+
client_secret=oauth_client_secret,
128+
redirect_uri=oauth_redirect_uri,
129+
scopes=oauth_scopes,
130+
)
131+
self._enable_oauth = enable_oauth if enable_oauth is not None else True
132+
if oauth_session_secret:
133+
self._session_mgr = SessionManager(oauth_session_secret)
134+
106135
if app is not None:
136+
# Auto-wire on construction; subsequent explicit init_app calls will no-op
107137
self.init_app(app, route=github_app_route)
108138

109139
@staticmethod
@@ -168,15 +198,26 @@ def init_app(self, app: FastAPI, *, route: str = "/"):
168198
Path used for GitHub hook requests as a string.
169199
Default: '/'
170200
"""
201+
# Idempotent setup: avoid mounting more than once
202+
if self._initialized:
203+
LOG.debug("GitHubApp.init_app called more than once; ignoring subsequent call")
204+
return
205+
171206
# Register router endpoint for GitHub webhook
207+
self._webhook_route = route or self._webhook_route or "/"
208+
self.router.post(self._webhook_route)(self._handle_request)
172209
app.include_router(self.router)
173-
self.router.post(route)(self._handle_request)
174210
# copy config from FastAPI app
175211
# ensure app has config dict (for backward compatibility)
176212
if not hasattr(app, "config"):
177213
app.config = {}
178214
self.config = app.config
179215

216+
# Honor base URL from config (e.g., GitHub Enterprise), if provided
217+
cfg_url = self.config.get("GITHUBAPP_URL")
218+
if cfg_url:
219+
self.base_url = cfg_url
220+
180221
# Set config values from constructor parameters if they were provided
181222
if self.id is not None:
182223
self.config["GITHUBAPP_ID"] = self.id
@@ -185,14 +226,111 @@ def init_app(self, app: FastAPI, *, route: str = "/"):
185226
if self.secret is not None:
186227
self.config["GITHUBAPP_WEBHOOK_SECRET"] = self.secret
187228

229+
# Mount OAuth2 routes only if enabled and fully configured (session secret)
230+
if self._enable_oauth and self.oauth and self._session_mgr:
231+
self._setup_oauth_routes(app)
232+
233+
self._initialized = True
234+
235+
def _setup_oauth_routes(self, app: FastAPI):
236+
prefix = self._oauth_routes_prefix or "/auth/github"
237+
router = APIRouter()
238+
239+
@router.get("/login")
240+
async def oauth_login(redirect_to: str = None, scopes: str = None):
241+
scope_list = scopes.split(",") if scopes else None
242+
url = self.oauth.generate_auth_url(scopes=scope_list)
243+
return JSONResponse({"auth_url": url})
244+
245+
@router.get("/callback")
246+
async def oauth_callback(code: str = None, state: str = None, error: str = None):
247+
if error:
248+
raise HTTPException(status_code=400, detail=f"OAuth error: {error}")
249+
if not code:
250+
raise HTTPException(status_code=400, detail="Missing authorization code")
251+
try:
252+
token = await self.oauth.exchange_code_for_token(code, state)
253+
user = await self.oauth.get_user_info(token["access_token"])
254+
except ValueError as ve:
255+
# For now, surface as 500 to match existing expectations
256+
raise HTTPException(status_code=500, detail=str(ve))
257+
except Exception as ex:
258+
# Surface upstream failures as 500 in this phase
259+
raise HTTPException(status_code=500, detail=str(ex))
260+
session_token = self._session_mgr.create_session_token(user)
261+
return JSONResponse({"user": user, "session_token": session_token})
262+
263+
@router.post("/logout")
264+
async def oauth_logout():
265+
# Stateless JWTs: clients drop the token; server may add blacklist if desired.
266+
return {"status": "logged_out"}
267+
268+
@router.get("/user")
269+
async def oauth_user(current=Depends(self.get_current_user)):
270+
return current
271+
272+
app.include_router(router, prefix=prefix, tags=["oauth2"])
273+
# Ensure OAuth2 http client is closed via lifespan (no deprecated on_event)
274+
self._install_lifespan_cleanup(app)
275+
276+
def _install_lifespan_cleanup(self, app: FastAPI):
277+
"""Install a lifespan context that closes shared resources on shutdown.
278+
279+
This chains any existing lifespan_context defined on the app's router
280+
to avoid clobbering user-defined lifespan behavior.
281+
"""
282+
existing_lifespan = getattr(app.router, "lifespan_context", None)
283+
284+
@asynccontextmanager
285+
async def lifespan(ap: FastAPI):
286+
if callable(existing_lifespan):
287+
# Chain existing lifespan
288+
async with existing_lifespan(ap) as state:
289+
try:
290+
yield state
291+
finally:
292+
if self.oauth and hasattr(self.oauth, "aclose"):
293+
await self.oauth.aclose()
294+
else:
295+
try:
296+
yield
297+
finally:
298+
if self.oauth and hasattr(self.oauth, "aclose"):
299+
await self.oauth.aclose()
300+
301+
app.router.lifespan_context = lifespan
302+
303+
def get_current_user(self, request: Request):
304+
if not self._session_mgr:
305+
raise HTTPException(status_code=401, detail="OAuth2 not configured")
306+
# Bearer token support
307+
auth = request.headers.get("Authorization", "")
308+
token = None
309+
if auth.lower().startswith("bearer "):
310+
token = auth.split(" ", 1)[1]
311+
if not token:
312+
token = request.cookies.get("session_token")
313+
if not token:
314+
raise HTTPException(status_code=401, detail="Missing session token")
315+
try:
316+
return self._session_mgr.verify_session_token(token)
317+
except Exception as e:
318+
raise HTTPException(status_code=401, detail=str(e))
319+
188320
@property
189321
def installation_token(self):
190322
return self._access_token
191323

192324
def client(self, installation_id: int = None):
193325
"""GitHub client authenticated as GitHub app installation"""
194326
if installation_id is None:
195-
installation_id = self.payload["installation"]["id"]
327+
try:
328+
installation_id = self.payload["installation"]["id"]
329+
except Exception:
330+
raise GitHubAppError(
331+
message="Missing installation id; provide installation_id or call within a webhook context",
332+
status=400,
333+
)
196334
token = self.get_access_token(installation_id).token
197335
return GhApi(token=token)
198336

@@ -219,10 +357,8 @@ def get_access_token(self, installation_id, user_id=None):
219357
:param installation_id: int
220358
:return: :class:`github.InstallationAuthorization.InstallationAuthorization`
221359
"""
222-
body = {}
223-
if user_id:
224-
body = {"user_id": user_id}
225-
response = requests.post(
360+
body = {"user_id": user_id} if user_id else {}
361+
response = httpx.post(
226362
f"{self.base_url}/app/installations/{installation_id}/access_tokens",
227363
headers={
228364
"Authorization": f"Bearer {self._create_jwt()}",
@@ -241,7 +377,11 @@ def get_access_token(self, installation_id, user_id=None):
241377
)
242378
elif response.status_code == 404:
243379
raise GithubAppUnkownObject(status=response.status_code, data=response.text)
244-
raise Exception(status=response.status_code, data=response.text)
380+
raise GitHubAppError(
381+
message="Failed to create installation access token",
382+
status=response.status_code,
383+
data=response.text,
384+
)
245385

246386
def list_installations(self, per_page=30, page=1):
247387
"""
@@ -250,7 +390,7 @@ def list_installations(self, per_page=30, page=1):
250390
"""
251391
params = {"page": page, "per_page": per_page}
252392

253-
response = requests.get(
393+
response = httpx.get(
254394
f"{self.base_url}/app/installations",
255395
headers={
256396
"Authorization": f"Bearer {self._create_jwt()}",
@@ -269,7 +409,11 @@ def list_installations(self, per_page=30, page=1):
269409
)
270410
elif response.status_code == 404:
271411
raise GithubAppUnkownObject(status=response.status_code, data=response.text)
272-
raise Exception(status=response.status_code, data=response.text)
412+
raise GitHubAppError(
413+
message="Failed to list installations",
414+
status=response.status_code,
415+
data=response.text,
416+
)
273417

274418
def on(self, event_action):
275419
"""Decorator routes a GitHub hook to the wrapped function.
@@ -394,9 +538,15 @@ async def _handle_request(self, request: Request):
394538
functions_to_call += self._hook_mappings[event_action]
395539

396540
if functions_to_call:
541+
import asyncio
542+
397543
for function in functions_to_call:
398544
try:
399-
result = await function() if inspect.iscoroutinefunction(function) else function()
545+
if inspect.iscoroutinefunction(function):
546+
result = await function()
547+
else:
548+
loop = asyncio.get_running_loop()
549+
result = await loop.run_in_executor(None, function)
400550
except Exception as e:
401551
raise HTTPException(
402552
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)

0 commit comments

Comments
 (0)