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.