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
8 changes: 5 additions & 3 deletions app/api/auth/callback/vercel/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import { OAuth2Client, type OAuth2Tokens } from 'arctic'
import { getAppBaseUrl } from '@/lib/auth/oauth'
import { createSession, saveSession } from '@/lib/session/create'
import { cookies } from 'next/headers'
import { getAuthCookiePolicyFromRequest } from '@/lib/auth/cookie-policy'

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()
const authCookiePolicy = getAuthCookiePolicyFromRequest(req)
const storedState = cookieStore.get(`vercel_oauth_state`)?.value ?? null
const storedVerifier = cookieStore.get(`vercel_oauth_code_verifier`)?.value ?? null
const storedRedirectTo = cookieStore.get(`vercel_oauth_redirect_to`)?.value ?? null
Expand All @@ -34,8 +36,8 @@ export async function GET(req: NextRequest): Promise<Response> {

try {
tokens = await client.validateAuthorizationCode('https://vercel.com/api/login/oauth/token', code, storedVerifier)
} catch (error) {
console.error('Failed to validate authorization code:', error)
} catch {
console.error('Failed to validate authorization code')
return new Response(null, {
status: 400,
})
Expand All @@ -61,7 +63,7 @@ export async function GET(req: NextRequest): Promise<Response> {

// Note: Vercel tokens are already stored in users table by upsertUser() in createSession()

await saveSession(response, session)
await saveSession(response, session, authCookiePolicy)

cookieStore.delete(`vercel_oauth_state`)
cookieStore.delete(`vercel_oauth_code_verifier`)
Expand Down
136 changes: 112 additions & 24 deletions app/api/auth/github/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ import { type NextRequest } from 'next/server'
import { cookies } from 'next/headers'
import { db } from '@/lib/db/client'
import { users, accounts, tasks, connectors, keys } from '@/lib/db/schema'
import { eq, and } from 'drizzle-orm'
import { eq, and, inArray } from 'drizzle-orm'
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 { planUserKeyMerge } from '@/lib/auth/account-merge'
import { getAuthCookiePolicyFromRequest } from '@/lib/auth/cookie-policy'
import {
GITHUB_AUTH_BROADCAST_CHANNEL,
GITHUB_AUTH_ERROR_MESSAGE_TYPE,
GITHUB_AUTH_POPUP_COOKIE,
GITHUB_AUTH_POPUP_VALUE,
Expand Down Expand Up @@ -37,6 +40,7 @@ function cleanupGitHubAuthCookies(cookieStore: CookieStore): void {
function createGitHubPopupResponse(req: NextRequest, status: PopupStatus, responseInit?: ResponseInit): Response {
const origin = new URL(getAppBaseUrl(req)).origin
const messageType = status === 'success' ? GITHUB_AUTH_SUCCESS_MESSAGE_TYPE : GITHUB_AUTH_ERROR_MESSAGE_TYPE
const closeDelayMs = 250
const html = `<!doctype html>
<html>
<head>
Expand All @@ -45,8 +49,32 @@ function createGitHubPopupResponse(req: NextRequest, status: PopupStatus, respon
</head>
<body>
<script>
window.opener?.postMessage({ type: ${JSON.stringify(messageType)}, status: ${JSON.stringify(status)} }, ${JSON.stringify(origin)});
window.close();
const githubAuthMessage = { type: ${JSON.stringify(messageType)}, status: ${JSON.stringify(status)} };
const githubAuthOrigin = ${JSON.stringify(origin)};

function postGitHubAuthMessage(target) {
try {
target?.postMessage(githubAuthMessage, githubAuthOrigin);
} catch {}
}

postGitHubAuthMessage(window.opener);

try {
if (window.opener?.frames) {
for (let index = 0; index < window.opener.frames.length; index += 1) {
postGitHubAuthMessage(window.opener.frames[index]);
}
}
} catch {}

try {
const channel = new BroadcastChannel(${JSON.stringify(GITHUB_AUTH_BROADCAST_CHANNEL)});
channel.postMessage(githubAuthMessage);
channel.close();
} catch {}

window.setTimeout(() => window.close(), ${closeDelayMs});
</script>
</body>
</html>`
Expand All @@ -65,6 +93,7 @@ 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()
const authCookiePolicy = getAuthCookiePolicyFromRequest(req)

const popupCookie = cookieStore.get(GITHUB_AUTH_POPUP_COOKIE)?.value ?? null
if (popupCookie !== GITHUB_AUTH_POPUP_VALUE) {
Expand Down Expand Up @@ -161,13 +190,14 @@ export async function GET(req: NextRequest): Promise<Response> {
}

const response = createGitHubPopupResponse(req, 'success')
await saveSession(response, session)
await saveSession(response, session, authCookiePolicy)
cleanupGitHubAuthCookies(cookieStore)

return response
}

const encryptedToken = encrypt(tokenData.access_token)
const targetUserId = storedUserId!

const existingAccount = await db
.select()
Expand All @@ -178,48 +208,106 @@ export async function GET(req: NextRequest): Promise<Response> {
if (existingAccount.length > 0) {
const connectedUserId = existingAccount[0].userId

if (connectedUserId !== storedUserId) {
if (connectedUserId !== targetUserId) {
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))
await db.transaction(async (tx) => {
await tx.update(tasks).set({ userId: targetUserId }).where(eq(tasks.userId, connectedUserId))
await tx.update(connectors).set({ userId: targetUserId }).where(eq(connectors.userId, connectedUserId))

const [targetAccount] = await tx
.select({ id: accounts.id })
.from(accounts)
.where(and(eq(accounts.userId, targetUserId), eq(accounts.provider, 'github')))
.limit(1)

const sourceKeys = await tx
.select({ id: keys.id, provider: keys.provider })
.from(keys)
.where(eq(keys.userId, connectedUserId))
const targetKeys = await tx
.select({ id: keys.id, provider: keys.provider })
.from(keys)
.where(eq(keys.userId, targetUserId))
const keyMergePlan = planUserKeyMerge({ sourceKeys, targetKeys })

if (keyMergePlan.moveKeyIds.length > 0) {
await tx.update(keys).set({ userId: targetUserId }).where(inArray(keys.id, keyMergePlan.moveKeyIds))
}

if (keyMergePlan.deleteKeyIds.length > 0) {
await tx.delete(keys).where(inArray(keys.id, keyMergePlan.deleteKeyIds))
}

if (targetAccount) {
await tx
.update(accounts)
.set({
accessToken: encryptedToken,
externalUserId: `${githubUser.id}`,
scope: tokenData.scope,
username: githubUser.login,
updatedAt: new Date(),
})
.where(eq(accounts.id, targetAccount.id))
await tx.delete(accounts).where(eq(accounts.id, existingAccount[0].id))
} else {
await tx
.update(accounts)
.set({
userId: targetUserId,
accessToken: encryptedToken,
scope: tokenData.scope,
username: githubUser.login,
updatedAt: new Date(),
})
.where(eq(accounts.id, existingAccount[0].id))
}

await tx.delete(users).where(eq(users.id, connectedUserId))
})

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

} else {
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 {
}
} else {
const [currentAccount] = await db
.select({ id: accounts.id })
.from(accounts)
.where(and(eq(accounts.userId, targetUserId), eq(accounts.provider, 'github')))
.limit(1)

if (currentAccount) {
await db
.update(accounts)
.set({
externalUserId: `${githubUser.id}`,
accessToken: encryptedToken,
scope: tokenData.scope,
username: githubUser.login,
updatedAt: new Date(),
})
.where(eq(accounts.id, existingAccount[0].id))
.where(eq(accounts.id, currentAccount.id))
} else {
await db.insert(accounts).values({
id: generateId(21),
userId: targetUserId,
provider: 'github',
externalUserId: `${githubUser.id}`,
accessToken: encryptedToken,
scope: tokenData.scope,
username: githubUser.login,
})
}
} else {
await db.insert(accounts).values({
id: generateId(21),
userId: storedUserId!,
provider: 'github',
externalUserId: `${githubUser.id}`,
accessToken: encryptedToken,
scope: tokenData.scope,
username: githubUser.login,
})
}

cleanupGitHubAuthCookies(cookieStore)
Expand Down
18 changes: 13 additions & 5 deletions app/api/auth/github/signin/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { getSessionFromReq } from '@/lib/session/server'
import { GITHUB_OAUTH_SCOPE, getAppBaseUrl, getGitHubClientId } from '@/lib/auth/oauth'
import { isRelativeUrl } from '@/lib/utils/is-relative-url'
import { generateState } from 'arctic'
import { getAuthCookiePolicyFromRequest, getAuthCookieSameSite, getAuthCookieSecure } from '@/lib/auth/cookie-policy'
import {
GITHUB_AUTH_POPUP_COOKIE,
GITHUB_AUTH_POPUP_PARAM,
Expand All @@ -12,13 +13,18 @@ import {

const GITHUB_AUTH_COOKIE_MAX_AGE = 60 * 10

function setGitHubAuthCookie(store: Awaited<ReturnType<typeof cookies>>, key: string, value: string): void {
function setGitHubAuthCookie(
store: Awaited<ReturnType<typeof cookies>>,
key: string,
value: string,
authCookiePolicy: ReturnType<typeof getAuthCookiePolicyFromRequest>,
): void {
store.set(key, value, {
path: '/',
secure: process.env.NODE_ENV === 'production',
secure: getAuthCookieSecure(authCookiePolicy),
httpOnly: true,
maxAge: GITHUB_AUTH_COOKIE_MAX_AGE,
sameSite: 'lax',
sameSite: getAuthCookieSameSite(authCookiePolicy),
})
}

Expand All @@ -42,6 +48,7 @@ export async function GET(req: NextRequest): Promise<Response> {

const state = generateState()
const store = await cookies()
const authCookiePolicy = getAuthCookiePolicyFromRequest(req)
const redirectTo = isRelativeUrl(req.nextUrl.searchParams.get('next') ?? '/')
? (req.nextUrl.searchParams.get('next') ?? '/')
: '/'
Expand All @@ -54,7 +61,7 @@ export async function GET(req: NextRequest): Promise<Response> {
['github_auth_mode', 'connect'],
['github_auth_user_id', session.user.id],
]) {
setGitHubAuthCookie(store, key, value)
setGitHubAuthCookie(store, key, value, authCookiePolicy)
}

// Build GitHub authorization URL
Expand Down Expand Up @@ -91,6 +98,7 @@ export async function POST(req: NextRequest): Promise<Response> {

const state = generateState()
const store = await cookies()
const authCookiePolicy = getAuthCookiePolicyFromRequest(req)
const redirectTo = isRelativeUrl(req.nextUrl.searchParams.get('next') ?? '/')
? (req.nextUrl.searchParams.get('next') ?? '/')
: '/'
Expand All @@ -103,7 +111,7 @@ export async function POST(req: NextRequest): Promise<Response> {
['github_auth_mode', 'connect'],
['github_auth_user_id', session.user.id],
]) {
setGitHubAuthCookie(store, key, value)
setGitHubAuthCookie(store, key, value, authCookiePolicy)
}

// Build GitHub authorization URL
Expand Down
6 changes: 4 additions & 2 deletions app/api/auth/info/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import { createSession, saveSession } from '@/lib/session/create'
import { saveSession as saveGitHubSession } from '@/lib/session/create-github'
import { getSessionFromReq } from '@/lib/session/server'
import { getOAuthToken } from '@/lib/session/get-oauth-token'
import { getAuthCookiePolicyFromRequest } from '@/lib/auth/cookie-policy'

export async function GET(req: NextRequest) {
const existingSession = await getSessionFromReq(req)
const authCookiePolicy = getAuthCookiePolicyFromRequest(req)

// For GitHub users, just return the existing session without recreating it
// For Vercel users, recreate the session to refresh user data
Expand Down Expand Up @@ -35,9 +37,9 @@ export async function GET(req: NextRequest) {

// Use the appropriate saveSession function based on auth provider
if (session && session.authProvider === 'github') {
await saveGitHubSession(response, session)
await saveGitHubSession(response, session, authCookiePolicy)
} else {
await saveSession(response, session)
await saveSession(response, session, authCookiePolicy)
}

return response
Expand Down
Loading
Loading