diff --git a/app/api/auth/github/callback/route.ts b/app/api/auth/github/callback/route.ts index e5b3ae4..a3947b4 100644 --- a/app/api/auth/github/callback/route.ts +++ b/app/api/auth/github/callback/route.ts @@ -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> +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 + const messageType = status === 'success' ? GITHUB_AUTH_SUCCESS_MESSAGE_TYPE : GITHUB_AUTH_ERROR_MESSAGE_TYPE + const html = ` + + + + GitHub Authentication + + + + +` + + 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 { 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: { @@ -73,31 +114,25 @@ export async function GET(req: NextRequest): Promise { }) 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}`, @@ -105,131 +140,93 @@ export async function GET(req: NextRequest): Promise { }, }) + 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 }) } } diff --git a/app/api/auth/github/signin/route.ts b/app/api/auth/github/signin/route.ts index 642f222..71dee59 100644 --- a/app/api/auth/github/signin/route.ts +++ b/app/api/auth/github/signin/route.ts @@ -4,8 +4,29 @@ 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 { + GITHUB_AUTH_POPUP_COOKIE, + GITHUB_AUTH_POPUP_PARAM, + GITHUB_AUTH_POPUP_VALUE, +} from '@/lib/auth/github-popup-contract' + +const GITHUB_AUTH_COOKIE_MAX_AGE = 60 * 10 + +function setGitHubAuthCookie(store: Awaited>, key: string, value: string): void { + store.set(key, value, { + path: '/', + secure: process.env.NODE_ENV === 'production', + httpOnly: true, + maxAge: GITHUB_AUTH_COOKIE_MAX_AGE, + sameSite: 'lax', + }) +} export async function GET(req: NextRequest): Promise { + if (req.nextUrl.searchParams.get(GITHUB_AUTH_POPUP_PARAM) !== GITHUB_AUTH_POPUP_VALUE) { + return new Response('Invalid GitHub authentication request', { status: 400 }) + } + // Check if user is authenticated with Vercel first const session = await getSessionFromReq(req) if (!session?.user) { @@ -27,17 +48,13 @@ export async function GET(req: NextRequest): Promise { // Store state and redirect URL for (const [key, value] of [ - [`github_oauth_redirect_to`, redirectTo], - [`github_oauth_state`, state], - [`github_oauth_user_id`, session.user.id], // Store Vercel user ID + [GITHUB_AUTH_POPUP_COOKIE, GITHUB_AUTH_POPUP_VALUE], + ['github_auth_redirect_to', redirectTo], + ['github_auth_state', state], + ['github_auth_mode', 'connect'], + ['github_auth_user_id', session.user.id], ]) { - store.set(key, value, { - path: '/', - secure: process.env.NODE_ENV === 'production', - httpOnly: true, - maxAge: 60 * 10, // 10 minutes - sameSite: 'lax', - }) + setGitHubAuthCookie(store, key, value) } // Build GitHub authorization URL @@ -55,6 +72,10 @@ export async function GET(req: NextRequest): Promise { } export async function POST(req: NextRequest): Promise { + if (req.nextUrl.searchParams.get(GITHUB_AUTH_POPUP_PARAM) !== GITHUB_AUTH_POPUP_VALUE) { + return Response.json({ error: 'Invalid GitHub authentication request' }, { status: 400 }) + } + // Check if user is authenticated with Vercel first const session = await getSessionFromReq(req) if (!session?.user) { @@ -76,17 +97,13 @@ export async function POST(req: NextRequest): Promise { // Store state and redirect URL for (const [key, value] of [ - [`github_oauth_redirect_to`, redirectTo], - [`github_oauth_state`, state], - [`github_oauth_user_id`, session.user.id], // Store Vercel user ID + [GITHUB_AUTH_POPUP_COOKIE, GITHUB_AUTH_POPUP_VALUE], + ['github_auth_redirect_to', redirectTo], + ['github_auth_state', state], + ['github_auth_mode', 'connect'], + ['github_auth_user_id', session.user.id], ]) { - store.set(key, value, { - path: '/', - secure: process.env.NODE_ENV === 'production', - httpOnly: true, - maxAge: 60 * 10, // 10 minutes - sameSite: 'lax', - }) + setGitHubAuthCookie(store, key, value) } // Build GitHub authorization URL diff --git a/app/api/auth/signin/github/route.ts b/app/api/auth/signin/github/route.ts index 2abd604..d8d6dbe 100644 --- a/app/api/auth/signin/github/route.ts +++ b/app/api/auth/signin/github/route.ts @@ -4,8 +4,29 @@ 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 { + GITHUB_AUTH_POPUP_COOKIE, + GITHUB_AUTH_POPUP_PARAM, + GITHUB_AUTH_POPUP_VALUE, +} from '@/lib/auth/github-popup-contract' + +const GITHUB_AUTH_COOKIE_MAX_AGE = 60 * 10 + +function setGitHubAuthCookie(store: Awaited>, key: string, value: string): void { + store.set(key, value, { + path: '/', + secure: process.env.NODE_ENV === 'production', + httpOnly: true, + maxAge: GITHUB_AUTH_COOKIE_MAX_AGE, + sameSite: 'lax', + }) +} export async function GET(req: NextRequest): Promise { + if (req.nextUrl.searchParams.get(GITHUB_AUTH_POPUP_PARAM) !== GITHUB_AUTH_POPUP_VALUE) { + return new Response('Invalid GitHub authentication request', { status: 400 }) + } + // Check if user is already authenticated with Vercel const session = await getSessionFromReq(req) @@ -36,24 +57,19 @@ export async function GET(req: NextRequest): Promise { // Store state and redirect URL const cookiesToSet: [string, string][] = [ - [`github_auth_redirect_to`, redirectTo], - [`github_auth_state`, state], - [`github_auth_mode`, authMode], + [GITHUB_AUTH_POPUP_COOKIE, GITHUB_AUTH_POPUP_VALUE], + ['github_auth_redirect_to', redirectTo], + ['github_auth_state', state], + ['github_auth_mode', authMode], ] // If connecting (user already signed in), store their user ID if (!isSignInFlow && session?.user?.id) { - cookiesToSet.push([`github_oauth_user_id`, session.user.id]) + cookiesToSet.push(['github_auth_user_id', session.user.id]) } for (const [key, value] of cookiesToSet) { - store.set(key, value, { - path: '/', - secure: process.env.NODE_ENV === 'production', - httpOnly: true, - maxAge: 60 * 10, // 10 minutes - sameSite: 'lax', - }) + setGitHubAuthCookie(store, key, value) } // Build GitHub authorization URL diff --git a/components/auth/sign-in.tsx b/components/auth/sign-in.tsx index 9b11a12..ff1811c 100644 --- a/components/auth/sign-in.tsx +++ b/components/auth/sign-in.tsx @@ -6,6 +6,8 @@ import { redirectToSignIn } from '@/lib/session/redirect-to-sign-in' import { GitHubIcon } from '@/components/icons/github-icon' import { useState } from 'react' import { getEnabledAuthProviders } from '@/lib/auth/providers' +import { GitHubPopupAuthError, startGitHubPopupAuth } from '@/lib/auth/github-popup' +import { toast } from 'sonner' export function SignIn() { const [showDialog, setShowDialog] = useState(false) @@ -20,9 +22,19 @@ export function SignIn() { await redirectToSignIn() } - const handleGitHubSignIn = () => { + const handleGitHubSignIn = async () => { setLoadingGitHub(true) - window.location.href = '/api/auth/signin/github' + try { + await startGitHubPopupAuth('/api/auth/signin/github') + window.location.reload() + } catch (error) { + if (error instanceof GitHubPopupAuthError && error.code === 'popup_blocked') { + toast.error('Please allow popups and try again.') + } else { + toast.error('GitHub authentication failed. Please try again.') + } + setLoadingGitHub(false) + } } return ( diff --git a/components/auth/sign-out.tsx b/components/auth/sign-out.tsx index 37f3195..364290a 100644 --- a/components/auth/sign-out.tsx +++ b/components/auth/sign-out.tsx @@ -22,6 +22,7 @@ import { ThemeToggle } from '@/components/theme-toggle' import { Key, Server } from 'lucide-react' import { useState, useEffect, useCallback } from 'react' import { getEnabledAuthProviders } from '@/lib/auth/providers' +import { GitHubPopupAuthError, startGitHubPopupAuth } from '@/lib/auth/github-popup' interface RateLimitInfo { used: number @@ -37,6 +38,7 @@ export function SignOut({ user, authProvider }: Pick(null) + const [loadingGitHub, setLoadingGitHub] = useState(false) // Check which auth providers are enabled const { github: hasGitHub } = getEnabledAuthProviders() @@ -59,12 +61,27 @@ export function SignOut({ user, authProvider }: Pick { + setLoadingGitHub(true) + try { + await startGitHubPopupAuth('/api/auth/github/signin') + window.location.reload() + } catch (error) { + if (error instanceof GitHubPopupAuthError && error.code === 'popup_blocked') { + toast.error('Please allow popups and try again.') + } else { + toast.error('GitHub authentication failed. Please try again.') + } + setLoadingGitHub(false) + } + } + // Fetch rate limit info on mount useEffect(() => { let mounted = true @@ -79,8 +96,8 @@ export function SignOut({ user, authProvider }: Pick { @@ -99,8 +116,8 @@ export function SignOut({ user, authProvider }: Pick ) : ( - (window.location.href = '/api/auth/github/signin')} - className="cursor-pointer" - > + - Connect + {loadingGitHub ? 'Connecting...' : 'Connect'} )} diff --git a/components/sealos-home-page-content.tsx b/components/sealos-home-page-content.tsx index f46145e..250ea5b 100644 --- a/components/sealos-home-page-content.tsx +++ b/components/sealos-home-page-content.tsx @@ -18,6 +18,7 @@ import { taskPromptAtom } from '@/lib/atoms/task' import { sessionAtom } from '@/lib/atoms/session' import { githubConnectionAtom, githubConnectionInitializedAtom } from '@/lib/atoms/github-connection' import type { Session } from '@/lib/session/types' +import { GitHubPopupAuthError, startGitHubPopupAuth } from '@/lib/auth/github-popup' interface SealosHomePageContentProps { initialSelectedOwner?: string @@ -121,8 +122,8 @@ export function SealosHomePageContent({ } await refreshTasks() - } catch (error) { - console.error('Error creating task:', error) + } catch { + console.error('Error creating task') toast.error('Failed to create task') await refreshTasks() } finally { @@ -135,13 +136,34 @@ export function SealosHomePageContent({ await redirectToSignIn() } - const handleGitHubSignIn = () => { + const handleGitHubSignIn = async () => { setLoadingGitHub(true) - window.location.href = '/api/auth/signin/github' + try { + await startGitHubPopupAuth('/api/auth/signin/github') + window.location.reload() + } catch (error) { + if (error instanceof GitHubPopupAuthError && error.code === 'popup_blocked') { + toast.error('Please allow popups and try again.') + } else { + toast.error('GitHub authentication failed. Please try again.') + } + setLoadingGitHub(false) + } } - const handleConnectGitHub = () => { - window.location.href = '/api/auth/github/signin' + const handleConnectGitHub = async () => { + setLoadingGitHub(true) + try { + await startGitHubPopupAuth('/api/auth/github/signin') + window.location.reload() + } catch (error) { + if (error instanceof GitHubPopupAuthError && error.code === 'popup_blocked') { + toast.error('Please allow popups and try again.') + } else { + toast.error('GitHub authentication failed. Please try again.') + } + setLoadingGitHub(false) + } } const openSignIn = () => { @@ -191,6 +213,7 @@ export function SealosHomePageContent({ type="button" variant="outline" onClick={handleConnectGitHub} + disabled={loadingGitHub} className="sealos-action-text h-10 rounded-full px-4" > diff --git a/lib/auth/github-popup-contract.ts b/lib/auth/github-popup-contract.ts new file mode 100644 index 0000000..05a26bc --- /dev/null +++ b/lib/auth/github-popup-contract.ts @@ -0,0 +1,22 @@ +export const GITHUB_AUTH_POPUP_PARAM = 'popup' +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 type GitHubAuthPopupMessage = + | { type: typeof GITHUB_AUTH_SUCCESS_MESSAGE_TYPE; status: 'success' } + | { type: typeof GITHUB_AUTH_ERROR_MESSAGE_TYPE; status: 'error' } + +export function isGitHubAuthPopupMessage(value: unknown): value is GitHubAuthPopupMessage { + if (!value || typeof value !== 'object') { + return false + } + + const message = value as { type?: unknown; status?: unknown } + + return ( + (message.type === GITHUB_AUTH_SUCCESS_MESSAGE_TYPE && message.status === 'success') || + (message.type === GITHUB_AUTH_ERROR_MESSAGE_TYPE && message.status === 'error') + ) +} diff --git a/lib/auth/github-popup.ts b/lib/auth/github-popup.ts new file mode 100644 index 0000000..ef26573 --- /dev/null +++ b/lib/auth/github-popup.ts @@ -0,0 +1,102 @@ +'use client' + +import { + GITHUB_AUTH_ERROR_MESSAGE_TYPE, + GITHUB_AUTH_POPUP_PARAM, + GITHUB_AUTH_POPUP_VALUE, + GITHUB_AUTH_SUCCESS_MESSAGE_TYPE, + isGitHubAuthPopupMessage, +} from '@/lib/auth/github-popup-contract' + +const POPUP_WIDTH = 600 +const POPUP_HEIGHT = 720 +const POPUP_CLOSE_POLL_MS = 500 +const POPUP_TIMEOUT_MS = 120000 + +export type GitHubPopupAuthErrorCode = 'popup_blocked' | 'popup_closed' | 'timeout' | 'auth_error' + +export class GitHubPopupAuthError extends Error { + constructor(readonly code: GitHubPopupAuthErrorCode) { + super('GitHub authentication failed') + this.name = 'GitHubPopupAuthError' + } +} + +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) + authUrl.searchParams.set('next', `${window.location.pathname}${window.location.search}`) + + const left = Math.max(0, window.screenX + (window.outerWidth - POPUP_WIDTH) / 2) + const top = Math.max(0, window.screenY + (window.outerHeight - POPUP_HEIGHT) / 2) + const popup = window.open( + authUrl.toString(), + 'github-auth', + `popup=yes,width=${POPUP_WIDTH},height=${POPUP_HEIGHT},left=${Math.round(left)},top=${Math.round(top)}`, + ) + + if (!popup) { + return Promise.reject(new GitHubPopupAuthError('popup_blocked')) + } + + popup.focus() + + return new Promise((resolve, reject) => { + let settled = false + let closePoll: number | undefined + let timeout: number | undefined + + const cleanup = () => { + window.removeEventListener('message', handleMessage) + if (closePoll !== undefined) { + window.clearInterval(closePoll) + } + if (timeout !== undefined) { + window.clearTimeout(timeout) + } + } + + const complete = (result: 'success' | GitHubPopupAuthErrorCode) => { + if (settled) { + return + } + + settled = true + cleanup() + + if (result === 'success') { + resolve() + } else { + reject(new GitHubPopupAuthError(result)) + } + } + + const handleMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin || !isGitHubAuthPopupMessage(event.data)) { + return + } + + if (event.data.type === GITHUB_AUTH_SUCCESS_MESSAGE_TYPE) { + complete('success') + return + } + + if (event.data.type === GITHUB_AUTH_ERROR_MESSAGE_TYPE) { + complete('auth_error') + } + } + + window.addEventListener('message', handleMessage) + + closePoll = window.setInterval(() => { + if (popup.closed) { + complete('popup_closed') + } + }, POPUP_CLOSE_POLL_MS) + + timeout = window.setTimeout(() => { + popup.close() + complete('timeout') + }, POPUP_TIMEOUT_MS) + }) +} diff --git a/lib/session/create-github.ts b/lib/session/create-github.ts index 7cd7005..b0cab7b 100644 --- a/lib/session/create-github.ts +++ b/lib/session/create-github.ts @@ -47,7 +47,7 @@ export async function createGitHubSession(accessToken: string, scope?: string): email = primaryEmail?.email || emails[0]?.email || null } } catch (error) { - console.error('Failed to fetch GitHub emails:', error) + console.error('Failed to fetch GitHub emails') } } @@ -76,7 +76,7 @@ export async function createGitHubSession(accessToken: string, scope?: string): }, } - console.log('Created GitHub session with internal user ID:', session.user.id) + console.log('Created GitHub session') return session }