Bladeren bron

feat(oauth): implement custom OAuth provider management #1106

- Add support for custom OAuth providers, including creation, retrieval, updating, and deletion.
- Introduce new model and controller for managing custom OAuth providers.
- Enhance existing OAuth logic to accommodate custom providers.
- Update API routes for custom OAuth provider management.
- Include i18n support for custom OAuth-related messages.
CaIon 6 dagen geleden
bovenliggende
commit
af54ea85d2

+ 386 - 0
controller/custom_oauth.go

@@ -0,0 +1,386 @@
+package controller
+
+import (
+	"net/http"
+	"strconv"
+
+	"github.com/QuantumNous/new-api/common"
+	"github.com/QuantumNous/new-api/model"
+	"github.com/QuantumNous/new-api/oauth"
+	"github.com/gin-gonic/gin"
+)
+
+// CustomOAuthProviderResponse is the response structure for custom OAuth providers
+// It excludes sensitive fields like client_secret
+type CustomOAuthProviderResponse struct {
+	Id                    int    `json:"id"`
+	Name                  string `json:"name"`
+	Slug                  string `json:"slug"`
+	Enabled               bool   `json:"enabled"`
+	ClientId              string `json:"client_id"`
+	AuthorizationEndpoint string `json:"authorization_endpoint"`
+	TokenEndpoint         string `json:"token_endpoint"`
+	UserInfoEndpoint      string `json:"user_info_endpoint"`
+	Scopes                string `json:"scopes"`
+	UserIdField           string `json:"user_id_field"`
+	UsernameField         string `json:"username_field"`
+	DisplayNameField      string `json:"display_name_field"`
+	EmailField            string `json:"email_field"`
+	WellKnown             string `json:"well_known"`
+	AuthStyle             int    `json:"auth_style"`
+}
+
+func toCustomOAuthProviderResponse(p *model.CustomOAuthProvider) *CustomOAuthProviderResponse {
+	return &CustomOAuthProviderResponse{
+		Id:                    p.Id,
+		Name:                  p.Name,
+		Slug:                  p.Slug,
+		Enabled:               p.Enabled,
+		ClientId:              p.ClientId,
+		AuthorizationEndpoint: p.AuthorizationEndpoint,
+		TokenEndpoint:         p.TokenEndpoint,
+		UserInfoEndpoint:      p.UserInfoEndpoint,
+		Scopes:                p.Scopes,
+		UserIdField:           p.UserIdField,
+		UsernameField:         p.UsernameField,
+		DisplayNameField:      p.DisplayNameField,
+		EmailField:            p.EmailField,
+		WellKnown:             p.WellKnown,
+		AuthStyle:             p.AuthStyle,
+	}
+}
+
+// GetCustomOAuthProviders returns all custom OAuth providers
+func GetCustomOAuthProviders(c *gin.Context) {
+	providers, err := model.GetAllCustomOAuthProviders()
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	response := make([]*CustomOAuthProviderResponse, len(providers))
+	for i, p := range providers {
+		response[i] = toCustomOAuthProviderResponse(p)
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "",
+		"data":    response,
+	})
+}
+
+// GetCustomOAuthProvider returns a single custom OAuth provider by ID
+func GetCustomOAuthProvider(c *gin.Context) {
+	idStr := c.Param("id")
+	id, err := strconv.Atoi(idStr)
+	if err != nil {
+		common.ApiErrorMsg(c, "无效的 ID")
+		return
+	}
+
+	provider, err := model.GetCustomOAuthProviderById(id)
+	if err != nil {
+		common.ApiErrorMsg(c, "未找到该 OAuth 提供商")
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "",
+		"data":    toCustomOAuthProviderResponse(provider),
+	})
+}
+
+// CreateCustomOAuthProviderRequest is the request structure for creating a custom OAuth provider
+type CreateCustomOAuthProviderRequest struct {
+	Name                  string `json:"name" binding:"required"`
+	Slug                  string `json:"slug" binding:"required"`
+	Enabled               bool   `json:"enabled"`
+	ClientId              string `json:"client_id" binding:"required"`
+	ClientSecret          string `json:"client_secret" binding:"required"`
+	AuthorizationEndpoint string `json:"authorization_endpoint" binding:"required"`
+	TokenEndpoint         string `json:"token_endpoint" binding:"required"`
+	UserInfoEndpoint      string `json:"user_info_endpoint" binding:"required"`
+	Scopes                string `json:"scopes"`
+	UserIdField           string `json:"user_id_field"`
+	UsernameField         string `json:"username_field"`
+	DisplayNameField      string `json:"display_name_field"`
+	EmailField            string `json:"email_field"`
+	WellKnown             string `json:"well_known"`
+	AuthStyle             int    `json:"auth_style"`
+}
+
+// CreateCustomOAuthProvider creates a new custom OAuth provider
+func CreateCustomOAuthProvider(c *gin.Context) {
+	var req CreateCustomOAuthProviderRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		common.ApiErrorMsg(c, "无效的请求参数: "+err.Error())
+		return
+	}
+
+	// Check if slug is already taken
+	if model.IsSlugTaken(req.Slug, 0) {
+		common.ApiErrorMsg(c, "该 Slug 已被使用")
+		return
+	}
+
+	// Check if slug conflicts with built-in providers
+	if oauth.IsProviderRegistered(req.Slug) && !oauth.IsCustomProvider(req.Slug) {
+		common.ApiErrorMsg(c, "该 Slug 与内置 OAuth 提供商冲突")
+		return
+	}
+
+	provider := &model.CustomOAuthProvider{
+		Name:                  req.Name,
+		Slug:                  req.Slug,
+		Enabled:               req.Enabled,
+		ClientId:              req.ClientId,
+		ClientSecret:          req.ClientSecret,
+		AuthorizationEndpoint: req.AuthorizationEndpoint,
+		TokenEndpoint:         req.TokenEndpoint,
+		UserInfoEndpoint:      req.UserInfoEndpoint,
+		Scopes:                req.Scopes,
+		UserIdField:           req.UserIdField,
+		UsernameField:         req.UsernameField,
+		DisplayNameField:      req.DisplayNameField,
+		EmailField:            req.EmailField,
+		WellKnown:             req.WellKnown,
+		AuthStyle:             req.AuthStyle,
+	}
+
+	if err := model.CreateCustomOAuthProvider(provider); err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	// Register the provider in the OAuth registry
+	oauth.RegisterOrUpdateCustomProvider(provider)
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "创建成功",
+		"data":    toCustomOAuthProviderResponse(provider),
+	})
+}
+
+// UpdateCustomOAuthProviderRequest is the request structure for updating a custom OAuth provider
+type UpdateCustomOAuthProviderRequest struct {
+	Name                  string `json:"name"`
+	Slug                  string `json:"slug"`
+	Enabled               bool   `json:"enabled"`
+	ClientId              string `json:"client_id"`
+	ClientSecret          string `json:"client_secret"` // Optional: if empty, keep existing
+	AuthorizationEndpoint string `json:"authorization_endpoint"`
+	TokenEndpoint         string `json:"token_endpoint"`
+	UserInfoEndpoint      string `json:"user_info_endpoint"`
+	Scopes                string `json:"scopes"`
+	UserIdField           string `json:"user_id_field"`
+	UsernameField         string `json:"username_field"`
+	DisplayNameField      string `json:"display_name_field"`
+	EmailField            string `json:"email_field"`
+	WellKnown             string `json:"well_known"`
+	AuthStyle             int    `json:"auth_style"`
+}
+
+// UpdateCustomOAuthProvider updates an existing custom OAuth provider
+func UpdateCustomOAuthProvider(c *gin.Context) {
+	idStr := c.Param("id")
+	id, err := strconv.Atoi(idStr)
+	if err != nil {
+		common.ApiErrorMsg(c, "无效的 ID")
+		return
+	}
+
+	var req UpdateCustomOAuthProviderRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		common.ApiErrorMsg(c, "无效的请求参数: "+err.Error())
+		return
+	}
+
+	// Get existing provider
+	provider, err := model.GetCustomOAuthProviderById(id)
+	if err != nil {
+		common.ApiErrorMsg(c, "未找到该 OAuth 提供商")
+		return
+	}
+
+	oldSlug := provider.Slug
+
+	// Check if new slug is taken by another provider
+	if req.Slug != "" && req.Slug != provider.Slug {
+		if model.IsSlugTaken(req.Slug, id) {
+			common.ApiErrorMsg(c, "该 Slug 已被使用")
+			return
+		}
+		// Check if slug conflicts with built-in providers
+		if oauth.IsProviderRegistered(req.Slug) && !oauth.IsCustomProvider(req.Slug) {
+			common.ApiErrorMsg(c, "该 Slug 与内置 OAuth 提供商冲突")
+			return
+		}
+	}
+
+	// Update fields
+	if req.Name != "" {
+		provider.Name = req.Name
+	}
+	if req.Slug != "" {
+		provider.Slug = req.Slug
+	}
+	provider.Enabled = req.Enabled
+	if req.ClientId != "" {
+		provider.ClientId = req.ClientId
+	}
+	if req.ClientSecret != "" {
+		provider.ClientSecret = req.ClientSecret
+	}
+	if req.AuthorizationEndpoint != "" {
+		provider.AuthorizationEndpoint = req.AuthorizationEndpoint
+	}
+	if req.TokenEndpoint != "" {
+		provider.TokenEndpoint = req.TokenEndpoint
+	}
+	if req.UserInfoEndpoint != "" {
+		provider.UserInfoEndpoint = req.UserInfoEndpoint
+	}
+	if req.Scopes != "" {
+		provider.Scopes = req.Scopes
+	}
+	if req.UserIdField != "" {
+		provider.UserIdField = req.UserIdField
+	}
+	if req.UsernameField != "" {
+		provider.UsernameField = req.UsernameField
+	}
+	if req.DisplayNameField != "" {
+		provider.DisplayNameField = req.DisplayNameField
+	}
+	if req.EmailField != "" {
+		provider.EmailField = req.EmailField
+	}
+	provider.WellKnown = req.WellKnown
+	provider.AuthStyle = req.AuthStyle
+
+	if err := model.UpdateCustomOAuthProvider(provider); err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	// Update the provider in the OAuth registry
+	if oldSlug != provider.Slug {
+		oauth.UnregisterCustomProvider(oldSlug)
+	}
+	oauth.RegisterOrUpdateCustomProvider(provider)
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "更新成功",
+		"data":    toCustomOAuthProviderResponse(provider),
+	})
+}
+
+// DeleteCustomOAuthProvider deletes a custom OAuth provider
+func DeleteCustomOAuthProvider(c *gin.Context) {
+	idStr := c.Param("id")
+	id, err := strconv.Atoi(idStr)
+	if err != nil {
+		common.ApiErrorMsg(c, "无效的 ID")
+		return
+	}
+
+	// Get existing provider to get slug
+	provider, err := model.GetCustomOAuthProviderById(id)
+	if err != nil {
+		common.ApiErrorMsg(c, "未找到该 OAuth 提供商")
+		return
+	}
+
+	// Check if there are any user bindings
+	count, _ := model.GetBindingCountByProviderId(id)
+	if count > 0 {
+		common.ApiErrorMsg(c, "该 OAuth 提供商还有用户绑定,无法删除。请先解除所有用户绑定。")
+		return
+	}
+
+	if err := model.DeleteCustomOAuthProvider(id); err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	// Unregister the provider from the OAuth registry
+	oauth.UnregisterCustomProvider(provider.Slug)
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "删除成功",
+	})
+}
+
+// GetUserOAuthBindings returns all OAuth bindings for the current user
+func GetUserOAuthBindings(c *gin.Context) {
+	userId := c.GetInt("id")
+	if userId == 0 {
+		common.ApiErrorMsg(c, "未登录")
+		return
+	}
+
+	bindings, err := model.GetUserOAuthBindingsByUserId(userId)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	// Build response with provider info
+	type BindingResponse struct {
+		ProviderId     int    `json:"provider_id"`
+		ProviderName   string `json:"provider_name"`
+		ProviderSlug   string `json:"provider_slug"`
+		ProviderUserId string `json:"provider_user_id"`
+	}
+
+	response := make([]BindingResponse, 0)
+	for _, binding := range bindings {
+		provider, err := model.GetCustomOAuthProviderById(binding.ProviderId)
+		if err != nil {
+			continue // Skip if provider not found
+		}
+		response = append(response, BindingResponse{
+			ProviderId:     binding.ProviderId,
+			ProviderName:   provider.Name,
+			ProviderSlug:   provider.Slug,
+			ProviderUserId: binding.ProviderUserId,
+		})
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "",
+		"data":    response,
+	})
+}
+
+// UnbindCustomOAuth unbinds a custom OAuth provider from the current user
+func UnbindCustomOAuth(c *gin.Context) {
+	userId := c.GetInt("id")
+	if userId == 0 {
+		common.ApiErrorMsg(c, "未登录")
+		return
+	}
+
+	providerIdStr := c.Param("provider_id")
+	providerId, err := strconv.Atoi(providerIdStr)
+	if err != nil {
+		common.ApiErrorMsg(c, "无效的提供商 ID")
+		return
+	}
+
+	if err := model.DeleteUserOAuthBinding(userId, providerId); err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "解绑成功",
+	})
+}

+ 25 - 0
controller/misc.go

@@ -10,6 +10,7 @@ import (
 	"github.com/QuantumNous/new-api/constant"
 	"github.com/QuantumNous/new-api/middleware"
 	"github.com/QuantumNous/new-api/model"
+	"github.com/QuantumNous/new-api/oauth"
 	"github.com/QuantumNous/new-api/setting"
 	"github.com/QuantumNous/new-api/setting/console_setting"
 	"github.com/QuantumNous/new-api/setting/operation_setting"
@@ -129,6 +130,30 @@ func GetStatus(c *gin.Context) {
 		data["faq"] = console_setting.GetFAQ()
 	}
 
+	// Add enabled custom OAuth providers
+	customProviders := oauth.GetEnabledCustomProviders()
+	if len(customProviders) > 0 {
+		type CustomOAuthInfo struct {
+			Name                  string `json:"name"`
+			Slug                  string `json:"slug"`
+			ClientId              string `json:"client_id"`
+			AuthorizationEndpoint string `json:"authorization_endpoint"`
+			Scopes                string `json:"scopes"`
+		}
+		providersInfo := make([]CustomOAuthInfo, 0, len(customProviders))
+		for _, p := range customProviders {
+			config := p.GetConfig()
+			providersInfo = append(providersInfo, CustomOAuthInfo{
+				Name:                  config.Name,
+				Slug:                  config.Slug,
+				ClientId:              config.ClientId,
+				AuthorizationEndpoint: config.AuthorizationEndpoint,
+				Scopes:                config.Scopes,
+			})
+		}
+		data["custom_oauth_providers"] = providersInfo
+	}
+
 	c.JSON(http.StatusOK, gin.H{
 		"success": true,
 		"message": "",

+ 35 - 9
controller/oauth.go

@@ -171,12 +171,22 @@ func handleOAuthBind(c *gin.Context, provider oauth.Provider) {
 		return
 	}
 
-	// Update user with OAuth ID
-	provider.SetProviderUserID(&user, oauthUser.ProviderUserID)
-	err = user.Update(false)
-	if err != nil {
-		common.ApiError(c, err)
-		return
+	// Handle binding based on provider type
+	if genericProvider, ok := provider.(*oauth.GenericOAuthProvider); ok {
+		// Custom provider: use user_oauth_bindings table
+		err = model.UpdateUserOAuthBinding(user.Id, genericProvider.GetProviderId(), oauthUser.ProviderUserID)
+		if err != nil {
+			common.ApiError(c, err)
+			return
+		}
+	} else {
+		// Built-in provider: update user record directly
+		provider.SetProviderUserID(&user, oauthUser.ProviderUserID)
+		err = user.Update(false)
+		if err != nil {
+			common.ApiError(c, err)
+			return
+		}
 	}
 
 	common.ApiSuccessI18n(c, i18n.MsgOAuthBindSuccess, nil)
@@ -188,7 +198,6 @@ func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *o
 
 	// Check if user already exists with new ID
 	if provider.IsUserIDTaken(oauthUser.ProviderUserID) {
-		provider.SetProviderUserID(user, oauthUser.ProviderUserID)
 		err := provider.FillUserByProviderID(user, oauthUser.ProviderUserID)
 		if err != nil {
 			return nil, err
@@ -203,7 +212,6 @@ func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *o
 	// Try to find user with legacy ID (for GitHub migration from login to numeric ID)
 	if legacyID, ok := oauthUser.Extra["legacy_id"].(string); ok && legacyID != "" {
 		if provider.IsUserIDTaken(legacyID) {
-			provider.SetProviderUserID(user, legacyID)
 			err := provider.FillUserByProviderID(user, legacyID)
 			if err != nil {
 				return nil, err
@@ -240,7 +248,6 @@ func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *o
 	}
 	user.Role = common.RoleCommonUser
 	user.Status = common.UserStatusEnabled
-	provider.SetProviderUserID(user, oauthUser.ProviderUserID)
 
 	// Handle affiliate code
 	affCode := session.Get("aff")
@@ -253,6 +260,25 @@ func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *o
 		return nil, err
 	}
 
+	// For custom providers, create the binding after user is created
+	if genericProvider, ok := provider.(*oauth.GenericOAuthProvider); ok {
+		binding := &model.UserOAuthBinding{
+			UserId:         user.Id,
+			ProviderId:     genericProvider.GetProviderId(),
+			ProviderUserId: oauthUser.ProviderUserID,
+		}
+		if err := model.CreateUserOAuthBinding(binding); err != nil {
+			common.SysError(fmt.Sprintf("[OAuth] Failed to create binding for user %d: %s", user.Id, err.Error()))
+			// Don't fail the registration, just log the error
+		}
+	} else {
+		// Built-in provider: set the provider user ID on the user model
+		provider.SetProviderUserID(user, oauthUser.ProviderUserID)
+		if err := user.Update(false); err != nil {
+			common.SysError(fmt.Sprintf("[OAuth] Failed to update provider ID for user %d: %s", user.Id, err.Error()))
+		}
+	}
+
 	return user, nil
 }
 

+ 11 - 0
i18n/keys.go

@@ -287,3 +287,14 @@ const (
 	MsgUuidDuplicate         = "common.uuid_duplicate"
 	MsgInvalidInput          = "common.invalid_input"
 )
+
+// Custom OAuth provider related messages
+const (
+	MsgCustomOAuthNotFound           = "custom_oauth.not_found"
+	MsgCustomOAuthSlugEmpty          = "custom_oauth.slug_empty"
+	MsgCustomOAuthSlugExists         = "custom_oauth.slug_exists"
+	MsgCustomOAuthNameEmpty          = "custom_oauth.name_empty"
+	MsgCustomOAuthHasBindings        = "custom_oauth.has_bindings"
+	MsgCustomOAuthBindingNotFound    = "custom_oauth.binding_not_found"
+	MsgCustomOAuthProviderIdInvalid  = "custom_oauth.provider_id_field_invalid"
+)

+ 9 - 0
i18n/locales/en.yaml

@@ -240,3 +240,12 @@ redeem.failed: "Redemption failed, please try again later"
 user.create_default_token_error: "Failed to create default token"
 common.uuid_duplicate: "Please retry, the system generated a duplicate UUID!"
 common.invalid_input: "Invalid input"
+
+# Custom OAuth provider messages
+custom_oauth.not_found: "Custom OAuth provider not found"
+custom_oauth.slug_empty: "Slug cannot be empty"
+custom_oauth.slug_exists: "Slug already exists"
+custom_oauth.name_empty: "Provider name cannot be empty"
+custom_oauth.has_bindings: "Cannot delete provider with existing user bindings"
+custom_oauth.binding_not_found: "OAuth binding not found"
+custom_oauth.provider_id_field_invalid: "Could not extract user ID from provider response"

+ 9 - 0
i18n/locales/zh.yaml

@@ -241,3 +241,12 @@ redeem.failed: "兑换失败,请稍后重试"
 user.create_default_token_error: "创建默认令牌失败"
 common.uuid_duplicate: "请重试,系统生成的 UUID 竟然重复了!"
 common.invalid_input: "输入不合法"
+
+# Custom OAuth provider messages
+custom_oauth.not_found: "自定义 OAuth 提供商不存在"
+custom_oauth.slug_empty: "标识符不能为空"
+custom_oauth.slug_exists: "标识符已存在"
+custom_oauth.name_empty: "提供商名称不能为空"
+custom_oauth.has_bindings: "无法删除已有用户绑定的提供商"
+custom_oauth.binding_not_found: "OAuth 绑定不存在"
+custom_oauth.provider_id_field_invalid: "无法从提供商响应中提取用户 ID"

+ 8 - 0
main.go

@@ -18,6 +18,7 @@ import (
 	"github.com/QuantumNous/new-api/logger"
 	"github.com/QuantumNous/new-api/middleware"
 	"github.com/QuantumNous/new-api/model"
+	"github.com/QuantumNous/new-api/oauth"
 	"github.com/QuantumNous/new-api/router"
 	"github.com/QuantumNous/new-api/service"
 	_ "github.com/QuantumNous/new-api/setting/performance_setting"
@@ -291,5 +292,12 @@ func InitResources() error {
 	// Register user language loader for lazy loading
 	i18n.SetUserLangLoader(model.GetUserLanguage)
 
+	// Load custom OAuth providers from database
+	err = oauth.LoadCustomProviders()
+	if err != nil {
+		common.SysError("failed to load custom OAuth providers: " + err.Error())
+		// Don't return error, custom OAuth is not critical
+	}
+
 	return nil
 }

+ 158 - 0
model/custom_oauth_provider.go

@@ -0,0 +1,158 @@
+package model
+
+import (
+	"errors"
+	"strings"
+	"time"
+)
+
+// CustomOAuthProvider stores configuration for custom OAuth providers
+type CustomOAuthProvider struct {
+	Id                    int       `json:"id" gorm:"primaryKey"`
+	Name                  string    `json:"name" gorm:"type:varchar(64);not null"`                 // Display name, e.g., "GitHub Enterprise"
+	Slug                  string    `json:"slug" gorm:"type:varchar(64);uniqueIndex;not null"`     // URL identifier, e.g., "github-enterprise"
+	Enabled               bool      `json:"enabled" gorm:"default:false"`                          // Whether this provider is enabled
+	ClientId              string    `json:"client_id" gorm:"type:varchar(256)"`                    // OAuth client ID
+	ClientSecret          string    `json:"-" gorm:"type:varchar(512)"`                            // OAuth client secret (not returned to frontend)
+	AuthorizationEndpoint string    `json:"authorization_endpoint" gorm:"type:varchar(512)"`       // Authorization URL
+	TokenEndpoint         string    `json:"token_endpoint" gorm:"type:varchar(512)"`               // Token exchange URL
+	UserInfoEndpoint      string    `json:"user_info_endpoint" gorm:"type:varchar(512)"`           // User info URL
+	Scopes                string    `json:"scopes" gorm:"type:varchar(256);default:'openid profile email'"` // OAuth scopes
+
+	// Field mapping configuration (supports JSONPath via gjson)
+	UserIdField       string `json:"user_id_field" gorm:"type:varchar(128);default:'sub'"`                // User ID field path, e.g., "sub", "id", "data.user.id"
+	UsernameField     string `json:"username_field" gorm:"type:varchar(128);default:'preferred_username'"` // Username field path
+	DisplayNameField  string `json:"display_name_field" gorm:"type:varchar(128);default:'name'"`          // Display name field path
+	EmailField        string `json:"email_field" gorm:"type:varchar(128);default:'email'"`                // Email field path
+
+	// Advanced options
+	WellKnown string `json:"well_known" gorm:"type:varchar(512)"` // OIDC discovery endpoint (optional)
+	AuthStyle int    `json:"auth_style" gorm:"default:0"`         // 0=auto, 1=params, 2=header (Basic Auth)
+
+	CreatedAt time.Time `json:"created_at"`
+	UpdatedAt time.Time `json:"updated_at"`
+}
+
+func (CustomOAuthProvider) TableName() string {
+	return "custom_oauth_providers"
+}
+
+// GetAllCustomOAuthProviders returns all custom OAuth providers
+func GetAllCustomOAuthProviders() ([]*CustomOAuthProvider, error) {
+	var providers []*CustomOAuthProvider
+	err := DB.Order("id asc").Find(&providers).Error
+	return providers, err
+}
+
+// GetEnabledCustomOAuthProviders returns all enabled custom OAuth providers
+func GetEnabledCustomOAuthProviders() ([]*CustomOAuthProvider, error) {
+	var providers []*CustomOAuthProvider
+	err := DB.Where("enabled = ?", true).Order("id asc").Find(&providers).Error
+	return providers, err
+}
+
+// GetCustomOAuthProviderById returns a custom OAuth provider by ID
+func GetCustomOAuthProviderById(id int) (*CustomOAuthProvider, error) {
+	var provider CustomOAuthProvider
+	err := DB.First(&provider, id).Error
+	if err != nil {
+		return nil, err
+	}
+	return &provider, nil
+}
+
+// GetCustomOAuthProviderBySlug returns a custom OAuth provider by slug
+func GetCustomOAuthProviderBySlug(slug string) (*CustomOAuthProvider, error) {
+	var provider CustomOAuthProvider
+	err := DB.Where("slug = ?", slug).First(&provider).Error
+	if err != nil {
+		return nil, err
+	}
+	return &provider, nil
+}
+
+// CreateCustomOAuthProvider creates a new custom OAuth provider
+func CreateCustomOAuthProvider(provider *CustomOAuthProvider) error {
+	if err := validateCustomOAuthProvider(provider); err != nil {
+		return err
+	}
+	return DB.Create(provider).Error
+}
+
+// UpdateCustomOAuthProvider updates an existing custom OAuth provider
+func UpdateCustomOAuthProvider(provider *CustomOAuthProvider) error {
+	if err := validateCustomOAuthProvider(provider); err != nil {
+		return err
+	}
+	return DB.Save(provider).Error
+}
+
+// DeleteCustomOAuthProvider deletes a custom OAuth provider by ID
+func DeleteCustomOAuthProvider(id int) error {
+	// First, delete all user bindings for this provider
+	if err := DB.Where("provider_id = ?", id).Delete(&UserOAuthBinding{}).Error; err != nil {
+		return err
+	}
+	return DB.Delete(&CustomOAuthProvider{}, id).Error
+}
+
+// IsSlugTaken checks if a slug is already taken by another provider
+func IsSlugTaken(slug string, excludeId int) bool {
+	var count int64
+	query := DB.Model(&CustomOAuthProvider{}).Where("slug = ?", slug)
+	if excludeId > 0 {
+		query = query.Where("id != ?", excludeId)
+	}
+	query.Count(&count)
+	return count > 0
+}
+
+// validateCustomOAuthProvider validates a custom OAuth provider configuration
+func validateCustomOAuthProvider(provider *CustomOAuthProvider) error {
+	if provider.Name == "" {
+		return errors.New("provider name is required")
+	}
+	if provider.Slug == "" {
+		return errors.New("provider slug is required")
+	}
+	// Slug must be lowercase and contain only alphanumeric characters and hyphens
+	slug := strings.ToLower(provider.Slug)
+	for _, c := range slug {
+		if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') {
+			return errors.New("provider slug must contain only lowercase letters, numbers, and hyphens")
+		}
+	}
+	provider.Slug = slug
+
+	if provider.ClientId == "" {
+		return errors.New("client ID is required")
+	}
+	if provider.AuthorizationEndpoint == "" {
+		return errors.New("authorization endpoint is required")
+	}
+	if provider.TokenEndpoint == "" {
+		return errors.New("token endpoint is required")
+	}
+	if provider.UserInfoEndpoint == "" {
+		return errors.New("user info endpoint is required")
+	}
+
+	// Set defaults for field mappings if empty
+	if provider.UserIdField == "" {
+		provider.UserIdField = "sub"
+	}
+	if provider.UsernameField == "" {
+		provider.UsernameField = "preferred_username"
+	}
+	if provider.DisplayNameField == "" {
+		provider.DisplayNameField = "name"
+	}
+	if provider.EmailField == "" {
+		provider.EmailField = "email"
+	}
+	if provider.Scopes == "" {
+		provider.Scopes = "openid profile email"
+	}
+
+	return nil
+}

+ 4 - 0
model/main.go

@@ -274,6 +274,8 @@ func migrateDB() error {
 		&SubscriptionOrder{},
 		&UserSubscription{},
 		&SubscriptionPreConsumeRecord{},
+		&CustomOAuthProvider{},
+		&UserOAuthBinding{},
 	)
 	if err != nil {
 		return err
@@ -320,6 +322,8 @@ func migrateDBFast() error {
 		{&SubscriptionOrder{}, "SubscriptionOrder"},
 		{&UserSubscription{}, "UserSubscription"},
 		{&SubscriptionPreConsumeRecord{}, "SubscriptionPreConsumeRecord"},
+		{&CustomOAuthProvider{}, "CustomOAuthProvider"},
+		{&UserOAuthBinding{}, "UserOAuthBinding"},
 	}
 	// 动态计算migration数量,确保errChan缓冲区足够大
 	errChan := make(chan error, len(migrations))

+ 125 - 0
model/user_oauth_binding.go

@@ -0,0 +1,125 @@
+package model
+
+import (
+	"errors"
+	"time"
+)
+
+// UserOAuthBinding stores the binding relationship between users and custom OAuth providers
+type UserOAuthBinding struct {
+	Id             int       `json:"id" gorm:"primaryKey"`
+	UserId         int       `json:"user_id" gorm:"index;not null"`                                               // User ID
+	ProviderId     int       `json:"provider_id" gorm:"index;not null"`                                           // Custom OAuth provider ID
+	ProviderUserId string    `json:"provider_user_id" gorm:"type:varchar(256);not null"`                          // User ID from OAuth provider
+	CreatedAt      time.Time `json:"created_at"`
+
+	// Composite unique index to prevent duplicate bindings
+	// One OAuth account can only be bound to one user
+}
+
+func (UserOAuthBinding) TableName() string {
+	return "user_oauth_bindings"
+}
+
+// GetUserOAuthBindingsByUserId returns all OAuth bindings for a user
+func GetUserOAuthBindingsByUserId(userId int) ([]*UserOAuthBinding, error) {
+	var bindings []*UserOAuthBinding
+	err := DB.Where("user_id = ?", userId).Find(&bindings).Error
+	return bindings, err
+}
+
+// GetUserOAuthBinding returns a specific binding for a user and provider
+func GetUserOAuthBinding(userId, providerId int) (*UserOAuthBinding, error) {
+	var binding UserOAuthBinding
+	err := DB.Where("user_id = ? AND provider_id = ?", userId, providerId).First(&binding).Error
+	if err != nil {
+		return nil, err
+	}
+	return &binding, nil
+}
+
+// GetUserByOAuthBinding finds a user by provider ID and provider user ID
+func GetUserByOAuthBinding(providerId int, providerUserId string) (*User, error) {
+	var binding UserOAuthBinding
+	err := DB.Where("provider_id = ? AND provider_user_id = ?", providerId, providerUserId).First(&binding).Error
+	if err != nil {
+		return nil, err
+	}
+
+	var user User
+	err = DB.First(&user, binding.UserId).Error
+	if err != nil {
+		return nil, err
+	}
+	return &user, nil
+}
+
+// IsProviderUserIdTaken checks if a provider user ID is already bound to any user
+func IsProviderUserIdTaken(providerId int, providerUserId string) bool {
+	var count int64
+	DB.Model(&UserOAuthBinding{}).Where("provider_id = ? AND provider_user_id = ?", providerId, providerUserId).Count(&count)
+	return count > 0
+}
+
+// CreateUserOAuthBinding creates a new OAuth binding
+func CreateUserOAuthBinding(binding *UserOAuthBinding) error {
+	if binding.UserId == 0 {
+		return errors.New("user ID is required")
+	}
+	if binding.ProviderId == 0 {
+		return errors.New("provider ID is required")
+	}
+	if binding.ProviderUserId == "" {
+		return errors.New("provider user ID is required")
+	}
+
+	// Check if this provider user ID is already taken
+	if IsProviderUserIdTaken(binding.ProviderId, binding.ProviderUserId) {
+		return errors.New("this OAuth account is already bound to another user")
+	}
+
+	binding.CreatedAt = time.Now()
+	return DB.Create(binding).Error
+}
+
+// UpdateUserOAuthBinding updates an existing OAuth binding (e.g., rebind to different OAuth account)
+func UpdateUserOAuthBinding(userId, providerId int, newProviderUserId string) error {
+	// Check if the new provider user ID is already taken by another user
+	var existingBinding UserOAuthBinding
+	err := DB.Where("provider_id = ? AND provider_user_id = ?", providerId, newProviderUserId).First(&existingBinding).Error
+	if err == nil && existingBinding.UserId != userId {
+		return errors.New("this OAuth account is already bound to another user")
+	}
+
+	// Check if user already has a binding for this provider
+	var binding UserOAuthBinding
+	err = DB.Where("user_id = ? AND provider_id = ?", userId, providerId).First(&binding).Error
+	if err != nil {
+		// No existing binding, create new one
+		return CreateUserOAuthBinding(&UserOAuthBinding{
+			UserId:         userId,
+			ProviderId:     providerId,
+			ProviderUserId: newProviderUserId,
+		})
+	}
+
+	// Update existing binding
+	return DB.Model(&binding).Update("provider_user_id", newProviderUserId).Error
+}
+
+// DeleteUserOAuthBinding deletes an OAuth binding
+func DeleteUserOAuthBinding(userId, providerId int) error {
+	return DB.Where("user_id = ? AND provider_id = ?", userId, providerId).Delete(&UserOAuthBinding{}).Error
+}
+
+// DeleteUserOAuthBindingsByUserId deletes all OAuth bindings for a user
+func DeleteUserOAuthBindingsByUserId(userId int) error {
+	return DB.Where("user_id = ?", userId).Delete(&UserOAuthBinding{}).Error
+}
+
+// GetBindingCountByProviderId returns the number of bindings for a provider
+func GetBindingCountByProviderId(providerId int) (int64, error) {
+	var count int64
+	err := DB.Model(&UserOAuthBinding{}).Where("provider_id = ?", providerId).Count(&count).Error
+	return count, err
+}

+ 268 - 0
oauth/generic.go

@@ -0,0 +1,268 @@
+package oauth
+
+import (
+	"context"
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+
+	"github.com/QuantumNous/new-api/i18n"
+	"github.com/QuantumNous/new-api/logger"
+	"github.com/QuantumNous/new-api/model"
+	"github.com/QuantumNous/new-api/setting/system_setting"
+	"github.com/gin-gonic/gin"
+	"github.com/tidwall/gjson"
+)
+
+// AuthStyle defines how to send client credentials
+const (
+	AuthStyleAutoDetect = 0 // Auto-detect based on server response
+	AuthStyleInParams   = 1 // Send client_id and client_secret as POST parameters
+	AuthStyleInHeader   = 2 // Send as Basic Auth header
+)
+
+// GenericOAuthProvider implements OAuth for custom/generic OAuth providers
+type GenericOAuthProvider struct {
+	config *model.CustomOAuthProvider
+}
+
+// NewGenericOAuthProvider creates a new generic OAuth provider from config
+func NewGenericOAuthProvider(config *model.CustomOAuthProvider) *GenericOAuthProvider {
+	return &GenericOAuthProvider{config: config}
+}
+
+func (p *GenericOAuthProvider) GetName() string {
+	return p.config.Name
+}
+
+func (p *GenericOAuthProvider) IsEnabled() bool {
+	return p.config.Enabled
+}
+
+func (p *GenericOAuthProvider) GetConfig() *model.CustomOAuthProvider {
+	return p.config
+}
+
+func (p *GenericOAuthProvider) ExchangeToken(ctx context.Context, code string, c *gin.Context) (*OAuthToken, error) {
+	if code == "" {
+		return nil, NewOAuthError(i18n.MsgOAuthInvalidCode, nil)
+	}
+
+	logger.LogDebug(ctx, "[OAuth-Generic-%s] ExchangeToken: code=%s...", p.config.Slug, code[:min(len(code), 10)])
+
+	redirectUri := fmt.Sprintf("%s/oauth/%s", system_setting.ServerAddress, p.config.Slug)
+	values := url.Values{}
+	values.Set("grant_type", "authorization_code")
+	values.Set("code", code)
+	values.Set("redirect_uri", redirectUri)
+
+	// Determine auth style
+	authStyle := p.config.AuthStyle
+	if authStyle == AuthStyleAutoDetect {
+		// Default to params style for most OAuth servers
+		authStyle = AuthStyleInParams
+	}
+
+	var req *http.Request
+	var err error
+
+	if authStyle == AuthStyleInParams {
+		values.Set("client_id", p.config.ClientId)
+		values.Set("client_secret", p.config.ClientSecret)
+	}
+
+	req, err = http.NewRequestWithContext(ctx, "POST", p.config.TokenEndpoint, strings.NewReader(values.Encode()))
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	req.Header.Set("Accept", "application/json")
+
+	if authStyle == AuthStyleInHeader {
+		// Basic Auth
+		credentials := base64.StdEncoding.EncodeToString([]byte(p.config.ClientId + ":" + p.config.ClientSecret))
+		req.Header.Set("Authorization", "Basic "+credentials)
+	}
+
+	logger.LogDebug(ctx, "[OAuth-Generic-%s] ExchangeToken: token_endpoint=%s, redirect_uri=%s, auth_style=%d",
+		p.config.Slug, p.config.TokenEndpoint, redirectUri, authStyle)
+
+	client := http.Client{
+		Timeout: 20 * time.Second,
+	}
+	res, err := client.Do(req)
+	if err != nil {
+		logger.LogError(ctx, fmt.Sprintf("[OAuth-Generic-%s] ExchangeToken error: %s", p.config.Slug, err.Error()))
+		return nil, NewOAuthErrorWithRaw(i18n.MsgOAuthConnectFailed, map[string]any{"Provider": p.config.Name}, err.Error())
+	}
+	defer res.Body.Close()
+
+	logger.LogDebug(ctx, "[OAuth-Generic-%s] ExchangeToken response status: %d", p.config.Slug, res.StatusCode)
+
+	body, err := io.ReadAll(res.Body)
+	if err != nil {
+		logger.LogError(ctx, fmt.Sprintf("[OAuth-Generic-%s] ExchangeToken read body error: %s", p.config.Slug, err.Error()))
+		return nil, err
+	}
+
+	bodyStr := string(body)
+	logger.LogDebug(ctx, "[OAuth-Generic-%s] ExchangeToken response body: %s", p.config.Slug, bodyStr[:min(len(bodyStr), 500)])
+
+	// Try to parse as JSON first
+	var tokenResponse struct {
+		AccessToken  string `json:"access_token"`
+		TokenType    string `json:"token_type"`
+		RefreshToken string `json:"refresh_token"`
+		ExpiresIn    int    `json:"expires_in"`
+		Scope        string `json:"scope"`
+		IDToken      string `json:"id_token"`
+		Error        string `json:"error"`
+		ErrorDesc    string `json:"error_description"`
+	}
+
+	if err := json.Unmarshal(body, &tokenResponse); err != nil {
+		// Try to parse as URL-encoded (some OAuth servers like GitHub return this format)
+		parsedValues, parseErr := url.ParseQuery(bodyStr)
+		if parseErr != nil {
+			logger.LogError(ctx, fmt.Sprintf("[OAuth-Generic-%s] ExchangeToken parse error: %s", p.config.Slug, err.Error()))
+			return nil, err
+		}
+		tokenResponse.AccessToken = parsedValues.Get("access_token")
+		tokenResponse.TokenType = parsedValues.Get("token_type")
+		tokenResponse.Scope = parsedValues.Get("scope")
+	}
+
+	if tokenResponse.Error != "" {
+		logger.LogError(ctx, fmt.Sprintf("[OAuth-Generic-%s] ExchangeToken OAuth error: %s - %s",
+			p.config.Slug, tokenResponse.Error, tokenResponse.ErrorDesc))
+		return nil, NewOAuthErrorWithRaw(i18n.MsgOAuthTokenFailed, map[string]any{"Provider": p.config.Name}, tokenResponse.ErrorDesc)
+	}
+
+	if tokenResponse.AccessToken == "" {
+		logger.LogError(ctx, fmt.Sprintf("[OAuth-Generic-%s] ExchangeToken failed: empty access token", p.config.Slug))
+		return nil, NewOAuthError(i18n.MsgOAuthTokenFailed, map[string]any{"Provider": p.config.Name})
+	}
+
+	logger.LogDebug(ctx, "[OAuth-Generic-%s] ExchangeToken success: scope=%s", p.config.Slug, tokenResponse.Scope)
+
+	return &OAuthToken{
+		AccessToken:  tokenResponse.AccessToken,
+		TokenType:    tokenResponse.TokenType,
+		RefreshToken: tokenResponse.RefreshToken,
+		ExpiresIn:    tokenResponse.ExpiresIn,
+		Scope:        tokenResponse.Scope,
+		IDToken:      tokenResponse.IDToken,
+	}, nil
+}
+
+func (p *GenericOAuthProvider) GetUserInfo(ctx context.Context, token *OAuthToken) (*OAuthUser, error) {
+	logger.LogDebug(ctx, "[OAuth-Generic-%s] GetUserInfo: fetching user info from %s", p.config.Slug, p.config.UserInfoEndpoint)
+
+	req, err := http.NewRequestWithContext(ctx, "GET", p.config.UserInfoEndpoint, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	// Set authorization header
+	tokenType := token.TokenType
+	if tokenType == "" {
+		tokenType = "Bearer"
+	}
+	req.Header.Set("Authorization", fmt.Sprintf("%s %s", tokenType, token.AccessToken))
+	req.Header.Set("Accept", "application/json")
+
+	client := http.Client{
+		Timeout: 20 * time.Second,
+	}
+	res, err := client.Do(req)
+	if err != nil {
+		logger.LogError(ctx, fmt.Sprintf("[OAuth-Generic-%s] GetUserInfo error: %s", p.config.Slug, err.Error()))
+		return nil, NewOAuthErrorWithRaw(i18n.MsgOAuthConnectFailed, map[string]any{"Provider": p.config.Name}, err.Error())
+	}
+	defer res.Body.Close()
+
+	logger.LogDebug(ctx, "[OAuth-Generic-%s] GetUserInfo response status: %d", p.config.Slug, res.StatusCode)
+
+	if res.StatusCode != http.StatusOK {
+		logger.LogError(ctx, fmt.Sprintf("[OAuth-Generic-%s] GetUserInfo failed: status=%d", p.config.Slug, res.StatusCode))
+		return nil, NewOAuthError(i18n.MsgOAuthGetUserErr, nil)
+	}
+
+	body, err := io.ReadAll(res.Body)
+	if err != nil {
+		logger.LogError(ctx, fmt.Sprintf("[OAuth-Generic-%s] GetUserInfo read body error: %s", p.config.Slug, err.Error()))
+		return nil, err
+	}
+
+	bodyStr := string(body)
+	logger.LogDebug(ctx, "[OAuth-Generic-%s] GetUserInfo response body: %s", p.config.Slug, bodyStr[:min(len(bodyStr), 500)])
+
+	// Extract fields using gjson (supports JSONPath-like syntax)
+	userId := gjson.Get(bodyStr, p.config.UserIdField).String()
+	username := gjson.Get(bodyStr, p.config.UsernameField).String()
+	displayName := gjson.Get(bodyStr, p.config.DisplayNameField).String()
+	email := gjson.Get(bodyStr, p.config.EmailField).String()
+
+	// If user ID field returns a number, convert it
+	if userId == "" {
+		// Try to get as number
+		userIdNum := gjson.Get(bodyStr, p.config.UserIdField)
+		if userIdNum.Exists() {
+			userId = userIdNum.Raw
+			// Remove quotes if present
+			userId = strings.Trim(userId, "\"")
+		}
+	}
+
+	if userId == "" {
+		logger.LogError(ctx, fmt.Sprintf("[OAuth-Generic-%s] GetUserInfo failed: empty user ID (field: %s)", p.config.Slug, p.config.UserIdField))
+		return nil, NewOAuthError(i18n.MsgOAuthUserInfoEmpty, map[string]any{"Provider": p.config.Name})
+	}
+
+	logger.LogDebug(ctx, "[OAuth-Generic-%s] GetUserInfo success: id=%s, username=%s, name=%s, email=%s",
+		p.config.Slug, userId, username, displayName, email)
+
+	return &OAuthUser{
+		ProviderUserID: userId,
+		Username:       username,
+		DisplayName:    displayName,
+		Email:          email,
+	}, nil
+}
+
+func (p *GenericOAuthProvider) IsUserIDTaken(providerUserID string) bool {
+	return model.IsProviderUserIdTaken(p.config.Id, providerUserID)
+}
+
+func (p *GenericOAuthProvider) FillUserByProviderID(user *model.User, providerUserID string) error {
+	foundUser, err := model.GetUserByOAuthBinding(p.config.Id, providerUserID)
+	if err != nil {
+		return err
+	}
+	*user = *foundUser
+	return nil
+}
+
+func (p *GenericOAuthProvider) SetProviderUserID(user *model.User, providerUserID string) {
+	// For generic providers, we store the binding in user_oauth_bindings table
+	// This is handled separately in the OAuth controller
+}
+
+func (p *GenericOAuthProvider) GetProviderPrefix() string {
+	return p.config.Slug + "_"
+}
+
+// GetProviderId returns the provider ID for binding purposes
+func (p *GenericOAuthProvider) GetProviderId() int {
+	return p.config.Id
+}
+
+// IsGenericProvider returns true for generic providers
+func (p *GenericOAuthProvider) IsGenericProvider() bool {
+	return true
+}

+ 91 - 0
oauth/registry.go

@@ -1,12 +1,18 @@
 package oauth
 
 import (
+	"fmt"
 	"sync"
+
+	"github.com/QuantumNous/new-api/common"
+	"github.com/QuantumNous/new-api/model"
 )
 
 var (
 	providers = make(map[string]Provider)
 	mu        sync.RWMutex
+	// customProviderSlugs tracks which providers are custom (can be unregistered)
+	customProviderSlugs = make(map[string]bool)
 )
 
 // Register registers an OAuth provider with the given name
@@ -16,6 +22,22 @@ func Register(name string, provider Provider) {
 	providers[name] = provider
 }
 
+// RegisterCustom registers a custom OAuth provider (can be unregistered later)
+func RegisterCustom(name string, provider Provider) {
+	mu.Lock()
+	defer mu.Unlock()
+	providers[name] = provider
+	customProviderSlugs[name] = true
+}
+
+// Unregister removes a provider from the registry
+func Unregister(name string) {
+	mu.Lock()
+	defer mu.Unlock()
+	delete(providers, name)
+	delete(customProviderSlugs, name)
+}
+
 // GetProvider returns the OAuth provider for the given name
 func GetProvider(name string) Provider {
 	mu.RLock()
@@ -34,6 +56,21 @@ func GetAllProviders() map[string]Provider {
 	return result
 }
 
+// GetEnabledCustomProviders returns all enabled custom OAuth providers
+func GetEnabledCustomProviders() []*GenericOAuthProvider {
+	mu.RLock()
+	defer mu.RUnlock()
+	var result []*GenericOAuthProvider
+	for name, provider := range providers {
+		if customProviderSlugs[name] {
+			if gp, ok := provider.(*GenericOAuthProvider); ok && gp.IsEnabled() {
+				result = append(result, gp)
+			}
+		}
+	}
+	return result
+}
+
 // IsProviderRegistered checks if a provider is registered
 func IsProviderRegistered(name string) bool {
 	mu.RLock()
@@ -41,3 +78,57 @@ func IsProviderRegistered(name string) bool {
 	_, ok := providers[name]
 	return ok
 }
+
+// IsCustomProvider checks if a provider is a custom provider
+func IsCustomProvider(name string) bool {
+	mu.RLock()
+	defer mu.RUnlock()
+	return customProviderSlugs[name]
+}
+
+// LoadCustomProviders loads all custom OAuth providers from the database
+func LoadCustomProviders() error {
+	// First, unregister all existing custom providers
+	mu.Lock()
+	for name := range customProviderSlugs {
+		delete(providers, name)
+	}
+	customProviderSlugs = make(map[string]bool)
+	mu.Unlock()
+
+	// Load all custom providers from database
+	customProviders, err := model.GetAllCustomOAuthProviders()
+	if err != nil {
+		common.SysError("Failed to load custom OAuth providers: " + err.Error())
+		return err
+	}
+
+	// Register each custom provider
+	for _, config := range customProviders {
+		provider := NewGenericOAuthProvider(config)
+		RegisterCustom(config.Slug, provider)
+		common.SysLog("Loaded custom OAuth provider: " + config.Name + " (" + config.Slug + ")")
+	}
+
+	common.SysLog(fmt.Sprintf("Loaded %d custom OAuth providers", len(customProviders)))
+	return nil
+}
+
+// ReloadCustomProviders reloads all custom OAuth providers from the database
+func ReloadCustomProviders() error {
+	return LoadCustomProviders()
+}
+
+// RegisterOrUpdateCustomProvider registers or updates a single custom provider
+func RegisterOrUpdateCustomProvider(config *model.CustomOAuthProvider) {
+	provider := NewGenericOAuthProvider(config)
+	mu.Lock()
+	defer mu.Unlock()
+	providers[config.Slug] = provider
+	customProviderSlugs[config.Slug] = true
+}
+
+// UnregisterCustomProvider unregisters a custom provider by slug
+func UnregisterCustomProvider(slug string) {
+	Unregister(slug)
+}

+ 15 - 0
router/api-router.go

@@ -102,6 +102,10 @@ func SetApiRouter(router *gin.Engine) {
 				// Check-in routes
 				selfRoute.GET("/checkin", controller.GetCheckinStatus)
 				selfRoute.POST("/checkin", middleware.TurnstileCheck(), controller.DoCheckin)
+
+				// Custom OAuth bindings
+				selfRoute.GET("/oauth/bindings", controller.GetUserOAuthBindings)
+				selfRoute.DELETE("/oauth/bindings/:provider_id", controller.UnbindCustomOAuth)
 			}
 
 			adminRoute := userRoute.Group("/")
@@ -166,6 +170,17 @@ func SetApiRouter(router *gin.Engine) {
 			optionRoute.POST("/rest_model_ratio", controller.ResetModelRatio)
 			optionRoute.POST("/migrate_console_setting", controller.MigrateConsoleSetting) // 用于迁移检测的旧键,下个版本会删除
 		}
+
+		// Custom OAuth provider management (admin only)
+		customOAuthRoute := apiRouter.Group("/custom-oauth-provider")
+		customOAuthRoute.Use(middleware.RootAuth())
+		{
+			customOAuthRoute.GET("/", controller.GetCustomOAuthProviders)
+			customOAuthRoute.GET("/:id", controller.GetCustomOAuthProvider)
+			customOAuthRoute.POST("/", controller.CreateCustomOAuthProvider)
+			customOAuthRoute.PUT("/:id", controller.UpdateCustomOAuthProvider)
+			customOAuthRoute.DELETE("/:id", controller.DeleteCustomOAuthProvider)
+		}
 		performanceRoute := apiRouter.Group("/performance")
 		performanceRoute.Use(middleware.RootAuth())
 		{

+ 36 - 0
web/src/components/auth/LoginForm.jsx

@@ -34,6 +34,7 @@ import {
   onDiscordOAuthClicked,
   onOIDCClicked,
   onLinuxDOOAuthClicked,
+  onCustomOAuthClicked,
   prepareCredentialRequestOptions,
   buildAssertionResult,
   isPasskeySupported,
@@ -109,6 +110,7 @@ const LoginForm = () => {
   const [githubButtonDisabled, setGithubButtonDisabled] = useState(false);
   const githubTimeoutRef = useRef(null);
   const githubButtonText = t(githubButtonTextKeyByState[githubButtonState]);
+  const [customOAuthLoading, setCustomOAuthLoading] = useState({});
 
   const logo = getLogo();
   const systemName = getSystemName();
@@ -373,6 +375,23 @@ const LoginForm = () => {
     }
   };
 
+  // 包装的自定义OAuth登录点击处理
+  const handleCustomOAuthClick = (provider) => {
+    if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
+      showInfo(t('请先阅读并同意用户协议和隐私政策'));
+      return;
+    }
+    setCustomOAuthLoading((prev) => ({ ...prev, [provider.slug]: true }));
+    try {
+      onCustomOAuthClicked(provider, { shouldLogout: true });
+    } finally {
+      // 由于重定向,这里不会执行到,但为了完整性添加
+      setTimeout(() => {
+        setCustomOAuthLoading((prev) => ({ ...prev, [provider.slug]: false }));
+      }, 3000);
+    }
+  };
+
   // 包装的邮箱登录选项点击处理
   const handleEmailLoginClick = () => {
     setEmailLoginLoading(true);
@@ -572,6 +591,23 @@ const LoginForm = () => {
                   </Button>
                 )}
 
+                {status.custom_oauth_providers &&
+                  status.custom_oauth_providers.map((provider) => (
+                    <Button
+                      key={provider.slug}
+                      theme='outline'
+                      className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
+                      type='tertiary'
+                      icon={<IconLock size='large' />}
+                      onClick={() => handleCustomOAuthClick(provider)}
+                      loading={customOAuthLoading[provider.slug]}
+                    >
+                      <span className='ml-3'>
+                        {t('使用 {{name}} 继续', { name: provider.name })}
+                      </span>
+                    </Button>
+                  ))}
+
                 {status.telegram_oauth && (
                   <div className='flex justify-center my-2'>
                     <TelegramLoginButton

+ 631 - 0
web/src/components/settings/CustomOAuthSetting.jsx

@@ -0,0 +1,631 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
+
+import React, { useEffect, useState } from 'react';
+import {
+  Button,
+  Form,
+  Row,
+  Col,
+  Typography,
+  Modal,
+  Banner,
+  Card,
+  Table,
+  Tag,
+  Popconfirm,
+  Space,
+  Select,
+} from '@douyinfe/semi-ui';
+import { IconPlus, IconEdit, IconDelete } from '@douyinfe/semi-icons';
+import { API, showError, showSuccess } from '../../helpers';
+import { useTranslation } from 'react-i18next';
+
+const { Text } = Typography;
+
+// Preset templates for common OAuth providers
+const OAUTH_PRESETS = {
+  'github-enterprise': {
+    name: 'GitHub Enterprise',
+    authorization_endpoint: '/login/oauth/authorize',
+    token_endpoint: '/login/oauth/access_token',
+    user_info_endpoint: '/api/v3/user',
+    scopes: 'user:email',
+    user_id_field: 'id',
+    username_field: 'login',
+    display_name_field: 'name',
+    email_field: 'email',
+  },
+  gitlab: {
+    name: 'GitLab',
+    authorization_endpoint: '/oauth/authorize',
+    token_endpoint: '/oauth/token',
+    user_info_endpoint: '/api/v4/user',
+    scopes: 'openid profile email',
+    user_id_field: 'id',
+    username_field: 'username',
+    display_name_field: 'name',
+    email_field: 'email',
+  },
+  gitea: {
+    name: 'Gitea',
+    authorization_endpoint: '/login/oauth/authorize',
+    token_endpoint: '/login/oauth/access_token',
+    user_info_endpoint: '/api/v1/user',
+    scopes: 'openid profile email',
+    user_id_field: 'id',
+    username_field: 'login',
+    display_name_field: 'full_name',
+    email_field: 'email',
+  },
+  nextcloud: {
+    name: 'Nextcloud',
+    authorization_endpoint: '/apps/oauth2/authorize',
+    token_endpoint: '/apps/oauth2/api/v1/token',
+    user_info_endpoint: '/ocs/v2.php/cloud/user?format=json',
+    scopes: 'openid profile email',
+    user_id_field: 'ocs.data.id',
+    username_field: 'ocs.data.id',
+    display_name_field: 'ocs.data.displayname',
+    email_field: 'ocs.data.email',
+  },
+  keycloak: {
+    name: 'Keycloak',
+    authorization_endpoint: '/realms/{realm}/protocol/openid-connect/auth',
+    token_endpoint: '/realms/{realm}/protocol/openid-connect/token',
+    user_info_endpoint: '/realms/{realm}/protocol/openid-connect/userinfo',
+    scopes: 'openid profile email',
+    user_id_field: 'sub',
+    username_field: 'preferred_username',
+    display_name_field: 'name',
+    email_field: 'email',
+  },
+  authentik: {
+    name: 'Authentik',
+    authorization_endpoint: '/application/o/authorize/',
+    token_endpoint: '/application/o/token/',
+    user_info_endpoint: '/application/o/userinfo/',
+    scopes: 'openid profile email',
+    user_id_field: 'sub',
+    username_field: 'preferred_username',
+    display_name_field: 'name',
+    email_field: 'email',
+  },
+  ory: {
+    name: 'ORY Hydra',
+    authorization_endpoint: '/oauth2/auth',
+    token_endpoint: '/oauth2/token',
+    user_info_endpoint: '/userinfo',
+    scopes: 'openid profile email',
+    user_id_field: 'sub',
+    username_field: 'preferred_username',
+    display_name_field: 'name',
+    email_field: 'email',
+  },
+};
+
+const CustomOAuthSetting = ({ serverAddress }) => {
+  const { t } = useTranslation();
+  const [providers, setProviders] = useState([]);
+  const [loading, setLoading] = useState(false);
+  const [modalVisible, setModalVisible] = useState(false);
+  const [editingProvider, setEditingProvider] = useState(null);
+  const [formValues, setFormValues] = useState({});
+  const [selectedPreset, setSelectedPreset] = useState('');
+  const [baseUrl, setBaseUrl] = useState('');
+  const formApiRef = React.useRef(null);
+
+  const fetchProviders = async () => {
+    setLoading(true);
+    try {
+      const res = await API.get('/api/custom-oauth-provider/');
+      if (res.data.success) {
+        setProviders(res.data.data || []);
+      } else {
+        showError(res.data.message);
+      }
+    } catch (error) {
+      showError(t('获取自定义 OAuth 提供商列表失败'));
+    }
+    setLoading(false);
+  };
+
+  useEffect(() => {
+    fetchProviders();
+  }, []);
+
+  const handleAdd = () => {
+    setEditingProvider(null);
+    setFormValues({
+      enabled: false,
+      scopes: 'openid profile email',
+      user_id_field: 'sub',
+      username_field: 'preferred_username',
+      display_name_field: 'name',
+      email_field: 'email',
+      auth_style: 0,
+    });
+    setSelectedPreset('');
+    setBaseUrl('');
+    setModalVisible(true);
+  };
+
+  const handleEdit = (provider) => {
+    setEditingProvider(provider);
+    setFormValues({ ...provider });
+    setSelectedPreset('');
+    setBaseUrl('');
+    setModalVisible(true);
+  };
+
+  const handleDelete = async (id) => {
+    try {
+      const res = await API.delete(`/api/custom-oauth-provider/${id}`);
+      if (res.data.success) {
+        showSuccess(t('删除成功'));
+        fetchProviders();
+      } else {
+        showError(res.data.message);
+      }
+    } catch (error) {
+      showError(t('删除失败'));
+    }
+  };
+
+  const handleSubmit = async () => {
+    // Validate required fields
+    const requiredFields = [
+      'name',
+      'slug',
+      'client_id',
+      'authorization_endpoint',
+      'token_endpoint',
+      'user_info_endpoint',
+    ];
+    
+    if (!editingProvider) {
+      requiredFields.push('client_secret');
+    }
+
+    for (const field of requiredFields) {
+      if (!formValues[field]) {
+        showError(t(`请填写 ${field}`));
+        return;
+      }
+    }
+
+    // Validate endpoint URLs must be full URLs
+    const endpointFields = ['authorization_endpoint', 'token_endpoint', 'user_info_endpoint'];
+    for (const field of endpointFields) {
+      const value = formValues[field];
+      if (value && !value.startsWith('http://') && !value.startsWith('https://')) {
+        // Check if user selected a preset but forgot to fill server address
+        if (selectedPreset && !baseUrl) {
+          showError(t('请先填写服务器地址,以自动生成完整的端点 URL'));
+        } else {
+          showError(t('端点 URL 必须是完整地址(以 http:// 或 https:// 开头)'));
+        }
+        return;
+      }
+    }
+
+    try {
+      let res;
+      if (editingProvider) {
+        res = await API.put(
+          `/api/custom-oauth-provider/${editingProvider.id}`,
+          formValues
+        );
+      } else {
+        res = await API.post('/api/custom-oauth-provider/', formValues);
+      }
+
+      if (res.data.success) {
+        showSuccess(editingProvider ? t('更新成功') : t('创建成功'));
+        setModalVisible(false);
+        fetchProviders();
+      } else {
+        showError(res.data.message);
+      }
+    } catch (error) {
+      showError(editingProvider ? t('更新失败') : t('创建失败'));
+    }
+  };
+
+  const handlePresetChange = (preset) => {
+    setSelectedPreset(preset);
+    if (preset && OAUTH_PRESETS[preset]) {
+      const presetConfig = OAUTH_PRESETS[preset];
+      const cleanUrl = baseUrl ? baseUrl.replace(/\/+$/, '') : '';
+      const newValues = {
+        name: presetConfig.name,
+        slug: preset,
+        scopes: presetConfig.scopes,
+        user_id_field: presetConfig.user_id_field,
+        username_field: presetConfig.username_field,
+        display_name_field: presetConfig.display_name_field,
+        email_field: presetConfig.email_field,
+        auth_style: presetConfig.auth_style ?? 0,
+      };
+      // Only fill endpoints if server address is provided
+      if (cleanUrl) {
+        newValues.authorization_endpoint = cleanUrl + presetConfig.authorization_endpoint;
+        newValues.token_endpoint = cleanUrl + presetConfig.token_endpoint;
+        newValues.user_info_endpoint = cleanUrl + presetConfig.user_info_endpoint;
+      }
+      setFormValues((prev) => ({ ...prev, ...newValues }));
+      // Update form fields directly via formApi
+      if (formApiRef.current) {
+        Object.entries(newValues).forEach(([key, value]) => {
+          formApiRef.current.setValue(key, value);
+        });
+      }
+    }
+  };
+
+  const handleBaseUrlChange = (url) => {
+    setBaseUrl(url);
+    if (url && selectedPreset && OAUTH_PRESETS[selectedPreset]) {
+      const presetConfig = OAUTH_PRESETS[selectedPreset];
+      const cleanUrl = url.replace(/\/+$/, ''); // Remove trailing slashes
+      const newValues = {
+        authorization_endpoint: cleanUrl + presetConfig.authorization_endpoint,
+        token_endpoint: cleanUrl + presetConfig.token_endpoint,
+        user_info_endpoint: cleanUrl + presetConfig.user_info_endpoint,
+      };
+      setFormValues((prev) => ({ ...prev, ...newValues }));
+      // Update form fields directly via formApi (use merge mode to preserve other fields)
+      if (formApiRef.current) {
+        Object.entries(newValues).forEach(([key, value]) => {
+          formApiRef.current.setValue(key, value);
+        });
+      }
+    }
+  };
+
+  const columns = [
+    {
+      title: t('名称'),
+      dataIndex: 'name',
+      key: 'name',
+    },
+    {
+      title: 'Slug',
+      dataIndex: 'slug',
+      key: 'slug',
+      render: (slug) => <Tag>{slug}</Tag>,
+    },
+    {
+      title: t('状态'),
+      dataIndex: 'enabled',
+      key: 'enabled',
+      render: (enabled) => (
+        <Tag color={enabled ? 'green' : 'grey'}>
+          {enabled ? t('已启用') : t('已禁用')}
+        </Tag>
+      ),
+    },
+    {
+      title: t('Client ID'),
+      dataIndex: 'client_id',
+      key: 'client_id',
+      render: (id) => (id ? id.substring(0, 20) + '...' : '-'),
+    },
+    {
+      title: t('操作'),
+      key: 'actions',
+      render: (_, record) => (
+        <Space>
+          <Button
+            icon={<IconEdit />}
+            size="small"
+            onClick={() => handleEdit(record)}
+          >
+            {t('编辑')}
+          </Button>
+          <Popconfirm
+            title={t('确定要删除此 OAuth 提供商吗?')}
+            onConfirm={() => handleDelete(record.id)}
+          >
+            <Button icon={<IconDelete />} size="small" type="danger">
+              {t('删除')}
+            </Button>
+          </Popconfirm>
+        </Space>
+      ),
+    },
+  ];
+
+  return (
+    <Card>
+      <Form.Section text={t('自定义 OAuth 提供商')}>
+        <Banner
+          type="info"
+          description={
+            <>
+              {t(
+                '配置自定义 OAuth 提供商,支持 GitHub Enterprise、GitLab、Gitea、Nextcloud、Keycloak、ORY 等兼容 OAuth 2.0 协议的身份提供商'
+              )}
+              <br />
+              {t('回调 URL 格式')}: {serverAddress || t('网站地址')}/oauth/
+              {'{slug}'}
+            </>
+          }
+          style={{ marginBottom: 20 }}
+        />
+
+        <Button
+          icon={<IconPlus />}
+          theme="solid"
+          onClick={handleAdd}
+          style={{ marginBottom: 16 }}
+        >
+          {t('添加 OAuth 提供商')}
+        </Button>
+
+        <Table
+          columns={columns}
+          dataSource={providers}
+          loading={loading}
+          rowKey="id"
+          pagination={false}
+          empty={t('暂无自定义 OAuth 提供商')}
+        />
+
+        <Modal
+          title={editingProvider ? t('编辑 OAuth 提供商') : t('添加 OAuth 提供商')}
+          visible={modalVisible}
+          onOk={handleSubmit}
+          onCancel={() => setModalVisible(false)}
+          okText={t('保存')}
+          cancelText={t('取消')}
+          width={800}
+        >
+          <Form
+            initValues={formValues}
+            onValueChange={(values) => setFormValues(values)}
+            getFormApi={(api) => (formApiRef.current = api)}
+          >
+            {!editingProvider && (
+              <Row gutter={16} style={{ marginBottom: 16 }}>
+                <Col span={12}>
+                  <Form.Select
+                    field="preset"
+                    label={t('预设模板')}
+                    placeholder={t('选择预设模板(可选)')}
+                    value={selectedPreset}
+                    onChange={handlePresetChange}
+                    optionList={[
+                      { value: '', label: t('自定义') },
+                      ...Object.entries(OAUTH_PRESETS).map(([key, config]) => ({
+                        value: key,
+                        label: config.name,
+                      })),
+                    ]}
+                  />
+                </Col>
+                <Col span={12}>
+                  <Form.Input
+                    field="base_url"
+                    label={
+                      selectedPreset
+                        ? t('服务器地址') + ' *'
+                        : t('服务器地址')
+                    }
+                    placeholder={t('例如:https://gitea.example.com')}
+                    value={baseUrl}
+                    onChange={handleBaseUrlChange}
+                    extraText={
+                      selectedPreset
+                        ? t('必填:请输入服务器地址以自动生成完整端点 URL')
+                        : t('选择预设模板后填写服务器地址可自动填充端点')
+                    }
+                  />
+                </Col>
+              </Row>
+            )}
+
+            <Row gutter={16}>
+              <Col span={12}>
+                <Form.Input
+                  field="name"
+                  label={t('显示名称')}
+                  placeholder={t('例如:GitHub Enterprise')}
+                  rules={[{ required: true, message: t('请输入显示名称') }]}
+                />
+              </Col>
+              <Col span={12}>
+                <Form.Input
+                  field="slug"
+                  label="Slug"
+                  placeholder={t('例如:github-enterprise')}
+                  extraText={t('URL 标识,只能包含小写字母、数字和连字符')}
+                  rules={[{ required: true, message: t('请输入 Slug') }]}
+                />
+              </Col>
+            </Row>
+
+            <Row gutter={16}>
+              <Col span={12}>
+                <Form.Input
+                  field="client_id"
+                  label="Client ID"
+                  placeholder={t('OAuth Client ID')}
+                  rules={[{ required: true, message: t('请输入 Client ID') }]}
+                />
+              </Col>
+              <Col span={12}>
+                <Form.Input
+                  field="client_secret"
+                  label="Client Secret"
+                  type="password"
+                  placeholder={
+                    editingProvider
+                      ? t('留空则保持原有密钥')
+                      : t('OAuth Client Secret')
+                  }
+                  rules={
+                    editingProvider
+                      ? []
+                      : [{ required: true, message: t('请输入 Client Secret') }]
+                  }
+                />
+              </Col>
+            </Row>
+
+            <Text strong style={{ display: 'block', margin: '16px 0 8px' }}>
+              {t('OAuth 端点')}
+            </Text>
+
+            <Row gutter={16}>
+              <Col span={24}>
+                <Form.Input
+                  field="authorization_endpoint"
+                  label={t('Authorization Endpoint')}
+                  placeholder={
+                    selectedPreset && OAUTH_PRESETS[selectedPreset]
+                      ? t('填写服务器地址后自动生成:') +
+                        OAUTH_PRESETS[selectedPreset].authorization_endpoint
+                      : 'https://example.com/oauth/authorize'
+                  }
+                  rules={[
+                    { required: true, message: t('请输入 Authorization Endpoint') },
+                  ]}
+                />
+              </Col>
+            </Row>
+
+            <Row gutter={16}>
+              <Col span={12}>
+                <Form.Input
+                  field="token_endpoint"
+                  label={t('Token Endpoint')}
+                  placeholder={
+                    selectedPreset && OAUTH_PRESETS[selectedPreset]
+                      ? t('自动生成:') + OAUTH_PRESETS[selectedPreset].token_endpoint
+                      : 'https://example.com/oauth/token'
+                  }
+                  rules={[{ required: true, message: t('请输入 Token Endpoint') }]}
+                />
+              </Col>
+              <Col span={12}>
+                <Form.Input
+                  field="user_info_endpoint"
+                  label={t('User Info Endpoint')}
+                  placeholder={
+                    selectedPreset && OAUTH_PRESETS[selectedPreset]
+                      ? t('自动生成:') + OAUTH_PRESETS[selectedPreset].user_info_endpoint
+                      : 'https://example.com/api/user'
+                  }
+                  rules={[
+                    { required: true, message: t('请输入 User Info Endpoint') },
+                  ]}
+                />
+              </Col>
+            </Row>
+
+            <Row gutter={16}>
+              <Col span={12}>
+                <Form.Input
+                  field="scopes"
+                  label={t('Scopes')}
+                  placeholder="openid profile email"
+                />
+              </Col>
+              <Col span={12}>
+                <Form.Input
+                  field="well_known"
+                  label={t('Well-Known URL')}
+                  placeholder={t('OIDC Discovery 端点(可选)')}
+                />
+              </Col>
+            </Row>
+
+            <Text strong style={{ display: 'block', margin: '16px 0 8px' }}>
+              {t('字段映射')}
+            </Text>
+            <Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
+              {t('配置如何从用户信息 API 响应中提取用户数据,支持 JSONPath 语法')}
+            </Text>
+
+            <Row gutter={16}>
+              <Col span={12}>
+                <Form.Input
+                  field="user_id_field"
+                  label={t('用户 ID 字段')}
+                  placeholder={t('例如:sub、id、data.user.id')}
+                  extraText={t('用于唯一标识用户的字段路径')}
+                />
+              </Col>
+              <Col span={12}>
+                <Form.Input
+                  field="username_field"
+                  label={t('用户名字段')}
+                  placeholder={t('例如:preferred_username、login')}
+                />
+              </Col>
+            </Row>
+
+            <Row gutter={16}>
+              <Col span={12}>
+                <Form.Input
+                  field="display_name_field"
+                  label={t('显示名称字段')}
+                  placeholder={t('例如:name、full_name')}
+                />
+              </Col>
+              <Col span={12}>
+                <Form.Input
+                  field="email_field"
+                  label={t('邮箱字段')}
+                  placeholder={t('例如:email')}
+                />
+              </Col>
+            </Row>
+
+            <Text strong style={{ display: 'block', margin: '16px 0 8px' }}>
+              {t('高级选项')}
+            </Text>
+
+            <Row gutter={16}>
+              <Col span={12}>
+                <Form.Select
+                  field="auth_style"
+                  label={t('认证方式')}
+                  optionList={[
+                    { value: 0, label: t('自动检测') },
+                    { value: 1, label: t('POST 参数') },
+                    { value: 2, label: t('Basic Auth 头') },
+                  ]}
+                />
+              </Col>
+              <Col span={12}>
+                <Form.Checkbox field="enabled" noLabel>
+                  {t('启用此 OAuth 提供商')}
+                </Form.Checkbox>
+              </Col>
+            </Row>
+          </Form>
+        </Modal>
+      </Form.Section>
+    </Card>
+  );
+};
+
+export default CustomOAuthSetting;

+ 3 - 0
web/src/components/settings/SystemSetting.jsx

@@ -42,6 +42,7 @@ import {
 } from '../../helpers';
 import axios from 'axios';
 import { useTranslation } from 'react-i18next';
+import CustomOAuthSetting from './CustomOAuthSetting';
 
 const SystemSetting = () => {
   const { t } = useTranslation();
@@ -1534,6 +1535,8 @@ const SystemSetting = () => {
                 </Form.Section>
               </Card>
 
+              <CustomOAuthSetting serverAddress={inputs.ServerAddress} />
+
               <Card>
                 <Form.Section text={t('配置 WeChat Server')}>
                   <Text>{t('用以支持通过微信进行登录注册')}</Text>

+ 122 - 0
web/src/components/settings/personal/cards/AccountManagement.jsx

@@ -42,10 +42,14 @@ import { SiTelegram, SiWechat, SiLinux, SiDiscord } from 'react-icons/si';
 import { UserPlus, ShieldCheck } from 'lucide-react';
 import TelegramLoginButton from 'react-telegram-login';
 import {
+  API,
+  showError,
+  showSuccess,
   onGitHubOAuthClicked,
   onOIDCClicked,
   onLinuxDOOAuthClicked,
   onDiscordOAuthClicked,
+  onCustomOAuthClicked,
 } from '../../../../helpers';
 import TwoFASetting from '../components/TwoFASetting';
 
@@ -94,6 +98,66 @@ const AccountManagement = ({
   const isBound = (accountId) => Boolean(accountId);
   const [showTelegramBindModal, setShowTelegramBindModal] =
     React.useState(false);
+  const [customOAuthBindings, setCustomOAuthBindings] = React.useState([]);
+  const [customOAuthLoading, setCustomOAuthLoading] = React.useState({});
+
+  // Fetch custom OAuth bindings
+  const loadCustomOAuthBindings = async () => {
+    try {
+      const res = await API.get('/api/user/oauth/bindings');
+      if (res.data.success) {
+        setCustomOAuthBindings(res.data.data || []);
+      }
+    } catch (error) {
+      // ignore
+    }
+  };
+
+  // Unbind custom OAuth provider
+  const handleUnbindCustomOAuth = async (providerId, providerName) => {
+    Modal.confirm({
+      title: t('确认解绑'),
+      content: t('确定要解绑 {{name}} 吗?', { name: providerName }),
+      okText: t('确认'),
+      cancelText: t('取消'),
+      onOk: async () => {
+        setCustomOAuthLoading((prev) => ({ ...prev, [providerId]: true }));
+        try {
+          const res = await API.delete(`/api/user/oauth/bindings/${providerId}`);
+          if (res.data.success) {
+            showSuccess(t('解绑成功'));
+            await loadCustomOAuthBindings();
+          } else {
+            showError(res.data.message);
+          }
+        } catch (error) {
+          showError(t('操作失败'));
+        } finally {
+          setCustomOAuthLoading((prev) => ({ ...prev, [providerId]: false }));
+        }
+      },
+    });
+  };
+
+  // Handle bind custom OAuth
+  const handleBindCustomOAuth = (provider) => {
+    onCustomOAuthClicked(provider);
+  };
+
+  // Check if custom OAuth provider is bound
+  const isCustomOAuthBound = (providerId) => {
+    return customOAuthBindings.some((b) => b.provider_id === providerId);
+  };
+
+  // Get binding info for a provider
+  const getCustomOAuthBinding = (providerId) => {
+    return customOAuthBindings.find((b) => b.provider_id === providerId);
+  };
+
+  React.useEffect(() => {
+    loadCustomOAuthBindings();
+  }, []);
+
   const passkeyEnabled = passkeyStatus?.enabled;
   const lastUsedLabel = passkeyStatus?.last_used_at
     ? new Date(passkeyStatus.last_used_at).toLocaleString()
@@ -447,6 +511,64 @@ const AccountManagement = ({
                   </div>
                 </div>
               </Card>
+
+              {/* 自定义 OAuth 提供商绑定 */}
+              {status.custom_oauth_providers &&
+                status.custom_oauth_providers.map((provider) => {
+                  const bound = isCustomOAuthBound(provider.id);
+                  const binding = getCustomOAuthBinding(provider.id);
+                  return (
+                    <Card key={provider.slug} className='!rounded-xl'>
+                      <div className='flex items-center justify-between gap-3'>
+                        <div className='flex items-center flex-1 min-w-0'>
+                          <div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
+                            <IconLock
+                              size='default'
+                              className='text-slate-600 dark:text-slate-300'
+                            />
+                          </div>
+                          <div className='flex-1 min-w-0'>
+                            <div className='font-medium text-gray-900'>
+                              {provider.name}
+                            </div>
+                            <div className='text-sm text-gray-500 truncate'>
+                              {bound
+                                ? renderAccountInfo(
+                                    binding?.provider_user_id,
+                                    t('{{name}} ID', { name: provider.name }),
+                                  )
+                                : t('未绑定')}
+                            </div>
+                          </div>
+                        </div>
+                        <div className='flex-shrink-0'>
+                          {bound ? (
+                            <Button
+                              type='danger'
+                              theme='outline'
+                              size='small'
+                              loading={customOAuthLoading[provider.id]}
+                              onClick={() =>
+                                handleUnbindCustomOAuth(provider.id, provider.name)
+                              }
+                            >
+                              {t('解绑')}
+                            </Button>
+                          ) : (
+                            <Button
+                              type='primary'
+                              theme='outline'
+                              size='small'
+                              onClick={() => handleBindCustomOAuth(provider)}
+                            >
+                              {t('绑定')}
+                            </Button>
+                          )}
+                        </div>
+                      </div>
+                    </Card>
+                  );
+                })}
             </div>
           </div>
         </TabPane>

+ 42 - 0
web/src/helpers/api.js

@@ -294,6 +294,48 @@ export async function onLinuxDOOAuthClicked(
   );
 }
 
+/**
+ * Initiate custom OAuth login
+ * @param {Object} provider - Custom OAuth provider config from status API
+ * @param {string} provider.slug - Provider slug (used for callback URL)
+ * @param {string} provider.client_id - OAuth client ID
+ * @param {string} provider.authorization_endpoint - Authorization URL
+ * @param {string} provider.scopes - OAuth scopes (space-separated)
+ * @param {Object} options - Options
+ * @param {boolean} options.shouldLogout - Whether to logout first
+ */
+export async function onCustomOAuthClicked(provider, options = {}) {
+  const state = await prepareOAuthState(options);
+  if (!state) return;
+  
+  try {
+    const redirect_uri = `${window.location.origin}/oauth/${provider.slug}`;
+    
+    // Check if authorization_endpoint is a full URL or relative path
+    let authUrl;
+    if (provider.authorization_endpoint.startsWith('http://') || 
+        provider.authorization_endpoint.startsWith('https://')) {
+      authUrl = new URL(provider.authorization_endpoint);
+    } else {
+      // Relative path - this is a configuration error, show error message
+      console.error('Custom OAuth authorization_endpoint must be a full URL:', provider.authorization_endpoint);
+      showError('OAuth 配置错误:授权端点必须是完整的 URL(以 http:// 或 https:// 开头)');
+      return;
+    }
+    
+    authUrl.searchParams.set('client_id', provider.client_id);
+    authUrl.searchParams.set('redirect_uri', redirect_uri);
+    authUrl.searchParams.set('response_type', 'code');
+    authUrl.searchParams.set('scope', provider.scopes || 'openid profile email');
+    authUrl.searchParams.set('state', state);
+    
+    window.open(authUrl.toString());
+  } catch (error) {
+    console.error('Failed to initiate custom OAuth:', error);
+    showError('OAuth 登录失败:' + (error.message || '未知错误'));
+  }
+}
+
 let channelModels = undefined;
 export async function loadChannelModels() {
   const res = await API.get('/api/models');

+ 44 - 1
web/src/i18n/locales/en.json

@@ -2795,6 +2795,49 @@
     "语言偏好": "Language Preference",
     "选择您的首选界面语言,设置将自动保存并同步到所有设备": "Select your preferred interface language. Settings will be saved automatically and synced across all devices",
     "语言偏好已保存": "Language preference saved",
-    "提示:语言偏好会同步到您登录的所有设备,并影响API返回的错误消息语言。": "Note: Language preference syncs across all your logged-in devices and affects the language of API error messages."
+    "提示:语言偏好会同步到您登录的所有设备,并影响API返回的错误消息语言。": "Note: Language preference syncs across all your logged-in devices and affects the language of API error messages.",
+    "自定义 OAuth 提供商": "Custom OAuth Providers",
+    "配置自定义 OAuth 提供商,支持 GitHub Enterprise、GitLab、Gitea、Nextcloud、Keycloak、ORY 等兼容 OAuth 2.0 协议的身份提供商": "Configure custom OAuth providers, supports GitHub Enterprise, GitLab, Gitea, Nextcloud, Keycloak, ORY and other OAuth 2.0 compatible identity providers",
+    "回调 URL 格式": "Callback URL format",
+    "添加提供商": "Add Provider",
+    "编辑提供商": "Edit Provider",
+    "选择预设...": "Select preset...",
+    "输入基础 URL": "Enter base URL",
+    "例如": "e.g.",
+    "提供商名称": "Provider Name",
+    "标识符 (Slug)": "Slug",
+    "授权端点": "Authorization Endpoint",
+    "令牌端点": "Token Endpoint",
+    "用户信息端点": "User Info Endpoint",
+    "用户 ID 字段": "User ID Field",
+    "支持 JSONPath,如 sub, id, data.user.id": "Supports JSONPath, e.g. sub, id, data.user.id",
+    "用户名字段": "Username Field",
+    "支持 JSONPath,如 preferred_username, login, data.user.username": "Supports JSONPath, e.g. preferred_username, login, data.user.username",
+    "显示名称字段": "Display Name Field",
+    "支持 JSONPath,如 name, display_name, data.user.name": "Supports JSONPath, e.g. name, display_name, data.user.name",
+    "邮箱字段": "Email Field",
+    "支持 JSONPath,如 email, data.user.email": "Supports JSONPath, e.g. email, data.user.email",
+    "授权范围 (Scopes)": "Scopes",
+    "认证方式": "Auth Style",
+    "自动检测": "Auto-detect",
+    "参数传递": "In Parameters",
+    "Basic Auth 头": "Basic Auth Header",
+    "暂无自定义 OAuth 提供商": "No custom OAuth providers",
+    "确定要删除该提供商吗?": "Are you sure you want to delete this provider?",
+    "创建成功": "Created successfully",
+    "更新成功": "Updated successfully",
+    "确认解绑": "Confirm Unbind",
+    "确定要解绑 {{name}} 吗?": "Are you sure you want to unbind {{name}}?",
+    "解绑成功": "Unbind successful",
+    "{{name}} ID": "{{name}} ID",
+    "使用 {{name}} 继续": "Continue with {{name}}",
+    "端点 URL 必须以 http:// 或 https:// 开头:": "Endpoint URL must start with http:// or https://: ",
+    "OAuth 配置错误:授权端点必须是完整的 URL(以 http:// 或 https:// 开头)": "OAuth configuration error: Authorization endpoint must be a full URL (starting with http:// or https://)",
+    "OAuth 登录失败:": "OAuth login failed: ",
+    "必填:请输入服务器地址以自动生成完整端点 URL": "Required: Enter server address to auto-generate full endpoint URLs",
+    "填写服务器地址后自动生成:": "Auto-generated after entering server address: ",
+    "自动生成:": "Auto-generated: ",
+    "请先填写服务器地址,以自动生成完整的端点 URL": "Please enter the server address first to auto-generate full endpoint URLs",
+    "端点 URL 必须是完整地址(以 http:// 或 https:// 开头)": "Endpoint URL must be a full address (starting with http:// or https://)"
   }
 }

+ 44 - 1
web/src/i18n/locales/zh.json

@@ -2740,6 +2740,49 @@
     "语言偏好": "语言偏好",
     "选择您的首选界面语言,设置将自动保存并同步到所有设备": "选择您的首选界面语言,设置将自动保存并同步到所有设备",
     "语言偏好已保存": "语言偏好已保存",
-    "提示:语言偏好会同步到您登录的所有设备,并影响API返回的错误消息语言。": "提示:语言偏好会同步到您登录的所有设备,并影响API返回的错误消息语言。"
+    "提示:语言偏好会同步到您登录的所有设备,并影响API返回的错误消息语言。": "提示:语言偏好会同步到您登录的所有设备,并影响API返回的错误消息语言。",
+    "自定义 OAuth 提供商": "自定义 OAuth 提供商",
+    "配置自定义 OAuth 提供商,支持 GitHub Enterprise、GitLab、Gitea、Nextcloud、Keycloak、ORY 等兼容 OAuth 2.0 协议的身份提供商": "配置自定义 OAuth 提供商,支持 GitHub Enterprise、GitLab、Gitea、Nextcloud、Keycloak、ORY 等兼容 OAuth 2.0 协议的身份提供商",
+    "回调 URL 格式": "回调 URL 格式",
+    "添加提供商": "添加提供商",
+    "编辑提供商": "编辑提供商",
+    "选择预设...": "选择预设...",
+    "输入基础 URL": "输入基础 URL",
+    "例如": "例如",
+    "提供商名称": "提供商名称",
+    "标识符 (Slug)": "标识符 (Slug)",
+    "授权端点": "授权端点",
+    "令牌端点": "令牌端点",
+    "用户信息端点": "用户信息端点",
+    "用户 ID 字段": "用户 ID 字段",
+    "支持 JSONPath,如 sub, id, data.user.id": "支持 JSONPath,如 sub, id, data.user.id",
+    "用户名字段": "用户名字段",
+    "支持 JSONPath,如 preferred_username, login, data.user.username": "支持 JSONPath,如 preferred_username, login, data.user.username",
+    "显示名称字段": "显示名称字段",
+    "支持 JSONPath,如 name, display_name, data.user.name": "支持 JSONPath,如 name, display_name, data.user.name",
+    "邮箱字段": "邮箱字段",
+    "支持 JSONPath,如 email, data.user.email": "支持 JSONPath,如 email, data.user.email",
+    "授权范围 (Scopes)": "授权范围 (Scopes)",
+    "认证方式": "认证方式",
+    "自动检测": "自动检测",
+    "参数传递": "参数传递",
+    "Basic Auth 头": "Basic Auth 头",
+    "暂无自定义 OAuth 提供商": "暂无自定义 OAuth 提供商",
+    "确定要删除该提供商吗?": "确定要删除该提供商吗?",
+    "创建成功": "创建成功",
+    "更新成功": "更新成功",
+    "确认解绑": "确认解绑",
+    "确定要解绑 {{name}} 吗?": "确定要解绑 {{name}} 吗?",
+    "解绑成功": "解绑成功",
+    "{{name}} ID": "{{name}} ID",
+    "使用 {{name}} 继续": "使用 {{name}} 继续",
+    "端点 URL 必须以 http:// 或 https:// 开头:": "端点 URL 必须以 http:// 或 https:// 开头:",
+    "OAuth 配置错误:授权端点必须是完整的 URL(以 http:// 或 https:// 开头)": "OAuth 配置错误:授权端点必须是完整的 URL(以 http:// 或 https:// 开头)",
+    "OAuth 登录失败:": "OAuth 登录失败:",
+    "必填:请输入服务器地址以自动生成完整端点 URL": "必填:请输入服务器地址以自动生成完整端点 URL",
+    "填写服务器地址后自动生成:": "填写服务器地址后自动生成:",
+    "自动生成:": "自动生成:",
+    "请先填写服务器地址,以自动生成完整的端点 URL": "请先填写服务器地址,以自动生成完整的端点 URL",
+    "端点 URL 必须是完整地址(以 http:// 或 https:// 开头)": "端点 URL 必须是完整地址(以 http:// 或 https:// 开头)"
   }
 }