diff --git a/README.md b/README.md index a157d39e68938401e9004c53a1c72c6290b8c7e4..3d042e0223c7fd0faa51e3eed5a2043d9b5978e8 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,21 @@ To authenticate your installation of `glab` with a personal access token: Not recommended for shared environments. - Credentials are stored in the global configuration file. +### CI Job Token + +To authenticate your installation of `glab` with a CI job token, the `glab` command must be run in a GitLab CI job. +The token is automatically provided by the GitLab Runner via the `CI_JOB_TOKEN` environment variable. + +Example: + +```shell +glab auth login --job-token $CI_JOB_TOKEN --hostname $CI_SERVER_HOST --api-protocol $CI_SERVER_PROTOCOL +GITLAB_HOST=$CI_SERVER_URL glab release list -R $CI_PROJECT_PATH +``` + +Endpoints allowing the use of the CI job token are listed in the +[GitLab documentation](https://docs.gitlab.com/ee/ci/jobs/ci_job_token.html#job-token-feature-access). + ## Configuration By default, `glab` follows the diff --git a/api/client.go b/api/client.go index 91cda99c541f0b0ab6da02067304ad2983971ae4..08f5882c6a6c23c7d007fd94645f05f185b6170d 100644 --- a/api/client.go +++ b/api/client.go @@ -58,6 +58,7 @@ type Client struct { isGraphQL bool isOauth2 bool + isJobToken bool allowInsecure bool refreshLabInstance bool } @@ -147,12 +148,13 @@ func tlsConfig(host string) *tls.Config { } // NewClient initializes a api client for use throughout glab. -func NewClient(host, token string, allowInsecure bool, isGraphQL bool, isOAuth2 bool) (*Client, error) { +func NewClient(host, token string, allowInsecure bool, isGraphQL bool, isOAuth2 bool, isJobToken bool) (*Client, error) { apiClient.host = host apiClient.token = token apiClient.allowInsecure = allowInsecure apiClient.isGraphQL = isGraphQL apiClient.isOauth2 = isOAuth2 + apiClient.isJobToken = isJobToken if apiClient.httpClientOverride == nil { apiClient.httpClient = &http.Client{ @@ -300,17 +302,21 @@ func NewClientWithCfg(repoHost string, cfg config.Config, isGraphQL bool) (clien } token, _ := cfg.Get(repoHost, "token") + jobToken, _ := cfg.Get(repoHost, "job_token") tlsVerify, _ := cfg.Get(repoHost, "skip_tls_verify") skipTlsVerify := tlsVerify == "true" || tlsVerify == "1" caCert, _ := cfg.Get(repoHost, "ca_cert") clientCert, _ := cfg.Get(repoHost, "client_cert") keyFile, _ := cfg.Get(repoHost, "client_key") + if caCert != "" && clientCert != "" && keyFile != "" { client, err = NewClientWithCustomCAClientCert(apiHost, token, caCert, clientCert, keyFile, isGraphQL, isOAuth2) } else if caCert != "" { client, err = NewClientWithCustomCA(apiHost, token, caCert, isGraphQL, isOAuth2) + } else if jobToken != "" { + client, err = NewClient(apiHost, jobToken, skipTlsVerify, isGraphQL, isOAuth2, true) } else { - client, err = NewClient(apiHost, token, skipTlsVerify, isGraphQL, isOAuth2) + client, err = NewClient(apiHost, token, skipTlsVerify, isGraphQL, isOAuth2, false) } return } @@ -336,6 +342,8 @@ func (c *Client) NewLab() error { if c.isOauth2 { c.LabClient, err = gitlab.NewOAuthClient(c.token, gitlab.WithHTTPClient(httpClient), gitlab.WithBaseURL(baseURL)) + } else if c.isJobToken { + c.LabClient, err = gitlab.NewJobClient(c.token, gitlab.WithHTTPClient(httpClient), gitlab.WithBaseURL(baseURL)) } else { c.LabClient, err = gitlab.NewClient(c.token, gitlab.WithHTTPClient(httpClient), gitlab.WithBaseURL(baseURL)) } @@ -417,7 +425,7 @@ func NewHTTPRequest(c *Client, method string, baseURL *url.URL, body io.Reader, } func TestClient(httpClient *http.Client, token, host string, isGraphQL bool) (*Client, error) { - testClient, err := NewClient(host, token, true, isGraphQL, false) + testClient, err := NewClient(host, token, true, isGraphQL, false, false) if err != nil { return nil, err } diff --git a/commands/auth/login/login.go b/commands/auth/login/login.go index 9cd8abbee653088b035bcde0fb4d8147629e6501..f2779dc89d989a006e05067e4951dea0ce031c55 100644 --- a/commands/auth/login/login.go +++ b/commands/auth/login/login.go @@ -32,6 +32,7 @@ type LoginOptions struct { Hostname string Token string + JobToken string ApiHost string ApiProtocol string @@ -75,10 +76,16 @@ func NewCmdLogin(f *cmdutils.Factory) *cobra.Command { # Non-interactive setup reading token from a file $ glab auth login --hostname gitlab.example.org --api-host gitlab.example.org:3443 --api-protocol https --git-protocol ssh --stdin < myaccesstoken.txt + # non-interactive job token setup + $ glab auth login --hostname gitlab.example.org --job-token $CI_JOB_TOKEN `, "`"), RunE: func(cmd *cobra.Command, args []string) error { - if !opts.IO.PromptEnabled() && !tokenStdin && opts.Token == "" { - return &cmdutils.FlagError{Err: errors.New("'--stdin' or '--token' required when not running interactively.")} + if !opts.IO.PromptEnabled() && !tokenStdin && opts.Token == "" && opts.JobToken == "" { + return &cmdutils.FlagError{Err: errors.New("'--stdin', '--token', or '--job-token' required when not running interactively.")} + } + + if opts.JobToken != "" && (opts.Token != "" || tokenStdin) { + return &cmdutils.FlagError{Err: errors.New("specify one of '--job-token' or '--token' or '--stdin'. You cannot use more than one of these at the same time.")} } if opts.Token != "" && tokenStdin { @@ -94,7 +101,7 @@ func NewCmdLogin(f *cmdutils.Factory) *cobra.Command { opts.Token = strings.TrimSpace(string(token)) } - if opts.IO.PromptEnabled() && opts.Token == "" && opts.IO.IsaTTY { + if opts.IO.PromptEnabled() && opts.Token == "" && opts.JobToken == "" && opts.IO.IsaTTY { opts.Interactive = true } @@ -122,6 +129,7 @@ func NewCmdLogin(f *cmdutils.Factory) *cobra.Command { cmd.Flags().StringVarP(&opts.Hostname, "hostname", "h", "", "The hostname of the GitLab instance to authenticate with.") cmd.Flags().StringVarP(&opts.Token, "token", "t", "", "Your GitLab access token.") + cmd.Flags().StringVarP(&opts.JobToken, "job-token", "j", "", "CI job token.") cmd.Flags().BoolVar(&tokenStdin, "stdin", false, "Read token from standard input.") cmd.Flags().BoolVar(&opts.UseKeyring, "use-keyring", false, "Store token in your operating system's keyring.") cmd.Flags().StringVarP(&opts.ApiHost, "api-host", "a", "", "API host url.") @@ -180,6 +188,44 @@ func loginRun(opts *LoginOptions) error { } + if opts.JobToken != "" { + if opts.Hostname == "" { + return errors.New("empty hostname would leak `oauth_token`") + } + + if opts.UseKeyring { + return keyring.Set("glab:"+opts.Hostname, "", opts.JobToken) + } else { + err := cfg.Set(opts.Hostname, "job_token", opts.JobToken) + if err != nil { + return err + } + + if opts.ApiHost != "" { + err = cfg.Set(opts.Hostname, "api_host", opts.ApiHost) + if err != nil { + return err + } + } + + if opts.ApiProtocol != "" { + err = cfg.Set(opts.Hostname, "api_protocol", opts.ApiProtocol) + if err != nil { + return err + } + } + + if opts.GitProtocol != "" { + err = cfg.Set(opts.Hostname, "git_protocol", opts.GitProtocol) + if err != nil { + return err + } + } + + return cfg.Write() + } + } + hostname := opts.Hostname apiHostname := opts.Hostname diff --git a/commands/auth/login/login_test.go b/commands/auth/login/login_test.go index 08db695f6bb0dc13db80ac1ab7466582bf6f8128..cceed5a7872dea451e4adcec6d6060c4cb1c6d5f 100644 --- a/commands/auth/login/login_test.go +++ b/commands/auth/login/login_test.go @@ -174,6 +174,26 @@ func Test_NewCmdLogin(t *testing.T) { UseKeyring: true, }, }, + { + name: "non-interactive hostname, jobToken, api-host", + cli: "--hostname gl.io --job-token foo --api-host api.gitlab.com", + wants: LoginOptions{ + Hostname: "gl.io", + JobToken: "foo", + ApiHost: "api.gitlab.com", + }, + }, + { + name: "non-interactive hostname, jobToken, api-host, api-protocol, git-protocol", + cli: "--hostname gl.io --job-token foo --api-host gl.io:3443 --api-protocol https --git-protocol ssh", + wants: LoginOptions{ + Hostname: "gl.io", + JobToken: "foo", + ApiHost: "gl.io:3443", + ApiProtocol: "https", + GitProtocol: "ssh", + }, + }, } // Enable keyring mocking, so no changes are made to it accidentally and to prevent failing in some environments @@ -218,6 +238,7 @@ func Test_NewCmdLogin(t *testing.T) { assert.NoError(t, err) assert.Equal(t, tt.wants.Token, opts.Token) + assert.Equal(t, tt.wants.JobToken, opts.JobToken) assert.Equal(t, tt.wants.Hostname, opts.Hostname) assert.Equal(t, tt.wants.Interactive, opts.Interactive) assert.Equal(t, tt.wants.ApiHost, opts.ApiHost) diff --git a/docs/source/auth/login.md b/docs/source/auth/login.md index 8ed3e0fad1fe0f6a2f76141fdd57689d4dfc5b49..cc3b6bf44ff63f6e28c2185079324cf5dec69b61 100644 --- a/docs/source/auth/login.md +++ b/docs/source/auth/login.md @@ -41,6 +41,8 @@ $ glab auth login --hostname gitlab.example.org --token glpat-xxx --api-host git # Non-interactive setup reading token from a file $ glab auth login --hostname gitlab.example.org --api-host gitlab.example.org:3443 --api-protocol https --git-protocol ssh --stdin < myaccesstoken.txt +# non-interactive job token setup +$ glab auth login --hostname gitlab.example.org --job-token $CI_JOB_TOKEN ``` @@ -51,6 +53,7 @@ $ glab auth login --hostname gitlab.example.org --api-host gitlab.example.org:34 -p, --api-protocol string API protocol: https, http -g, --git-protocol string Git protocol: ssh, https, http -h, --hostname string The hostname of the GitLab instance to authenticate with. + -j, --job-token string CI job token. --stdin Read token from standard input. -t, --token string Your GitLab access token. --use-keyring Store token in your operating system's keyring.