diff --git a/modules/google/groups.go b/modules/google/groups.go index b941d37cab..b8a3af159d 100644 --- a/modules/google/groups.go +++ b/modules/google/groups.go @@ -68,7 +68,7 @@ func (c *Client) FetchGroups(ctx context.Context, email string) ([]string, error groups := make([]string, 0, 16) pageToken := "" - for range maxGroupPages { + for range make([]struct{}, maxGroupPages) { params := url.Values{} params.Set("query", fmt.Sprintf("member_key_id=='%s'", email)) if pageToken != "" { diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 80692acdaf..d15a4ab993 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -429,6 +429,8 @@ "auth.oauth.signin.error.general": "There was an error processing the authorization request: %s. If this error persists, please contact the site administrator.", "auth.oauth.signin.error.access_denied": "The authorization request was denied.", "auth.oauth.signin.error.temporarily_unavailable": "Authorization failed because the authentication server is temporarily unavailable. Please try again later.", + "auth.oauth.required_additional_info_fetch_failed_banner.title": "OAuth2 additional information synchronization warning", + "auth.oauth.required_additional_info_fetch_failed_banner.desc": "Gitea recently failed to retrieve required additional information from authentication source \"%s\" (last failure: %s). Users may keep their previously synchronized admin/restricted/team state until retrieval succeeds again. Check logs for details.", "auth.oauth_callback_unable_auto_reg": "Auto Registration is enabled, but OAuth2 Provider %[1]s returned missing fields: %[2]s, unable to create an account automatically. Please create or link to an account, or contact the site administrator.", "auth.openid_connect_submit": "Connect", "auth.openid_connect_title": "Connect to an existing account", diff --git a/routers/common/pagetmpl.go b/routers/common/pagetmpl.go index c48596d48b..af86fb5038 100644 --- a/routers/common/pagetmpl.go +++ b/routers/common/pagetmpl.go @@ -12,6 +12,7 @@ import ( "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/modules/log" + oauth2_source "code.gitea.io/gitea/services/auth/source/oauth2" "code.gitea.io/gitea/services/context" ) @@ -69,8 +70,16 @@ type pageGlobalDataType struct { IsSigned bool IsSiteAdmin bool - GetNotificationUnreadCount func() int64 - GetActiveStopwatch func() *StopwatchTmplInfo + GetNotificationUnreadCount func() int64 + GetActiveStopwatch func() *StopwatchTmplInfo + GetRequiredAdditionalInfoFailureWarning func() *oauth2_source.RequiredAdditionalInfoFailureWarning +} + +func oauth2RequiredAdditionalInfoFailureWarning(ctx *context.Context) *oauth2_source.RequiredAdditionalInfoFailureWarning { + if ctx.Doer == nil || !ctx.Doer.IsAdmin { + return nil + } + return oauth2_source.GetRequiredAdditionalInfoFailureWarning(ctx.Cache) } func PageGlobalData(ctx *context.Context) { @@ -79,5 +88,8 @@ func PageGlobalData(ctx *context.Context) { data.IsSiteAdmin = ctx.Doer != nil && ctx.Doer.IsAdmin data.GetNotificationUnreadCount = sync.OnceValue(func() int64 { return notificationUnreadCount(ctx) }) data.GetActiveStopwatch = sync.OnceValue(func() *StopwatchTmplInfo { return getActiveStopwatch(ctx) }) + data.GetRequiredAdditionalInfoFailureWarning = sync.OnceValue(func() *oauth2_source.RequiredAdditionalInfoFailureWarning { + return oauth2RequiredAdditionalInfoFailureWarning(ctx) + }) ctx.Data["PageGlobalData"] = data } diff --git a/routers/common/pagetmpl_test.go b/routers/common/pagetmpl_test.go new file mode 100644 index 0000000000..bf81957233 --- /dev/null +++ b/routers/common/pagetmpl_test.go @@ -0,0 +1,41 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package common + +import ( + "testing" + "time" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/cache" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" + oauth2_source "code.gitea.io/gitea/services/auth/source/oauth2" + "code.gitea.io/gitea/services/context" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOAuth2RequiredAdditionalInfoFailureWarning_AdminOnlyVisibility(t *testing.T) { + c, err := cache.NewStringCache(setting.Cache{Adapter: "memory", Interval: 1}) + require.NoError(t, err) + + defer timeutil.MockSet(time.Unix(1_700_000_000, 0))() + oauth2_source.SetRequiredAdditionalInfoFetchFailureWarning(c, "Google Workspace") + + adminCtx := &context.Context{ + Doer: &user_model.User{IsAdmin: true}, + Cache: c, + } + nonAdminCtx := &context.Context{ + Doer: &user_model.User{IsAdmin: false}, + Cache: c, + } + + adminWarning := oauth2RequiredAdditionalInfoFailureWarning(adminCtx) + require.NotNil(t, adminWarning) + assert.Equal(t, "Google Workspace", adminWarning.SourceName) + assert.Nil(t, oauth2RequiredAdditionalInfoFailureWarning(nonAdminCtx)) +} diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go index 750b90c3fa..3cbffc92e8 100644 --- a/routers/web/auth/oauth.go +++ b/routers/web/auth/oauth.go @@ -490,12 +490,18 @@ func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, requ if err != nil { log.Warn("OAuth2: failed to fetch additional info for %s: %v", gothUser.Email, err) if provider.FailLoginOnAdditionalInfoError() { + sourceName := authSource.Name + if sourceName == "" { + sourceName = fmt.Sprintf("source #%d", authSource.ID) + } + oauth2.SetRequiredAdditionalInfoFetchFailureWarning(ctx.Cache, sourceName) // Fail closed only when login directly depends on the group claim // (for example RequiredClaimName == GroupClaimName). Other // group-based sync features are fail-open and preserve prior state. return nil, goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID} } } else { + oauth2.ClearRequiredAdditionalInfoFetchFailureWarning(ctx.Cache) gothUser = enriched } } diff --git a/services/auth/source/oauth2/additional_info_provider_test.go b/services/auth/source/oauth2/additional_info_provider_test.go index a4b7af1881..65e662ec9e 100644 --- a/services/auth/source/oauth2/additional_info_provider_test.go +++ b/services/auth/source/oauth2/additional_info_provider_test.go @@ -5,8 +5,14 @@ package oauth2 import ( "testing" + "time" + + "code.gitea.io/gitea/modules/cache" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestIsGoogleGroupClaimRequiredForLoginFlow(t *testing.T) { @@ -60,3 +66,45 @@ func TestIsGoogleGroupClaimRequiredForLoginFlow(t *testing.T) { assert.False(t, isGoogleGroupClaimRequiredForLoginFlow(source)) }) } + +func TestRequiredAdditionalInfoFailureWarningLifecycle(t *testing.T) { + c, err := cache.NewStringCache(setting.Cache{Adapter: "memory", Interval: 1}) + require.NoError(t, err) + + mockNow := time.Unix(1_700_000_000, 0) + defer timeutil.MockSet(mockNow)() + SetRequiredAdditionalInfoFetchFailureWarning(c, "Google Workspace") + + warning := GetRequiredAdditionalInfoFailureWarning(c) + require.NotNil(t, warning) + assert.Equal(t, "Google Workspace", warning.SourceName) + assert.Equal(t, timeutil.TimeStamp(mockNow.Unix()), warning.LastFailedUnix) + + ClearRequiredAdditionalInfoFetchFailureWarning(c) + assert.Nil(t, GetRequiredAdditionalInfoFailureWarning(c)) +} + +func TestRequiredAdditionalInfoFailureWarningThrottle(t *testing.T) { + c, err := cache.NewStringCache(setting.Cache{Adapter: "memory", Interval: 1}) + require.NoError(t, err) + + first := time.Unix(1_700_000_000, 0) + defer timeutil.MockSet(first)() + SetRequiredAdditionalInfoFetchFailureWarning(c, "Google Workspace") + initial := GetRequiredAdditionalInfoFailureWarning(c) + require.NotNil(t, initial) + + // Within throttle window, keep previous timestamp to avoid cache churn. + timeutil.MockSet(first.Add(30 * time.Second)) + SetRequiredAdditionalInfoFetchFailureWarning(c, "Google Workspace") + throttled := GetRequiredAdditionalInfoFailureWarning(c) + require.NotNil(t, throttled) + assert.Equal(t, initial.LastFailedUnix, throttled.LastFailedUnix) + + // After throttle window, timestamp is refreshed. + timeutil.MockSet(first.Add(61 * time.Second)) + SetRequiredAdditionalInfoFetchFailureWarning(c, "Google Workspace") + refreshed := GetRequiredAdditionalInfoFailureWarning(c) + require.NotNil(t, refreshed) + assert.Equal(t, timeutil.TimeStamp(first.Add(61*time.Second).Unix()), refreshed.LastFailedUnix) +} diff --git a/services/auth/source/oauth2/required_additional_info_failure_warning.go b/services/auth/source/oauth2/required_additional_info_failure_warning.go new file mode 100644 index 0000000000..5eee30207a --- /dev/null +++ b/services/auth/source/oauth2/required_additional_info_failure_warning.go @@ -0,0 +1,77 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package oauth2 + +import ( + "code.gitea.io/gitea/modules/cache" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/timeutil" +) + +const ( + requiredAdditionalInfoFailureWarningCacheKey = "oauth2.required.additional.info.failure.warning" + requiredAdditionalInfoFailureWarningTTL = 3600 + requiredAdditionalInfoFailureWarningThrottle = 60 +) + +type RequiredAdditionalInfoFailureWarning struct { + SourceName string `json:"sourceName"` + LastFailedUnix timeutil.TimeStamp `json:"lastFailedUnix"` +} + +func GetRequiredAdditionalInfoFailureWarning(c cache.StringCache) *RequiredAdditionalInfoFailureWarning { + if c == nil { + return nil + } + + rawWarning, ok := c.Get(requiredAdditionalInfoFailureWarningCacheKey) + if !ok || rawWarning == "" { + return nil + } + + warning := &RequiredAdditionalInfoFailureWarning{} + if err := json.Unmarshal([]byte(rawWarning), warning); err != nil { + _ = c.Delete(requiredAdditionalInfoFailureWarningCacheKey) + return nil + } + if warning.SourceName == "" || warning.LastFailedUnix.IsZero() { + _ = c.Delete(requiredAdditionalInfoFailureWarningCacheKey) + return nil + } + + return warning +} + +func SetRequiredAdditionalInfoFetchFailureWarning(c cache.StringCache, sourceName string) { + if c == nil { + return + } + if sourceName == "" { + sourceName = "OAuth2" + } + + now := timeutil.TimeStampNow() + current := GetRequiredAdditionalInfoFailureWarning(c) + if current != nil && current.SourceName == sourceName && now-current.LastFailedUnix < requiredAdditionalInfoFailureWarningThrottle { + return + } + + rawWarning, err := json.Marshal(&RequiredAdditionalInfoFailureWarning{ + SourceName: sourceName, + LastFailedUnix: now, + }) + if err != nil { + return + } + if err := c.Put(requiredAdditionalInfoFailureWarningCacheKey, string(rawWarning), requiredAdditionalInfoFailureWarningTTL); err != nil { + return + } +} + +func ClearRequiredAdditionalInfoFetchFailureWarning(c cache.StringCache) { + if c == nil { + return + } + _ = c.Delete(requiredAdditionalInfoFailureWarningCacheKey) +} diff --git a/services/context/context_template.go b/services/context/context_template.go index 0f083d097e..e5f8982293 100644 --- a/services/context/context_template.go +++ b/services/context/context_template.go @@ -17,6 +17,7 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web/middleware" + oauth2_source "code.gitea.io/gitea/services/auth/source/oauth2" "code.gitea.io/gitea/services/webtheme" ) @@ -78,6 +79,14 @@ func (c TemplateContext) CurrentWebBanner() *setting.WebBannerType { return nil } +func (c TemplateContext) CurrentRequiredAdditionalInfoFailureWarning() *oauth2_source.RequiredAdditionalInfoFailureWarning { + webCtx := GetWebContext(c) + if webCtx == nil || webCtx.Doer == nil || !webCtx.Doer.IsAdmin { + return nil + } + return oauth2_source.GetRequiredAdditionalInfoFailureWarning(webCtx.Cache) +} + // AppFullLink returns a full URL link with AppSubURL for the given app link (no AppSubURL) // If no link is given, it returns the current app full URL with sub-path but without trailing slash (that's why it is not named as AppURL) func (c TemplateContext) AppFullLink(link ...string) template.URL { diff --git a/templates/base/head_banner.tmpl b/templates/base/head_banner.tmpl index d237161622..dc4e76b283 100644 --- a/templates/base/head_banner.tmpl +++ b/templates/base/head_banner.tmpl @@ -9,3 +9,13 @@ {{end}} + +{{$requiredAdditionalInfoWarning := ctx.CurrentRequiredAdditionalInfoFailureWarning}} +{{if $requiredAdditionalInfoWarning}} +
+{{end}}