diff --git a/connector/gitlab/gitlab.go b/connector/gitlab/gitlab.go index a4ccf738..b9fb3bec 100644 --- a/connector/gitlab/gitlab.go +++ b/connector/gitlab/gitlab.go @@ -87,8 +87,9 @@ type connectorData struct { } var ( - _ connector.CallbackConnector = (*gitlabConnector)(nil) - _ connector.RefreshConnector = (*gitlabConnector)(nil) + _ connector.CallbackConnector = (*gitlabConnector)(nil) + _ connector.RefreshConnector = (*gitlabConnector)(nil) + _ connector.TokenIdentityConnector = (*gitlabConnector)(nil) ) type gitlabConnector struct { @@ -243,6 +244,34 @@ func (c *gitlabConnector) Refresh(ctx context.Context, s connector.Scopes, ident } } +// TokenIdentity is used for token exchange, verifying a GitLab access token +// and returning the associated user identity. This enables direct authentication +// with Dex using an existing GitLab token without going through the OAuth flow. +// +// Note: The connector decides whether to fetch groups based on its configuration +// (groups filter, getGroupsPermission), not on the scopes from the token exchange request. +// The server will then decide whether to include groups in the final token based on +// the requested scopes. This matches the behavior of other connectors (e.g., OIDC). +func (c *gitlabConnector) TokenIdentity(ctx context.Context, _, subjectToken string) (connector.Identity, error) { + if c.httpClient != nil { + ctx = context.WithValue(ctx, oauth2.HTTPClient, c.httpClient) + } + + token := &oauth2.Token{ + AccessToken: subjectToken, + TokenType: "Bearer", // GitLab tokens are typically Bearer tokens even if the type is not explicitly provided. + } + + // For token exchange, we determine if groups should be fetched based on connector configuration. + // If the connector has groups filter or getGroupsPermission enabled, we fetch groups. + scopes := connector.Scopes{ + // Scopes are not provided in token exchange, so we request groups every time and return only if configured. + Groups: true, + } + + return c.identity(ctx, scopes, token) +} + func (c *gitlabConnector) groupsRequired(groupScope bool) bool { return len(c.groups) > 0 || groupScope } diff --git a/connector/gitlab/gitlab_test.go b/connector/gitlab/gitlab_test.go index 28ff2643..92614643 100644 --- a/connector/gitlab/gitlab_test.go +++ b/connector/gitlab/gitlab_test.go @@ -485,3 +485,88 @@ func expectEquals(t *testing.T, a interface{}, b interface{}) { t.Errorf("Expected %+v to equal %+v", a, b) } } + +func TestTokenIdentity(t *testing.T) { + // Note: These tests verify that the connector returns groups based on its configuration. + // The actual inclusion of groups in the final Dex token depends on the 'groups' scope + // in the token exchange request, which is handled by the Dex server, not the connector. + tests := []struct { + name string + userInfo userInfo + groups []string + getGroupsPermission bool + useLoginAsID bool + expectUserID string + expectGroups []string + }{ + { + name: "without groups config", + expectUserID: "12345678", + expectGroups: nil, + }, + { + name: "with groups filter", + userInfo: userInfo{ + Groups: []string{"team-1", "team-2"}, + }, + groups: []string{"team-1"}, + expectUserID: "12345678", + expectGroups: []string{"team-1"}, + }, + { + name: "with groups permission", + userInfo: userInfo{ + Groups: []string{"ops", "dev"}, + OwnerPermission: []string{"ops"}, + DeveloperPermission: []string{"dev"}, + MaintainerPermission: []string{}, + }, + getGroupsPermission: true, + expectUserID: "12345678", + expectGroups: []string{"ops", "dev", "ops:owner", "dev:developer"}, + }, + { + name: "with useLoginAsID", + useLoginAsID: true, + expectUserID: "joebloggs", + expectGroups: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + responses := map[string]interface{}{ + "/api/v4/user": gitlabUser{ + Email: "some@email.com", + ID: 12345678, + Name: "Joe Bloggs", + Username: "joebloggs", + }, + "/oauth/userinfo": tc.userInfo, + } + + s := newTestServer(responses) + defer s.Close() + + c := gitlabConnector{ + baseURL: s.URL, + httpClient: newClient(), + groups: tc.groups, + getGroupsPermission: tc.getGroupsPermission, + useLoginAsID: tc.useLoginAsID, + } + + accessToken := "test-access-token" + ctx := context.Background() + identity, err := c.TokenIdentity(ctx, "urn:ietf:params:oauth:token-type:access_token", accessToken) + + expectNil(t, err) + expectEquals(t, identity.UserID, tc.expectUserID) + expectEquals(t, identity.Username, "Joe Bloggs") + expectEquals(t, identity.PreferredUsername, "joebloggs") + expectEquals(t, identity.Email, "some@email.com") + expectEquals(t, identity.EmailVerified, true) + expectEquals(t, identity.Groups, tc.expectGroups) + }) + } +}