diff --git a/app/api/auth/callback/vercel/route.ts b/app/api/auth/callback/vercel/route.ts index 4a05581..ee7d4af 100644 --- a/app/api/auth/callback/vercel/route.ts +++ b/app/api/auth/callback/vercel/route.ts @@ -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 { 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 @@ -34,8 +36,8 @@ export async function GET(req: NextRequest): Promise { 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, }) @@ -61,7 +63,7 @@ export async function GET(req: NextRequest): Promise { // 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`) diff --git a/app/api/auth/github/callback/route.ts b/app/api/auth/github/callback/route.ts index a3947b4..51560a5 100644 --- a/app/api/auth/github/callback/route.ts +++ b/app/api/auth/github/callback/route.ts @@ -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, @@ -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 = ` @@ -45,8 +49,32 @@ function createGitHubPopupResponse(req: NextRequest, status: PopupStatus, respon ` @@ -65,6 +93,7 @@ export async function GET(req: NextRequest): Promise { 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) { @@ -161,13 +190,14 @@ export async function GET(req: NextRequest): Promise { } 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() @@ -178,48 +208,106 @@ export async function GET(req: NextRequest): Promise { 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) diff --git a/app/api/auth/github/signin/route.ts b/app/api/auth/github/signin/route.ts index 71dee59..8103bf4 100644 --- a/app/api/auth/github/signin/route.ts +++ b/app/api/auth/github/signin/route.ts @@ -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, @@ -12,13 +13,18 @@ import { const GITHUB_AUTH_COOKIE_MAX_AGE = 60 * 10 -function setGitHubAuthCookie(store: Awaited>, key: string, value: string): void { +function setGitHubAuthCookie( + store: Awaited>, + key: string, + value: string, + authCookiePolicy: ReturnType, +): 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), }) } @@ -42,6 +48,7 @@ export async function GET(req: NextRequest): Promise { const state = generateState() const store = await cookies() + const authCookiePolicy = getAuthCookiePolicyFromRequest(req) const redirectTo = isRelativeUrl(req.nextUrl.searchParams.get('next') ?? '/') ? (req.nextUrl.searchParams.get('next') ?? '/') : '/' @@ -54,7 +61,7 @@ export async function GET(req: NextRequest): Promise { ['github_auth_mode', 'connect'], ['github_auth_user_id', session.user.id], ]) { - setGitHubAuthCookie(store, key, value) + setGitHubAuthCookie(store, key, value, authCookiePolicy) } // Build GitHub authorization URL @@ -91,6 +98,7 @@ export async function POST(req: NextRequest): Promise { const state = generateState() const store = await cookies() + const authCookiePolicy = getAuthCookiePolicyFromRequest(req) const redirectTo = isRelativeUrl(req.nextUrl.searchParams.get('next') ?? '/') ? (req.nextUrl.searchParams.get('next') ?? '/') : '/' @@ -103,7 +111,7 @@ export async function POST(req: NextRequest): Promise { ['github_auth_mode', 'connect'], ['github_auth_user_id', session.user.id], ]) { - setGitHubAuthCookie(store, key, value) + setGitHubAuthCookie(store, key, value, authCookiePolicy) } // Build GitHub authorization URL diff --git a/app/api/auth/info/route.ts b/app/api/auth/info/route.ts index 852e489..c163db6 100644 --- a/app/api/auth/info/route.ts +++ b/app/api/auth/info/route.ts @@ -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 @@ -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 diff --git a/app/api/auth/signin/github/route.ts b/app/api/auth/signin/github/route.ts index d8d6dbe..c26c08c 100644 --- a/app/api/auth/signin/github/route.ts +++ b/app/api/auth/signin/github/route.ts @@ -3,7 +3,7 @@ import { cookies } from 'next/headers' import { generateState } from 'arctic' import { GITHUB_OAUTH_SCOPE, getAppBaseUrl, getGitHubClientId } from '@/lib/auth/oauth' import { isRelativeUrl } from '@/lib/utils/is-relative-url' -import { getSessionFromReq } from '@/lib/session/server' +import { getAuthCookiePolicyFromRequest, getAuthCookieSameSite, getAuthCookieSecure } from '@/lib/auth/cookie-policy' import { GITHUB_AUTH_POPUP_COOKIE, GITHUB_AUTH_POPUP_PARAM, @@ -12,13 +12,18 @@ import { const GITHUB_AUTH_COOKIE_MAX_AGE = 60 * 10 -function setGitHubAuthCookie(store: Awaited>, key: string, value: string): void { +function setGitHubAuthCookie( + store: Awaited>, + key: string, + value: string, + authCookiePolicy: ReturnType, +): 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), }) } @@ -27,9 +32,6 @@ export async function GET(req: NextRequest): Promise { return new Response('Invalid GitHub authentication request', { status: 400 }) } - // Check if user is already authenticated with Vercel - const session = await getSessionFromReq(req) - const clientId = getGitHubClientId() const redirectUri = `${getAppBaseUrl(req)}/api/auth/github/callback` @@ -39,37 +41,21 @@ export async function GET(req: NextRequest): Promise { const state = generateState() const store = await cookies() - let redirectTo = isRelativeUrl(req.nextUrl.searchParams.get('next') ?? '/') + const authCookiePolicy = getAuthCookiePolicyFromRequest(req) + const redirectTo = isRelativeUrl(req.nextUrl.searchParams.get('next') ?? '/') ? (req.nextUrl.searchParams.get('next') ?? '/') : '/' - // If user is already authenticated with Vercel, treat this as a "Connect GitHub" flow - // Otherwise, treat it as a "Sign in with GitHub" flow - const isSignInFlow = !session?.user - const authMode = isSignInFlow ? 'signin' : 'connect' - - // Add a query parameter to show a toast message after redirect - if (!isSignInFlow) { - const redirectUrl = new URL(redirectTo, `${getAppBaseUrl(req)}/`) - redirectUrl.searchParams.set('github_connected', 'true') - redirectTo = redirectUrl.pathname + redirectUrl.search - } - // Store state and redirect URL const cookiesToSet: [string, string][] = [ [GITHUB_AUTH_POPUP_COOKIE, GITHUB_AUTH_POPUP_VALUE], ['github_auth_redirect_to', redirectTo], ['github_auth_state', state], - ['github_auth_mode', authMode], + ['github_auth_mode', 'signin'], ] - // If connecting (user already signed in), store their user ID - if (!isSignInFlow && session?.user?.id) { - cookiesToSet.push(['github_auth_user_id', session.user.id]) - } - for (const [key, value] of cookiesToSet) { - setGitHubAuthCookie(store, key, value) + setGitHubAuthCookie(store, key, value, authCookiePolicy) } // Build GitHub authorization URL diff --git a/app/api/auth/signin/vercel/route.ts b/app/api/auth/signin/vercel/route.ts index 189dfba..9a340d8 100644 --- a/app/api/auth/signin/vercel/route.ts +++ b/app/api/auth/signin/vercel/route.ts @@ -3,6 +3,7 @@ import { cookies } from 'next/headers' import { getAppBaseUrl } from '@/lib/auth/oauth' import { isRelativeUrl } from '@/lib/utils/is-relative-url' import { CodeChallengeMethod, OAuth2Client, generateCodeVerifier, generateState } from 'arctic' +import { getAuthCookiePolicyFromRequest, getAuthCookieSameSite, getAuthCookieSecure } from '@/lib/auth/cookie-policy' export async function POST(req: NextRequest): Promise { const client = new OAuth2Client( @@ -22,6 +23,7 @@ export async function POST(req: NextRequest): Promise { ) const store = await cookies() + const authCookiePolicy = getAuthCookiePolicyFromRequest(req) const redirectTo = isRelativeUrl(req.nextUrl.searchParams.get('next') ?? '/') ? (req.nextUrl.searchParams.get('next') ?? '/') : '/' @@ -33,10 +35,10 @@ export async function POST(req: NextRequest): Promise { ]) { store.set(key, value, { path: '/', - secure: process.env.NODE_ENV === 'production', + secure: getAuthCookieSecure(authCookiePolicy), httpOnly: true, maxAge: 60 * 10, - sameSite: 'lax', + sameSite: getAuthCookieSameSite(authCookiePolicy), }) } diff --git a/app/api/auth/signout/route.ts b/app/api/auth/signout/route.ts index 9d8c974..55e54eb 100644 --- a/app/api/auth/signout/route.ts +++ b/app/api/auth/signout/route.ts @@ -4,9 +4,11 @@ import { getSessionFromReq } from '@/lib/session/server' import { isRelativeUrl } from '@/lib/utils/is-relative-url' import { saveSession } from '@/lib/session/create' import { getOAuthToken } from '@/lib/session/get-oauth-token' +import { getAuthCookiePolicyFromRequest } from '@/lib/auth/cookie-policy' export async function GET(req: NextRequest) { const session = await getSessionFromReq(req) + const authCookiePolicy = getAuthCookiePolicyFromRequest(req) if (session) { // Check which provider the user authenticated with if (session.authProvider === 'github') { @@ -24,8 +26,8 @@ export async function GET(req: NextRequest) { body: JSON.stringify({ access_token: tokenData.accessToken }), }) } - } catch (error) { - console.error('Failed to revoke GitHub token:', error) + } catch { + console.error('Failed to revoke GitHub token') } } else { // Revoke Vercel token - fetch from database @@ -41,8 +43,8 @@ export async function GET(req: NextRequest) { }, }) } - } catch (error) { - console.error('Failed to revoke Vercel token:', error) + } catch { + console.error('Failed to revoke Vercel token') } } } @@ -51,6 +53,6 @@ export async function GET(req: NextRequest) { url: isRelativeUrl(req.nextUrl.searchParams.get('next') ?? '/') ? req.nextUrl.searchParams.get('next') : '/', }) - await saveSession(response, undefined) + await saveSession(response, undefined, authCookiePolicy) return response } diff --git a/lib/auth/account-merge.ts b/lib/auth/account-merge.ts new file mode 100644 index 0000000..7f94400 --- /dev/null +++ b/lib/auth/account-merge.ts @@ -0,0 +1,36 @@ +import type { keys } from '@/lib/db/schema' + +type KeyProvider = (typeof keys.$inferSelect)['provider'] + +interface MergeKeyRow { + id: string + provider: KeyProvider +} + +export function planUserKeyMerge({ + sourceKeys, + targetKeys, +}: { + sourceKeys: MergeKeyRow[] + targetKeys: MergeKeyRow[] +}): { + deleteKeyIds: string[] + moveKeyIds: string[] +} { + const targetProviders = new Set(targetKeys.map((key) => key.provider)) + const moveKeyIds: string[] = [] + const deleteKeyIds: string[] = [] + + for (const key of sourceKeys) { + if (targetProviders.has(key.provider)) { + deleteKeyIds.push(key.id) + } else { + moveKeyIds.push(key.id) + } + } + + return { + deleteKeyIds, + moveKeyIds, + } +} diff --git a/lib/auth/cookie-policy.ts b/lib/auth/cookie-policy.ts new file mode 100644 index 0000000..cf32859 --- /dev/null +++ b/lib/auth/cookie-policy.ts @@ -0,0 +1,88 @@ +export type AuthCookieSameSite = 'lax' | 'none' + +interface AuthCookiePolicyOptions { + isHttps?: boolean + isLocalhost?: boolean + nodeEnv?: string +} + +interface AuthCookieRequest { + headers: { + get(name: string): string | null + } + nextUrl: { + hostname?: string + protocol: string + } +} + +export type AuthCookiePolicyInput = AuthCookiePolicyOptions | string | undefined + +function normalizeAuthCookiePolicyInput(input?: AuthCookiePolicyInput): Required { + if (typeof input === 'string') { + return { + isHttps: false, + isLocalhost: false, + nodeEnv: input, + } + } + + return { + isHttps: input?.isHttps ?? false, + isLocalhost: input?.isLocalhost ?? false, + nodeEnv: input?.nodeEnv ?? process.env.NODE_ENV, + } +} + +function getHostnameWithoutPort(host: string): string { + const normalizedHost = host.trim().toLowerCase() + + if (normalizedHost.startsWith('[')) { + return normalizedHost.slice(1, normalizedHost.indexOf(']')) + } + + if (normalizedHost === '::1') { + return normalizedHost + } + + return normalizedHost.split(':')[0] ?? '' +} + +function isLocalhost(hostname: string | undefined): boolean { + if (!hostname) { + return false + } + + const host = getHostnameWithoutPort(hostname) + return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host.endsWith('.localhost') +} + +export function getAuthCookiePolicyFromRequest(req: AuthCookieRequest): AuthCookiePolicyOptions { + const forwardedProto = req.headers.get('x-forwarded-proto')?.split(',')[0]?.trim().toLowerCase() + const isHttps = forwardedProto ? forwardedProto === 'https' : req.nextUrl.protocol === 'https:' + const forwardedHost = req.headers.get('x-forwarded-host')?.split(',')[0]?.trim().toLowerCase() + const requestHost = req.headers.get('host')?.split(',')[0]?.trim().toLowerCase() + + return { + isHttps, + isLocalhost: isLocalhost(forwardedHost) || isLocalhost(requestHost) || isLocalhost(req.nextUrl.hostname), + } +} + +export function getAuthCookieSecure(input?: AuthCookiePolicyInput): boolean { + const options = normalizeAuthCookiePolicyInput(input) + return options.nodeEnv === 'production' || options.isHttps || options.isLocalhost +} + +export function getAuthCookieSameSite(input?: AuthCookiePolicyInput): AuthCookieSameSite { + return getAuthCookieSecure(input) ? 'none' : 'lax' +} + +export function getAuthCookieSameSiteHeader(input?: AuthCookiePolicyInput): 'Lax' | 'None' { + return getAuthCookieSameSite(input) === 'none' ? 'None' : 'Lax' +} + +export function getAuthCookieHeaderAttributes(input?: AuthCookiePolicyInput): string { + const secure = getAuthCookieSecure(input) ? 'Secure; ' : '' + return `${secure}SameSite=${getAuthCookieSameSiteHeader(input)}` +} diff --git a/lib/auth/github-popup-contract.ts b/lib/auth/github-popup-contract.ts index 05a26bc..d38096e 100644 --- a/lib/auth/github-popup-contract.ts +++ b/lib/auth/github-popup-contract.ts @@ -3,6 +3,7 @@ export const GITHUB_AUTH_POPUP_VALUE = 'true' export const GITHUB_AUTH_POPUP_COOKIE = 'github_auth_popup' export const GITHUB_AUTH_SUCCESS_MESSAGE_TYPE = 'github-auth-success' export const GITHUB_AUTH_ERROR_MESSAGE_TYPE = 'github-auth-error' +export const GITHUB_AUTH_BROADCAST_CHANNEL = 'github-auth' export type GitHubAuthPopupMessage = | { type: typeof GITHUB_AUTH_SUCCESS_MESSAGE_TYPE; status: 'success' } diff --git a/lib/auth/github-popup.ts b/lib/auth/github-popup.ts index ef26573..a85f9cb 100644 --- a/lib/auth/github-popup.ts +++ b/lib/auth/github-popup.ts @@ -1,6 +1,7 @@ 'use client' import { + GITHUB_AUTH_BROADCAST_CHANNEL, GITHUB_AUTH_ERROR_MESSAGE_TYPE, GITHUB_AUTH_POPUP_PARAM, GITHUB_AUTH_POPUP_VALUE, @@ -12,6 +13,8 @@ const POPUP_WIDTH = 600 const POPUP_HEIGHT = 720 const POPUP_CLOSE_POLL_MS = 500 const POPUP_TIMEOUT_MS = 120000 +const AUTH_INFO_POLL_MS = 250 +const AUTH_INFO_TIMEOUT_MS = 5000 export type GitHubPopupAuthErrorCode = 'popup_blocked' | 'popup_closed' | 'timeout' | 'auth_error' @@ -22,6 +25,30 @@ export class GitHubPopupAuthError extends Error { } } +async function hasAuthSession(): Promise { + try { + const response = await fetch('/api/auth/info', { + cache: 'no-store', + credentials: 'same-origin', + }) + + if (!response.ok) { + return false + } + + const data = (await response.json()) as { user?: unknown } + return Boolean(data.user) + } catch { + return false + } +} + +function waitForNextAuthInfoPoll(): Promise { + return new Promise((resolve) => { + window.setTimeout(resolve, AUTH_INFO_POLL_MS) + }) +} + export function startGitHubPopupAuth(authPath: string): Promise { const authUrl = new URL(authPath, window.location.origin) authUrl.searchParams.set(GITHUB_AUTH_POPUP_PARAM, GITHUB_AUTH_POPUP_VALUE) @@ -43,11 +70,14 @@ export function startGitHubPopupAuth(authPath: string): Promise { return new Promise((resolve, reject) => { let settled = false + let checkingAuthState = false + let broadcastChannel: BroadcastChannel | undefined let closePoll: number | undefined let timeout: number | undefined const cleanup = () => { window.removeEventListener('message', handleMessage) + broadcastChannel?.close() if (closePoll !== undefined) { window.clearInterval(closePoll) } @@ -56,6 +86,21 @@ export function startGitHubPopupAuth(authPath: string): Promise { } } + const handlePopupMessageData = (data: unknown) => { + if (!isGitHubAuthPopupMessage(data)) { + return + } + + if (data.type === GITHUB_AUTH_SUCCESS_MESSAGE_TYPE) { + complete('success') + return + } + + if (data.type === GITHUB_AUTH_ERROR_MESSAGE_TYPE) { + complete('auth_error') + } + } + const complete = (result: 'success' | GitHubPopupAuthErrorCode) => { if (settled) { return @@ -71,26 +116,50 @@ export function startGitHubPopupAuth(authPath: string): Promise { } } - const handleMessage = (event: MessageEvent) => { - if (event.origin !== window.location.origin || !isGitHubAuthPopupMessage(event.data)) { + const completeFromAuthStateOrError = async (errorCode: GitHubPopupAuthErrorCode) => { + if (settled || checkingAuthState) { return } - if (event.data.type === GITHUB_AUTH_SUCCESS_MESSAGE_TYPE) { - complete('success') - return + checkingAuthState = true + const deadline = Date.now() + AUTH_INFO_TIMEOUT_MS + + while (!settled && Date.now() < deadline) { + if (await hasAuthSession()) { + complete('success') + return + } + + await waitForNextAuthInfoPoll() } - if (event.data.type === GITHUB_AUTH_ERROR_MESSAGE_TYPE) { - complete('auth_error') + complete(errorCode) + } + + const handleMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) { + return } + + handlePopupMessageData(event.data) } window.addEventListener('message', handleMessage) + try { + broadcastChannel = new BroadcastChannel(GITHUB_AUTH_BROADCAST_CHANNEL) + broadcastChannel.addEventListener('message', (event: MessageEvent) => { + handlePopupMessageData(event.data) + }) + } catch {} + closePoll = window.setInterval(() => { if (popup.closed) { - complete('popup_closed') + if (closePoll !== undefined) { + window.clearInterval(closePoll) + closePoll = undefined + } + void completeFromAuthStateOrError('popup_closed') } }, POPUP_CLOSE_POLL_MS) diff --git a/lib/auth/iframe-oauth.test.ts b/lib/auth/iframe-oauth.test.ts new file mode 100644 index 0000000..4db4067 --- /dev/null +++ b/lib/auth/iframe-oauth.test.ts @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..') + +function readRepoFile(path: string): string { + return readFileSync(join(repoRoot, path), 'utf8') +} + +test('auth cookie policy uses iframe-compatible production attributes', async () => { + const policy = await import('./cookie-policy').catch(() => null) + + assert.ok(policy, 'cookie policy helper should exist') + assert.equal(policy.getAuthCookieSameSite('production'), 'none') + assert.equal(policy.getAuthCookieSecure('production'), true) + assert.equal(policy.getAuthCookieSameSite('development'), 'lax') +}) + +test('auth cookie policy supports HTTPS iframe development hosts', async () => { + const policy = await import('./cookie-policy') + + assert.equal(policy.getAuthCookieSameSite({ isHttps: true, nodeEnv: 'development' }), 'none') + assert.equal(policy.getAuthCookieSecure({ isHttps: true, nodeEnv: 'development' }), true) + assert.equal(policy.getAuthCookieSameSiteHeader({ isHttps: true, nodeEnv: 'development' }), 'None') +}) + +test('auth cookie policy supports localhost iframe development hosts', async () => { + const policy = await import('./cookie-policy') + const requestPolicy = policy.getAuthCookiePolicyFromRequest({ + headers: { + get(name: string) { + return name === 'host' ? 'localhost:3000' : null + }, + }, + nextUrl: { + hostname: 'localhost', + protocol: 'http:', + }, + }) + + assert.equal(policy.getAuthCookieSameSite(requestPolicy), 'none') + assert.equal(policy.getAuthCookieSecure(requestPolicy), true) + assert.equal(policy.getAuthCookieSameSiteHeader(requestPolicy), 'None') +}) + +test('public GitHub sign-in route preserves sign-in intent inside iframes', () => { + const source = readRepoFile('app/api/auth/signin/github/route.ts') + + assert.doesNotMatch(source, /getSessionFromReq/) + assert.match(source, /\['github_auth_mode', 'signin'\]/) + assert.doesNotMatch(source, /authMode = isSignInFlow \? 'signin' : 'connect'/) +}) + +test('account merge does not move duplicate key providers onto the target user', async () => { + const { planUserKeyMerge } = await import('./account-merge') + + const plan = planUserKeyMerge({ + sourceKeys: [ + { id: 'source-aiproxy-key', provider: 'aiproxy' }, + { id: 'source-openai-key', provider: 'openai' }, + ], + targetKeys: [{ id: 'target-aiproxy-key', provider: 'aiproxy' }], + }) + + assert.deepEqual(plan.moveKeyIds, ['source-openai-key']) + assert.deepEqual(plan.deleteKeyIds, ['source-aiproxy-key']) +}) + +test('GitHub callback notifies embedded opener frames before closing popup', () => { + const source = readRepoFile('app/api/auth/github/callback/route.ts') + + assert.match(source, /postGitHubAuthMessage\(window\.opener\)/) + assert.match(source, /postGitHubAuthMessage\(window\.opener\.frames\[index\]\)/) + assert.match(source, /window\.setTimeout\(\(\) => window\.close\(\),/) +}) + +test('GitHub popup verifies auth state if the iframe misses the callback message', () => { + const source = readRepoFile('lib/auth/github-popup.ts') + + assert.match(source, /fetch\('\/api\/auth\/info'/) + assert.match(source, /completeFromAuthStateOrError\('popup_closed'\)/) + assert.doesNotMatch(source, /if \(popup\.closed\) {\s*complete\('popup_closed'\)/) +}) diff --git a/lib/db/users.ts b/lib/db/users.ts index 26b5b02..daccf3f 100644 --- a/lib/db/users.ts +++ b/lib/db/users.ts @@ -57,9 +57,7 @@ export async function upsertUser( .limit(1) if (existingAccount.length > 0) { - console.log( - `[upsertUser] GitHub account (${externalId}) is already connected to user ${existingAccount[0].userId}. Using existing user.`, - ) + console.log('GitHub account is already connected to an existing user') // Update the existing user's last login await db diff --git a/lib/session/create-github.ts b/lib/session/create-github.ts index b0cab7b..ec5c8a8 100644 --- a/lib/session/create-github.ts +++ b/lib/session/create-github.ts @@ -5,6 +5,7 @@ import { SESSION_COOKIE_NAME } from './constants' import { encryptJWE } from '@/lib/jwe/encrypt' import { upsertUser } from '@/lib/db/users' import { encrypt } from '@/lib/crypto' +import { getAuthCookieHeaderAttributes, type AuthCookiePolicyInput } from '@/lib/auth/cookie-policy' import ms from 'ms' interface GitHubUser { @@ -82,11 +83,15 @@ export async function createGitHubSession(accessToken: string, scope?: string): const COOKIE_TTL = ms('1y') -export async function saveSession(res: Response, session: Session | undefined): Promise { +export async function saveSession( + res: Response, + session: Session | undefined, + authCookiePolicy?: AuthCookiePolicyInput, +): Promise { if (!session) { res.headers.append( 'Set-Cookie', - `${SESSION_COOKIE_NAME}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; ${process.env.NODE_ENV === 'production' ? 'Secure; ' : ''}SameSite=Lax`, + `${SESSION_COOKIE_NAME}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; ${getAuthCookieHeaderAttributes(authCookiePolicy)}`, ) return } @@ -95,7 +100,7 @@ export async function saveSession(res: Response, session: Session | undefined): const expires = new Date(Date.now() + COOKIE_TTL).toUTCString() res.headers.append( 'Set-Cookie', - `${SESSION_COOKIE_NAME}=${value}; Path=/; Max-Age=${COOKIE_TTL / 1000}; Expires=${expires}; HttpOnly; ${process.env.NODE_ENV === 'production' ? 'Secure; ' : ''}SameSite=Lax`, + `${SESSION_COOKIE_NAME}=${value}; Path=/; Max-Age=${COOKIE_TTL / 1000}; Expires=${expires}; HttpOnly; ${getAuthCookieHeaderAttributes(authCookiePolicy)}`, ) return value } diff --git a/lib/session/create.ts b/lib/session/create.ts index 6fa7f29..3825963 100644 --- a/lib/session/create.ts +++ b/lib/session/create.ts @@ -6,6 +6,7 @@ import { encryptJWE } from '@/lib/jwe/encrypt' import { fetchUser } from '@/lib/vercel-client/user' import { upsertUser } from '@/lib/db/users' import { encrypt } from '@/lib/crypto' +import { getAuthCookieHeaderAttributes, type AuthCookiePolicyInput } from '@/lib/auth/cookie-policy' import ms from 'ms' export async function createSession(tokens: Tokens): Promise { @@ -42,17 +43,21 @@ export async function createSession(tokens: Tokens): Promise { +export async function saveSession( + res: Response, + session: Session | undefined, + authCookiePolicy?: AuthCookiePolicyInput, +): Promise { if (!session) { res.headers.append( 'Set-Cookie', - `${SESSION_COOKIE_NAME}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; ${process.env.NODE_ENV === 'production' ? 'Secure; ' : ''}SameSite=Lax`, + `${SESSION_COOKIE_NAME}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; ${getAuthCookieHeaderAttributes(authCookiePolicy)}`, ) return } @@ -61,7 +66,7 @@ export async function saveSession(res: Response, session: Session | undefined): const expires = new Date(Date.now() + COOKIE_TTL).toUTCString() res.headers.append( 'Set-Cookie', - `${SESSION_COOKIE_NAME}=${value}; Path=/; Max-Age=${COOKIE_TTL / 1000}; Expires=${expires}; HttpOnly; ${process.env.NODE_ENV === 'production' ? 'Secure; ' : ''}SameSite=Lax`, + `${SESSION_COOKIE_NAME}=${value}; Path=/; Max-Age=${COOKIE_TTL / 1000}; Expires=${expires}; HttpOnly; ${getAuthCookieHeaderAttributes(authCookiePolicy)}`, ) return value }