From c8b875914676232d95624d0f2d122d74d2afeb0b Mon Sep 17 00:00:00 2001 From: Andy Mrichko Date: Sat, 21 Mar 2026 20:54:03 +0200 Subject: [PATCH] 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,