44import time
55import hmac
66import hashlib
7- import requests
7+ import httpx
88import inspect
9- from fastapi import FastAPI , APIRouter , Request , HTTPException , status
9+ from fastapi import FastAPI , APIRouter , Request , HTTPException , status , Depends
1010from fastapi .responses import JSONResponse
1111from ghapi .all import GhApi
1212from os import environ
1313import jwt
14+ from .oauth import GitHubOAuth2
15+ from .session import SessionManager
16+ from contextlib import asynccontextmanager
1417
1518
1619LOG = 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