-
Notifications
You must be signed in to change notification settings - Fork 231
feat: Add GitHub Account Binding, One-click Sync & Security Updates #112
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
norberia
merged 11 commits into
FullAgent:main
from
Che-Zhu:feat/github-account-binding
Dec 12, 2025
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
c79ffd4
feat: add GitHub account binding for logged-in users
norberia 91920e1
Merge branch 'FullAgent:main' into feat/github-account-binding
norberia 63ea875
add repo connection status to status bar
norberia 8bf5c7e
fix: ensure changes are pushed to remote and improve repo status UI
norberia afa6163
fix lint issue
norberia 217bb2a
fix(security): sanitize repo URL and patch command injection in repoS…
norberia 106a1dc
feat(repo): auto-prompt settings dialog when GitHub is not bound
norberia 1c09474
chore(deps): bump next.js to 16.0.10 for security fixes
norberia 34c6d74
Update lib/services/repoService.ts
norberia 90fdc60
Update lib/services/repoService.ts
norberia 253e1a5
Update components/layout/status-bar.tsx
norberia File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,219 @@ | ||
| import { NextRequest, NextResponse } from 'next/server' | ||
|
|
||
| import { prisma } from '@/lib/db' | ||
| import { env } from '@/lib/env' | ||
| import { logger as baseLogger } from '@/lib/logger' | ||
|
|
||
| const logger = baseLogger.child({ module: 'api/auth/github/callback' }) | ||
|
|
||
| /** | ||
| * GET /api/auth/github/callback | ||
| * Handles the OAuth callback from GitHub for account binding | ||
| */ | ||
| export async function GET(request: NextRequest) { | ||
| try { | ||
| const searchParams = request.nextUrl.searchParams | ||
| const code = searchParams.get('code') | ||
| const state = searchParams.get('state') | ||
|
|
||
| if (!code || !state) { | ||
| return NextResponse.json({ error: 'Missing code or state parameter' }, { status: 400 }) | ||
| } | ||
|
|
||
| // Verify state parameter | ||
| const stateCookie = request.cookies.get('github_oauth_state')?.value | ||
|
|
||
| if (!stateCookie || stateCookie !== state) { | ||
| logger.warn('State mismatch in GitHub OAuth callback') | ||
| return NextResponse.json({ error: 'Invalid state parameter' }, { status: 400 }) | ||
| } | ||
|
|
||
| // Decode state to get userId | ||
| let userId: string | ||
| try { | ||
| const decodedState = Buffer.from(state, 'base64').toString('utf-8') | ||
| const [, extractedUserId, timestamp] = decodedState.split('|') | ||
|
|
||
| // Check if state is expired (10 minutes) | ||
| const stateAge = Date.now() - parseInt(timestamp, 10) | ||
| if (stateAge > 10 * 60 * 1000) { | ||
| return NextResponse.json({ error: 'State expired' }, { status: 400 }) | ||
| } | ||
|
|
||
| userId = extractedUserId | ||
| } catch { | ||
| return NextResponse.json({ error: 'Invalid state format' }, { status: 400 }) | ||
| } | ||
|
|
||
| // Exchange code for access token | ||
| const tokenResponse = await fetch('https://github.com/login/oauth/access_token', { | ||
| method: 'POST', | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| Accept: 'application/json', | ||
| }, | ||
| body: JSON.stringify({ | ||
| client_id: env.GITHUB_CLIENT_ID, | ||
| client_secret: env.GITHUB_CLIENT_SECRET, | ||
| code, | ||
| }), | ||
| }) | ||
|
|
||
| const tokenData = await tokenResponse.json() | ||
|
|
||
| if (!tokenData.access_token) { | ||
| logger.error('Failed to get access token from GitHub') | ||
| return NextResponse.json({ error: 'Failed to get access token' }, { status: 500 }) | ||
| } | ||
|
|
||
| const accessToken = tokenData.access_token | ||
| const scope = tokenData.scope || 'repo read:user' | ||
|
|
||
| // Get GitHub user info | ||
| const userResponse = await fetch('https://api.github.com/user', { | ||
| headers: { | ||
| Authorization: `Bearer ${accessToken}`, | ||
| Accept: 'application/vnd.github.v3+json', | ||
| }, | ||
| }) | ||
|
|
||
| const githubUser = await userResponse.json() | ||
|
|
||
| if (!githubUser.id) { | ||
| logger.error('Failed to get GitHub user info') | ||
| return NextResponse.json({ error: 'Failed to get user info' }, { status: 500 }) | ||
| } | ||
|
|
||
| const githubUserId = githubUser.id.toString() | ||
| const githubLogin = githubUser.login | ||
| const githubAvatarUrl = githubUser.avatar_url | ||
|
|
||
| // Check if this GitHub account is already bound to another user | ||
| const existingIdentity = await prisma.userIdentity.findUnique({ | ||
| where: { | ||
| unique_provider_user: { | ||
| provider: 'GITHUB', | ||
| providerUserId: githubUserId, | ||
| }, | ||
| }, | ||
| }) | ||
|
|
||
| if (existingIdentity && existingIdentity.userId !== userId) { | ||
| logger.warn(`GitHub account ${githubLogin} is already bound to another user`) | ||
| return createCallbackPage( | ||
| false, | ||
| 'This GitHub account is already bound to another user account.' | ||
| ) | ||
| } | ||
|
|
||
| // Upsert GitHub identity | ||
| await prisma.userIdentity.upsert({ | ||
| where: { | ||
| unique_provider_user: { | ||
| provider: 'GITHUB', | ||
| providerUserId: githubUserId, | ||
| }, | ||
| }, | ||
| update: { | ||
| metadata: { | ||
| token: accessToken, | ||
| scope, | ||
| login: githubLogin, | ||
| avatar_url: githubAvatarUrl, | ||
| }, | ||
| }, | ||
| create: { | ||
| userId, | ||
| provider: 'GITHUB', | ||
| providerUserId: githubUserId, | ||
| metadata: { | ||
| token: accessToken, | ||
| scope, | ||
| login: githubLogin, | ||
| avatar_url: githubAvatarUrl, | ||
| }, | ||
| isPrimary: false, // This is a binding, not primary login | ||
| }, | ||
| }) | ||
|
|
||
| logger.info(`GitHub account ${githubLogin} bound successfully for user ${userId}`) | ||
|
|
||
| // Return success page that notifies parent window | ||
| return createCallbackPage(true, 'GitHub account connected successfully!') | ||
| } catch (error) { | ||
| logger.error(`Error in GitHub OAuth callback: ${error}`) | ||
| return createCallbackPage(false, 'An error occurred during GitHub authentication.') | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Create an HTML page that sends a message to the parent window (popup opener) | ||
| * and closes itself | ||
| */ | ||
| function createCallbackPage(success: boolean, message: string): NextResponse { | ||
| const html = ` | ||
| <!DOCTYPE html> | ||
| <html> | ||
| <head> | ||
| <meta charset="utf-8"> | ||
| <title>GitHub Authentication</title> | ||
| <style> | ||
| body { | ||
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| height: 100vh; | ||
| margin: 0; | ||
| background: ${success ? '#f0fdf4' : '#fef2f2'}; | ||
| } | ||
| .container { | ||
| text-align: center; | ||
| padding: 2rem; | ||
| } | ||
| .icon { | ||
| font-size: 3rem; | ||
| margin-bottom: 1rem; | ||
| } | ||
| .message { | ||
| font-size: 1.125rem; | ||
| color: ${success ? '#166534' : '#991b1b'}; | ||
| margin-bottom: 1rem; | ||
| } | ||
| .subtitle { | ||
| font-size: 0.875rem; | ||
| color: #6b7280; | ||
| } | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <div class="container"> | ||
| <div class="icon">${success ? '✅' : '❌'}</div> | ||
| <div class="message">${message}</div> | ||
| <div class="subtitle">This window will close automatically...</div> | ||
| </div> | ||
| <script> | ||
| // Notify parent window | ||
| if (window.opener) { | ||
| window.opener.postMessage( | ||
| { type: 'github-oauth-callback', success: ${success}, message: '${message}' }, | ||
|
norberia marked this conversation as resolved.
|
||
| window.location.origin | ||
| ); | ||
| } | ||
|
|
||
| // Close window after a short delay | ||
| setTimeout(() => { | ||
| window.close(); | ||
| }, 1500); | ||
| </script> | ||
| </body> | ||
| </html> | ||
| ` | ||
|
|
||
| return new NextResponse(html, { | ||
| status: 200, | ||
| headers: { | ||
| 'Content-Type': 'text/html', | ||
| }, | ||
| }) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| import { randomBytes } from 'crypto' | ||
| import { NextResponse } from 'next/server' | ||
|
|
||
| import { auth } from '@/lib/auth' | ||
| import { env } from '@/lib/env' | ||
| import { logger as baseLogger } from '@/lib/logger' | ||
|
|
||
| const logger = baseLogger.child({ module: 'api/user/github/bind' }) | ||
|
|
||
| /** | ||
| * GET /api/user/github/bind | ||
| * Initiates the GitHub OAuth flow for binding | ||
| * Redirects to GitHub authorization page with a secure state parameter | ||
| */ | ||
| export async function GET() { | ||
| try { | ||
| const session = await auth() | ||
|
|
||
| if (!session?.user?.id) { | ||
| return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) | ||
| } | ||
|
|
||
| if (!env.GITHUB_CLIENT_ID || !env.GITHUB_CLIENT_SECRET) { | ||
| logger.error('GitHub OAuth is not configured') | ||
| return NextResponse.json({ error: 'GitHub OAuth is not configured' }, { status: 500 }) | ||
| } | ||
|
|
||
| // Generate a secure random state parameter | ||
| const state = randomBytes(32).toString('hex') | ||
|
|
||
| // Store state in a cookie for verification in callback | ||
| // Format: state|userId|timestamp | ||
| const stateData = `${state}|${session.user.id}|${Date.now()}` | ||
| const encodedState = Buffer.from(stateData).toString('base64') | ||
|
|
||
| // Build GitHub OAuth URL | ||
| const githubAuthUrl = new URL('https://github.com/login/oauth/authorize') | ||
| githubAuthUrl.searchParams.set('client_id', env.GITHUB_CLIENT_ID) | ||
| githubAuthUrl.searchParams.set('redirect_uri', `${process.env.NEXTAUTH_URL}/api/auth/github/callback`) | ||
| githubAuthUrl.searchParams.set('scope', 'repo read:user') | ||
| githubAuthUrl.searchParams.set('state', encodedState) | ||
|
|
||
| logger.info(`GitHub OAuth bind initiated for user ${session.user.id}`) | ||
|
|
||
| // Create response with redirect | ||
| const response = NextResponse.redirect(githubAuthUrl.toString()) | ||
|
|
||
| // Set state cookie (expires in 10 minutes) | ||
| response.cookies.set('github_oauth_state', encodedState, { | ||
| httpOnly: true, | ||
| secure: process.env.NODE_ENV === 'production', | ||
| sameSite: 'lax', | ||
| maxAge: 600, // 10 minutes | ||
| path: '/', | ||
| }) | ||
|
|
||
| return response | ||
| } catch (error) { | ||
| logger.error(`Error initiating GitHub OAuth bind: ${error}`) | ||
| return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| import { NextResponse } from 'next/server' | ||
|
|
||
| import { auth } from '@/lib/auth' | ||
| import { prisma } from '@/lib/db' | ||
| import { logger as baseLogger } from '@/lib/logger' | ||
|
|
||
| const logger = baseLogger.child({ module: 'api/user/github' }) | ||
|
|
||
| /** | ||
| * GET /api/user/github | ||
| * Returns the GitHub binding status for the current user | ||
| */ | ||
| export async function GET() { | ||
| try { | ||
| const session = await auth() | ||
|
|
||
| if (!session?.user?.id) { | ||
| return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) | ||
| } | ||
|
|
||
| // Find GitHub identity for this user | ||
| const githubIdentity = await prisma.userIdentity.findFirst({ | ||
| where: { | ||
| userId: session.user.id, | ||
| provider: 'GITHUB', | ||
| }, | ||
| }) | ||
|
|
||
| if (!githubIdentity) { | ||
| return NextResponse.json({ connected: false }) | ||
| } | ||
|
|
||
| // Extract GitHub info from metadata | ||
| const metadata = githubIdentity.metadata as { | ||
| login?: string | ||
| avatar_url?: string | ||
| } | ||
|
|
||
| return NextResponse.json({ | ||
| connected: true, | ||
| login: metadata.login, | ||
| avatar_url: metadata.avatar_url, | ||
| }) | ||
| } catch (error) { | ||
| logger.error(`Error fetching GitHub status: ${error}`) | ||
| return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * DELETE /api/user/github | ||
| * Unbinds the GitHub account from the current user | ||
| */ | ||
| export async function DELETE() { | ||
| try { | ||
| const session = await auth() | ||
|
|
||
| if (!session?.user?.id) { | ||
| return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) | ||
| } | ||
|
|
||
| // Find and delete GitHub identity | ||
| const githubIdentity = await prisma.userIdentity.findFirst({ | ||
| where: { | ||
| userId: session.user.id, | ||
| provider: 'GITHUB', | ||
| }, | ||
| }) | ||
|
|
||
| if (!githubIdentity) { | ||
| return NextResponse.json({ error: 'No GitHub account connected' }, { status: 404 }) | ||
| } | ||
|
|
||
| // Check if this is the primary (and only) identity | ||
| const identityCount = await prisma.userIdentity.count({ | ||
| where: { | ||
| userId: session.user.id, | ||
| }, | ||
| }) | ||
|
|
||
| if (identityCount === 1 && githubIdentity.isPrimary) { | ||
| return NextResponse.json( | ||
| { | ||
| error: 'Cannot unbind the only login method. Please add another login method first.', | ||
| }, | ||
| { status: 400 } | ||
| ) | ||
| } | ||
|
|
||
| // Delete the GitHub identity | ||
| await prisma.userIdentity.delete({ | ||
| where: { | ||
| id: githubIdentity.id, | ||
| }, | ||
| }) | ||
|
|
||
| logger.info(`GitHub account unbound for user ${session.user.id}`) | ||
|
|
||
| return NextResponse.json({ success: true }) | ||
| } catch (error) { | ||
| logger.error(`Error unbinding GitHub account: ${error}`) | ||
| return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.