Skip to content
Merged
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
219 changes: 219 additions & 0 deletions app/api/auth/github/callback/route.ts
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
Comment thread
norberia marked this conversation as resolved.
} 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}' },
Comment thread
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',
},
})
}
62 changes: 62 additions & 0 deletions app/api/user/github/bind/route.ts
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 })
}
}
104 changes: 104 additions & 0 deletions app/api/user/github/route.ts
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 })
}
}
Loading