From 74dca6331a374dd8600ae32d333e6b8ee668c306 Mon Sep 17 00:00:00 2001 From: Andy Mrichko Date: Sat, 21 Mar 2026 20:54:03 +0200 Subject: [PATCH 1/7] feat: Retrieving groups for Google OAuth2 provider --- modules/google/groups.go | 83 ++++++++++++++++++++++++ modules/google/groups_test.go | 118 ++++++++++++++++++++++++++++++++++ routers/web/auth/oauth.go | 28 ++++++++ 3 files changed, 229 insertions(+) create mode 100644 modules/google/groups.go create mode 100644 modules/google/groups_test.go diff --git a/modules/google/groups.go b/modules/google/groups.go new file mode 100644 index 0000000000..373b6e3b23 --- /dev/null +++ b/modules/google/groups.go @@ -0,0 +1,83 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package google + +import ( + "context" + "fmt" + "io" + "net/http" + + "code.gitea.io/gitea/modules/json" +) + +const IAMScope = "https://www.googleapis.com/auth/cloud-identity.groups.readonly" + +var IAMGroupsEndpoint = "https://content-cloudidentity.googleapis.com/v1/groups/-/memberships:searchDirectGroups" + +// groupMembership represents a single membership entry returned by the +// Cloud Identity Groups API searchDirectGroups endpoint. +type groupMembership struct { + GroupKey struct { + ID string `json:"id"` + } `json:"groupKey"` +} + +// groupsResponse is the paged response from the Cloud Identity API. +type groupsResponse struct { + Memberships []groupMembership `json:"memberships"` + NextPageToken string `json:"nextPageToken"` +} + +// FetchGroups queries the Google Cloud Identity Groups API for all +// groups the given user (identified by email) is a direct member of. +// The caller must supply an HTTP client already authenticated with an access +// token that carries the IAMScope scope. +func FetchGroups(ctx context.Context, client *http.Client, email string) ([]string, error) { + groups := make([]string, 0, 16) + pageToken := "" + + for { + url := fmt.Sprintf("%s?query=member_key_id=='%s'", IAMGroupsEndpoint, email) + if pageToken != "" { + url = fmt.Sprintf("%s&pageToken=%s", url, pageToken) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("google groups: build request: %w", err) + } + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("google groups: HTTP request: %w", err) + } + body, readErr := io.ReadAll(resp.Body) + resp.Body.Close() + if readErr != nil { + return nil, fmt.Errorf("google groups: read response: %w", readErr) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("google groups: API returned %d: %s", resp.StatusCode, body) + } + + var page groupsResponse + if err := json.Unmarshal(body, &page); err != nil { + return nil, fmt.Errorf("google groups: decode response: %w", err) + } + + for _, m := range page.Memberships { + if m.GroupKey.ID != "" { + groups = append(groups, m.GroupKey.ID) + } + } + + if page.NextPageToken == "" { + break + } + pageToken = page.NextPageToken + } + + return groups, nil +} diff --git a/modules/google/groups_test.go b/modules/google/groups_test.go new file mode 100644 index 0000000000..49c2948c08 --- /dev/null +++ b/modules/google/groups_test.go @@ -0,0 +1,118 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package google + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockGroupsServer(t *testing.T, pages [][]string) *httptest.Server { + t.Helper() + pageIndex := 0 + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var memberships []string + for _, g := range pages[pageIndex] { + memberships = append(memberships, fmt.Sprintf(`{"groupKey":{"id":%q}}`, g)) + } + + nextPageToken := "" + if pageIndex < len(pages)-1 { + nextPageToken = "page-token" + } + pageIndex++ + + body := fmt.Sprintf( + `{"memberships":[%s],"nextPageToken":%q}`, + strings.Join(memberships, ","), + nextPageToken, + ) + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, body) + })) +} + +func TestFetchGoogleGroups_SinglePage(t *testing.T) { + server := mockGroupsServer(t, [][]string{ + {"group-a@example.com", "group-b@example.com"}, + }) + defer server.Close() + + origEndpoint := IAMGroupsEndpoint + IAMGroupsEndpoint = server.URL + defer func() { IAMGroupsEndpoint = origEndpoint }() + + groups, err := FetchGroups(context.Background(), &http.Client{}, "user@example.com") + require.NoError(t, err) + assert.ElementsMatch(t, []string{"group-a@example.com", "group-b@example.com"}, groups) +} + +func TestFetchGoogleGroups_MultiPage(t *testing.T) { + server := mockGroupsServer(t, [][]string{ + {"group-a@example.com"}, + {"group-b@example.com", "group-c@example.com"}, + }) + defer server.Close() + + origEndpoint := IAMGroupsEndpoint + IAMGroupsEndpoint = server.URL + defer func() { IAMGroupsEndpoint = origEndpoint }() + + groups, err := FetchGroups(context.Background(), &http.Client{}, "user@example.com") + require.NoError(t, err) + assert.ElementsMatch(t, []string{"group-a@example.com", "group-b@example.com", "group-c@example.com"}, groups) +} + +func TestFetchGoogleGroups_Empty(t *testing.T) { + server := mockGroupsServer(t, [][]string{{}}) + defer server.Close() + + origEndpoint := IAMGroupsEndpoint + IAMGroupsEndpoint = server.URL + defer func() { IAMGroupsEndpoint = origEndpoint }() + + groups, err := FetchGroups(context.Background(), &http.Client{}, "user@example.com") + require.NoError(t, err) + assert.Empty(t, groups) +} + +func TestFetchGoogleGroups_APIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = fmt.Fprint(w, `{"error":"forbidden"}`) + })) + defer server.Close() + + origEndpoint := IAMGroupsEndpoint + IAMGroupsEndpoint = server.URL + defer func() { IAMGroupsEndpoint = origEndpoint }() + + groups, err := FetchGroups(context.Background(), &http.Client{}, "user@example.com") + require.Error(t, err) + assert.Nil(t, groups) + assert.Contains(t, err.Error(), "403") +} + +func TestFetchGoogleGroups_InvalidJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprint(w, `not valid json`) + })) + defer server.Close() + + origEndpoint := IAMGroupsEndpoint + IAMGroupsEndpoint = server.URL + defer func() { IAMGroupsEndpoint = origEndpoint }() + + groups, err := FetchGroups(context.Background(), &http.Client{}, "user@example.com") + require.Error(t, err) + assert.Nil(t, groups) +} diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go index 3b1744669d..92b6504419 100644 --- a/routers/web/auth/oauth.go +++ b/routers/web/auth/oauth.go @@ -11,6 +11,7 @@ import ( "io" "net/http" "net/url" + "slices" "sort" "strings" @@ -18,6 +19,7 @@ import ( user_model "code.gitea.io/gitea/models/user" auth_module "code.gitea.io/gitea/modules/auth" "code.gitea.io/gitea/modules/container" + google_module "code.gitea.io/gitea/modules/google" "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/optional" @@ -476,6 +478,32 @@ func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, requ } } + // For Google Workspace: if the cloud-identity groups scope is present, + // fetch group memberships via the Cloud Identity API and inject them into + // RawData so that the standard GroupClaimName mechanism can pick them up. + if oauth2Source.Provider == "gplus" && slices.Contains(oauth2Source.Scopes, google_module.IAMScope) { + // Build an HTTP client that carries the access token via + // golang.org/x/oauth2 transport, which is what Google APIs expect. + oauthToken := &go_oauth2.Token{AccessToken: gothUser.AccessToken} + tokenSource := go_oauth2.StaticTokenSource(oauthToken) + authenticatedClient := go_oauth2.NewClient(ctx, tokenSource) + googleGroups, err := google_module.FetchGroups(ctx, authenticatedClient, gothUser.Email) + if err != nil { + log.Warn("OAuth2 Google: failed to fetch Workspace groups for %s: %v", gothUser.Email, err) + // Non-fatal: continue login without groups rather than blocking the user. + } else { + if gothUser.RawData == nil { + gothUser.RawData = make(map[string]any) + } + claimName := oauth2Source.GroupClaimName + if claimName == "" { + claimName = "groups" + } + gothUser.RawData[claimName] = googleGroups + log.Debug("OAuth2 Google: injected %d Workspace groups to claim '%s' for %s", len(googleGroups), claimName, gothUser.Email) + } + } + user := &user_model.User{ LoginName: gothUser.UserID, LoginType: auth.OAuth2, From 590653084c6873c9578efae64ee28158d3ef5f06 Mon Sep 17 00:00:00 2001 From: Andy Mrichko Date: Mon, 23 Mar 2026 00:55:02 +0200 Subject: [PATCH 2/7] Fix PR issues --- modules/google/groups.go | 41 +++++++++++++++++----- modules/google/groups_test.go | 66 ++++++++++++++++++++--------------- routers/web/auth/oauth.go | 12 +++++-- 3 files changed, 79 insertions(+), 40 deletions(-) diff --git a/modules/google/groups.go b/modules/google/groups.go index 373b6e3b23..4bee4f500b 100644 --- a/modules/google/groups.go +++ b/modules/google/groups.go @@ -8,13 +8,36 @@ import ( "fmt" "io" "net/http" + "net/url" "code.gitea.io/gitea/modules/json" ) -const IAMScope = "https://www.googleapis.com/auth/cloud-identity.groups.readonly" +const ( + IAMScope = "https://www.googleapis.com/auth/cloud-identity.groups.readonly" + defaultIAMGroupsEndpoint = "https://content-cloudidentity.googleapis.com/v1/groups/-/memberships:searchDirectGroups" +) -var IAMGroupsEndpoint = "https://content-cloudidentity.googleapis.com/v1/groups/-/memberships:searchDirectGroups" +// maxGroupPages is the maximum number of pages fetched from the Google +// Cloud Identity API. The API returns up to 200 groups per page by default, +// so this caps group membership at 4,000 groups per user — far beyond any +// realistic Google Workspace organization. +const maxGroupPages = 20 + +// Client calls Google Workspace APIs. +type Client struct { + httpClient *http.Client + groupsEndpoint string +} + +// NewClient creates a Client using the given authenticated HTTP client. +// The client should be built from an OAuth2 token carrying IAMScope. +func NewClient(httpClient *http.Client) *Client { + return &Client{ + httpClient: httpClient, + groupsEndpoint: defaultIAMGroupsEndpoint, + } +} // groupMembership represents a single membership entry returned by the // Cloud Identity Groups API searchDirectGroups endpoint. @@ -34,22 +57,24 @@ type groupsResponse struct { // groups the given user (identified by email) is a direct member of. // The caller must supply an HTTP client already authenticated with an access // token that carries the IAMScope scope. -func FetchGroups(ctx context.Context, client *http.Client, email string) ([]string, error) { +func (c *Client) FetchGroups(ctx context.Context, email string) ([]string, error) { groups := make([]string, 0, 16) pageToken := "" - for { - url := fmt.Sprintf("%s?query=member_key_id=='%s'", IAMGroupsEndpoint, email) + for range maxGroupPages { + params := url.Values{} + params.Set("query", fmt.Sprintf("member_key_id=='%s'", email)) if pageToken != "" { - url = fmt.Sprintf("%s&pageToken=%s", url, pageToken) + params.Set("pageToken", pageToken) } + apiURL := c.groupsEndpoint + "?" + params.Encode() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) if err != nil { return nil, fmt.Errorf("google groups: build request: %w", err) } - resp, err := client.Do(req) + resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("google groups: HTTP request: %w", err) } diff --git a/modules/google/groups_test.go b/modules/google/groups_test.go index 49c2948c08..645c698889 100644 --- a/modules/google/groups_test.go +++ b/modules/google/groups_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 The Gitea Authors. All rights reserved. +// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package google @@ -15,10 +15,21 @@ import ( "github.com/stretchr/testify/require" ) -func mockGroupsServer(t *testing.T, pages [][]string) *httptest.Server { +func newTestClient(t *testing.T, server *httptest.Server) *Client { + t.Helper() + c := NewClient(server.Client()) + c.groupsEndpoint = server.URL + return c +} + +func mockGroupsServer(t *testing.T, expectedEmail string, pages [][]string) *httptest.Server { t.Helper() pageIndex := 0 return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Validate the query contains the correct member_key_id + expectedQuery := fmt.Sprintf("member_key_id=='%s'", expectedEmail) + assert.Equal(t, expectedQuery, r.URL.Query().Get("query")) + var memberships []string for _, g := range pages[pageIndex] { memberships = append(memberships, fmt.Sprintf(`{"groupKey":{"id":%q}}`, g)) @@ -41,45 +52,48 @@ func mockGroupsServer(t *testing.T, pages [][]string) *httptest.Server { } func TestFetchGoogleGroups_SinglePage(t *testing.T) { - server := mockGroupsServer(t, [][]string{ + server := mockGroupsServer(t, "user@example.com", [][]string{ {"group-a@example.com", "group-b@example.com"}, }) defer server.Close() - origEndpoint := IAMGroupsEndpoint - IAMGroupsEndpoint = server.URL - defer func() { IAMGroupsEndpoint = origEndpoint }() + client := newTestClient(t, server) + groups, err := client.FetchGroups(context.Background(), "user@example.com") + require.NoError(t, err) + assert.ElementsMatch(t, []string{"group-a@example.com", "group-b@example.com"}, groups) +} - groups, err := FetchGroups(context.Background(), &http.Client{}, "user@example.com") +func TestFetchGroups_SinglePage(t *testing.T) { + server := mockGroupsServer(t, "user@example.com", [][]string{ + {"group-a@example.com", "group-b@example.com"}, + }) + defer server.Close() + + client := newTestClient(t, server) + groups, err := client.FetchGroups(context.Background(), "user@example.com") require.NoError(t, err) assert.ElementsMatch(t, []string{"group-a@example.com", "group-b@example.com"}, groups) } func TestFetchGoogleGroups_MultiPage(t *testing.T) { - server := mockGroupsServer(t, [][]string{ + server := mockGroupsServer(t, "user@example.com", [][]string{ {"group-a@example.com"}, {"group-b@example.com", "group-c@example.com"}, }) defer server.Close() - origEndpoint := IAMGroupsEndpoint - IAMGroupsEndpoint = server.URL - defer func() { IAMGroupsEndpoint = origEndpoint }() - - groups, err := FetchGroups(context.Background(), &http.Client{}, "user@example.com") + client := newTestClient(t, server) + groups, err := client.FetchGroups(context.Background(), "user@example.com") require.NoError(t, err) assert.ElementsMatch(t, []string{"group-a@example.com", "group-b@example.com", "group-c@example.com"}, groups) } func TestFetchGoogleGroups_Empty(t *testing.T) { - server := mockGroupsServer(t, [][]string{{}}) + server := mockGroupsServer(t, "user@example.com", [][]string{{}}) defer server.Close() - origEndpoint := IAMGroupsEndpoint - IAMGroupsEndpoint = server.URL - defer func() { IAMGroupsEndpoint = origEndpoint }() - - groups, err := FetchGroups(context.Background(), &http.Client{}, "user@example.com") + client := newTestClient(t, server) + groups, err := client.FetchGroups(context.Background(), "user@example.com") require.NoError(t, err) assert.Empty(t, groups) } @@ -91,11 +105,8 @@ func TestFetchGoogleGroups_APIError(t *testing.T) { })) defer server.Close() - origEndpoint := IAMGroupsEndpoint - IAMGroupsEndpoint = server.URL - defer func() { IAMGroupsEndpoint = origEndpoint }() - - groups, err := FetchGroups(context.Background(), &http.Client{}, "user@example.com") + client := newTestClient(t, server) + groups, err := client.FetchGroups(context.Background(), "user@example.com") require.Error(t, err) assert.Nil(t, groups) assert.Contains(t, err.Error(), "403") @@ -108,11 +119,8 @@ func TestFetchGoogleGroups_InvalidJSON(t *testing.T) { })) defer server.Close() - origEndpoint := IAMGroupsEndpoint - IAMGroupsEndpoint = server.URL - defer func() { IAMGroupsEndpoint = origEndpoint }() - - groups, err := FetchGroups(context.Background(), &http.Client{}, "user@example.com") + client := newTestClient(t, server) + groups, err := client.FetchGroups(context.Background(), "user@example.com") require.Error(t, err) assert.Nil(t, groups) } diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go index 92b6504419..d55b334a13 100644 --- a/routers/web/auth/oauth.go +++ b/routers/web/auth/oauth.go @@ -484,10 +484,16 @@ func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, requ if oauth2Source.Provider == "gplus" && slices.Contains(oauth2Source.Scopes, google_module.IAMScope) { // Build an HTTP client that carries the access token via // golang.org/x/oauth2 transport, which is what Google APIs expect. + // Note: we use only the access token here without a refresh token. This is + // intentional — gothUser.AccessToken is issued moments before this call + // during the OAuth2 login flow, so it is guaranteed to be fresh. If the + // groups API call fails due to expiry in an edge case, the login continues + // without groups (non-fatal warning is logged below). oauthToken := &go_oauth2.Token{AccessToken: gothUser.AccessToken} - tokenSource := go_oauth2.StaticTokenSource(oauthToken) - authenticatedClient := go_oauth2.NewClient(ctx, tokenSource) - googleGroups, err := google_module.FetchGroups(ctx, authenticatedClient, gothUser.Email) + authenticatedClient := go_oauth2.NewClient(ctx, go_oauth2.StaticTokenSource(oauthToken)) + + googleClient := google_module.NewClient(authenticatedClient) + googleGroups, err := googleClient.FetchGroups(ctx, gothUser.Email) if err != nil { log.Warn("OAuth2 Google: failed to fetch Workspace groups for %s: %v", gothUser.Email, err) // Non-fatal: continue login without groups rather than blocking the user. From c8b875914676232d95624d0f2d122d74d2afeb0b Mon Sep 17 00:00:00 2001 From: Andy Mrichko Date: Sat, 21 Mar 2026 20:54:03 +0200 Subject: [PATCH 3/7] feat: Retrieving groups for Google OAuth2 provider --- modules/google/groups.go | 83 ++++++++++++++++++++++++ modules/google/groups_test.go | 118 ++++++++++++++++++++++++++++++++++ routers/web/auth/oauth.go | 28 ++++++++ 3 files changed, 229 insertions(+) create mode 100644 modules/google/groups.go create mode 100644 modules/google/groups_test.go diff --git a/modules/google/groups.go b/modules/google/groups.go new file mode 100644 index 0000000000..373b6e3b23 --- /dev/null +++ b/modules/google/groups.go @@ -0,0 +1,83 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package google + +import ( + "context" + "fmt" + "io" + "net/http" + + "code.gitea.io/gitea/modules/json" +) + +const IAMScope = "https://www.googleapis.com/auth/cloud-identity.groups.readonly" + +var IAMGroupsEndpoint = "https://content-cloudidentity.googleapis.com/v1/groups/-/memberships:searchDirectGroups" + +// groupMembership represents a single membership entry returned by the +// Cloud Identity Groups API searchDirectGroups endpoint. +type groupMembership struct { + GroupKey struct { + ID string `json:"id"` + } `json:"groupKey"` +} + +// groupsResponse is the paged response from the Cloud Identity API. +type groupsResponse struct { + Memberships []groupMembership `json:"memberships"` + NextPageToken string `json:"nextPageToken"` +} + +// FetchGroups queries the Google Cloud Identity Groups API for all +// groups the given user (identified by email) is a direct member of. +// The caller must supply an HTTP client already authenticated with an access +// token that carries the IAMScope scope. +func FetchGroups(ctx context.Context, client *http.Client, email string) ([]string, error) { + groups := make([]string, 0, 16) + pageToken := "" + + for { + url := fmt.Sprintf("%s?query=member_key_id=='%s'", IAMGroupsEndpoint, email) + if pageToken != "" { + url = fmt.Sprintf("%s&pageToken=%s", url, pageToken) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("google groups: build request: %w", err) + } + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("google groups: HTTP request: %w", err) + } + body, readErr := io.ReadAll(resp.Body) + resp.Body.Close() + if readErr != nil { + return nil, fmt.Errorf("google groups: read response: %w", readErr) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("google groups: API returned %d: %s", resp.StatusCode, body) + } + + var page groupsResponse + if err := json.Unmarshal(body, &page); err != nil { + return nil, fmt.Errorf("google groups: decode response: %w", err) + } + + for _, m := range page.Memberships { + if m.GroupKey.ID != "" { + groups = append(groups, m.GroupKey.ID) + } + } + + if page.NextPageToken == "" { + break + } + pageToken = page.NextPageToken + } + + return groups, nil +} diff --git a/modules/google/groups_test.go b/modules/google/groups_test.go new file mode 100644 index 0000000000..49c2948c08 --- /dev/null +++ b/modules/google/groups_test.go @@ -0,0 +1,118 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package google + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockGroupsServer(t *testing.T, pages [][]string) *httptest.Server { + t.Helper() + pageIndex := 0 + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var memberships []string + for _, g := range pages[pageIndex] { + memberships = append(memberships, fmt.Sprintf(`{"groupKey":{"id":%q}}`, g)) + } + + nextPageToken := "" + if pageIndex < len(pages)-1 { + nextPageToken = "page-token" + } + pageIndex++ + + body := fmt.Sprintf( + `{"memberships":[%s],"nextPageToken":%q}`, + strings.Join(memberships, ","), + nextPageToken, + ) + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, body) + })) +} + +func TestFetchGoogleGroups_SinglePage(t *testing.T) { + server := mockGroupsServer(t, [][]string{ + {"group-a@example.com", "group-b@example.com"}, + }) + defer server.Close() + + origEndpoint := IAMGroupsEndpoint + IAMGroupsEndpoint = server.URL + defer func() { IAMGroupsEndpoint = origEndpoint }() + + groups, err := FetchGroups(context.Background(), &http.Client{}, "user@example.com") + require.NoError(t, err) + assert.ElementsMatch(t, []string{"group-a@example.com", "group-b@example.com"}, groups) +} + +func TestFetchGoogleGroups_MultiPage(t *testing.T) { + server := mockGroupsServer(t, [][]string{ + {"group-a@example.com"}, + {"group-b@example.com", "group-c@example.com"}, + }) + defer server.Close() + + origEndpoint := IAMGroupsEndpoint + IAMGroupsEndpoint = server.URL + defer func() { IAMGroupsEndpoint = origEndpoint }() + + groups, err := FetchGroups(context.Background(), &http.Client{}, "user@example.com") + require.NoError(t, err) + assert.ElementsMatch(t, []string{"group-a@example.com", "group-b@example.com", "group-c@example.com"}, groups) +} + +func TestFetchGoogleGroups_Empty(t *testing.T) { + server := mockGroupsServer(t, [][]string{{}}) + defer server.Close() + + origEndpoint := IAMGroupsEndpoint + IAMGroupsEndpoint = server.URL + defer func() { IAMGroupsEndpoint = origEndpoint }() + + groups, err := FetchGroups(context.Background(), &http.Client{}, "user@example.com") + require.NoError(t, err) + assert.Empty(t, groups) +} + +func TestFetchGoogleGroups_APIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = fmt.Fprint(w, `{"error":"forbidden"}`) + })) + defer server.Close() + + origEndpoint := IAMGroupsEndpoint + IAMGroupsEndpoint = server.URL + defer func() { IAMGroupsEndpoint = origEndpoint }() + + groups, err := FetchGroups(context.Background(), &http.Client{}, "user@example.com") + require.Error(t, err) + assert.Nil(t, groups) + assert.Contains(t, err.Error(), "403") +} + +func TestFetchGoogleGroups_InvalidJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprint(w, `not valid json`) + })) + defer server.Close() + + origEndpoint := IAMGroupsEndpoint + IAMGroupsEndpoint = server.URL + defer func() { IAMGroupsEndpoint = origEndpoint }() + + groups, err := FetchGroups(context.Background(), &http.Client{}, "user@example.com") + require.Error(t, err) + assert.Nil(t, groups) +} diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go index 3b1744669d..92b6504419 100644 --- a/routers/web/auth/oauth.go +++ b/routers/web/auth/oauth.go @@ -11,6 +11,7 @@ import ( "io" "net/http" "net/url" + "slices" "sort" "strings" @@ -18,6 +19,7 @@ import ( user_model "code.gitea.io/gitea/models/user" auth_module "code.gitea.io/gitea/modules/auth" "code.gitea.io/gitea/modules/container" + google_module "code.gitea.io/gitea/modules/google" "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/optional" @@ -476,6 +478,32 @@ func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, requ } } + // For Google Workspace: if the cloud-identity groups scope is present, + // fetch group memberships via the Cloud Identity API and inject them into + // RawData so that the standard GroupClaimName mechanism can pick them up. + if oauth2Source.Provider == "gplus" && slices.Contains(oauth2Source.Scopes, google_module.IAMScope) { + // Build an HTTP client that carries the access token via + // golang.org/x/oauth2 transport, which is what Google APIs expect. + oauthToken := &go_oauth2.Token{AccessToken: gothUser.AccessToken} + tokenSource := go_oauth2.StaticTokenSource(oauthToken) + authenticatedClient := go_oauth2.NewClient(ctx, tokenSource) + googleGroups, err := google_module.FetchGroups(ctx, authenticatedClient, gothUser.Email) + if err != nil { + log.Warn("OAuth2 Google: failed to fetch Workspace groups for %s: %v", gothUser.Email, err) + // Non-fatal: continue login without groups rather than blocking the user. + } else { + if gothUser.RawData == nil { + gothUser.RawData = make(map[string]any) + } + claimName := oauth2Source.GroupClaimName + if claimName == "" { + claimName = "groups" + } + gothUser.RawData[claimName] = googleGroups + log.Debug("OAuth2 Google: injected %d Workspace groups to claim '%s' for %s", len(googleGroups), claimName, gothUser.Email) + } + } + user := &user_model.User{ LoginName: gothUser.UserID, LoginType: auth.OAuth2, From c979cc215dc2008c1facb3f51a2c443d781905ae Mon Sep 17 00:00:00 2001 From: Andy Mrichko Date: Mon, 23 Mar 2026 00:55:02 +0200 Subject: [PATCH 4/7] Fix PR issues --- modules/google/groups.go | 41 +++++++++++++++++----- modules/google/groups_test.go | 66 ++++++++++++++++++++--------------- routers/web/auth/oauth.go | 12 +++++-- 3 files changed, 79 insertions(+), 40 deletions(-) diff --git a/modules/google/groups.go b/modules/google/groups.go index 373b6e3b23..4bee4f500b 100644 --- a/modules/google/groups.go +++ b/modules/google/groups.go @@ -8,13 +8,36 @@ import ( "fmt" "io" "net/http" + "net/url" "code.gitea.io/gitea/modules/json" ) -const IAMScope = "https://www.googleapis.com/auth/cloud-identity.groups.readonly" +const ( + IAMScope = "https://www.googleapis.com/auth/cloud-identity.groups.readonly" + defaultIAMGroupsEndpoint = "https://content-cloudidentity.googleapis.com/v1/groups/-/memberships:searchDirectGroups" +) -var IAMGroupsEndpoint = "https://content-cloudidentity.googleapis.com/v1/groups/-/memberships:searchDirectGroups" +// maxGroupPages is the maximum number of pages fetched from the Google +// Cloud Identity API. The API returns up to 200 groups per page by default, +// so this caps group membership at 4,000 groups per user — far beyond any +// realistic Google Workspace organization. +const maxGroupPages = 20 + +// Client calls Google Workspace APIs. +type Client struct { + httpClient *http.Client + groupsEndpoint string +} + +// NewClient creates a Client using the given authenticated HTTP client. +// The client should be built from an OAuth2 token carrying IAMScope. +func NewClient(httpClient *http.Client) *Client { + return &Client{ + httpClient: httpClient, + groupsEndpoint: defaultIAMGroupsEndpoint, + } +} // groupMembership represents a single membership entry returned by the // Cloud Identity Groups API searchDirectGroups endpoint. @@ -34,22 +57,24 @@ type groupsResponse struct { // groups the given user (identified by email) is a direct member of. // The caller must supply an HTTP client already authenticated with an access // token that carries the IAMScope scope. -func FetchGroups(ctx context.Context, client *http.Client, email string) ([]string, error) { +func (c *Client) FetchGroups(ctx context.Context, email string) ([]string, error) { groups := make([]string, 0, 16) pageToken := "" - for { - url := fmt.Sprintf("%s?query=member_key_id=='%s'", IAMGroupsEndpoint, email) + for range maxGroupPages { + params := url.Values{} + params.Set("query", fmt.Sprintf("member_key_id=='%s'", email)) if pageToken != "" { - url = fmt.Sprintf("%s&pageToken=%s", url, pageToken) + params.Set("pageToken", pageToken) } + apiURL := c.groupsEndpoint + "?" + params.Encode() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) if err != nil { return nil, fmt.Errorf("google groups: build request: %w", err) } - resp, err := client.Do(req) + resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("google groups: HTTP request: %w", err) } diff --git a/modules/google/groups_test.go b/modules/google/groups_test.go index 49c2948c08..645c698889 100644 --- a/modules/google/groups_test.go +++ b/modules/google/groups_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 The Gitea Authors. All rights reserved. +// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package google @@ -15,10 +15,21 @@ import ( "github.com/stretchr/testify/require" ) -func mockGroupsServer(t *testing.T, pages [][]string) *httptest.Server { +func newTestClient(t *testing.T, server *httptest.Server) *Client { + t.Helper() + c := NewClient(server.Client()) + c.groupsEndpoint = server.URL + return c +} + +func mockGroupsServer(t *testing.T, expectedEmail string, pages [][]string) *httptest.Server { t.Helper() pageIndex := 0 return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Validate the query contains the correct member_key_id + expectedQuery := fmt.Sprintf("member_key_id=='%s'", expectedEmail) + assert.Equal(t, expectedQuery, r.URL.Query().Get("query")) + var memberships []string for _, g := range pages[pageIndex] { memberships = append(memberships, fmt.Sprintf(`{"groupKey":{"id":%q}}`, g)) @@ -41,45 +52,48 @@ func mockGroupsServer(t *testing.T, pages [][]string) *httptest.Server { } func TestFetchGoogleGroups_SinglePage(t *testing.T) { - server := mockGroupsServer(t, [][]string{ + server := mockGroupsServer(t, "user@example.com", [][]string{ {"group-a@example.com", "group-b@example.com"}, }) defer server.Close() - origEndpoint := IAMGroupsEndpoint - IAMGroupsEndpoint = server.URL - defer func() { IAMGroupsEndpoint = origEndpoint }() + client := newTestClient(t, server) + groups, err := client.FetchGroups(context.Background(), "user@example.com") + require.NoError(t, err) + assert.ElementsMatch(t, []string{"group-a@example.com", "group-b@example.com"}, groups) +} - groups, err := FetchGroups(context.Background(), &http.Client{}, "user@example.com") +func TestFetchGroups_SinglePage(t *testing.T) { + server := mockGroupsServer(t, "user@example.com", [][]string{ + {"group-a@example.com", "group-b@example.com"}, + }) + defer server.Close() + + client := newTestClient(t, server) + groups, err := client.FetchGroups(context.Background(), "user@example.com") require.NoError(t, err) assert.ElementsMatch(t, []string{"group-a@example.com", "group-b@example.com"}, groups) } func TestFetchGoogleGroups_MultiPage(t *testing.T) { - server := mockGroupsServer(t, [][]string{ + server := mockGroupsServer(t, "user@example.com", [][]string{ {"group-a@example.com"}, {"group-b@example.com", "group-c@example.com"}, }) defer server.Close() - origEndpoint := IAMGroupsEndpoint - IAMGroupsEndpoint = server.URL - defer func() { IAMGroupsEndpoint = origEndpoint }() - - groups, err := FetchGroups(context.Background(), &http.Client{}, "user@example.com") + client := newTestClient(t, server) + groups, err := client.FetchGroups(context.Background(), "user@example.com") require.NoError(t, err) assert.ElementsMatch(t, []string{"group-a@example.com", "group-b@example.com", "group-c@example.com"}, groups) } func TestFetchGoogleGroups_Empty(t *testing.T) { - server := mockGroupsServer(t, [][]string{{}}) + server := mockGroupsServer(t, "user@example.com", [][]string{{}}) defer server.Close() - origEndpoint := IAMGroupsEndpoint - IAMGroupsEndpoint = server.URL - defer func() { IAMGroupsEndpoint = origEndpoint }() - - groups, err := FetchGroups(context.Background(), &http.Client{}, "user@example.com") + client := newTestClient(t, server) + groups, err := client.FetchGroups(context.Background(), "user@example.com") require.NoError(t, err) assert.Empty(t, groups) } @@ -91,11 +105,8 @@ func TestFetchGoogleGroups_APIError(t *testing.T) { })) defer server.Close() - origEndpoint := IAMGroupsEndpoint - IAMGroupsEndpoint = server.URL - defer func() { IAMGroupsEndpoint = origEndpoint }() - - groups, err := FetchGroups(context.Background(), &http.Client{}, "user@example.com") + client := newTestClient(t, server) + groups, err := client.FetchGroups(context.Background(), "user@example.com") require.Error(t, err) assert.Nil(t, groups) assert.Contains(t, err.Error(), "403") @@ -108,11 +119,8 @@ func TestFetchGoogleGroups_InvalidJSON(t *testing.T) { })) defer server.Close() - origEndpoint := IAMGroupsEndpoint - IAMGroupsEndpoint = server.URL - defer func() { IAMGroupsEndpoint = origEndpoint }() - - groups, err := FetchGroups(context.Background(), &http.Client{}, "user@example.com") + client := newTestClient(t, server) + groups, err := client.FetchGroups(context.Background(), "user@example.com") require.Error(t, err) assert.Nil(t, groups) } diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go index 92b6504419..d55b334a13 100644 --- a/routers/web/auth/oauth.go +++ b/routers/web/auth/oauth.go @@ -484,10 +484,16 @@ func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, requ if oauth2Source.Provider == "gplus" && slices.Contains(oauth2Source.Scopes, google_module.IAMScope) { // Build an HTTP client that carries the access token via // golang.org/x/oauth2 transport, which is what Google APIs expect. + // Note: we use only the access token here without a refresh token. This is + // intentional — gothUser.AccessToken is issued moments before this call + // during the OAuth2 login flow, so it is guaranteed to be fresh. If the + // groups API call fails due to expiry in an edge case, the login continues + // without groups (non-fatal warning is logged below). oauthToken := &go_oauth2.Token{AccessToken: gothUser.AccessToken} - tokenSource := go_oauth2.StaticTokenSource(oauthToken) - authenticatedClient := go_oauth2.NewClient(ctx, tokenSource) - googleGroups, err := google_module.FetchGroups(ctx, authenticatedClient, gothUser.Email) + authenticatedClient := go_oauth2.NewClient(ctx, go_oauth2.StaticTokenSource(oauthToken)) + + googleClient := google_module.NewClient(authenticatedClient) + googleGroups, err := googleClient.FetchGroups(ctx, gothUser.Email) if err != nil { log.Warn("OAuth2 Google: failed to fetch Workspace groups for %s: %v", gothUser.Email, err) // Non-fatal: continue login without groups rather than blocking the user. From e0dd03088b5849e04c3547993539a67792f72834 Mon Sep 17 00:00:00 2001 From: Andy Mrichko Date: Thu, 2 Apr 2026 01:11:21 +0300 Subject: [PATCH 5/7] Make injection of additional information into user oauth2 raw data more abstract. Make sure we don't ovveride existing claims --- modules/google/groups.go | 25 +++++++++- modules/google/groups_test.go | 2 +- routers/web/auth/oauth.go | 33 ++----------- .../source/oauth2/additional_info_provider.go | 46 +++++++++++++++++++ 4 files changed, 75 insertions(+), 31 deletions(-) create mode 100644 services/auth/source/oauth2/additional_info_provider.go diff --git a/modules/google/groups.go b/modules/google/groups.go index 4bee4f500b..58c3823357 100644 --- a/modules/google/groups.go +++ b/modules/google/groups.go @@ -11,6 +11,9 @@ import ( "net/url" "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + + "github.com/markbates/goth" ) const ( @@ -28,14 +31,16 @@ const maxGroupPages = 20 type Client struct { httpClient *http.Client groupsEndpoint string + claimName string } // NewClient creates a Client using the given authenticated HTTP client. // The client should be built from an OAuth2 token carrying IAMScope. -func NewClient(httpClient *http.Client) *Client { +func NewClient(httpClient *http.Client, claimName string) *Client { return &Client{ httpClient: httpClient, groupsEndpoint: defaultIAMGroupsEndpoint, + claimName: claimName, } } @@ -106,3 +111,21 @@ func (c *Client) FetchGroups(ctx context.Context, email string) ([]string, error return groups, nil } + +// FetchAdditionalInfo implements oauth2.AdditionalInfoProvider. +// It fetches Google Workspace group memberships and injects them into +// gothUser.RawData under the given claimName key. +func (c *Client) FetchAdditionalInfo(ctx context.Context, user goth.User) (goth.User, error) { + groups, err := c.FetchGroups(ctx, user.Email) + if err != nil { + return user, err + } + if user.RawData == nil { + user.RawData = make(map[string]any) + } + if existing, has := user.RawData[c.claimName]; has { + log.Warn("OAuth2 Google: RawData already contains claim %q with some value. Consider to use different claim name for groups information", c.claimName, existing) + } + user.RawData[c.claimName] = groups + return user, nil +} diff --git a/modules/google/groups_test.go b/modules/google/groups_test.go index 645c698889..b53c53e032 100644 --- a/modules/google/groups_test.go +++ b/modules/google/groups_test.go @@ -17,7 +17,7 @@ import ( func newTestClient(t *testing.T, server *httptest.Server) *Client { t.Helper() - c := NewClient(server.Client()) + c := NewClient(server.Client(), "groups") c.groupsEndpoint = server.URL return c } diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go index d55b334a13..25796d404a 100644 --- a/routers/web/auth/oauth.go +++ b/routers/web/auth/oauth.go @@ -11,7 +11,6 @@ import ( "io" "net/http" "net/url" - "slices" "sort" "strings" @@ -19,7 +18,6 @@ import ( user_model "code.gitea.io/gitea/models/user" auth_module "code.gitea.io/gitea/modules/auth" "code.gitea.io/gitea/modules/container" - google_module "code.gitea.io/gitea/modules/google" "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/optional" @@ -478,35 +476,12 @@ func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, requ } } - // For Google Workspace: if the cloud-identity groups scope is present, - // fetch group memberships via the Cloud Identity API and inject them into - // RawData so that the standard GroupClaimName mechanism can pick them up. - if oauth2Source.Provider == "gplus" && slices.Contains(oauth2Source.Scopes, google_module.IAMScope) { - // Build an HTTP client that carries the access token via - // golang.org/x/oauth2 transport, which is what Google APIs expect. - // Note: we use only the access token here without a refresh token. This is - // intentional — gothUser.AccessToken is issued moments before this call - // during the OAuth2 login flow, so it is guaranteed to be fresh. If the - // groups API call fails due to expiry in an edge case, the login continues - // without groups (non-fatal warning is logged below). - oauthToken := &go_oauth2.Token{AccessToken: gothUser.AccessToken} - authenticatedClient := go_oauth2.NewClient(ctx, go_oauth2.StaticTokenSource(oauthToken)) - - googleClient := google_module.NewClient(authenticatedClient) - googleGroups, err := googleClient.FetchGroups(ctx, gothUser.Email) + if provider := oauth2.GetAdditionalInfoProvider(oauth2Source, &gothUser); provider != nil { + enriched, err := provider.FetchAdditionalInfo(ctx, gothUser) if err != nil { - log.Warn("OAuth2 Google: failed to fetch Workspace groups for %s: %v", gothUser.Email, err) - // Non-fatal: continue login without groups rather than blocking the user. + log.Warn("OAuth2: failed to fetch additional info for %s: %v", gothUser.Email, err) } else { - if gothUser.RawData == nil { - gothUser.RawData = make(map[string]any) - } - claimName := oauth2Source.GroupClaimName - if claimName == "" { - claimName = "groups" - } - gothUser.RawData[claimName] = googleGroups - log.Debug("OAuth2 Google: injected %d Workspace groups to claim '%s' for %s", len(googleGroups), claimName, gothUser.Email) + gothUser = enriched } } diff --git a/services/auth/source/oauth2/additional_info_provider.go b/services/auth/source/oauth2/additional_info_provider.go new file mode 100644 index 0000000000..d9146c4967 --- /dev/null +++ b/services/auth/source/oauth2/additional_info_provider.go @@ -0,0 +1,46 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package oauth2 + +import ( + "context" + "slices" + + google_module "code.gitea.io/gitea/modules/google" + + "github.com/markbates/goth" + go_oauth2 "golang.org/x/oauth2" +) + +// AdditionalInfoProvider is implemented by OAuth2 providers that can fetch +// additional user information (such as group memberships) that is not +// included in the standard token or userinfo response. +// The provider receives the resolved goth user, and returns a modified copy +// with any extra data injected into RawData. +type AdditionalInfoProvider interface { + FetchAdditionalInfo(ctx context.Context, user goth.User) (goth.User, error) +} + +// GetAdditionalInfoProvider returns an AdditionalInfoProvider for the given +// source if that provider supports fetching additional info, or nil if none +// applies. The returned provider is already configured with an authenticated +// HTTP client built from the access token. +func GetAdditionalInfoProvider(source *Source, gothUser *goth.User) AdditionalInfoProvider { + switch source.Provider { + case "gplus": + if slices.Contains(source.Scopes, google_module.IAMScope) { + claimName := source.GroupClaimName + if claimName == "" { + claimName = "groups" + } + oauthToken := &go_oauth2.Token{AccessToken: gothUser.AccessToken} + // Note: we use only the access token without a refresh token. + // This is intentional — the token is issued moments before this + // call during the login flow and is guaranteed to be fresh. + authenticatedClient := go_oauth2.NewClient(context.Background(), go_oauth2.StaticTokenSource(oauthToken)) + return google_module.NewClient(authenticatedClient, claimName) + } + } + return nil +} From 7a338cfd598b85e940325bb6a92cb1d766f23446 Mon Sep 17 00:00:00 2001 From: Andy Mrichko Date: Fri, 8 May 2026 22:03:11 +0300 Subject: [PATCH 6/7] Fixed issues identified in PR review --- modules/google/groups.go | 21 ++-- modules/google/groups_test.go | 50 +++++++- routers/web/auth/oauth.go | 48 ++++++-- routers/web/auth/oauth_group_claims_test.go | 108 ++++++++++++++++++ .../source/oauth2/additional_info_provider.go | 16 ++- .../oauth2/additional_info_provider_test.go | 62 ++++++++++ 6 files changed, 287 insertions(+), 18 deletions(-) create mode 100644 routers/web/auth/oauth_group_claims_test.go create mode 100644 services/auth/source/oauth2/additional_info_provider_test.go diff --git a/modules/google/groups.go b/modules/google/groups.go index 58c3823357..b941d37cab 100644 --- a/modules/google/groups.go +++ b/modules/google/groups.go @@ -29,18 +29,20 @@ const maxGroupPages = 20 // Client calls Google Workspace APIs. type Client struct { - httpClient *http.Client - groupsEndpoint string - claimName string + httpClient *http.Client + groupsEndpoint string + claimName string + failLoginOnAdditionalInfoError bool } // NewClient creates a Client using the given authenticated HTTP client. // The client should be built from an OAuth2 token carrying IAMScope. -func NewClient(httpClient *http.Client, claimName string) *Client { +func NewClient(httpClient *http.Client, claimName string, failLoginOnAdditionalInfoError bool) *Client { return &Client{ - httpClient: httpClient, - groupsEndpoint: defaultIAMGroupsEndpoint, - claimName: claimName, + httpClient: httpClient, + groupsEndpoint: defaultIAMGroupsEndpoint, + claimName: claimName, + failLoginOnAdditionalInfoError: failLoginOnAdditionalInfoError, } } @@ -129,3 +131,8 @@ func (c *Client) FetchAdditionalInfo(ctx context.Context, user goth.User) (goth. user.RawData[c.claimName] = groups return user, nil } + +// FailLoginOnAdditionalInfoError implements oauth2.AdditionalInfoProvider. +func (c *Client) FailLoginOnAdditionalInfoError() bool { + return c.failLoginOnAdditionalInfoError +} diff --git a/modules/google/groups_test.go b/modules/google/groups_test.go index b53c53e032..d5bc83567d 100644 --- a/modules/google/groups_test.go +++ b/modules/google/groups_test.go @@ -11,13 +11,14 @@ import ( "strings" "testing" + "github.com/markbates/goth" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func newTestClient(t *testing.T, server *httptest.Server) *Client { t.Helper() - c := NewClient(server.Client(), "groups") + c := NewClient(server.Client(), "groups", false) c.groupsEndpoint = server.URL return c } @@ -124,3 +125,50 @@ func TestFetchGoogleGroups_InvalidJSON(t *testing.T) { require.Error(t, err) assert.Nil(t, groups) } + +func TestFetchAdditionalInfo_InjectsClaimBeforeValidation(t *testing.T) { + server := mockGroupsServer(t, "user@example.com", [][]string{ + {"required-group@example.com"}, + }) + defer server.Close() + + c := newTestClient(t, server) + c.claimName = "groups" + + user := goth.User{ + Email: "user@example.com", + RawData: map[string]any{}, + } + + enriched, err := c.FetchAdditionalInfo(context.Background(), user) + require.NoError(t, err) + + // Verify the claim is present and contains the group — simulating what + // RequiredClaimName validation would check after enrichment runs. + groups, ok := enriched.RawData["groups"] + require.True(t, ok, "groups claim must be present in RawData after enrichment") + groupSlice, ok := groups.([]string) + require.True(t, ok) + assert.Contains(t, groupSlice, "required-group@example.com") +} + +func TestFetchAdditionalInfo_ErrorDoesNotInjectClaim(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = fmt.Fprint(w, `{"error":"forbidden"}`) + })) + defer server.Close() + + c := newTestClient(t, server) + c.claimName = "groups" + + user := goth.User{ + Email: "user@example.com", + RawData: map[string]any{}, + } + + enriched, err := c.FetchAdditionalInfo(context.Background(), user) + require.Error(t, err) + _, hasGroups := enriched.RawData["groups"] + assert.False(t, hasGroups) +} diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go index 83726bfd81..750b90c3fa 100644 --- a/routers/web/auth/oauth.go +++ b/routers/web/auth/oauth.go @@ -232,6 +232,10 @@ func claimValueToStringSet(claimValue any) container.Set[string] { } func syncGroupsToTeams(ctx *context.Context, source *oauth2.Source, gothUser *goth.User, u *user_model.User) error { + if !shouldSyncFromGroupClaim(source, gothUser) { + return nil + } + if source.GroupTeamMap != "" || source.GroupTeamMapRemoval { groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(source.GroupTeamMap) if err != nil { @@ -257,7 +261,22 @@ func getClaimedGroups(source *oauth2.Source, gothUser *goth.User) container.Set[ return claimValueToStringSet(groupClaims) } +func shouldSyncFromGroupClaim(source *oauth2.Source, gothUser *goth.User) bool { + // Keep historical behavior for all providers except Google Workspace: + // if the claim is missing, group-derived sync still runs on an empty set. + if source.Provider != "gplus" { + return true + } + + _, hasGroupClaim := gothUser.RawData[source.GroupClaimName] + return hasGroupClaim +} + func getUserAdminAndRestrictedFromGroupClaims(source *oauth2.Source, gothUser *goth.User) (isAdmin optional.Option[user_service.UpdateOptionField[bool]], isRestricted optional.Option[bool]) { + if !shouldSyncFromGroupClaim(source, gothUser) { + return isAdmin, isRestricted + } + groups := getClaimedGroups(source, gothUser) if source.AdminGroup != "" { @@ -461,6 +480,26 @@ func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, requ return nil, goth.User{}, err } + // Enrichment must run before RequiredClaimName validation so that claims + // injected by the provider (e.g. Google Workspace groups fetched via the + // Cloud Identity API) are available when the required-claim check executes. + // Moving this block after the RequiredClaimName check would cause users to + // be incorrectly rejected when RequiredClaimName references an injected claim. + if provider := oauth2.GetAdditionalInfoProvider(oauth2Source, &gothUser); provider != nil { + enriched, err := provider.FetchAdditionalInfo(ctx, gothUser) + if err != nil { + log.Warn("OAuth2: failed to fetch additional info for %s: %v", gothUser.Email, err) + if provider.FailLoginOnAdditionalInfoError() { + // 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 { + gothUser = enriched + } + } + if oauth2Source.RequiredClaimName != "" { claimInterface, has := gothUser.RawData[oauth2Source.RequiredClaimName] if !has { @@ -476,15 +515,6 @@ func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, requ } } - if provider := oauth2.GetAdditionalInfoProvider(oauth2Source, &gothUser); provider != nil { - enriched, err := provider.FetchAdditionalInfo(ctx, gothUser) - if err != nil { - log.Warn("OAuth2: failed to fetch additional info for %s: %v", gothUser.Email, err) - } else { - gothUser = enriched - } - } - user := &user_model.User{ LoginName: gothUser.UserID, LoginType: auth.OAuth2, diff --git a/routers/web/auth/oauth_group_claims_test.go b/routers/web/auth/oauth_group_claims_test.go new file mode 100644 index 0000000000..d3e5a4f329 --- /dev/null +++ b/routers/web/auth/oauth_group_claims_test.go @@ -0,0 +1,108 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "testing" + + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/services/auth/source/oauth2" + user_service "code.gitea.io/gitea/services/user" + + "github.com/markbates/goth" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestShouldSyncFromGroupClaim(t *testing.T) { + t.Run("google claim missing", func(t *testing.T) { + source := &oauth2.Source{ + Provider: "gplus", + GroupClaimName: "groups", + } + user := &goth.User{ + RawData: map[string]any{}, + } + assert.False(t, shouldSyncFromGroupClaim(source, user)) + }) + + t.Run("google claim present and empty", func(t *testing.T) { + source := &oauth2.Source{ + Provider: "gplus", + GroupClaimName: "groups", + } + user := &goth.User{ + RawData: map[string]any{ + "groups": []string{}, + }, + } + assert.True(t, shouldSyncFromGroupClaim(source, user)) + }) + + t.Run("non google provider keeps old behavior", func(t *testing.T) { + source := &oauth2.Source{ + Provider: "openidConnect", + GroupClaimName: "groups", + } + user := &goth.User{ + RawData: map[string]any{}, + } + assert.True(t, shouldSyncFromGroupClaim(source, user)) + }) +} + +func TestGetUserAdminAndRestrictedFromGroupClaims_GoogleMissingClaim(t *testing.T) { + source := &oauth2.Source{ + Provider: "gplus", + GroupClaimName: "groups", + AdminGroup: "g-admins@example.com", + RestrictedGroup: "g-restricted@example.com", + } + user := &goth.User{ + RawData: map[string]any{}, + } + + isAdmin, isRestricted := getUserAdminAndRestrictedFromGroupClaims(source, user) + + assert.False(t, isAdmin.Has()) + assert.Equal(t, optional.None[bool](), isRestricted) +} + +func TestGetUserAdminAndRestrictedFromGroupClaims_GoogleEmptyClaim(t *testing.T) { + source := &oauth2.Source{ + Provider: "gplus", + GroupClaimName: "groups", + AdminGroup: "g-admins@example.com", + RestrictedGroup: "g-restricted@example.com", + } + user := &goth.User{ + RawData: map[string]any{ + "groups": []string{}, + }, + } + + isAdmin, isRestricted := getUserAdminAndRestrictedFromGroupClaims(source, user) + + require.True(t, isAdmin.Has()) + assert.Equal(t, user_service.UpdateOptionFieldFromSync(false), isAdmin) + assert.Equal(t, optional.Some(false), isRestricted) +} + +func TestGetUserAdminAndRestrictedFromGroupClaims_NonGoogleMissingClaim(t *testing.T) { + source := &oauth2.Source{ + Provider: "openidConnect", + GroupClaimName: "groups", + AdminGroup: "g-admins@example.com", + RestrictedGroup: "g-restricted@example.com", + } + user := &goth.User{ + RawData: map[string]any{}, + } + + isAdmin, isRestricted := getUserAdminAndRestrictedFromGroupClaims(source, user) + + require.True(t, isAdmin.Has()) + assert.Equal(t, user_service.UpdateOptionFieldFromSync(false), isAdmin) + assert.Equal(t, optional.Some(false), isRestricted) +} diff --git a/services/auth/source/oauth2/additional_info_provider.go b/services/auth/source/oauth2/additional_info_provider.go index d9146c4967..fb31864aad 100644 --- a/services/auth/source/oauth2/additional_info_provider.go +++ b/services/auth/source/oauth2/additional_info_provider.go @@ -20,6 +20,7 @@ import ( // with any extra data injected into RawData. type AdditionalInfoProvider interface { FetchAdditionalInfo(ctx context.Context, user goth.User) (goth.User, error) + FailLoginOnAdditionalInfoError() bool } // GetAdditionalInfoProvider returns an AdditionalInfoProvider for the given @@ -39,8 +40,21 @@ func GetAdditionalInfoProvider(source *Source, gothUser *goth.User) AdditionalIn // This is intentional — the token is issued moments before this // call during the login flow and is guaranteed to be fresh. authenticatedClient := go_oauth2.NewClient(context.Background(), go_oauth2.StaticTokenSource(oauthToken)) - return google_module.NewClient(authenticatedClient, claimName) + return google_module.NewClient(authenticatedClient, claimName, isGoogleGroupClaimRequiredForLoginFlow(source)) } } return nil } + +func isGoogleGroupClaimRequiredForLoginFlow(source *Source) bool { + groupClaimName := source.GroupClaimName + if groupClaimName == "" { + groupClaimName = "groups" + } + + // Fail closed only when login itself depends on the group claim. + // + // Admin/restricted/team sync can preserve the user's previous state when the + // group claim is missing, so those options intentionally stay fail-open. + return source.RequiredClaimName == groupClaimName +} diff --git a/services/auth/source/oauth2/additional_info_provider_test.go b/services/auth/source/oauth2/additional_info_provider_test.go new file mode 100644 index 0000000000..a4b7af1881 --- /dev/null +++ b/services/auth/source/oauth2/additional_info_provider_test.go @@ -0,0 +1,62 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package oauth2 + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsGoogleGroupClaimRequiredForLoginFlow(t *testing.T) { + t.Run("no group-dependent options", func(t *testing.T) { + source := &Source{ + GroupClaimName: "groups", + } + assert.False(t, isGoogleGroupClaimRequiredForLoginFlow(source)) + }) + + t.Run("required claim uses group claim", func(t *testing.T) { + source := &Source{ + GroupClaimName: "custom_groups", + RequiredClaimName: "custom_groups", + } + assert.True(t, isGoogleGroupClaimRequiredForLoginFlow(source)) + }) + + t.Run("required claim uses default groups claim", func(t *testing.T) { + source := &Source{ + RequiredClaimName: "groups", + } + assert.True(t, isGoogleGroupClaimRequiredForLoginFlow(source)) + }) + + t.Run("admin group configured", func(t *testing.T) { + source := &Source{ + AdminGroup: "admins@example.com", + } + assert.False(t, isGoogleGroupClaimRequiredForLoginFlow(source)) + }) + + t.Run("restricted group configured", func(t *testing.T) { + source := &Source{ + RestrictedGroup: "restricted@example.com", + } + assert.False(t, isGoogleGroupClaimRequiredForLoginFlow(source)) + }) + + t.Run("group team mapping configured", func(t *testing.T) { + source := &Source{ + GroupTeamMap: "{\"a\": {\"org\": [\"team\"]}}", + } + assert.False(t, isGoogleGroupClaimRequiredForLoginFlow(source)) + }) + + t.Run("group team mapping removal enabled", func(t *testing.T) { + source := &Source{ + GroupTeamMapRemoval: true, + } + assert.False(t, isGoogleGroupClaimRequiredForLoginFlow(source)) + }) +} From b6d082d136698ff53d436fbea31df3a01aaa75e6 Mon Sep 17 00:00:00 2001 From: Andy Mrichko Date: Fri, 8 May 2026 23:05:13 +0300 Subject: [PATCH 7/7] Added displaying banner for admin user in case of failure during required additional information retrieval --- modules/google/groups.go | 2 +- options/locale/locale_en-US.json | 2 + routers/common/pagetmpl.go | 16 +++- routers/common/pagetmpl_test.go | 41 ++++++++++ routers/web/auth/oauth.go | 6 ++ .../oauth2/additional_info_provider_test.go | 48 ++++++++++++ ...equired_additional_info_failure_warning.go | 77 +++++++++++++++++++ services/context/context_template.go | 9 +++ templates/base/head_banner.tmpl | 10 +++ 9 files changed, 208 insertions(+), 3 deletions(-) create mode 100644 routers/common/pagetmpl_test.go create mode 100644 services/auth/source/oauth2/required_additional_info_failure_warning.go 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}} +
+
+ {{ctx.Locale.Tr "auth.oauth.required_additional_info_fetch_failed_banner.title"}} +

{{ctx.Locale.Tr "auth.oauth.required_additional_info_fetch_failed_banner.desc" $requiredAdditionalInfoWarning.SourceName (DateUtils.AbsoluteShort $requiredAdditionalInfoWarning.LastFailedUnix)}}

+
+
+{{end}}