From f38ed0dbcd4f3a13bd3e7d6807a998a226515e20 Mon Sep 17 00:00:00 2001 From: Che <30403707+Che-Zhu@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:38:14 +0800 Subject: [PATCH] feat(auth): replace GitHub OAuth with GitHub App OAuth - Create GitHub App OAuth provider for NextAuth - Update auth.ts to use new provider with refresh token support - Update repoService.ts to use getUserGitHubToken() for token management - Add auto-trigger GitHub App installation after login - Update settings-dialog.tsx to use GitHub App installation flow - Remove deprecated GitHub OAuth API routes - Update environment variables template Breaking change: GITHUB_CLIENT_ID/GITHUB_CLIENT_SECRET replaced with GITHUB_APP_CLIENT_ID/GITHUB_APP_CLIENT_SECRET~ --- .env.template | 16 +- app/(auth)/login/page.tsx | 2 +- .../(list)/_components/home-page-content.tsx | 77 +++++- app/api/auth/github/callback/route.ts | 224 ------------------ app/api/user/github/bind/route.ts | 67 ------ app/api/user/github/route.ts | 114 --------- components/dialog/settings-dialog.tsx | 93 ++++---- lib/actions/github.ts | 38 --- lib/auth.ts | 113 +++++++-- lib/services/repoService.ts | 41 +--- 10 files changed, 229 insertions(+), 556 deletions(-) delete mode 100644 app/api/auth/github/callback/route.ts delete mode 100644 app/api/user/github/bind/route.ts delete mode 100644 app/api/user/github/route.ts diff --git a/.env.template b/.env.template index 00b04a5..1f58795 100644 --- a/.env.template +++ b/.env.template @@ -5,9 +5,17 @@ NEXTAUTH_URL="" NEXTAUTH_SECRET="" AUTH_TRUST_HOST="true" -# GitHub OAuth (replace with your actual values) -GITHUB_CLIENT_ID="" -GITHUB_CLIENT_SECRET="" +# GitHub App Configuration (Required for GitHub login) +GITHUB_APP_ID="" +GITHUB_APP_PRIVATE_KEY="" +GITHUB_APP_WEBHOOK_SECRET="" +GITHUB_APP_CLIENT_ID="" +GITHUB_APP_CLIENT_SECRET="" +NEXT_PUBLIC_GITHUB_APP_NAME="" + +# GitHub OAuth (DEPRECATED - Use GitHub App instead) +# GITHUB_CLIENT_ID="" +# GITHUB_CLIENT_SECRET="" # Sealos OAuth SEALOS_JWT_SECRET="" @@ -31,5 +39,5 @@ LOG_LEVEL="info" # login ENABLE_PASSWORD_AUTH="" -ENABLE_PASSWORD_AUTH="" +ENABLE_GITHUB_AUTH="true" ENABLE_SEALOS_AUTH="" diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index c060920..f4776c2 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -117,7 +117,7 @@ export default function LoginPage() { ) : ( - // Not connected state

- No GitHub account connected. Connect your GitHub account to access repositories and enable version control features. + No GitHub App installed. Connect your GitHub account to access repositories and enable version control features.

@@ -661,7 +658,7 @@ export default function SettingsDialog({ className="bg-primary hover:bg-primary/90 text-primary-foreground" > - {isGithubLoading ? 'Connecting...' : 'Connect GitHub Account'} + {isGithubLoading ? 'Connecting...' : 'Connect GitHub App'}
)} diff --git a/lib/actions/github.ts b/lib/actions/github.ts index b6c0dea..54bb659 100644 --- a/lib/actions/github.ts +++ b/lib/actions/github.ts @@ -3,7 +3,6 @@ import type { GitHubAppInstallation } from '@prisma/client' import { auth } from '@/lib/auth' -import { prisma } from '@/lib/db' import { logger as baseLogger } from '@/lib/logger' import { getInstallationByGitHubId, getInstallationsForUser } from '@/lib/repo/github' import { listInstallationRepos } from '@/lib/services/github-app' @@ -33,43 +32,6 @@ export interface GitHubRepo { html_url: string } -/** - * @deprecated Use GitHub App OAuth flow instead - * Check if user has GitHub identity linked - * Will be removed in v0.5.0 - */ -export async function checkGitHubIdentity(): Promise< - ActionResult<{ linked: boolean; githubId?: string; githubLogin?: string }> -> { - logger.warn('checkGitHubIdentity() is deprecated - use GitHub App OAuth flow') - - const session = await auth() - - if (!session?.user?.id) { - return { success: false, error: 'Unauthorized' } - } - - const githubIdentity = await prisma.userIdentity.findFirst({ - where: { - userId: session.user.id, - provider: 'GITHUB', - }, - }) - - if (!githubIdentity) { - return { success: true, data: { linked: false } } - } - - return { - success: true, - data: { - linked: true, - githubId: githubIdentity.providerUserId, - githubLogin: (githubIdentity.metadata as Record)?.login, - }, - } -} - export async function getInstallations(): Promise> { const session = await auth() diff --git a/lib/auth.ts b/lib/auth.ts index 35d0e7d..78c50ce 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -1,7 +1,7 @@ import bcrypt from 'bcryptjs' import NextAuth from 'next-auth' +import type { OAuthConfig, OAuthUserConfig } from 'next-auth/providers' import Credentials from 'next-auth/providers/credentials' -import GitHub from 'next-auth/providers/github' import { prisma } from '@/lib/db' import { env } from '@/lib/env' @@ -12,6 +12,58 @@ import { createAiproxyToken } from '@/lib/services/aiproxy' const logger = baseLogger.child({ module: 'lib/auth' }) +interface GitHubAppProfile { + id: number + login: string + name: string | null + email: string | null + avatar_url: string +} + +function GitHubApp

(options: OAuthUserConfig

): OAuthConfig

{ + return { + id: 'github-app', + name: 'GitHub', + type: 'oauth', + clientId: options.clientId, + clientSecret: options.clientSecret, + authorization: { + url: 'https://github.com/login/oauth/authorize', + params: { + scope: 'repo read:user user:email', + }, + }, + token: 'https://github.com/login/oauth/access_token', + userinfo: { + url: 'https://api.github.com/user', + async request({ tokens }: { tokens: { access_token: string } }) { + const res = await fetch('https://api.github.com/user', { + headers: { + Authorization: `Bearer ${tokens.access_token}`, + 'User-Agent': 'next-auth', + }, + }) + if (!res.ok) { + throw new Error(`Failed to fetch user info: ${res.status}`) + } + return await res.json() + }, + }, + profile(profile) { + return { + id: profile.id.toString(), + name: profile.name || profile.login, + email: profile.email, + image: profile.avatar_url, + } + }, + style: { + brandColor: '#24292F', + logo: '/github.svg', + }, + } +} + // Build providers array dynamically based on feature flags const buildProviders = () => { const providers = [] @@ -359,23 +411,18 @@ const buildProviders = () => { logger.info('Sealos authentication is DISABLED') } - // GitHub OAuth + // GitHub App OAuth (replaces legacy GitHub OAuth) if (env.ENABLE_GITHUB_AUTH) { logger.info('GitHub authentication is ENABLED') - if (!env.GITHUB_CLIENT_ID || !env.GITHUB_CLIENT_SECRET) { + if (!env.GITHUB_APP_CLIENT_ID || !env.GITHUB_APP_CLIENT_SECRET) { logger.warn( - 'GitHub authentication is enabled but GITHUB_CLIENT_ID or GITHUB_CLIENT_SECRET is missing' + 'GitHub authentication is enabled but GITHUB_APP_CLIENT_ID or GITHUB_APP_CLIENT_SECRET is missing' ) } else { providers.push( - GitHub({ - clientId: env.GITHUB_CLIENT_ID, - clientSecret: env.GITHUB_CLIENT_SECRET, - authorization: { - params: { - scope: 'repo read:user', - }, - }, + GitHubApp({ + clientId: env.GITHUB_APP_CLIENT_ID, + clientSecret: env.GITHUB_APP_CLIENT_SECRET, }) ) } @@ -394,13 +441,18 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ providers: buildProviders(), callbacks: { async signIn({ user, account, profile }) { - if (account?.provider === 'github') { + if (account?.provider === 'github-app') { try { const githubId = account.providerAccountId - const githubToken = account.access_token - const scope = account.scope || 'repo read:user' + const accessToken = account.access_token + const refreshToken = account.refresh_token + const expiresAt = account.expires_at + ? new Date(account.expires_at * 1000).toISOString() + : new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString() + const scope = account.scope || 'repo read:user user:email' + + const githubProfile = profile as { login?: string; name?: string; avatar_url?: string; email?: string } - // Check if identity exists const existingIdentity = await prisma.userIdentity.findUnique({ where: { unique_provider_user: { @@ -414,27 +466,32 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ }) if (existingIdentity) { - // Update GitHub token in metadata await prisma.userIdentity.update({ where: { id: existingIdentity.id }, data: { metadata: { - token: githubToken, + accessToken, + refreshToken, + expiresAt, + tokenType: 'bearer', scope, + login: githubProfile.login, + name: githubProfile.name, + avatarUrl: githubProfile.avatar_url, + email: githubProfile.email, + githubId: parseInt(githubId, 10), }, }, }) - // Set user info for JWT callback user.id = existingIdentity.user.id user.name = existingIdentity.user.name } else { - // Create new user with GitHub identity const newUser = await prisma.user.create({ data: { name: - (profile?.name as string) || - (profile?.login as string) || + githubProfile.name || + githubProfile.login || user.name || 'GitHub User', identities: { @@ -442,8 +499,16 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ provider: 'GITHUB', providerUserId: githubId, metadata: { - token: githubToken, + accessToken, + refreshToken, + expiresAt, + tokenType: 'bearer', scope, + login: githubProfile.login, + name: githubProfile.name, + avatarUrl: githubProfile.avatar_url, + email: githubProfile.email, + githubId: parseInt(githubId, 10), }, isPrimary: true, }, @@ -455,7 +520,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ user.name = newUser.name } } catch (error) { - logger.error(`Error in GitHub signIn callback: ${error}`) + logger.error(`Error in GitHub App signIn callback: ${error}`) return false } } diff --git a/lib/services/repoService.ts b/lib/services/repoService.ts index 2485be3..ab430d5 100644 --- a/lib/services/repoService.ts +++ b/lib/services/repoService.ts @@ -2,6 +2,7 @@ import { auth } from '@/lib/auth' import { prisma } from '@/lib/db' +import { getUserGitHubToken } from '@/lib/services/github-token-refresh' import { execCommand, TtydExecError } from '@/lib/util/ttyd-exec' export type RepoInitResult = { @@ -136,26 +137,12 @@ export async function createGithubRepo(repoName: string): Promise { try { const { baseUrl, accessToken, authorization, project } = await getTtydContext(projectId, session.user.id) - // Use new fields with fallback to legacy field const repoFullName = project.githubRepoFullName || project.githubRepo if (!repoFullName) { return { success: false, message: 'No GitHub repository linked to this project' } } - // Get GitHub token - const identity = await prisma.userIdentity.findFirst({ - where: { - userId: session.user.id, - provider: 'GITHUB', - }, - }) - - // Type checking for metadata token - const metadata = identity?.metadata as { token?: string } | undefined - const githubToken = metadata?.token + const githubToken = await getUserGitHubToken(session.user.id) if (!githubToken) { return { success: false, message: 'GitHub token not found', code: 'GITHUB_NOT_BOUND' } } - // Construct repo URL from full name (owner/repo format) - // We want to construct: https://oauth2:token@github.com/owner/repo.git const authUrl = `https://oauth2:${githubToken}@github.com/${repoFullName}.git` - // Configure remote and push - // We use 'git remote set-url' if origin exists, or 'git remote add' if it doesn't - // SECURITY: Use single quotes around URL to prevent command injection const command = ` (git remote get-url origin > /dev/null 2>&1 && git remote set-url origin '${authUrl}') || git remote add origin '${authUrl}' && git branch -M main &&