Loading Makefile +2 −0 Original line number Diff line number Diff line Loading @@ -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},./...) Loading @@ -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},./...) Loading internal/api/auth_sources.go +14 −1 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 } internal/api/client.go +10 −1 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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 Loading internal/commands/auth/auth.go +2 −0 Original line number Diff line number Diff line Loading @@ -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" Loading @@ -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 } internal/commands/auth/credentialhelper/credentialhelper.go 0 → 100644 +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
Makefile +2 −0 Original line number Diff line number Diff line Loading @@ -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},./...) Loading @@ -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},./...) Loading
internal/api/auth_sources.go +14 −1 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 }
internal/api/client.go +10 −1 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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 Loading
internal/commands/auth/auth.go +2 −0 Original line number Diff line number Diff line Loading @@ -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" Loading @@ -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 }
internal/commands/auth/credentialhelper/credentialhelper.go 0 → 100644 +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"} } }