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
309 changes: 153 additions & 156 deletions app/api/auth/github/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,58 +7,99 @@ import { getAppBaseUrl, getGitHubClientId } from '@/lib/auth/oauth'
import { createGitHubSession, saveSession } from '@/lib/session/create-github'
import { encrypt } from '@/lib/crypto'
import { generateId } from '@/lib/utils/id'
import {
GITHUB_AUTH_ERROR_MESSAGE_TYPE,
GITHUB_AUTH_POPUP_COOKIE,
GITHUB_AUTH_POPUP_VALUE,
GITHUB_AUTH_SUCCESS_MESSAGE_TYPE,
} from '@/lib/auth/github-popup-contract'

const GITHUB_AUTH_COOKIES = [
GITHUB_AUTH_POPUP_COOKIE,
'github_auth_state',
'github_auth_redirect_to',
'github_auth_mode',
'github_auth_user_id',
'github_oauth_state',
'github_oauth_redirect_to',
'github_oauth_user_id',
] as const

type CookieStore = Awaited<ReturnType<typeof cookies>>
type PopupStatus = 'success' | 'error'

function cleanupGitHubAuthCookies(cookieStore: CookieStore): void {
for (const cookieName of GITHUB_AUTH_COOKIES) {
cookieStore.delete(cookieName)
}
}

function createGitHubPopupResponse(req: NextRequest, status: PopupStatus, responseInit?: ResponseInit): Response {
const origin = new URL(getAppBaseUrl(req)).origin
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Post auth result to opener origin, not configured base URL

The popup callback currently sends postMessage to new URL(getAppBaseUrl(req)).origin, but the popup client only accepts messages from the opener tab’s window.location.origin. When APP_BASE_URL (or forwarded host) differs from the origin where the user initiated auth (for example preview/alternate hostnames), the callback message is dropped and the parent tab reports popup_closed/timeout even though OAuth succeeded. This is a functional auth regression in multi-origin deployments introduced by the popup flow.

Useful? React with 👍 / 👎.

const messageType = status === 'success' ? GITHUB_AUTH_SUCCESS_MESSAGE_TYPE : GITHUB_AUTH_ERROR_MESSAGE_TYPE
const html = `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>GitHub Authentication</title>
</head>
<body>
<script>
window.opener?.postMessage({ type: ${JSON.stringify(messageType)}, status: ${JSON.stringify(status)} }, ${JSON.stringify(origin)});
window.close();
</script>
</body>
</html>`

return new Response(html, {
status: responseInit?.status ?? 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
...responseInit?.headers,
},
})
}

export async function GET(req: NextRequest): Promise<Response> {
const code = req.nextUrl.searchParams.get('code')
const state = req.nextUrl.searchParams.get('state')
const cookieStore = await cookies()

// Check if this is a sign-in flow or connect flow
const authMode = cookieStore.get(`github_auth_mode`)?.value ?? null
const popupCookie = cookieStore.get(GITHUB_AUTH_POPUP_COOKIE)?.value ?? null
if (popupCookie !== GITHUB_AUTH_POPUP_VALUE) {
cleanupGitHubAuthCookies(cookieStore)
return createGitHubPopupResponse(req, 'error', { status: 400 })
}

const authMode = cookieStore.get('github_auth_mode')?.value ?? null
const isSignInFlow = authMode === 'signin'
const isConnectFlow = authMode === 'connect'
const storedState = cookieStore.get('github_auth_state')?.value ?? null
const storedRedirectTo = cookieStore.get('github_auth_redirect_to')?.value ?? null
const storedUserId = cookieStore.get('github_auth_user_id')?.value ?? null

// Try both cookie patterns (new unified flow vs legacy oauth flow)
const storedState = cookieStore.get(authMode ? `github_auth_state` : `github_oauth_state`)?.value ?? null
const storedRedirectTo =
cookieStore.get(authMode ? `github_auth_redirect_to` : `github_oauth_redirect_to`)?.value ?? null
const storedUserId = cookieStore.get(`github_oauth_user_id`)?.value ?? null // Required for connect flow

// For sign-in flow, we don't need storedUserId
if (isSignInFlow) {
if (code === null || state === null || storedState !== state || storedRedirectTo === null) {
return new Response('Invalid OAuth state', {
status: 400,
})
}
} else {
// For connect flow (including legacy oauth flow), we need storedUserId
if (
code === null ||
state === null ||
storedState !== state ||
storedRedirectTo === null ||
storedUserId === null
) {
return new Response('Invalid OAuth state', {
status: 400,
})
}
if (code === null || state === null || storedState !== state || storedRedirectTo === null) {
cleanupGitHubAuthCookies(cookieStore)
return createGitHubPopupResponse(req, 'error', { status: 400 })
}

if ((!isSignInFlow && !isConnectFlow) || (isConnectFlow && storedUserId === null)) {
cleanupGitHubAuthCookies(cookieStore)
return createGitHubPopupResponse(req, 'error', { status: 400 })
}

const clientId = getGitHubClientId()
const clientSecret = process.env.GITHUB_CLIENT_SECRET

if (!clientId || !clientSecret) {
return new Response('GitHub OAuth not configured', {
status: 500,
})
cleanupGitHubAuthCookies(cookieStore)
return createGitHubPopupResponse(req, 'error', { status: 500 })
}

try {
console.log('[GitHub Callback] Starting OAuth flow, mode:', authMode)
console.info('GitHub OAuth callback started')

// Exchange code for access token
const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {
Expand All @@ -73,163 +114,119 @@ export async function GET(req: NextRequest): Promise<Response> {
})

if (!tokenResponse.ok) {
console.error('[GitHub Callback] Token exchange failed with status:', tokenResponse.status)
const errorText = await tokenResponse.text()
console.error('[GitHub Callback] Error response:', errorText)
return new Response('Failed to exchange code for token', { status: 400 })
console.error('GitHub OAuth token exchange failed')
cleanupGitHubAuthCookies(cookieStore)
return createGitHubPopupResponse(req, 'error', { status: 400 })
}

const tokenData = (await tokenResponse.json()) as {
access_token: string
scope: string
token_type: string
access_token?: string
scope?: string
token_type?: string
error?: string
error_description?: string
}

console.log('[GitHub Callback] Token data received, has access_token:', !!tokenData.access_token)

if (!tokenData.access_token) {
console.error('[GitHub Callback] Failed to get GitHub access token:', tokenData)
return new Response(
`Failed to authenticate with GitHub: ${tokenData.error_description || tokenData.error || 'Unknown error'}`,
{ status: 400 },
)
console.error('GitHub OAuth access token missing')
cleanupGitHubAuthCookies(cookieStore)
return createGitHubPopupResponse(req, 'error', { status: 400 })
}

// Fetch GitHub user info
const userResponse = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${tokenData.access_token}`,
Accept: 'application/vnd.github.v3+json',
},
})

if (!userResponse.ok) {
console.error('GitHub OAuth user fetch failed')
cleanupGitHubAuthCookies(cookieStore)
return createGitHubPopupResponse(req, 'error', { status: 400 })
}

const githubUser = (await userResponse.json()) as {
login: string
id: number
}

if (isSignInFlow) {
// SIGN-IN FLOW: Create a new session for the GitHub user
console.log('[GitHub Callback] Sign-in flow - creating GitHub session')
const session = await createGitHubSession(tokenData.access_token, tokenData.scope)

if (!session) {
console.error('[GitHub Callback] Failed to create GitHub session')
return new Response('Failed to create session', { status: 500 })
console.error('GitHub OAuth session creation failed')
cleanupGitHubAuthCookies(cookieStore)
return createGitHubPopupResponse(req, 'error', { status: 500 })
}

console.log('[GitHub Callback] GitHub session created for user:', session.user.id)
// Note: Tokens are already stored in users table by upsertUser() in createGitHubSession()

// Create response with redirect
const response = new Response(null, {
status: 302,
headers: {
Location: storedRedirectTo,
},
})

// Save session to cookie
const response = createGitHubPopupResponse(req, 'success')
await saveSession(response, session)

// Clean up cookies
cookieStore.delete(`github_auth_state`)
cookieStore.delete(`github_auth_redirect_to`)
cookieStore.delete(`github_auth_mode`)
cleanupGitHubAuthCookies(cookieStore)

return response
} else {
// CONNECT FLOW: Add GitHub account to existing Vercel user
// Encrypt the access token before storing
const encryptedToken = encrypt(tokenData.access_token)

// Check if this GitHub account is already connected somewhere
const existingAccount = await db
.select()
.from(accounts)
.where(and(eq(accounts.provider, 'github'), eq(accounts.externalUserId, `${githubUser.id}`)))
.limit(1)

if (existingAccount.length > 0) {
const connectedUserId = existingAccount[0].userId

// If the GitHub account belongs to a different user, we need to merge accounts
if (connectedUserId !== storedUserId) {
console.log(
`[GitHub Callback] Merging accounts: GitHub account ${githubUser.id} belongs to user ${connectedUserId}, connecting to user ${storedUserId}`,
)

// Transfer all tasks, connectors, accounts, and keys from old user to new user
await db.update(tasks).set({ userId: storedUserId! }).where(eq(tasks.userId, connectedUserId))
await db.update(connectors).set({ userId: storedUserId! }).where(eq(connectors.userId, connectedUserId))
await db.update(accounts).set({ userId: storedUserId! }).where(eq(accounts.userId, connectedUserId))
await db.update(keys).set({ userId: storedUserId! }).where(eq(keys.userId, connectedUserId))

// Delete the old user record (this will cascade delete their accounts/keys)
await db.delete(users).where(eq(users.id, connectedUserId))

console.log(
`[GitHub Callback] Account merge complete. Old user ${connectedUserId} merged into ${storedUserId}`,
)

// Update the GitHub account token
await db
.update(accounts)
.set({
userId: storedUserId!,
accessToken: encryptedToken,
scope: tokenData.scope,
username: githubUser.login,
updatedAt: new Date(),
})
.where(eq(accounts.id, existingAccount[0].id))
} else {
// Same user, just update the token
await db
.update(accounts)
.set({
accessToken: encryptedToken,
scope: tokenData.scope,
username: githubUser.login,
updatedAt: new Date(),
})
.where(eq(accounts.id, existingAccount[0].id))
}
} else {
// No existing GitHub account connection, create a new one
await db.insert(accounts).values({
id: generateId(21),
userId: storedUserId!,
provider: 'github',
externalUserId: `${githubUser.id}`, // Store GitHub numeric ID
accessToken: encryptedToken,
scope: tokenData.scope,
username: githubUser.login,
})
}
}

// Clean up cookies (handle both new and legacy cookie names)
if (authMode) {
cookieStore.delete(`github_auth_state`)
cookieStore.delete(`github_auth_redirect_to`)
cookieStore.delete(`github_auth_mode`)
const encryptedToken = encrypt(tokenData.access_token)

const existingAccount = await db
.select()
.from(accounts)
.where(and(eq(accounts.provider, 'github'), eq(accounts.externalUserId, `${githubUser.id}`)))
.limit(1)

if (existingAccount.length > 0) {
const connectedUserId = existingAccount[0].userId

if (connectedUserId !== storedUserId) {
console.info('GitHub OAuth account merge started')

await db.update(tasks).set({ userId: storedUserId! }).where(eq(tasks.userId, connectedUserId))
await db.update(connectors).set({ userId: storedUserId! }).where(eq(connectors.userId, connectedUserId))
await db.update(accounts).set({ userId: storedUserId! }).where(eq(accounts.userId, connectedUserId))
await db.update(keys).set({ userId: storedUserId! }).where(eq(keys.userId, connectedUserId))
await db.delete(users).where(eq(users.id, connectedUserId))

console.info('GitHub OAuth account merge completed')

await db
.update(accounts)
.set({
userId: storedUserId!,
accessToken: encryptedToken,
scope: tokenData.scope,
username: githubUser.login,
updatedAt: new Date(),
})
.where(eq(accounts.id, existingAccount[0].id))
} else {
cookieStore.delete(`github_oauth_state`)
cookieStore.delete(`github_oauth_redirect_to`)
await db
.update(accounts)
.set({
accessToken: encryptedToken,
scope: tokenData.scope,
username: githubUser.login,
updatedAt: new Date(),
})
.where(eq(accounts.id, existingAccount[0].id))
}
cookieStore.delete(`github_oauth_user_id`)

// Redirect back to app
return Response.redirect(new URL(storedRedirectTo, `${getAppBaseUrl(req)}/`))
} else {
await db.insert(accounts).values({
id: generateId(21),
userId: storedUserId!,
provider: 'github',
externalUserId: `${githubUser.id}`,
accessToken: encryptedToken,
scope: tokenData.scope,
username: githubUser.login,
})
}
} catch (error) {
console.error('[GitHub Callback] OAuth callback error:', error)
console.error('[GitHub Callback] Error stack:', error instanceof Error ? error.stack : 'No stack trace')
return new Response(
`Failed to complete GitHub authentication: ${error instanceof Error ? error.message : 'Unknown error'}`,
{ status: 500 },
)

cleanupGitHubAuthCookies(cookieStore)
return createGitHubPopupResponse(req, 'success')
} catch {
console.error('GitHub OAuth callback failed')
cleanupGitHubAuthCookies(cookieStore)
return createGitHubPopupResponse(req, 'error', { status: 500 })
}
}
Loading
Loading