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 +}