Skip to content

Commit 729059f

Browse files
committed
feat: GitHub OAuth one-click repo import
Secure server-side OAuth implementation for importing repos from GitHub. Backend: - Add github_connections table for secure token storage (RLS protected) - OAuth flow: /github/connect, /github/callback, /github/status - GitHubService for API interactions (repos, user info) - Token never exposed to frontend, stored server-side only - CSRF protection via state parameter Frontend: - GitHubRepoSelector modal for browsing/selecting repos - GitHubCallbackPage for OAuth redirect handling - useGitHubRepos hook for API integration - Dashboard integration with 'Import from GitHub' button - Free tier: 3 repo limit enforced in UI Security: - Separate OAuth scopes (user:email for login, repo for import) - Token validation on each use - Auto-cleanup on token revocation - Supabase service_role for server-side token access
1 parent 5604d28 commit 729059f

11 files changed

Lines changed: 1243 additions & 9 deletions

File tree

backend/.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ COHERE_API_KEY=your_cohere_api_key_here
1010
SUPABASE_URL=https://your-project.supabase.co
1111
SUPABASE_ANON_KEY=your_supabase_anon_key_here
1212
SUPABASE_JWT_SECRET=your_jwt_secret_here
13+
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key_here
14+
15+
# GitHub OAuth (for repo import feature)
16+
GITHUB_CLIENT_ID=your_github_oauth_app_client_id
17+
GITHUB_CLIENT_SECRET=your_github_oauth_app_client_secret
18+
GITHUB_REDIRECT_URI=http://localhost:3000/github/callback
19+
FRONTEND_URL=http://localhost:3000
1320

1421
# Backend API
1522
BACKEND_API_URL=http://localhost:8000

backend/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from routes.api_keys import router as api_keys_router
2828
from routes.users import router as users_router
2929
from routes.search_v2 import router as search_v2_router
30+
from routes.github import router as github_router
3031
from routes.ws_playground import websocket_playground_index
3132
from routes.ws_repos import websocket_repo_indexing
3233

@@ -92,6 +93,7 @@ async def dispatch(self, request: Request, call_next):
9293
app.include_router(api_keys_router, prefix=API_PREFIX)
9394
app.include_router(users_router, prefix=API_PREFIX)
9495
app.include_router(search_v2_router, prefix=API_PREFIX)
96+
app.include_router(github_router, prefix=API_PREFIX)
9597

9698
# WebSocket endpoints (versioned)
9799
app.add_api_websocket_route(f"{API_PREFIX}/ws/index/{{repo_id}}", websocket_index)

backend/routes/github.py

Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
"""
2+
GitHub Integration Routes
3+
Handles OAuth flow and repository listing for one-click import
4+
5+
SECURITY: Token exchange and storage happens server-side only.
6+
Frontend never sees the GitHub access token.
7+
"""
8+
import os
9+
import secrets
10+
import httpx
11+
from fastapi import APIRouter, HTTPException, Depends, Query
12+
from fastapi.responses import RedirectResponse
13+
from typing import Optional
14+
from pydantic import BaseModel
15+
from urllib.parse import urlencode
16+
17+
from middleware.auth import require_auth, AuthContext
18+
from services.github import GitHubService
19+
from services.observability import logger
20+
21+
22+
router = APIRouter(prefix="/github", tags=["GitHub"])
23+
24+
# GitHub OAuth config - load from env
25+
GITHUB_CLIENT_ID = os.getenv("GITHUB_CLIENT_ID")
26+
GITHUB_CLIENT_SECRET = os.getenv("GITHUB_CLIENT_SECRET")
27+
GITHUB_REDIRECT_URI = os.getenv("GITHUB_REDIRECT_URI", "http://localhost:3000/github/callback")
28+
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:3000")
29+
30+
31+
class GitHubStatusResponse(BaseModel):
32+
connected: bool
33+
username: Optional[str] = None
34+
avatar_url: Optional[str] = None
35+
36+
37+
class GitHubConnectResponse(BaseModel):
38+
auth_url: str
39+
state: str
40+
41+
42+
class GitHubCallbackRequest(BaseModel):
43+
code: str
44+
state: str
45+
46+
47+
class GitHubRepoResponse(BaseModel):
48+
id: int
49+
name: str
50+
full_name: str
51+
description: Optional[str]
52+
html_url: str
53+
clone_url: str
54+
default_branch: str
55+
private: bool
56+
fork: bool
57+
stars: int
58+
language: Optional[str]
59+
size_kb: int
60+
owner: str
61+
owner_avatar: str
62+
63+
64+
async def _get_github_connection(user_id: str) -> Optional[dict]:
65+
"""Get user's GitHub connection from database"""
66+
try:
67+
from services.supabase_service import get_supabase_service
68+
db = get_supabase_service().client
69+
result = db.table("github_connections").select("*").eq("user_id", user_id).execute()
70+
return result.data[0] if result.data else None
71+
except Exception as e:
72+
logger.error("Failed to get GitHub connection", error=str(e), user_id=user_id)
73+
return None
74+
75+
76+
async def _save_github_connection(
77+
user_id: str,
78+
access_token: str,
79+
github_user_id: int,
80+
github_username: str,
81+
github_avatar_url: Optional[str],
82+
scope: str
83+
) -> bool:
84+
"""Save or update GitHub connection in database"""
85+
try:
86+
from services.supabase_service import get_supabase_service
87+
db = get_supabase_service().client
88+
89+
data = {
90+
"user_id": user_id,
91+
"access_token": access_token,
92+
"github_user_id": github_user_id,
93+
"github_username": github_username,
94+
"github_avatar_url": github_avatar_url,
95+
"token_scope": scope,
96+
}
97+
98+
db.table("github_connections").upsert(data, on_conflict="user_id").execute()
99+
return True
100+
except Exception as e:
101+
logger.error("Failed to save GitHub connection", error=str(e), user_id=user_id)
102+
return False
103+
104+
105+
async def _delete_github_connection(user_id: str) -> bool:
106+
"""Remove GitHub connection"""
107+
try:
108+
from services.supabase_service import get_supabase_service
109+
db = get_supabase_service().client
110+
db.table("github_connections").delete().eq("user_id", user_id).execute()
111+
return True
112+
except Exception as e:
113+
logger.error("Failed to delete GitHub connection", error=str(e), user_id=user_id)
114+
return False
115+
116+
117+
async def _update_last_used(user_id: str) -> None:
118+
"""Update last_used_at timestamp"""
119+
try:
120+
from services.supabase_service import get_supabase_service
121+
db = get_supabase_service().client
122+
db.table("github_connections").update(
123+
{"last_used_at": "now()"}
124+
).eq("user_id", user_id).execute()
125+
except Exception:
126+
pass
127+
128+
129+
@router.get("/status", response_model=GitHubStatusResponse)
130+
async def get_github_status(auth: AuthContext = Depends(require_auth)):
131+
"""Check if user has GitHub connected and token is valid"""
132+
if not auth.user_id:
133+
raise HTTPException(status_code=401, detail="User ID required")
134+
135+
connection = await _get_github_connection(auth.user_id)
136+
if not connection:
137+
return GitHubStatusResponse(connected=False)
138+
139+
# Verify token is still valid by making a test API call
140+
try:
141+
github = GitHubService(connection["access_token"])
142+
is_valid = await github.validate_token()
143+
144+
if not is_valid:
145+
# Token expired or revoked, clean up
146+
await _delete_github_connection(auth.user_id)
147+
return GitHubStatusResponse(connected=False)
148+
149+
return GitHubStatusResponse(
150+
connected=True,
151+
username=connection.get("github_username"),
152+
avatar_url=connection.get("github_avatar_url")
153+
)
154+
except Exception as e:
155+
logger.warning("GitHub token validation failed", error=str(e))
156+
return GitHubStatusResponse(connected=False)
157+
158+
159+
@router.get("/connect", response_model=GitHubConnectResponse)
160+
async def initiate_github_connect(auth: AuthContext = Depends(require_auth)):
161+
"""
162+
Start GitHub OAuth flow for repo import
163+
164+
Returns URL to redirect user to GitHub authorization page.
165+
Frontend should redirect user to this URL.
166+
"""
167+
if not auth.user_id:
168+
raise HTTPException(status_code=401, detail="User ID required")
169+
170+
if not GITHUB_CLIENT_ID:
171+
raise HTTPException(status_code=500, detail="GitHub OAuth not configured")
172+
173+
# Generate state token to prevent CSRF
174+
# In production, store this in Redis/DB with expiry and user_id association
175+
state = f"{auth.user_id}:{secrets.token_urlsafe(32)}"
176+
177+
params = {
178+
"client_id": GITHUB_CLIENT_ID,
179+
"redirect_uri": GITHUB_REDIRECT_URI,
180+
"scope": "repo", # Full repo access for private repos
181+
"state": state,
182+
"allow_signup": "false", # User should already have GitHub account
183+
}
184+
185+
auth_url = f"https://github.com/login/oauth/authorize?{urlencode(params)}"
186+
187+
return GitHubConnectResponse(auth_url=auth_url, state=state)
188+
189+
190+
@router.post("/callback")
191+
async def github_oauth_callback(
192+
request: GitHubCallbackRequest,
193+
auth: AuthContext = Depends(require_auth)
194+
):
195+
"""
196+
Handle GitHub OAuth callback
197+
198+
Exchanges authorization code for access token and stores it.
199+
Called by frontend after GitHub redirects back.
200+
"""
201+
if not auth.user_id:
202+
raise HTTPException(status_code=401, detail="User ID required")
203+
204+
if not GITHUB_CLIENT_ID or not GITHUB_CLIENT_SECRET:
205+
raise HTTPException(status_code=500, detail="GitHub OAuth not configured")
206+
207+
# Verify state contains user_id (basic CSRF protection)
208+
if not request.state.startswith(auth.user_id):
209+
raise HTTPException(status_code=400, detail="Invalid state parameter")
210+
211+
# Exchange code for access token
212+
async with httpx.AsyncClient() as client:
213+
response = await client.post(
214+
"https://github.com/login/oauth/access_token",
215+
data={
216+
"client_id": GITHUB_CLIENT_ID,
217+
"client_secret": GITHUB_CLIENT_SECRET,
218+
"code": request.code,
219+
"redirect_uri": GITHUB_REDIRECT_URI,
220+
},
221+
headers={"Accept": "application/json"},
222+
timeout=30.0
223+
)
224+
225+
if response.status_code != 200:
226+
logger.error("GitHub token exchange failed", status=response.status_code)
227+
raise HTTPException(status_code=400, detail="Failed to exchange code for token")
228+
229+
token_data = response.json()
230+
231+
if "error" in token_data:
232+
logger.error("GitHub OAuth error", error=token_data.get("error_description"))
233+
raise HTTPException(status_code=400, detail=token_data.get("error_description", "OAuth failed"))
234+
235+
access_token = token_data.get("access_token")
236+
scope = token_data.get("scope", "")
237+
238+
if not access_token:
239+
raise HTTPException(status_code=400, detail="No access token received")
240+
241+
# Get GitHub user info
242+
github = GitHubService(access_token)
243+
user_info = await github.get_user()
244+
245+
if not user_info:
246+
raise HTTPException(status_code=400, detail="Failed to get GitHub user info")
247+
248+
# Save connection to database
249+
saved = await _save_github_connection(
250+
user_id=auth.user_id,
251+
access_token=access_token,
252+
github_user_id=user_info.id,
253+
github_username=user_info.login,
254+
github_avatar_url=user_info.avatar_url,
255+
scope=scope
256+
)
257+
258+
if not saved:
259+
raise HTTPException(status_code=500, detail="Failed to save GitHub connection")
260+
261+
logger.info("GitHub connected successfully", user_id=auth.user_id, github_user=user_info.login)
262+
263+
return {
264+
"success": True,
265+
"username": user_info.login,
266+
"avatar_url": user_info.avatar_url
267+
}
268+
269+
270+
@router.delete("/disconnect")
271+
async def disconnect_github(auth: AuthContext = Depends(require_auth)):
272+
"""Remove GitHub connection"""
273+
if not auth.user_id:
274+
raise HTTPException(status_code=401, detail="User ID required")
275+
276+
deleted = await _delete_github_connection(auth.user_id)
277+
return {"success": deleted}
278+
279+
280+
@router.get("/repos", response_model=list[GitHubRepoResponse])
281+
async def list_github_repos(
282+
auth: AuthContext = Depends(require_auth),
283+
include_forks: bool = False,
284+
page: int = Query(default=1, ge=1),
285+
per_page: int = Query(default=50, ge=1, le=100)
286+
):
287+
"""
288+
List user's GitHub repositories for import selection
289+
290+
Returns repos sorted by last updated, excludes forks by default.
291+
Includes both personal repos and org repos user has access to.
292+
"""
293+
if not auth.user_id:
294+
raise HTTPException(status_code=401, detail="User ID required")
295+
296+
connection = await _get_github_connection(auth.user_id)
297+
if not connection:
298+
raise HTTPException(
299+
status_code=400,
300+
detail="GitHub not connected. Please connect your GitHub account first."
301+
)
302+
303+
try:
304+
github = GitHubService(connection["access_token"])
305+
repos = await github.get_repos(
306+
include_forks=include_forks,
307+
per_page=per_page,
308+
page=page
309+
)
310+
311+
# Update last used timestamp
312+
await _update_last_used(auth.user_id)
313+
314+
return [
315+
GitHubRepoResponse(
316+
id=repo.id,
317+
name=repo.name,
318+
full_name=repo.full_name,
319+
description=repo.description,
320+
html_url=repo.html_url,
321+
clone_url=repo.clone_url,
322+
default_branch=repo.default_branch,
323+
private=repo.private,
324+
fork=repo.fork,
325+
stars=repo.stargazers_count,
326+
language=repo.language,
327+
size_kb=repo.size,
328+
owner=repo.owner_login,
329+
owner_avatar=repo.owner_avatar
330+
)
331+
for repo in repos
332+
]
333+
except Exception as e:
334+
logger.error("Failed to fetch GitHub repos", error=str(e), user_id=auth.user_id)
335+
336+
# Check if token was revoked
337+
if "401" in str(e) or "Bad credentials" in str(e):
338+
await _delete_github_connection(auth.user_id)
339+
raise HTTPException(
340+
status_code=401,
341+
detail="GitHub access revoked. Please reconnect your GitHub account."
342+
)
343+
344+
raise HTTPException(status_code=500, detail=f"Failed to fetch repositories: {str(e)}")

0 commit comments

Comments
 (0)