From 69572d688e65b9b78835cf1935013d9e3c08fe52 Mon Sep 17 00:00:00 2001 From: Kashif Ali Khan Date: Sun, 28 Dec 2025 23:46:29 +0530 Subject: [PATCH 1/2] Add selectable GitHub OAuth scopes to login flow Implements frontend UI for users to choose which GitHub OAuth scopes to grant, with backend validation against an allow-list. The login and profile pages now display requested and granted scopes, and documentation has been added to explain each scope and recommended usage. Tests ensure invalid scopes are rejected and valid ones are accepted. --- README.md | 1 + backend/app/api/v1/endpoints/auth.py | 43 ++++++++++++++-- backend/tests/test_auth_scopes.py | 18 +++++++ docs/oauth-scopes.md | 34 +++++++++++++ frontend/app/login/page.tsx | 75 +++++++++++++++++++++++++++- frontend/app/profile/page.tsx | 22 ++++++++ frontend/components/navbar.tsx | 4 +- 7 files changed, 190 insertions(+), 7 deletions(-) create mode 100644 backend/tests/test_auth_scopes.py create mode 100644 docs/oauth-scopes.md diff --git a/README.md b/README.md index 11b4ec5..f80678b 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ IssueMatch is an intelligent platform that bridges the gap between developers an ### Authentication - **GitHub OAuth**: For secure user authentication + - The login page now allows selecting which GitHub OAuth scopes to grant. See [docs/oauth-scopes.md](docs/oauth-scopes.md) for details on each scope and recommendations. ### 🔄 How It Works diff --git a/backend/app/api/v1/endpoints/auth.py b/backend/app/api/v1/endpoints/auth.py index 93930bf..7304980 100644 --- a/backend/app/api/v1/endpoints/auth.py +++ b/backend/app/api/v1/endpoints/auth.py @@ -11,24 +11,50 @@ GITHUB_AUTH_URL = "https://github.com/login/oauth/authorize" GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token" GITHUB_CALLBACK_URL = f"http://localhost:8000{settings.API_V1_STR}/auth/callback" -GITHUB_SCOPES = "read:user repo" # Permissions needed for user data and repo access +GITHUB_SCOPES = "read:user repo" # Default permissions needed for user data and repo access + +# Limit which scopes can be requested by the frontend to avoid arbitrary scope injection +ALLOWED_GITHUB_SCOPES = { + "read:user", + "user:email", + "repo", + "public_repo", + "repo:status", + "read:org", +} FRONTEND_LOGIN_SUCCESS_URL = "http://localhost:3000/skills" # Redirect after successful login FRONTEND_LOGIN_FAILURE_URL = "http://localhost:3000/login?error=auth_failed" FRONTEND_LOGOUT_REDIRECT_URL = "http://localhost:3000/login" @router.get("/login") -async def github_login_redirect(request: Request): +async def github_login_redirect(request: Request, scopes: str = None): """ Initiates GitHub OAuth flow by redirecting to GitHub's authorization page. """ state = secrets.token_urlsafe(16) request.session['oauth_state'] = state + # Use scopes supplied by frontend if provided and valid, otherwise use defaults + used_scopes = GITHUB_SCOPES + if scopes: + # Normalize spaces and commas + candidate = scopes.replace(",", " ").strip() + # Validate requested scopes against allowlist + requested = [s for s in candidate.split() if s] + invalid = [s for s in requested if s not in ALLOWED_GITHUB_SCOPES] + if invalid: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid scope(s) requested: {invalid}") + if requested: + used_scopes = " ".join(requested) + + # Store requested scopes in session for later reference + request.session['github_requested_scopes'] = used_scopes + github_auth_url = ( f"{GITHUB_AUTH_URL}" f"?client_id={settings.GITHUB_CLIENT_ID}" f"&redirect_uri={GITHUB_CALLBACK_URL}" - f"&scope={GITHUB_SCOPES}" + f"&scope={used_scopes}" f"&state={state}" ) return RedirectResponse(url=github_auth_url, status_code=status.HTTP_307_TEMPORARY_REDIRECT) @@ -105,6 +131,17 @@ async def logout_user(request: Request): request.session.clear() return RedirectResponse(url=FRONTEND_LOGOUT_REDIRECT_URL, status_code=status.HTTP_307_TEMPORARY_REDIRECT) + +@router.get("/scopes") +async def get_requested_and_granted_scopes(request: Request): + """ + Returns the scopes that were requested during the OAuth flow and the scopes actually granted by GitHub + (if available in the session). This is useful for the frontend to display what the user granted. + """ + requested = request.session.get('github_requested_scopes', '') + granted = request.session.get('github_scope', '') + return {"requested": requested, "granted": granted} + # Dependency to extract GitHub token from session async def get_github_token(request: Request) -> str: """ diff --git a/backend/tests/test_auth_scopes.py b/backend/tests/test_auth_scopes.py new file mode 100644 index 0000000..302ffc8 --- /dev/null +++ b/backend/tests/test_auth_scopes.py @@ -0,0 +1,18 @@ +from fastapi.testclient import TestClient +from ..app.main import app + +client = TestClient(app) + + +def test_invalid_scope_rejected(): + resp = client.get("/api/v1/auth/login?scopes=invalid_scope") + assert resp.status_code == 400 + assert "Invalid scope" in resp.json().get("detail", "") + + +def test_valid_scopes_redirect(): + # Requesting allowed scopes should redirect to GitHub authorize URL (307) + resp = client.get("/api/v1/auth/login?scopes=read:user,repo:status") + # Should be a redirect response + assert resp.status_code in (301, 302, 307, 308) + assert "github.com/login/oauth/authorize" in resp.headers.get("location", "") diff --git a/docs/oauth-scopes.md b/docs/oauth-scopes.md new file mode 100644 index 0000000..04c0c99 --- /dev/null +++ b/docs/oauth-scopes.md @@ -0,0 +1,34 @@ +# OAuth Scopes Guide for IssueMatch + +This guide explains which GitHub OAuth scopes IssueMatch requests, why they are needed, and how to minimize access to protect user privacy. + +## Default behavior + +- By default IssueMatch requests `read:user` (basic profile) and `repo:status` (commit status access). These allow the app to read your public profile and access commit statuses without requesting access to repository code. + +## Common scopes explained + +- `read:user` (recommended, required): Read basic profile information (username, avatar, bio). No repo access. +- `user:email` (optional): Read your verified email addresses. Useful to show a contact email. +- `repo:status` (optional): Read/write commit statuses. Does NOT grant access to repository code. Use this if you want status-related features without granting code access. +- `public_repo` (optional): Access to public repositories only (read and write). Does not include private repositories. +- `repo` (optional, powerful): Full control of private repositories (read/write). This includes code access and is only necessary if you explicitly want IssueMatch to read your private repositories. +- `read:org` (optional): Read organization and team membership. + +## Recommendations + +- If you only want IssueMatch to analyze your public activity (public repos, READMEs, languages), you can avoid `repo` and either sign in without extra repo scopes or choose `public_repo` (if you need public repo write features). +- If you want analysis that includes private repositories, you must consent to `repo`. Note: `repo` grants broad access, so only opt-in if you trust the app. +- `repo:status` is a useful middle-ground when you need commit-status functionality but not code access. + +## How IssueMatch respects privacy + +- The login page allows you to select which scopes to grant before redirecting to GitHub. The backend validates requested scopes against an allow-list. +- The profile page shows which scopes were requested and which were granted by GitHub. + +## Future work + +- Add UI to revoke scopes or unlink GitHub from the app (requires GitHub settings or a token revocation flow). +- Allow users to add/remove specific repository access via GitHub Apps (granular permissions) in the future. + +If you have specific concerns about a scope or want help deciding which scopes to grant for a particular feature, please open an issue with the use case and we can advise. diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index 4d8edd5..bfb7a0e 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -46,10 +46,27 @@ export default function LoginPage() { setIsLoading(true) setError("") - // Redirect to the backend's GitHub OAuth login endpoint - window.location.href = "http://localhost:8000/api/v1/auth/login" + // Build selected scopes and redirect to backend OAuth endpoint + // Default required scope + const required = ["read:user"] + const extras = [] + if (includeRepoStatus) extras.push("repo:status") + if (includeRepo) extras.push("repo") + if (includePublicRepo) extras.push("public_repo") + if (includeReadOrg) extras.push("read:org") + if (includeEmail) extras.push("user:email") + + const scopes = [...required, ...extras].join(" ") + window.location.href = `http://localhost:8000/api/v1/auth/login?scopes=${encodeURIComponent(scopes)}` } + // Scope selection state (kept near top for clarity) + const [includeRepoStatus, setIncludeRepoStatus] = useState(true) + const [includeRepo, setIncludeRepo] = useState(false) + const [includePublicRepo, setIncludePublicRepo] = useState(false) + const [includeReadOrg, setIncludeReadOrg] = useState(false) + const [includeEmail, setIncludeEmail] = useState(true) + return (
@@ -79,6 +96,60 @@ export default function LoginPage() { {isLoading ? "Redirecting to GitHub..." : "Sign in with GitHub"} + {/* Scope selection - let users choose minimal permissions */} +
+

Choose which access to grant (optional)

+
+
+ +
+
Basic profile
+
read:user (required)
+
+
+ +
+ setIncludeRepoStatus(e.target.checked)} className="mr-2" /> +
+
Commit statuses
+
repo:status — allows reading and writing commit statuses (no code access)
+
+
+ +
+ setIncludePublicRepo(e.target.checked)} className="mr-2" /> +
+
Public repo access
+
public_repo — read and write access to public repositories only
+
+
+ +
+ setIncludeRepo(e.target.checked)} className="mr-2" /> +
+
Private repo access
+
repo — full control of private repositories (includes code access). Use only if you want IssueMatch to read your private repos.
+
+
+ +
+ setIncludeReadOrg(e.target.checked)} className="mr-2" /> +
+
Organization membership
+
read:org — read organization and team membership (no repo code access)
+
+
+ +
+ setIncludeEmail(e.target.checked)} className="mr-2" /> +
+
Email
+
user:email — access to your verified email addresses
+
+
+
+
+

By signing in, you agree to our{" "} diff --git a/frontend/app/profile/page.tsx b/frontend/app/profile/page.tsx index 678aefd..74a81ec 100644 --- a/frontend/app/profile/page.tsx +++ b/frontend/app/profile/page.tsx @@ -66,6 +66,7 @@ export default function ProfilePage() { const [profile, setProfile] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState("") + const [scopesInfo, setScopesInfo] = useState<{requested: string; granted: string} | null>(null) const mockData: { skills: Skill[]; stats: Stats; achievements: Achievement[]; resumeUploaded: boolean } = { skills: [ @@ -116,6 +117,16 @@ export default function ProfilePage() { const data: GitHubProfile = await response.json() setProfile(data) // Store fetched profile data in state + // Also fetch requested/granted scopes for display + try { + const scopeRes = await fetch("http://localhost:8000/api/v1/auth/scopes", { credentials: "include" }) + if (scopeRes.ok) { + const scopeData = await scopeRes.json() + setScopesInfo({ requested: scopeData.requested || "", granted: scopeData.granted || "" }) + } + } catch (e) { + // Non-fatal + } } catch (err: any) { // Handle errors during fetch setError(err.message || "Failed to load profile data") @@ -276,6 +287,17 @@ export default function ProfilePage() {

+ {/* OAuth Scopes Info */} + {scopesInfo && ( +
+
Requested Scopes
+
{scopesInfo.requested || "(none)"}
+
Granted Scopes
+
{scopesInfo.granted || "(none)"}
+ Revoke / change permissions +
+ )} + {/* Skills Section */}
{/* Added items-center */} diff --git a/frontend/components/navbar.tsx b/frontend/components/navbar.tsx index 028dfeb..405095c 100644 --- a/frontend/components/navbar.tsx +++ b/frontend/components/navbar.tsx @@ -23,8 +23,8 @@ export function Navbar() { }; const handleLogin = () => { - // Redirect to the backend login endpoint to start the GitHub OAuth flow - window.location.href = `${API_URL}/auth/login`; + // Navigate to the frontend login page where users can choose scopes + router.push('/login') }; const handleSearch = (e: React.FormEvent) => { From cded822f868b91464d9273ef1afb70870dbd158c Mon Sep 17 00:00:00 2001 From: Kashif Ali Khan Date: Mon, 29 Dec 2025 00:08:21 +0530 Subject: [PATCH 2/2] Add debug login script and improve auth scope tests Added a _debug_login.py script for manual login endpoint testing. Updated test_auth_scopes.py to avoid following external redirects and clarified assertions. Provided safe default values for sensitive config variables in config.py to ease local development and testing, and improved debug logging to avoid printing secrets. --- backend/app/core/config.py | 11 +++++++---- backend/tests/_debug_login.py | 16 ++++++++++++++++ backend/tests/test_auth_scopes.py | 10 ++++++---- 3 files changed, 29 insertions(+), 8 deletions(-) create mode 100644 backend/tests/_debug_login.py diff --git a/backend/app/core/config.py b/backend/app/core/config.py index b5277f3..b3bccff 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -7,11 +7,12 @@ class Settings(BaseSettings): API_V1_STR: str = "/api/v1" # GitHub OAuth settings - GITHUB_CLIENT_ID: str - GITHUB_CLIENT_SECRET: str + # Provide safe defaults for local development/tests. In production, set these via env or .env file. + GITHUB_CLIENT_ID: str = "" + GITHUB_CLIENT_SECRET: str = "" # Security - SECRET_KEY: str + SECRET_KEY: str = "dev-secret" # Google Sheets SHEETS_ID: Optional[str] = None @@ -27,4 +28,6 @@ class Config: settings = Settings() -print(f"--- DEBUG [config.py]: Loaded GITHUB_CLIENT_ID = '{settings.GITHUB_CLIENT_ID}' ---") +# Avoid printing secrets in logs; only confirm that config loaded in debug scenarios. +if settings.GITHUB_CLIENT_ID: + print(f"--- DEBUG [config.py]: Loaded GITHUB_CLIENT_ID (non-empty) ---") diff --git a/backend/tests/_debug_login.py b/backend/tests/_debug_login.py new file mode 100644 index 0000000..bf084e6 --- /dev/null +++ b/backend/tests/_debug_login.py @@ -0,0 +1,16 @@ +import sys +from pathlib import Path + +# Ensure project root is on sys.path when running this script directly +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from fastapi.testclient import TestClient +from backend.app.main import app + +client = TestClient(app) + +resp = client.get('/api/v1/auth/login?scopes=read:user,repo:status') +print('status:', resp.status_code) +print('headers:', resp.headers) +print('text:', resp.text[:1000]) diff --git a/backend/tests/test_auth_scopes.py b/backend/tests/test_auth_scopes.py index 302ffc8..dbadab4 100644 --- a/backend/tests/test_auth_scopes.py +++ b/backend/tests/test_auth_scopes.py @@ -1,5 +1,5 @@ from fastapi.testclient import TestClient -from ..app.main import app +from backend.app.main import app client = TestClient(app) @@ -12,7 +12,9 @@ def test_invalid_scope_rejected(): def test_valid_scopes_redirect(): # Requesting allowed scopes should redirect to GitHub authorize URL (307) - resp = client.get("/api/v1/auth/login?scopes=read:user,repo:status") - # Should be a redirect response + # Do not follow external redirects in tests; inspect initial redirect + resp = client.get("/api/v1/auth/login?scopes=read:user,repo:status", allow_redirects=False) + # Should be a redirect response from our service to GitHub assert resp.status_code in (301, 302, 307, 308) - assert "github.com/login/oauth/authorize" in resp.headers.get("location", "") + location = resp.headers.get("location", "") + assert "github.com/login/oauth/authorize" in location