Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
43 changes: 40 additions & 3 deletions backend/app/api/v1/endpoints/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
"""
Expand Down
11 changes: 7 additions & 4 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) ---")
16 changes: 16 additions & 0 deletions backend/tests/_debug_login.py
Original file line number Diff line number Diff line change
@@ -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])
20 changes: 20 additions & 0 deletions backend/tests/test_auth_scopes.py
Original file line number Diff line number Diff line change
@@ -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
34 changes: 34 additions & 0 deletions docs/oauth-scopes.md
Original file line number Diff line number Diff line change
@@ -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.
75 changes: 73 additions & 2 deletions frontend/app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="min-h-screen bg-white dark:bg-[#0d1117] flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
Expand Down Expand Up @@ -79,6 +96,60 @@ export default function LoginPage() {
{isLoading ? "Redirecting to GitHub..." : "Sign in with GitHub"}
</button>

{/* Scope selection - let users choose minimal permissions */}
<div className="mt-4 text-sm text-gray-700 dark:text-gray-400">
<p className="font-medium mb-2">Choose which access to grant (optional)</p>
<div className="space-y-2">
<div className="flex items-center">
<input type="checkbox" checked disabled className="mr-2" />
<div>
<div className="font-medium">Basic profile</div>
<div className="text-xs text-muted-foreground">read:user (required)</div>
</div>
</div>

<div className="flex items-center">
<input type="checkbox" checked={includeRepoStatus} onChange={(e) => setIncludeRepoStatus(e.target.checked)} className="mr-2" />
<div>
<div className="font-medium">Commit statuses</div>
<div className="text-xs text-muted-foreground">repo:status — allows reading and writing commit statuses (no code access)</div>
</div>
</div>

<div className="flex items-center">
<input type="checkbox" checked={includePublicRepo} onChange={(e) => setIncludePublicRepo(e.target.checked)} className="mr-2" />
<div>
<div className="font-medium">Public repo access</div>
<div className="text-xs text-muted-foreground">public_repo — read and write access to public repositories only</div>
</div>
</div>

<div className="flex items-center">
<input type="checkbox" checked={includeRepo} onChange={(e) => setIncludeRepo(e.target.checked)} className="mr-2" />
<div>
<div className="font-medium">Private repo access</div>
<div className="text-xs text-muted-foreground">repo — full control of private repositories (includes code access). Use only if you want IssueMatch to read your private repos.</div>
</div>
</div>

<div className="flex items-center">
<input type="checkbox" checked={includeReadOrg} onChange={(e) => setIncludeReadOrg(e.target.checked)} className="mr-2" />
<div>
<div className="font-medium">Organization membership</div>
<div className="text-xs text-muted-foreground">read:org — read organization and team membership (no repo code access)</div>
</div>
</div>

<div className="flex items-center">
<input type="checkbox" checked={includeEmail} onChange={(e) => setIncludeEmail(e.target.checked)} className="mr-2" />
<div>
<div className="font-medium">Email</div>
<div className="text-xs text-muted-foreground">user:email — access to your verified email addresses</div>
</div>
</div>
</div>
</div>

<div className="mt-6 text-center text-sm text-gray-700 dark:text-gray-400">
<p>
By signing in, you agree to our{" "}
Expand Down
22 changes: 22 additions & 0 deletions frontend/app/profile/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export default function ProfilePage() {
const [profile, setProfile] = useState<GitHubProfile | null>(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: [
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -276,6 +287,17 @@ export default function ProfilePage() {
</div>
</div>

{/* OAuth Scopes Info */}
{scopesInfo && (
<div className="mt-4 text-sm text-gray-700 dark:text-gray-400">
<div className="text-xs text-muted-foreground mb-1">Requested Scopes</div>
<div className="text-sm mb-2">{scopesInfo.requested || "(none)"}</div>
<div className="text-xs text-muted-foreground mb-1">Granted Scopes</div>
<div className="text-sm mb-3">{scopesInfo.granted || "(none)"}</div>
<a href="/login" className="text-purple-600 dark:text-purple-400 hover:underline text-sm">Revoke / change permissions</a>
</div>
)}

{/* Skills Section */}
<div className="border-t border-gray-300 dark:border-gray-700 pt-4">
<div className="flex justify-between items-center mb-4"> {/* Added items-center */}
Expand Down
4 changes: 2 additions & 2 deletions frontend/components/navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down