Verified Commit 570f7ece authored by Tomas Vik's avatar Tomas Vik Ⓜ️ Committed by GitLab
Browse files

Merge branch 'duo-cli-credential-helper' into 'main'

feat: Support generic credential helper

See merge request !2770



Merged-by: Tomas Vik's avatarTomas Vik <tvik@gitlab.com>
Approved-by: Tomas Vik's avatarTomas Vik <tvik@gitlab.com>
Reviewed-by: default avatarGitLab Duo <gitlab-duo@gitlab.com>
Co-authored-by: Timo Furrer's avatarTimo Furrer <tfurrer@gitlab.com>
parents f71b4b97 1426c9ff
Loading
Loading
Loading
Loading
Loading
+2 −0
Original line number Diff line number Diff line
@@ -119,6 +119,7 @@ test: SHELL = /bin/bash # set environment variables to ensure consistent test be
test: VISUAL=
test: EDITOR=
test: PAGER=
test: GITLAB_TOKEN=
test: export CI_PROJECT_PATH=$(shell git remote get-url origin)
test: bin/gotestsum ## Run tests
	$(GOTEST) --no-summary=skipped --junitfile ./coverage.xml --format ${TEST_FORMAT} -- -coverprofile=./coverage.txt -covermode=atomic $(filter-out -v,${GOARGS}) $(if ${TEST_PKGS},${TEST_PKGS},./...)
@@ -129,6 +130,7 @@ test-race: SHELL = /bin/bash # set environment variables to ensure consistent te
test-race: VISUAL=
test-race: EDITOR=
test-race: PAGER=
test-race: GITLAB_TOKEN=
test-race: export CI_PROJECT_PATH=$(shell git remote get-url origin)
test-race: bin/gotestsum ## Run tests with race detection
	$(GOTEST) --no-summary=skipped --junitfile ./coverage.xml --format ${TEST_FORMAT} -- -coverprofile=./coverage.txt -covermode=atomic -race $(filter-out -v,${GOARGS}) $(if ${TEST_PKGS},${TEST_PKGS},./...)
+14 −1
Original line number Diff line number Diff line
@@ -6,7 +6,10 @@ import (
	gitlab "gitlab.com/gitlab-org/api/client-go"
)

var _ gitlab.AuthSource = (*oauth2AccessTokenOnlyAuthSource)(nil)
var (
	_ gitlab.AuthSource = (*oauth2AccessTokenOnlyAuthSource)(nil)
	_ gitlab.AuthSource = (*UnauthenticatedAuthSource)(nil)
)

type oauth2AccessTokenOnlyAuthSource struct {
	token string
@@ -19,3 +22,13 @@ func (as oauth2AccessTokenOnlyAuthSource) Init(context.Context, *gitlab.Client)
func (as oauth2AccessTokenOnlyAuthSource) Header(_ context.Context) (string, string, error) {
	return "Authorization", "Bearer " + as.token, nil
}

type UnauthenticatedAuthSource struct{}

func (as UnauthenticatedAuthSource) Init(context.Context, *gitlab.Client) error {
	return nil
}

func (as UnauthenticatedAuthSource) Header(_ context.Context) (string, string, error) {
	return gitlab.AccessTokenHeaderName, "", nil
}
+10 −1
Original line number Diff line number Diff line
@@ -66,6 +66,10 @@ func (c *Client) AuthSource() gitlab.AuthSource {
	return c.authSource
}

func (c *Client) BaseURL() string {
	return c.baseURL
}

// Lab returns the initialized GitLab client.
func (c *Client) Lab() *gitlab.Client {
	return c.gitlabClient
@@ -333,10 +337,15 @@ func NewClientFromConfig(repoHost string, cfg config.Config, isGraphQL bool, use
		newAuthSource = func(*http.Client) (gitlab.AuthSource, error) {
			return gitlab.JobTokenAuthSource{Token: jobToken}, nil
		}
	default:
	case token != "":
		newAuthSource = func(*http.Client) (gitlab.AuthSource, error) {
			return gitlab.AccessTokenAuthSource{Token: token}, nil
		}
	default:
		// NOTE: use an unauthenticated client.
		newAuthSource = func(*http.Client) (gitlab.AuthSource, error) {
			return UnauthenticatedAuthSource{}, nil
		}
	}

	var baseURL string
+2 −0
Original line number Diff line number Diff line
@@ -4,6 +4,7 @@ import (
	"github.com/spf13/cobra"

	"gitlab.com/gitlab-org/cli/internal/cmdutils"
	credentialHelperCmd "gitlab.com/gitlab-org/cli/internal/commands/auth/credentialhelper"
	authDockerCredentialHelperCmd "gitlab.com/gitlab-org/cli/internal/commands/auth/docker"
	cmdGenerate "gitlab.com/gitlab-org/cli/internal/commands/auth/generate"
	authLoginCmd "gitlab.com/gitlab-org/cli/internal/commands/auth/login"
@@ -24,6 +25,7 @@ func NewCmdAuth(f cmdutils.Factory) *cobra.Command {
	cmd.AddCommand(authLogoutCmd.NewCmdLogout(f))
	cmd.AddCommand(authDockerCredentialHelperCmd.NewCmdConfigureDocker(f))
	cmd.AddCommand(authDockerCredentialHelperCmd.NewCmdCredentialHelper(f))
	cmd.AddCommand(credentialHelperCmd.NewCmd(f))

	return cmd
}
+162 −0
Original line number Diff line number Diff line
package credentialhelper

import (
	"encoding/json"
	"fmt"
	"strings"
	"time"

	"github.com/spf13/cobra"
	"golang.org/x/oauth2"

	gitlab "gitlab.com/gitlab-org/api/client-go"

	"gitlab.com/gitlab-org/cli/internal/api"
	"gitlab.com/gitlab-org/cli/internal/cmdutils"
	"gitlab.com/gitlab-org/cli/internal/glrepo"
	"gitlab.com/gitlab-org/cli/internal/mcpannotations"
)

const tokenGracePeriod = 5 * time.Minute

type responseType any

type errorResponseType struct{}

func (errorResponseType) MarshalJSON() ([]byte, error) {
	return []byte(`"error"`), nil
}

type successResponseType struct{}

func (successResponseType) MarshalJSON() ([]byte, error) {
	return []byte(`"success"`), nil
}

type response struct {
	Type        successResponseType `json:"type"` // always evaluates to "success"
	InstanceURL string              `json:"instance_url"`
	Token       token               `json:"token"`
}

type token struct {
	Type            string    `json:"type"`
	Token           string    `json:"token"`
	ExpiryTimestamp time.Time `json:"expiry_timestamp,omitzero"`
}

type errorResponse struct {
	Type    errorResponseType `json:"type"` // always evaluates to "error"
	Message string            `json:"message"`
}

type options struct {
	baseRepo  func() (glrepo.Interface, error)
	apiClient func(repoHost string) (*api.Client, error)
}

func NewCmd(f cmdutils.Factory) *cobra.Command {
	opts := &options{
		baseRepo:  f.BaseRepo,
		apiClient: f.ApiClient,
	}

	writeResponse := func(resp responseType) error {
		io := f.IO()
		enc := json.NewEncoder(io.StdOut)
		if err := enc.Encode(resp); err != nil {
			errEnc := json.NewEncoder(io.StdErr)
			if err := errEnc.Encode(errorResponse{Message: err.Error()}); err != nil {
				return err
			}
			return cmdutils.SilentError
		}
		return nil
	}

	cmd := &cobra.Command{
		Use:    "credential-helper [flags]",
		Args:   cobra.NoArgs,
		Short:  "Implements a generic credential helper.",
		Hidden: true,
		Annotations: map[string]string{
			mcpannotations.Skip: "true",
		},
		RunE: func(cmd *cobra.Command, args []string) error {
			resp := opts.run()

			return writeResponse(resp)
		},
	}

	cmdutils.EnableRepoOverride(cmd, f)
	// NOTE: this is a hack to ensure the JSON protocol for the hook that EnableRepoOverride added.
	repoOverridePersistentPreRunE := cmd.PersistentPreRunE
	cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
		err := repoOverridePersistentPreRunE(cmd, args)
		if err != nil {
			_ = writeResponse(errorResponse{Message: err.Error()})
			// We need to signal cobra that we want to error, but that cobra shouldn't log anything else.
			// This silent error is the way to go here.
			return cmdutils.SilentError
		}
		return nil
	}

	return cmd
}

func (o *options) run() responseType {
	baseRepo, err := o.baseRepo()
	if err != nil {
		return errorResponse{Message: err.Error()}
	}

	host := baseRepo.RepoHost()
	apiClient, err := o.apiClient(host)
	if err != nil {
		return errorResponse{Message: err.Error()}
	}

	// NOTE: the API client ensures this suffix via glinstance.APIEndpoint().
	instanceURL := strings.TrimSuffix(apiClient.BaseURL(), "/api/v4/")

	switch as := apiClient.AuthSource().(type) {
	case gitlab.OAuthTokenSource:
		// Trying to refresh access token
		tokenSource := oauth2.ReuseTokenSourceWithExpiry(nil, as.TokenSource, tokenGracePeriod)
		oauth2Token, err := tokenSource.Token()
		if err != nil {
			return errorResponse{Message: fmt.Sprintf("failed to refresh token for %q: %v", host, err)}
		}

		return response{
			InstanceURL: instanceURL,
			Token: token{
				Type:            "oauth2",
				Token:           oauth2Token.AccessToken,
				ExpiryTimestamp: oauth2Token.Expiry.UTC(),
			},
		}
	case gitlab.JobTokenAuthSource:
		return response{
			InstanceURL: instanceURL,
			Token: token{
				Type:  "job-token",
				Token: as.Token,
			},
		}
	case gitlab.AccessTokenAuthSource:
		return response{
			InstanceURL: instanceURL,
			Token: token{
				Type:  "pat",
				Token: as.Token,
			},
		}
	case api.UnauthenticatedAuthSource:
		return errorResponse{Message: "glab is not authenticated. Use glab auth login to authenticate"}
	default:
		return errorResponse{Message: "unable to determine token"}
	}
}
Loading