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/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 new file mode 100644 index 0000000..dbadab4 --- /dev/null +++ b/backend/tests/test_auth_scopes.py @@ -0,0 +1,20 @@ +from fastapi.testclient import TestClient +from backend.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) + # 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) + location = resp.headers.get("location", "") + assert "github.com/login/oauth/authorize" in 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 (
Choose which access to grant (optional)
+
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