From 10e56675255d92d681641ebd5e794a7a238ad300 Mon Sep 17 00:00:00 2001 From: TomyJan Date: Sun, 31 May 2026 20:50:21 +0800 Subject: [PATCH 1/3] fix(oauth): fix OIDC and custom OAuth binding flows - Derive OAuth redirect URIs from request headers before falling back to ServerAddress - Improve OIDC token exchange diagnostics - Fix custom OAuth profile binding to use provider slug and OAuth state - Show custom OAuth provider user IDs in admin binding management - Add configurable auto-link policy for custom OAuth providers - Remove custom OAuth bindings when users are deleted - Add custom OAuth UI controls, translations, and built-in OIDC guidance --- controller/custom_oauth.go | 8 +++ controller/oauth.go | 55 +++++++++++++++++++ model/custom_oauth_provider.go | 9 +++ model/task_cas_test.go | 2 + model/user.go | 6 ++ model/user_oauth_binding_delete_test.go | 51 +++++++++++++++++ oauth/generic.go | 17 +++++- oauth/oidc.go | 23 +++++++- oauth/redirect_uri.go | 35 ++++++++++++ web/default/src/features/profile/api.ts | 8 ++- .../components/tabs/account-bindings-tab.tsx | 28 ++++++++-- .../components/provider-form-dialog.tsx | 42 ++++++++++++++ .../auth/custom-oauth/types.ts | 8 +++ .../system-settings/auth/oauth-section.tsx | 6 ++ web/default/src/features/users/api.ts | 7 ++- .../dialogs/user-binding-dialog.tsx | 6 +- web/default/src/i18n/locales/en.json | 6 ++ web/default/src/i18n/locales/fr.json | 6 ++ web/default/src/i18n/locales/ja.json | 6 ++ web/default/src/i18n/locales/ru.json | 6 ++ web/default/src/i18n/locales/vi.json | 6 ++ web/default/src/i18n/locales/zh.json | 6 ++ 22 files changed, 327 insertions(+), 20 deletions(-) create mode 100644 model/user_oauth_binding_delete_test.go create mode 100644 oauth/redirect_uri.go diff --git a/controller/custom_oauth.go b/controller/custom_oauth.go index 8172e29718f3..6da3e646b92b 100644 --- a/controller/custom_oauth.go +++ b/controller/custom_oauth.go @@ -34,6 +34,7 @@ type CustomOAuthProviderResponse struct { EmailField string `json:"email_field"` WellKnown string `json:"well_known"` AuthStyle int `json:"auth_style"` + AutoLinkPolicy string `json:"auto_link_policy"` AccessPolicy string `json:"access_policy"` AccessDeniedMessage string `json:"access_denied_message"` } @@ -64,6 +65,7 @@ func toCustomOAuthProviderResponse(p *model.CustomOAuthProvider) *CustomOAuthPro EmailField: p.EmailField, WellKnown: p.WellKnown, AuthStyle: p.AuthStyle, + AutoLinkPolicy: p.AutoLinkPolicy, AccessPolicy: p.AccessPolicy, AccessDeniedMessage: p.AccessDeniedMessage, } @@ -129,6 +131,7 @@ type CreateCustomOAuthProviderRequest struct { EmailField string `json:"email_field"` WellKnown string `json:"well_known"` AuthStyle int `json:"auth_style"` + AutoLinkPolicy string `json:"auto_link_policy"` AccessPolicy string `json:"access_policy"` AccessDeniedMessage string `json:"access_denied_message"` } @@ -247,6 +250,7 @@ func CreateCustomOAuthProvider(c *gin.Context) { EmailField: req.EmailField, WellKnown: req.WellKnown, AuthStyle: req.AuthStyle, + AutoLinkPolicy: req.AutoLinkPolicy, AccessPolicy: req.AccessPolicy, AccessDeniedMessage: req.AccessDeniedMessage, } @@ -284,6 +288,7 @@ type UpdateCustomOAuthProviderRequest struct { EmailField string `json:"email_field"` WellKnown *string `json:"well_known"` // Optional: if nil, keep existing AuthStyle *int `json:"auth_style"` // Optional: if nil, keep existing + AutoLinkPolicy *string `json:"auto_link_policy"` // Optional: if nil, keep existing AccessPolicy *string `json:"access_policy"` // Optional: if nil, keep existing AccessDeniedMessage *string `json:"access_denied_message"` // Optional: if nil, keep existing } @@ -374,6 +379,9 @@ func UpdateCustomOAuthProvider(c *gin.Context) { if req.AuthStyle != nil { provider.AuthStyle = *req.AuthStyle } + if req.AutoLinkPolicy != nil { + provider.AutoLinkPolicy = *req.AutoLinkPolicy + } if req.AccessPolicy != nil { provider.AccessPolicy = *req.AccessPolicy } diff --git a/controller/oauth.go b/controller/oauth.go index 9951f22b035f..6a02e4fc3c99 100644 --- a/controller/oauth.go +++ b/controller/oauth.go @@ -1,6 +1,7 @@ package controller import ( + "errors" "fmt" "net/http" "strconv" @@ -232,6 +233,17 @@ func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *o } } + // Custom providers may explicitly link to an existing local user before creating a new one. + if genericProvider, ok := provider.(*oauth.GenericOAuthProvider); ok { + linkedUser, linked, err := tryAutoLinkCustomOAuthUser(genericProvider, oauthUser) + if err != nil { + return nil, err + } + if linked { + return linkedUser, nil + } + } + // User doesn't exist, create new user if registration is enabled if !common.RegisterEnabled { return nil, &OAuthRegistrationDisabledError{} @@ -330,6 +342,49 @@ func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *o return user, nil } +func tryAutoLinkCustomOAuthUser(provider *oauth.GenericOAuthProvider, oauthUser *oauth.OAuthUser) (*model.User, bool, error) { + policy := provider.GetAutoLinkPolicy() + if policy == "none" { + return nil, false, nil + } + + user := &model.User{} + var err error + switch policy { + case "email_verified": + if oauthUser.Email == "" { + return nil, false, nil + } + if verified, ok := oauthUser.Extra["email_verified"].(bool); !ok || !verified { + return nil, false, nil + } + err = model.DB.Where("email = ?", oauthUser.Email).First(user).Error + case "username": + if oauthUser.Username == "" { + return nil, false, nil + } + err = model.DB.Where("username = ?", oauthUser.Username).First(user).Error + default: + return nil, false, nil + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + if user.Id == 0 { + return nil, false, nil + } + if user.Status != common.UserStatusEnabled { + return user, true, nil + } + if err := model.UpdateUserOAuthBinding(user.Id, provider.GetProviderId(), oauthUser.ProviderUserID); err != nil { + return nil, false, err + } + return user, true, nil +} + // Error types for OAuth type OAuthUserDeletedError struct{} diff --git a/model/custom_oauth_provider.go b/model/custom_oauth_provider.go index 12b4d11113d0..7dfa2a7bd276 100644 --- a/model/custom_oauth_provider.go +++ b/model/custom_oauth_provider.go @@ -59,6 +59,7 @@ type CustomOAuthProvider struct { // 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) + AutoLinkPolicy string `json:"auto_link_policy" gorm:"type:varchar(32);default:'none'"` AccessPolicy string `json:"access_policy" gorm:"type:text"` // JSON policy for access control based on user info AccessDeniedMessage string `json:"access_denied_message" gorm:"type:varchar(512)"` // Custom error message template when access is denied @@ -191,6 +192,14 @@ func validateCustomOAuthProvider(provider *CustomOAuthProvider) error { if provider.Scopes == "" { provider.Scopes = "openid profile email" } + if provider.AutoLinkPolicy == "" { + provider.AutoLinkPolicy = "none" + } + switch provider.AutoLinkPolicy { + case "none", "email_verified", "username": + default: + return errors.New("invalid auto link policy") + } if strings.TrimSpace(provider.AccessPolicy) != "" { var policy accessPolicyPayload if err := common.UnmarshalJsonStr(provider.AccessPolicy, &policy); err != nil { diff --git a/model/task_cas_test.go b/model/task_cas_test.go index 052bf638f989..54ec7bda5949 100644 --- a/model/task_cas_test.go +++ b/model/task_cas_test.go @@ -45,6 +45,7 @@ func TestMain(m *testing.M) { &SubscriptionPlan{}, &SubscriptionOrder{}, &UserSubscription{}, + &UserOAuthBinding{}, &PerfMetric{}, ); err != nil { panic("failed to migrate: " + err.Error()) @@ -66,6 +67,7 @@ func truncateTables(t *testing.T) { DB.Exec("DELETE FROM subscription_orders") DB.Exec("DELETE FROM subscription_plans") DB.Exec("DELETE FROM user_subscriptions") + DB.Exec("DELETE FROM user_oauth_bindings") DB.Exec("DELETE FROM perf_metrics") }) } diff --git a/model/user.go b/model/user.go index e40ac3d21444..5e4d152ffb90 100644 --- a/model/user.go +++ b/model/user.go @@ -316,6 +316,9 @@ func DeleteUserById(id int) (err error) { if id == 0 { return errors.New("id 为空!") } + if err = DeleteUserOAuthBindingsByUserId(id); err != nil { + return err + } user := User{Id: id} return user.Delete() } @@ -324,6 +327,9 @@ func HardDeleteUserById(id int) error { if id == 0 { return errors.New("id 为空!") } + if err := DeleteUserOAuthBindingsByUserId(id); err != nil { + return err + } err := DB.Unscoped().Delete(&User{}, "id = ?", id).Error return err } diff --git a/model/user_oauth_binding_delete_test.go b/model/user_oauth_binding_delete_test.go new file mode 100644 index 000000000000..0c1edd623bac --- /dev/null +++ b/model/user_oauth_binding_delete_test.go @@ -0,0 +1,51 @@ +package model + +import ( + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/stretchr/testify/require" +) + +func insertUserWithCustomOAuthBinding(t *testing.T, userId int, providerId int) { + t.Helper() + require.NoError(t, DB.Create(&User{ + Id: userId, + Username: "custom_oauth_deleted_user", + Status: common.UserStatusEnabled, + }).Error) + require.NoError(t, CreateUserOAuthBinding(&UserOAuthBinding{ + UserId: userId, + ProviderId: providerId, + ProviderUserId: "provider-user-id", + })) +} + +func countCustomOAuthBindingsForUser(t *testing.T, userId int) int64 { + t.Helper() + var count int64 + require.NoError(t, DB.Model(&UserOAuthBinding{}).Where("user_id = ?", userId).Count(&count).Error) + return count +} + +func TestDeleteUserById_RemovesCustomOAuthBindings(t *testing.T) { + truncateTables(t) + + insertUserWithCustomOAuthBinding(t, 201, 301) + require.Equal(t, int64(1), countCustomOAuthBindingsForUser(t, 201)) + + require.NoError(t, DeleteUserById(201)) + + require.Equal(t, int64(0), countCustomOAuthBindingsForUser(t, 201)) +} + +func TestHardDeleteUserById_RemovesCustomOAuthBindings(t *testing.T) { + truncateTables(t) + + insertUserWithCustomOAuthBinding(t, 202, 302) + require.Equal(t, int64(1), countCustomOAuthBindingsForUser(t, 202)) + + require.NoError(t, HardDeleteUserById(202)) + + require.Equal(t, int64(0), countCustomOAuthBindingsForUser(t, 202)) +} diff --git a/oauth/generic.go b/oauth/generic.go index 11bbb9b625f6..8d6dbda32744 100644 --- a/oauth/generic.go +++ b/oauth/generic.go @@ -18,7 +18,6 @@ import ( "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/samber/lo" "github.com/tidwall/gjson" @@ -94,7 +93,7 @@ func (p *GenericOAuthProvider) ExchangeToken(ctx context.Context, code string, c 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) + redirectUri := BuildOAuthRedirectURI(c, p.config.Slug) values := url.Values{} values.Set("grant_type", "authorization_code") values.Set("code", code) @@ -243,6 +242,10 @@ func (p *GenericOAuthProvider) GetUserInfo(ctx context.Context, token *OAuthToke username := gjson.Get(bodyStr, p.config.UsernameField).String() displayName := gjson.Get(bodyStr, p.config.DisplayNameField).String() email := gjson.Get(bodyStr, p.config.EmailField).String() + emailVerified := false + if emailVerifiedValue := gjson.Get(bodyStr, "email_verified"); emailVerifiedValue.Exists() { + emailVerified = emailVerifiedValue.Bool() + } // If user ID field returns a number, convert it if userId == "" { @@ -285,7 +288,8 @@ func (p *GenericOAuthProvider) GetUserInfo(ctx context.Context, token *OAuthToke DisplayName: displayName, Email: email, Extra: map[string]any{ - "provider": p.config.Slug, + "provider": p.config.Slug, + "email_verified": emailVerified, }, }, nil } @@ -317,6 +321,13 @@ func (p *GenericOAuthProvider) GetProviderId() int { return p.config.Id } +func (p *GenericOAuthProvider) GetAutoLinkPolicy() string { + if p.config.AutoLinkPolicy == "" { + return "none" + } + return p.config.AutoLinkPolicy +} + func normalizeAuthorizationTokenType(tokenType string) string { tokenType = strings.TrimSpace(tokenType) if tokenType == "" || strings.EqualFold(tokenType, "Bearer") { diff --git a/oauth/oidc.go b/oauth/oidc.go index 9bdc6d01e572..7c8fa755c538 100644 --- a/oauth/oidc.go +++ b/oauth/oidc.go @@ -4,11 +4,13 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "net/url" "strings" "time" + "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/i18n" "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/model" @@ -30,6 +32,8 @@ type oidcOAuthResponse struct { TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in"` Scope string `json:"scope"` + Error string `json:"error"` + ErrorDesc string `json:"error_description"` } type oidcUser struct { @@ -56,7 +60,7 @@ func (p *OIDCProvider) ExchangeToken(ctx context.Context, code string, c *gin.Co logger.LogDebug(ctx, "[OAuth-OIDC] ExchangeToken: code=%s...", code[:min(len(code), 10)]) settings := system_setting.GetOIDCSettings() - redirectUri := fmt.Sprintf("%s/oauth/oidc", system_setting.ServerAddress) + redirectUri := BuildOAuthRedirectURI(c, "oidc") values := url.Values{} values.Set("client_id", settings.ClientId) values.Set("client_secret", settings.ClientSecret) @@ -85,16 +89,29 @@ func (p *OIDCProvider) ExchangeToken(ctx context.Context, code string, c *gin.Co logger.LogDebug(ctx, "[OAuth-OIDC] ExchangeToken response status: %d", res.StatusCode) + body, err := io.ReadAll(res.Body) + if err != nil { + logger.LogError(ctx, fmt.Sprintf("[OAuth-OIDC] ExchangeToken read body error: %s", err.Error())) + return nil, err + } + bodyStr := string(body) + logger.LogDebug(ctx, "[OAuth-OIDC] ExchangeToken response body: %s", bodyStr[:min(len(bodyStr), 500)]) + var oidcResponse oidcOAuthResponse - err = json.NewDecoder(res.Body).Decode(&oidcResponse) + err = common.Unmarshal(body, &oidcResponse) if err != nil { logger.LogError(ctx, fmt.Sprintf("[OAuth-OIDC] ExchangeToken decode error: %s", err.Error())) return nil, err } + if oidcResponse.Error != "" { + logger.LogError(ctx, fmt.Sprintf("[OAuth-OIDC] ExchangeToken OAuth error: %s - %s", oidcResponse.Error, oidcResponse.ErrorDesc)) + return nil, NewOAuthErrorWithRaw(i18n.MsgOAuthTokenFailed, map[string]any{"Provider": "OIDC"}, oidcResponse.ErrorDesc) + } + if oidcResponse.AccessToken == "" { logger.LogError(ctx, "[OAuth-OIDC] ExchangeToken failed: empty access token") - return nil, NewOAuthError(i18n.MsgOAuthTokenFailed, map[string]any{"Provider": "OIDC"}) + return nil, NewOAuthErrorWithRaw(i18n.MsgOAuthTokenFailed, map[string]any{"Provider": "OIDC"}, bodyStr) } logger.LogDebug(ctx, "[OAuth-OIDC] ExchangeToken success: scope=%s", oidcResponse.Scope) diff --git a/oauth/redirect_uri.go b/oauth/redirect_uri.go new file mode 100644 index 000000000000..5f86dc9fbbff --- /dev/null +++ b/oauth/redirect_uri.go @@ -0,0 +1,35 @@ +package oauth + +import ( + "fmt" + "strings" + + "github.com/QuantumNous/new-api/setting/system_setting" + "github.com/gin-gonic/gin" +) + +func BuildOAuthRedirectURI(c *gin.Context, provider string) string { + base := "" + if c != nil && c.Request != nil { + proto := strings.TrimSpace(c.GetHeader("X-Forwarded-Proto")) + host := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")) + if host == "" { + host = strings.TrimSpace(c.Request.Host) + } + if proto == "" { + if c.Request.TLS != nil { + proto = "https" + } else { + proto = "http" + } + } + if host != "" { + base = proto + "://" + host + } + } + if base == "" { + base = system_setting.ServerAddress + } + base = strings.TrimRight(base, "/") + return fmt.Sprintf("%s/oauth/%s", base, provider) +} diff --git a/web/default/src/features/profile/api.ts b/web/default/src/features/profile/api.ts index 82bc1d851ced..18ff756957f9 100644 --- a/web/default/src/features/profile/api.ts +++ b/web/default/src/features/profile/api.ts @@ -133,9 +133,11 @@ export async function bindWeChat(code: string): Promise { // ============================================================================ export interface CustomOAuthBinding { - provider_id: string + provider_id: number provider_name: string - external_id?: string + provider_slug: string + provider_icon: string + provider_user_id: string } /** @@ -152,7 +154,7 @@ export async function getSelfOAuthBindings(): Promise< * Unbind a custom OAuth provider for current user */ export async function unbindCustomOAuth( - providerId: string + providerId: number | string ): Promise { const res = await api.delete(`/api/user/oauth/bindings/${providerId}`) return res.data diff --git a/web/default/src/features/profile/components/tabs/account-bindings-tab.tsx b/web/default/src/features/profile/components/tabs/account-bindings-tab.tsx index 014699175253..17dc8cdc15ff 100644 --- a/web/default/src/features/profile/components/tabs/account-bindings-tab.tsx +++ b/web/default/src/features/profile/components/tabs/account-bindings-tab.tsx @@ -27,7 +27,9 @@ import { handleOIDCOAuth, handleDiscordOAuth, handleLinuxDOOAuth, + getOAuthState, } from '@/lib/oauth' +import type { CustomOAuthProviderInfo } from '@/features/auth/types' import { useDialogs } from '@/hooks/use-dialog' import { useStatus } from '@/hooks/use-status' import { Button } from '@/components/ui/button' @@ -70,7 +72,7 @@ export function AccountBindingsTab({ const [unbinding, setUnbinding] = useState(false) const customProviders = status?.custom_oauth_providers as - | Array<{ id: string; name: string }> + | CustomOAuthProviderInfo[] | undefined const fetchCustomBindings = useCallback(async () => { @@ -113,9 +115,25 @@ export function AccountBindingsTab({ } } - const handleBindCustomOAuth = (provider: { id: string; name: string }) => { - const redirectUrl = `${window.location.origin}/oauth/${provider.id}?bind=true` - window.location.href = `/api/oauth/${provider.id}?redirect=${encodeURIComponent(redirectUrl)}` + const handleBindCustomOAuth = async (provider: CustomOAuthProviderInfo) => { + if (!provider.authorization_endpoint || !provider.client_id) return + const state = await getOAuthState() + if (!state) { + toast.error(t('Failed to initialize OAuth')) + return + } + + const redirectUri = `${window.location.origin}/oauth/${provider.slug}` + const url = new URL(provider.authorization_endpoint) + url.searchParams.set('client_id', provider.client_id) + url.searchParams.set('redirect_uri', redirectUri) + url.searchParams.set('response_type', 'code') + url.searchParams.set('state', state) + if (provider.scopes) { + url.searchParams.set('scope', provider.scopes) + } + + window.open(url.toString(), '_blank') } useEffect(() => { @@ -341,7 +359,7 @@ export function AccountBindingsTab({

{isBound - ? binding?.external_id || t('Bound') + ? binding?.provider_user_id || t('Bound') : t('Not bound')}

diff --git a/web/default/src/features/system-settings/auth/custom-oauth/components/provider-form-dialog.tsx b/web/default/src/features/system-settings/auth/custom-oauth/components/provider-form-dialog.tsx index e34afe15165c..4a9976c15d80 100644 --- a/web/default/src/features/system-settings/auth/custom-oauth/components/provider-form-dialog.tsx +++ b/web/default/src/features/system-settings/auth/custom-oauth/components/provider-form-dialog.tsx @@ -62,6 +62,7 @@ import { import { customOAuthFormSchema, AUTH_STYLE_OPTIONS, + AUTO_LINK_POLICY_OPTIONS, type CustomOAuthProvider, type CustomOAuthFormValues, } from '../types' @@ -101,6 +102,7 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) { email_field: '', well_known: '', auth_style: 0, + auto_link_policy: 'none', access_policy: '', access_denied_message: '', }, @@ -125,6 +127,7 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) { email_field: props.provider.email_field || '', well_known: props.provider.well_known || '', auth_style: props.provider.auth_style ?? 0, + auto_link_policy: props.provider.auto_link_policy || 'none', access_policy: props.provider.access_policy || '', access_denied_message: props.provider.access_denied_message || '', }) @@ -543,6 +546,45 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) {

{t('Advanced')}

+ ( + + {t('Auto-link existing users')} + + + {t( + 'Default is disabled. Enable only for trusted identity providers.' + )} + + + + )} + /> + + + {t( + 'Built-in OIDC is a single fixed provider. Use Custom OAuth if you need multiple providers, custom names, icons, field mapping, or access policies.' + )} + + > { // ============================================================================ export interface OAuthBinding { - provider_id: string + provider_id: number provider_name: string - user_id?: number - external_id?: string + provider_slug: string + provider_icon: string + provider_user_id: string } /** diff --git a/web/default/src/features/users/components/dialogs/user-binding-dialog.tsx b/web/default/src/features/users/components/dialogs/user-binding-dialog.tsx index fc394e213623..86d95bc96da2 100644 --- a/web/default/src/features/users/components/dialogs/user-binding-dialog.tsx +++ b/web/default/src/features/users/components/dialogs/user-binding-dialog.tsx @@ -85,7 +85,7 @@ interface StatusInfo { telegram_oauth?: boolean linuxdo_oauth?: boolean custom_oauth_providers?: Array<{ - id: string + id: number name: string icon?: string }> @@ -254,7 +254,7 @@ export function UserBindingDialog(props: Props) { key: `oauth_${provider.id}`, label: provider.name || provider.id, icon: , - value: binding?.external_id || '', + value: binding?.provider_user_id || '', type: 'custom', providerId: String(provider.id), isBound: !!binding, @@ -268,7 +268,7 @@ export function UserBindingDialog(props: Props) { key: `oauth_${binding.provider_id}`, label: binding.provider_name || binding.provider_id, icon: , - value: binding.external_id || '-', + value: binding.provider_user_id || '-', type: 'custom', providerId: String(binding.provider_id), isBound: true, diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 7abaed071457..ac544ef4bf8c 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -1058,6 +1058,12 @@ "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.": "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.", "Custom OAuth": "Custom OAuth", "Custom OAuth Providers": "Custom OAuth Providers", + "Auto-link existing users": "Auto-link existing users", + "Do not auto-link existing users": "Do not auto-link existing users", + "Auto-link by email": "Auto-link by email", + "Auto-link by username": "Auto-link by username", + "Default is disabled. Enable only for trusted identity providers.": "Default is disabled. Enable only for trusted identity providers.", + "Built-in OIDC is a single fixed provider. Use Custom OAuth if you need multiple providers, custom names, icons, field mapping, or access policies.": "Built-in OIDC is a single fixed provider. Use Custom OAuth if you need multiple providers, custom names, icons, field mapping, or access policies.", "Custom Seconds": "Custom Seconds", "Custom sidebar section": "Custom sidebar section", "Custom Time Range": "Custom Time Range", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index 27e3ea4995cd..7ce7fe781da0 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -1058,6 +1058,12 @@ "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.": "Multiplicateurs personnalisés lorsque des groupes d'utilisateurs spécifiques utilisent des groupes de jetons spécifiques. Exemple : les utilisateurs VIP obtiennent un taux de 0,9x lorsqu'ils utilisent les jetons du groupe \"edit_this\".", "Custom OAuth": "OAuth personnalisé", "Custom OAuth Providers": "Fournisseurs OAuth personnalisés", + "Auto-link existing users": "Lier automatiquement les utilisateurs existants", + "Do not auto-link existing users": "Ne pas lier automatiquement les utilisateurs existants", + "Auto-link by email": "Lier automatiquement par e-mail", + "Auto-link by username": "Lier automatiquement par nom d'utilisateur", + "Default is disabled. Enable only for trusted identity providers.": "Désactivé par défaut. À activer uniquement pour les fournisseurs d'identité de confiance.", + "Built-in OIDC is a single fixed provider. Use Custom OAuth if you need multiple providers, custom names, icons, field mapping, or access policies.": "L'OIDC intégré est un fournisseur fixe unique. Utilisez OAuth personnalisé si vous avez besoin de plusieurs fournisseurs, de noms personnalisés, d'icônes, de mappages de champs ou de politiques d'accès.", "Custom Seconds": "Secondes personnalisées", "Custom sidebar section": "Section de barre latérale personnalisée", "Custom Time Range": "Plage horaire personnalisée", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index 26ed41748094..f9a2fdc83235 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -1058,6 +1058,12 @@ "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.": "特定のユーザーグループが特定のトークングループを使用する場合のカスタム乗数。例: VIPユーザーが「edit_this」グループトークンを使用する場合、0.9倍のレートが適用されます。", "Custom OAuth": "カスタム OAuth", "Custom OAuth Providers": "カスタムOAuthプロバイダー", + "Auto-link existing users": "既存ユーザーを自動リンク", + "Do not auto-link existing users": "既存ユーザーを自動リンクしない", + "Auto-link by email": "メールアドレスで自動リンク", + "Auto-link by username": "ユーザー名で自動リンク", + "Default is disabled. Enable only for trusted identity providers.": "デフォルトでは無効です。信頼できる ID プロバイダーでのみ有効にしてください。", + "Built-in OIDC is a single fixed provider. Use Custom OAuth if you need multiple providers, custom names, icons, field mapping, or access policies.": "組み込み OIDC は単一の固定プロバイダーです。複数のプロバイダー、カスタム名、アイコン、フィールドマッピング、アクセス ポリシーが必要な場合はカスタム OAuth を使用してください。", "Custom Seconds": "カスタム秒数", "Custom sidebar section": "カスタムサイドバーセクション", "Custom Time Range": "カスタム時間範囲", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index 50382fd0f8e8..b7f4f182b124 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -1058,6 +1058,12 @@ "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.": "Пользовательские множители, когда определенные группы пользователей используют определенные группы токенов. Пример: VIP-пользователи получают ставку 0.9x при использовании токенов группы \"edit_this\".", "Custom OAuth": "Пользовательский OAuth", "Custom OAuth Providers": "Пользовательские OAuth-провайдеры", + "Auto-link existing users": "Автоматически связывать существующих пользователей", + "Do not auto-link existing users": "Не связывать существующих пользователей автоматически", + "Auto-link by email": "Автоматически связывать по email", + "Auto-link by username": "Автоматически связывать по имени пользователя", + "Default is disabled. Enable only for trusted identity providers.": "По умолчанию отключено. Включайте только для доверенных поставщиков идентификации.", + "Built-in OIDC is a single fixed provider. Use Custom OAuth if you need multiple providers, custom names, icons, field mapping, or access policies.": "Встроенный OIDC — это один фиксированный провайдер. Используйте пользовательский OAuth, если нужны несколько провайдеров, пользовательские имена, иконки, сопоставление полей или политики доступа.", "Custom Seconds": "Пользовательские секунды", "Custom sidebar section": "Пользовательский раздел боковой панели", "Custom Time Range": "Пользовательский диапазон времени", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index a28b90fee45d..a578bc4189c0 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -1058,6 +1058,12 @@ "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.": "Các hệ số nhân tùy chỉnh khi các nhóm người dùng cụ thể sử dụng các nhóm token cụ thể. Ví dụ: Người dùng VIP được hưởng tỷ lệ 0.9x khi sử dụng các token thuộc nhóm \"edit_this\".", "Custom OAuth": "OAuth tùy chỉnh", "Custom OAuth Providers": "Nhà cung cấp OAuth tùy chỉnh", + "Auto-link existing users": "Tự động liên kết người dùng hiện có", + "Do not auto-link existing users": "Không tự động liên kết người dùng hiện có", + "Auto-link by email": "Tự động liên kết theo email", + "Auto-link by username": "Tự động liên kết theo tên người dùng", + "Default is disabled. Enable only for trusted identity providers.": "Mặc định bị tắt. Chỉ bật cho nhà cung cấp danh tính đáng tin cậy.", + "Built-in OIDC is a single fixed provider. Use Custom OAuth if you need multiple providers, custom names, icons, field mapping, or access policies.": "OIDC tích hợp là một nhà cung cấp cố định duy nhất. Sử dụng OAuth tùy chỉnh nếu bạn cần nhiều nhà cung cấp, tên tùy chỉnh, biểu tượng, ánh xạ trường hoặc chính sách truy cập.", "Custom Seconds": "Giây tùy chỉnh", "Custom sidebar section": "Phần thanh bên tùy chỉnh", "Custom Time Range": "Khoảng thời gian tùy chỉnh", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 1ce821f56abd..c3942c81e749 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -1058,6 +1058,12 @@ "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.": "当特定用户分组使用特定令牌分组时的自定义乘数。示例:VIP 用户在使用“edit_this”分组令牌时获得 0.9 倍费率。", "Custom OAuth": "自定义 OAuth", "Custom OAuth Providers": "自定义OAuth提供商", + "Auto-link existing users": "自动关联已有用户", + "Do not auto-link existing users": "不自动关联已有用户", + "Auto-link by email": "按邮箱自动关联", + "Auto-link by username": "按用户名自动关联", + "Default is disabled. Enable only for trusted identity providers.": "默认关闭。仅对可信身份提供商启用。", + "Built-in OIDC is a single fixed provider. Use Custom OAuth if you need multiple providers, custom names, icons, field mapping, or access policies.": "内置 OIDC 是单一固定提供商。如果需要多个提供商、自定义名称、图标、字段映射或访问策略,请使用自定义 OAuth。", "Custom Seconds": "自定义秒数", "Custom sidebar section": "自定义侧边栏部分", "Custom Time Range": "自定义时间范围", From b49c1554646bec677cc1ce940610b5b50178bbb8 Mon Sep 17 00:00:00 2001 From: TomyJan Date: Sun, 31 May 2026 22:41:53 +0800 Subject: [PATCH 2/3] fix(oauth): harden custom OAuth binding flow - Open the custom OAuth popup synchronously before fetching OAuth state so browsers do not block the binding window - Decode OIDC userinfo responses through common.DecodeJson instead of encoding/json directly - Preserve auto_link_policy defaults when resetting the custom OAuth provider create form - Normalize custom provider IDs to strings when rendering admin binding labels --- oauth/oidc.go | 3 +-- .../profile/components/tabs/account-bindings-tab.tsx | 11 +++++++++-- .../custom-oauth/components/provider-form-dialog.tsx | 1 + .../users/components/dialogs/user-binding-dialog.tsx | 4 ++-- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/oauth/oidc.go b/oauth/oidc.go index 7c8fa755c538..bf8006d2ac3a 100644 --- a/oauth/oidc.go +++ b/oauth/oidc.go @@ -2,7 +2,6 @@ package oauth import ( "context" - "encoding/json" "fmt" "io" "net/http" @@ -155,7 +154,7 @@ func (p *OIDCProvider) GetUserInfo(ctx context.Context, token *OAuthToken) (*OAu } var oidcUser oidcUser - err = json.NewDecoder(res.Body).Decode(&oidcUser) + err = common.DecodeJson(res.Body, &oidcUser) if err != nil { logger.LogError(ctx, fmt.Sprintf("[OAuth-OIDC] GetUserInfo decode error: %s", err.Error())) return nil, err diff --git a/web/default/src/features/profile/components/tabs/account-bindings-tab.tsx b/web/default/src/features/profile/components/tabs/account-bindings-tab.tsx index 17dc8cdc15ff..3e1f275aab97 100644 --- a/web/default/src/features/profile/components/tabs/account-bindings-tab.tsx +++ b/web/default/src/features/profile/components/tabs/account-bindings-tab.tsx @@ -29,7 +29,6 @@ import { handleLinuxDOOAuth, getOAuthState, } from '@/lib/oauth' -import type { CustomOAuthProviderInfo } from '@/features/auth/types' import { useDialogs } from '@/hooks/use-dialog' import { useStatus } from '@/hooks/use-status' import { Button } from '@/components/ui/button' @@ -37,6 +36,7 @@ import { Separator } from '@/components/ui/separator' import { ConfirmDialog } from '@/components/confirm-dialog' import { StatusBadge } from '@/components/status-badge' import { OAUTH_BIND_STORAGE_KEY } from '@/features/auth/constants' +import type { CustomOAuthProviderInfo } from '@/features/auth/types' import { getSelfOAuthBindings, unbindCustomOAuth, @@ -117,8 +117,11 @@ export function AccountBindingsTab({ const handleBindCustomOAuth = async (provider: CustomOAuthProviderInfo) => { if (!provider.authorization_endpoint || !provider.client_id) return + + const popup = window.open('', '_blank') const state = await getOAuthState() if (!state) { + popup?.close() toast.error(t('Failed to initialize OAuth')) return } @@ -133,7 +136,11 @@ export function AccountBindingsTab({ url.searchParams.set('scope', provider.scopes) } - window.open(url.toString(), '_blank') + if (popup) { + popup.location.href = url.toString() + } else { + window.location.href = url.toString() + } } useEffect(() => { diff --git a/web/default/src/features/system-settings/auth/custom-oauth/components/provider-form-dialog.tsx b/web/default/src/features/system-settings/auth/custom-oauth/components/provider-form-dialog.tsx index 4a9976c15d80..653f6afd887c 100644 --- a/web/default/src/features/system-settings/auth/custom-oauth/components/provider-form-dialog.tsx +++ b/web/default/src/features/system-settings/auth/custom-oauth/components/provider-form-dialog.tsx @@ -149,6 +149,7 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) { email_field: '', well_known: '', auth_style: 0, + auto_link_policy: 'none', access_policy: '', access_denied_message: '', }) diff --git a/web/default/src/features/users/components/dialogs/user-binding-dialog.tsx b/web/default/src/features/users/components/dialogs/user-binding-dialog.tsx index 86d95bc96da2..3cc6e93a3ca6 100644 --- a/web/default/src/features/users/components/dialogs/user-binding-dialog.tsx +++ b/web/default/src/features/users/components/dialogs/user-binding-dialog.tsx @@ -252,7 +252,7 @@ export function UserBindingDialog(props: Props) { const binding = oauthBindingMap.get(String(provider.id)) items.push({ key: `oauth_${provider.id}`, - label: provider.name || provider.id, + label: provider.name || String(provider.id), icon: , value: binding?.provider_user_id || '', type: 'custom', @@ -266,7 +266,7 @@ export function UserBindingDialog(props: Props) { if (!seenProviderIds.has(String(binding.provider_id))) { items.push({ key: `oauth_${binding.provider_id}`, - label: binding.provider_name || binding.provider_id, + label: binding.provider_name || String(binding.provider_id), icon: , value: binding.provider_user_id || '-', type: 'custom', From 347994988e016b458685ab0f28825eb5d9a51e62 Mon Sep 17 00:00:00 2001 From: TomyJan Date: Wed, 1 Jul 2026 01:56:39 +0800 Subject: [PATCH 3/3] fix: use existing deleteUserOAuthBindingsByUserId function --- model/user.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/model/user.go b/model/user.go index 2b298cca4ed9..be8180af754c 100644 --- a/model/user.go +++ b/model/user.go @@ -321,7 +321,7 @@ func DeleteUserById(id int) (err error) { if id == 0 { return errors.New("id 为空!") } - if err = DeleteUserOAuthBindingsByUserId(id); err != nil { + if err = deleteUserOAuthBindingsByUserId(DB, id); err != nil { return err } user := User{Id: id}