@@ -406,7 +406,161 @@ def linkedin_callback(code: str = None, state: str = None, redirect_uri: str = N
406406 return {"error" : str (e ), "status" : "failed" }
407407
408408
409- # User settings endpoints
409+ # GitHub OAuth configuration
410+ GITHUB_CLIENT_ID = os .getenv ('GITHUB_CLIENT_ID' , '' )
411+ GITHUB_CLIENT_SECRET = os .getenv ('GITHUB_CLIENT_SECRET' , '' )
412+
413+
414+ @app .get ('/auth/github/start' )
415+ def github_oauth_start (redirect_uri : str , user_id : str ):
416+ """
417+ Start GitHub OAuth flow.
418+
419+ Redirects user to GitHub's authorization page.
420+ Requested scopes: read:user, repo (for private repo access)
421+
422+ Args:
423+ redirect_uri: Where to redirect after auth
424+ user_id: Clerk user ID (stored in state for callback)
425+ """
426+ if not GITHUB_CLIENT_ID :
427+ return {"error" : "GitHub OAuth not configured" }
428+
429+ state = f"{ user_id } :{ uuid4 ().hex } "
430+
431+ # Request read:user and repo scope for private activity access
432+ scopes = "read:user,repo"
433+
434+ auth_url = (
435+ f"https://github.com/login/oauth/authorize"
436+ f"?client_id={ GITHUB_CLIENT_ID } "
437+ f"&redirect_uri={ redirect_uri } "
438+ f"&scope={ scopes } "
439+ f"&state={ state } "
440+ )
441+
442+ return RedirectResponse (auth_url )
443+
444+
445+ @app .get ('/auth/github/callback' )
446+ def github_oauth_callback (code : str = None , state : str = None , redirect_uri : str = None ):
447+ """
448+ Handle GitHub OAuth callback.
449+
450+ Exchanges authorization code for access token and stores it encrypted.
451+
452+ Returns redirect with success/failure status.
453+ """
454+ if not code :
455+ return {"error" : "missing code" , "status" : "failed" }
456+
457+ # Extract user_id from state
458+ user_id = None
459+ if state and ':' in state :
460+ parts = state .split (':' , 1 )
461+ user_id = parts [0 ]
462+
463+ if not user_id :
464+ return {"error" : "missing user_id in state" , "status" : "failed" }
465+
466+ try :
467+ import requests
468+
469+ # Exchange code for access token
470+ token_response = requests .post (
471+ 'https://github.com/login/oauth/access_token' ,
472+ data = {
473+ 'client_id' : GITHUB_CLIENT_ID ,
474+ 'client_secret' : GITHUB_CLIENT_SECRET ,
475+ 'code' : code ,
476+ },
477+ headers = {'Accept' : 'application/json' },
478+ timeout = 10
479+ )
480+
481+ token_data = token_response .json ()
482+
483+ if 'error' in token_data :
484+ return {"error" : token_data .get ('error_description' , 'OAuth failed' ), "status" : "failed" }
485+
486+ access_token = token_data .get ('access_token' )
487+
488+ if not access_token :
489+ return {"error" : "No access token received" , "status" : "failed" }
490+
491+ # Get GitHub username from API
492+ user_response = requests .get (
493+ 'https://api.github.com/user' ,
494+ headers = {
495+ 'Authorization' : f'Bearer { access_token } ' ,
496+ 'Accept' : 'application/vnd.github.v3+json'
497+ },
498+ timeout = 10
499+ )
500+
501+ github_user = user_response .json ()
502+ github_username = github_user .get ('login' , '' )
503+
504+ # Store the token encrypted
505+ from services .token_store import save_github_token
506+ save_github_token (user_id , github_username , access_token )
507+
508+ # Also update user settings with username
509+ if save_user_settings and get_user_settings :
510+ settings = get_user_settings (user_id ) or {}
511+ settings ['github_username' ] = github_username
512+ save_user_settings (user_id , settings )
513+
514+ return {
515+ "status" : "success" ,
516+ "github_username" : github_username ,
517+ "github_connected" : True
518+ }
519+ except Exception as e :
520+ import traceback
521+ print (f"GitHub OAuth Error: { e } " )
522+ print (traceback .format_exc ())
523+ return {"error" : str (e ), "status" : "failed" }
524+
525+
526+ @app .post ("/api/disconnect-github" )
527+ def disconnect_github (request : DisconnectRequest ):
528+ """
529+ Disconnect a user's GitHub OAuth token.
530+
531+ Removes the stored GitHub PAT while keeping the username.
532+
533+ SECURITY:
534+ - User can only disconnect their own account
535+ - Only removes GitHub token, not LinkedIn
536+ """
537+ try :
538+ from services .token_store import get_conn , init_db
539+
540+ init_db ()
541+ conn = get_conn ()
542+ cur = conn .cursor ()
543+
544+ # Clear only the GitHub token, keep the rest
545+ cur .execute ('''
546+ UPDATE accounts
547+ SET github_access_token = NULL
548+ WHERE user_id = ?
549+ ''' , (request .user_id ,))
550+
551+ updated = cur .rowcount > 0
552+ conn .commit ()
553+ conn .close ()
554+
555+ if updated :
556+ return {"success" : True , "message" : "GitHub disconnected" }
557+ else :
558+ return {"success" : False , "message" : "No GitHub connection found" }
559+ except Exception as e :
560+ return {"success" : False , "error" : str (e )}
561+
562+
563+
410564# SECURITY: Only safe fields are accepted from frontend
411565class UserSettingsRequest (BaseModel ):
412566 user_id : str
@@ -484,31 +638,47 @@ def get_connection_status_endpoint(user_id: str):
484638
485639 SECURITY: Returns ONLY boolean status and public identifiers.
486640 No tokens or credentials are ever returned.
641+
642+ Returns:
643+ - linkedin_connected: Has LinkedIn OAuth token
644+ - github_connected: Has GitHub username
645+ - github_oauth_connected: Has GitHub OAuth token (for private repos)
487646 """
488647 try :
489648 # Import get_connection_status from token_store
490- from services .token_store import get_connection_status
649+ from services .token_store import get_connection_status , get_token_by_user_id
491650
492651 status = get_connection_status (user_id )
493652
494- # Also get github_username from settings
653+ # Get github_username from settings
495654 github_username = ''
496655 if get_user_settings :
497656 settings = get_user_settings (user_id )
498657 if settings :
499658 github_username = settings .get ('github_username' , '' )
500659
660+ # Check if user has GitHub OAuth token (for private repos)
661+ github_oauth_connected = False
662+ try :
663+ token_data = get_token_by_user_id (user_id )
664+ if token_data and token_data .get ('github_access_token' ):
665+ github_oauth_connected = True
666+ except :
667+ pass
668+
501669 return {
502670 "linkedin_connected" : status .get ("linkedin_connected" , False ),
503671 "linkedin_urn" : status .get ("linkedin_urn" , "" ),
504672 "github_connected" : bool (github_username ),
505673 "github_username" : github_username ,
674+ "github_oauth_connected" : github_oauth_connected ,
506675 "token_expires_at" : status .get ("token_expires_at" ),
507676 }
508677 except Exception as e :
509678 return {
510679 "linkedin_connected" : False ,
511680 "github_connected" : False ,
681+ "github_oauth_connected" : False ,
512682 "error" : str (e )
513683 }
514684
0 commit comments