Skip to content

Commit 82ba12a

Browse files
Che-ZhuCopilot
andauthored
feat: Add GitHub Account Binding, One-click Sync & Security Updates (#112)
* feat: add GitHub account binding for logged-in users Implement GitHub OAuth binding functionality that allows users (e.g., password-authenticated users) to link their GitHub accounts to their existing sessions. Changes: - Add GitHub binding status API (GET /api/user/github) - Add GitHub unbind API (DELETE /api/user/github) - Add OAuth initiation endpoint (GET /api/user/github/bind) - Add OAuth callback handler (GET /api/auth/github/callback) - Add GitHub tab to Settings Dialog with binding UI - Implement popup-based OAuth flow with postMessage communication Technical details: - Use CSRF protection with state parameter stored in httpOnly cookie - Store GitHub credentials in UserIdentity.metadata (token, login, avatar) - Prevent unbinding if GitHub is the only login method - Set isPrimary=false for binding (not primary authentication) - State expires after 10 minutes for security The binding flow uses a popup window to avoid disrupting the main application, with automatic status refresh upon successful binding. * add repo connection status to status bar * fix: ensure changes are pushed to remote and improve repo status UI - Integrate pushToGithub into initializeRepo and commitChanges workflows to ensure code syncs to remote. - Add router.refresh() in RepoStatusIndicator to update UI state immediately after initialization. - Refactor RepoStatusIndicator to use semantic button elements for accessibility. - Enhance UI with better loading states and adjusted icon sizes. * fix lint issue * fix(security): sanitize repo URL and patch command injection in repoService * feat(repo): auto-prompt settings dialog when GitHub is not bound Update repoService to propagate 'GITHUB_NOT_BOUND' error code when identity is missing. Update RepoStatusIndicator to catch this error and trigger the local SettingsDialog, streamlining the authentication flow. * chore(deps): bump next.js to 16.0.10 for security fixes Addresses CVE-2025-55184 and CVE-2025-55183. Ref: https://vercel.com/kb/bulletin/security-bulletin-cve-2025-55184-and-cve-2025-55183 * Update lib/services/repoService.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update lib/services/repoService.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update components/layout/status-bar.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 350813a commit 82ba12a

10 files changed

Lines changed: 1028 additions & 55 deletions

File tree

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import { NextRequest, NextResponse } from 'next/server'
2+
3+
import { prisma } from '@/lib/db'
4+
import { env } from '@/lib/env'
5+
import { logger as baseLogger } from '@/lib/logger'
6+
7+
const logger = baseLogger.child({ module: 'api/auth/github/callback' })
8+
9+
/**
10+
* GET /api/auth/github/callback
11+
* Handles the OAuth callback from GitHub for account binding
12+
*/
13+
export async function GET(request: NextRequest) {
14+
try {
15+
const searchParams = request.nextUrl.searchParams
16+
const code = searchParams.get('code')
17+
const state = searchParams.get('state')
18+
19+
if (!code || !state) {
20+
return NextResponse.json({ error: 'Missing code or state parameter' }, { status: 400 })
21+
}
22+
23+
// Verify state parameter
24+
const stateCookie = request.cookies.get('github_oauth_state')?.value
25+
26+
if (!stateCookie || stateCookie !== state) {
27+
logger.warn('State mismatch in GitHub OAuth callback')
28+
return NextResponse.json({ error: 'Invalid state parameter' }, { status: 400 })
29+
}
30+
31+
// Decode state to get userId
32+
let userId: string
33+
try {
34+
const decodedState = Buffer.from(state, 'base64').toString('utf-8')
35+
const [, extractedUserId, timestamp] = decodedState.split('|')
36+
37+
// Check if state is expired (10 minutes)
38+
const stateAge = Date.now() - parseInt(timestamp, 10)
39+
if (stateAge > 10 * 60 * 1000) {
40+
return NextResponse.json({ error: 'State expired' }, { status: 400 })
41+
}
42+
43+
userId = extractedUserId
44+
} catch {
45+
return NextResponse.json({ error: 'Invalid state format' }, { status: 400 })
46+
}
47+
48+
// Exchange code for access token
49+
const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
50+
method: 'POST',
51+
headers: {
52+
'Content-Type': 'application/json',
53+
Accept: 'application/json',
54+
},
55+
body: JSON.stringify({
56+
client_id: env.GITHUB_CLIENT_ID,
57+
client_secret: env.GITHUB_CLIENT_SECRET,
58+
code,
59+
}),
60+
})
61+
62+
const tokenData = await tokenResponse.json()
63+
64+
if (!tokenData.access_token) {
65+
logger.error('Failed to get access token from GitHub')
66+
return NextResponse.json({ error: 'Failed to get access token' }, { status: 500 })
67+
}
68+
69+
const accessToken = tokenData.access_token
70+
const scope = tokenData.scope || 'repo read:user'
71+
72+
// Get GitHub user info
73+
const userResponse = await fetch('https://api.github.com/user', {
74+
headers: {
75+
Authorization: `Bearer ${accessToken}`,
76+
Accept: 'application/vnd.github.v3+json',
77+
},
78+
})
79+
80+
const githubUser = await userResponse.json()
81+
82+
if (!githubUser.id) {
83+
logger.error('Failed to get GitHub user info')
84+
return NextResponse.json({ error: 'Failed to get user info' }, { status: 500 })
85+
}
86+
87+
const githubUserId = githubUser.id.toString()
88+
const githubLogin = githubUser.login
89+
const githubAvatarUrl = githubUser.avatar_url
90+
91+
// Check if this GitHub account is already bound to another user
92+
const existingIdentity = await prisma.userIdentity.findUnique({
93+
where: {
94+
unique_provider_user: {
95+
provider: 'GITHUB',
96+
providerUserId: githubUserId,
97+
},
98+
},
99+
})
100+
101+
if (existingIdentity && existingIdentity.userId !== userId) {
102+
logger.warn(`GitHub account ${githubLogin} is already bound to another user`)
103+
return createCallbackPage(
104+
false,
105+
'This GitHub account is already bound to another user account.'
106+
)
107+
}
108+
109+
// Upsert GitHub identity
110+
await prisma.userIdentity.upsert({
111+
where: {
112+
unique_provider_user: {
113+
provider: 'GITHUB',
114+
providerUserId: githubUserId,
115+
},
116+
},
117+
update: {
118+
metadata: {
119+
token: accessToken,
120+
scope,
121+
login: githubLogin,
122+
avatar_url: githubAvatarUrl,
123+
},
124+
},
125+
create: {
126+
userId,
127+
provider: 'GITHUB',
128+
providerUserId: githubUserId,
129+
metadata: {
130+
token: accessToken,
131+
scope,
132+
login: githubLogin,
133+
avatar_url: githubAvatarUrl,
134+
},
135+
isPrimary: false, // This is a binding, not primary login
136+
},
137+
})
138+
139+
logger.info(`GitHub account ${githubLogin} bound successfully for user ${userId}`)
140+
141+
// Return success page that notifies parent window
142+
return createCallbackPage(true, 'GitHub account connected successfully!')
143+
} catch (error) {
144+
logger.error(`Error in GitHub OAuth callback: ${error}`)
145+
return createCallbackPage(false, 'An error occurred during GitHub authentication.')
146+
}
147+
}
148+
149+
/**
150+
* Create an HTML page that sends a message to the parent window (popup opener)
151+
* and closes itself
152+
*/
153+
function createCallbackPage(success: boolean, message: string): NextResponse {
154+
const html = `
155+
<!DOCTYPE html>
156+
<html>
157+
<head>
158+
<meta charset="utf-8">
159+
<title>GitHub Authentication</title>
160+
<style>
161+
body {
162+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
163+
display: flex;
164+
align-items: center;
165+
justify-content: center;
166+
height: 100vh;
167+
margin: 0;
168+
background: ${success ? '#f0fdf4' : '#fef2f2'};
169+
}
170+
.container {
171+
text-align: center;
172+
padding: 2rem;
173+
}
174+
.icon {
175+
font-size: 3rem;
176+
margin-bottom: 1rem;
177+
}
178+
.message {
179+
font-size: 1.125rem;
180+
color: ${success ? '#166534' : '#991b1b'};
181+
margin-bottom: 1rem;
182+
}
183+
.subtitle {
184+
font-size: 0.875rem;
185+
color: #6b7280;
186+
}
187+
</style>
188+
</head>
189+
<body>
190+
<div class="container">
191+
<div class="icon">${success ? '✅' : '❌'}</div>
192+
<div class="message">${message}</div>
193+
<div class="subtitle">This window will close automatically...</div>
194+
</div>
195+
<script>
196+
// Notify parent window
197+
if (window.opener) {
198+
window.opener.postMessage(
199+
{ type: 'github-oauth-callback', success: ${success}, message: '${message}' },
200+
window.location.origin
201+
);
202+
}
203+
204+
// Close window after a short delay
205+
setTimeout(() => {
206+
window.close();
207+
}, 1500);
208+
</script>
209+
</body>
210+
</html>
211+
`
212+
213+
return new NextResponse(html, {
214+
status: 200,
215+
headers: {
216+
'Content-Type': 'text/html',
217+
},
218+
})
219+
}

app/api/user/github/bind/route.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { randomBytes } from 'crypto'
2+
import { NextResponse } from 'next/server'
3+
4+
import { auth } from '@/lib/auth'
5+
import { env } from '@/lib/env'
6+
import { logger as baseLogger } from '@/lib/logger'
7+
8+
const logger = baseLogger.child({ module: 'api/user/github/bind' })
9+
10+
/**
11+
* GET /api/user/github/bind
12+
* Initiates the GitHub OAuth flow for binding
13+
* Redirects to GitHub authorization page with a secure state parameter
14+
*/
15+
export async function GET() {
16+
try {
17+
const session = await auth()
18+
19+
if (!session?.user?.id) {
20+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
21+
}
22+
23+
if (!env.GITHUB_CLIENT_ID || !env.GITHUB_CLIENT_SECRET) {
24+
logger.error('GitHub OAuth is not configured')
25+
return NextResponse.json({ error: 'GitHub OAuth is not configured' }, { status: 500 })
26+
}
27+
28+
// Generate a secure random state parameter
29+
const state = randomBytes(32).toString('hex')
30+
31+
// Store state in a cookie for verification in callback
32+
// Format: state|userId|timestamp
33+
const stateData = `${state}|${session.user.id}|${Date.now()}`
34+
const encodedState = Buffer.from(stateData).toString('base64')
35+
36+
// Build GitHub OAuth URL
37+
const githubAuthUrl = new URL('https://github.com/login/oauth/authorize')
38+
githubAuthUrl.searchParams.set('client_id', env.GITHUB_CLIENT_ID)
39+
githubAuthUrl.searchParams.set('redirect_uri', `${process.env.NEXTAUTH_URL}/api/auth/github/callback`)
40+
githubAuthUrl.searchParams.set('scope', 'repo read:user')
41+
githubAuthUrl.searchParams.set('state', encodedState)
42+
43+
logger.info(`GitHub OAuth bind initiated for user ${session.user.id}`)
44+
45+
// Create response with redirect
46+
const response = NextResponse.redirect(githubAuthUrl.toString())
47+
48+
// Set state cookie (expires in 10 minutes)
49+
response.cookies.set('github_oauth_state', encodedState, {
50+
httpOnly: true,
51+
secure: process.env.NODE_ENV === 'production',
52+
sameSite: 'lax',
53+
maxAge: 600, // 10 minutes
54+
path: '/',
55+
})
56+
57+
return response
58+
} catch (error) {
59+
logger.error(`Error initiating GitHub OAuth bind: ${error}`)
60+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
61+
}
62+
}

app/api/user/github/route.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { NextResponse } from 'next/server'
2+
3+
import { auth } from '@/lib/auth'
4+
import { prisma } from '@/lib/db'
5+
import { logger as baseLogger } from '@/lib/logger'
6+
7+
const logger = baseLogger.child({ module: 'api/user/github' })
8+
9+
/**
10+
* GET /api/user/github
11+
* Returns the GitHub binding status for the current user
12+
*/
13+
export async function GET() {
14+
try {
15+
const session = await auth()
16+
17+
if (!session?.user?.id) {
18+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
19+
}
20+
21+
// Find GitHub identity for this user
22+
const githubIdentity = await prisma.userIdentity.findFirst({
23+
where: {
24+
userId: session.user.id,
25+
provider: 'GITHUB',
26+
},
27+
})
28+
29+
if (!githubIdentity) {
30+
return NextResponse.json({ connected: false })
31+
}
32+
33+
// Extract GitHub info from metadata
34+
const metadata = githubIdentity.metadata as {
35+
login?: string
36+
avatar_url?: string
37+
}
38+
39+
return NextResponse.json({
40+
connected: true,
41+
login: metadata.login,
42+
avatar_url: metadata.avatar_url,
43+
})
44+
} catch (error) {
45+
logger.error(`Error fetching GitHub status: ${error}`)
46+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
47+
}
48+
}
49+
50+
/**
51+
* DELETE /api/user/github
52+
* Unbinds the GitHub account from the current user
53+
*/
54+
export async function DELETE() {
55+
try {
56+
const session = await auth()
57+
58+
if (!session?.user?.id) {
59+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
60+
}
61+
62+
// Find and delete GitHub identity
63+
const githubIdentity = await prisma.userIdentity.findFirst({
64+
where: {
65+
userId: session.user.id,
66+
provider: 'GITHUB',
67+
},
68+
})
69+
70+
if (!githubIdentity) {
71+
return NextResponse.json({ error: 'No GitHub account connected' }, { status: 404 })
72+
}
73+
74+
// Check if this is the primary (and only) identity
75+
const identityCount = await prisma.userIdentity.count({
76+
where: {
77+
userId: session.user.id,
78+
},
79+
})
80+
81+
if (identityCount === 1 && githubIdentity.isPrimary) {
82+
return NextResponse.json(
83+
{
84+
error: 'Cannot unbind the only login method. Please add another login method first.',
85+
},
86+
{ status: 400 }
87+
)
88+
}
89+
90+
// Delete the GitHub identity
91+
await prisma.userIdentity.delete({
92+
where: {
93+
id: githubIdentity.id,
94+
},
95+
})
96+
97+
logger.info(`GitHub account unbound for user ${session.user.id}`)
98+
99+
return NextResponse.json({ success: true })
100+
} catch (error) {
101+
logger.error(`Error unbinding GitHub account: ${error}`)
102+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
103+
}
104+
}

0 commit comments

Comments
 (0)