Merge branch 'feature/allow-to-set-client-id' into 'main'

feat: allow to provide custom client-id

See merge request https://gitlab.com/gitlab-org/cli/-/merge_requests/1440

Merged-by: Oscar Tovar <otovar@gitlab.com>
Approved-by: Amy Qualls <aqualls@gitlab.com>
Approved-by: Greg Alfaro <galfaro@gitlab.com>
Approved-by: Oscar Tovar <otovar@gitlab.com>
Reviewed-by: Oscar Tovar <otovar@gitlab.com>
Co-authored-by: avoidik <avoidik@gmail.com>
This commit is contained in:
Oscar Tovar 2024-04-24 19:27:12 +00:00
commit 9ed43f6507
7 changed files with 150 additions and 43 deletions

View File

@ -140,9 +140,9 @@ To build from source:
## Authentication
### OAuth (GitLab.com only)
### OAuth (GitLab.com)
To authenticate your installation of `glab` with OAuth:
To authenticate your installation of `glab` with an OAuth application connected to GitLab.com:
1. Start interactive setup with `glab auth login`.
1. For the GitLab instance you want to sign in to, select **GitLab.com**.
@ -151,6 +151,30 @@ To authenticate your installation of `glab` with OAuth:
1. Select **Authorize**.
1. Complete the authentication process in your terminal, selecting the appropriate options for your needs.
### OAuth (self-managed)
Prerequisites:
- You've created an OAuth application at the user, group, or instance level, and you
have its application ID. For instructions, see how to configure GitLab
[as an OAuth 2.0 authentication identity provider](https://docs.gitlab.com/ee/integration/oauth_provider.html)
in the GitLab documentation.
- Your OAuth application is configured with these parameters:
- **Redirect URI** is `http://localhost:7171/auth/redirect`.
- **Confidential** is not selected.
- **Scopes** are `openid`, `profile`, `read_user`, `write_repository`, and `api`.
To authenticate your installation of `glab` with an OAuth application connected
to your self-managed instance:
1. Store the application ID with `glab config set client_id <CLIENT_ID> --host <HOSTNAME>`.
For `<CLIENT_ID>`, provide your application ID.
1. Start interactive setup with `glab auth login --hostname <HOSTNAME>`.
1. For the login method, select **Web**. This selection launches your web browser
to request authorization for the GitLab CLI to use your self-managed account.
1. Select **Authorize**.
1. Complete the authentication process in your terminal, selecting the appropriate options for your needs.
### Personal Access Token
To authenticate your installation of `glab` with a personal access token:
@ -235,6 +259,7 @@ glab config set ca_cert /path/to/server.pem --host gitlab.example.com
Can be set in the config with `glab config set token xxxxxx`
- `GITLAB_URI` or `GITLAB_HOST`: specify the URL of the GitLab server if self-managed (eg: `https://gitlab.example.com`). Default is `https://gitlab.com`.
- `GITLAB_API_HOST`: specify the host where the API endpoint is found. Useful when there are separate (sub)domains or hosts for Git and the API endpoint: defaults to the hostname found in the Git URL
- `GITLAB_CLIENT_ID`: a custom Client-ID generated by the GitLab OAuth 2.0 application. Defaults to the Client-ID for GitLab.com.
- `GITLAB_REPO`: Default GitLab repository used for commands accepting the `--repo` option. Only used if no `--repo` option is given.
- `GITLAB_GROUP`: Default GitLab group used for listing merge requests, issues and variables. Only used if no `--group` option is given.
- `REMOTE_ALIAS` or `GIT_REMOTE_URL_VAR`: `git remote` variable or alias that contains the GitLab URL.

View File

@ -16,6 +16,7 @@ import (
"github.com/MakeNowJust/heredoc"
"github.com/spf13/cobra"
"github.com/zalando/go-keyring"
"gitlab.com/gitlab-org/cli/api"
"gitlab.com/gitlab-org/cli/commands/cmdutils"
"gitlab.com/gitlab-org/cli/internal/config"
@ -90,13 +91,15 @@ func NewCmdLogin(f *cmdutils.Factory) *cobra.Command {
}
}
if !opts.Interactive {
if opts.Hostname == "" {
opts.Hostname = glinstance.Default()
}
if !opts.Interactive && opts.Hostname == "" {
opts.Hostname = glinstance.Default()
}
return loginRun()
if err := loginRun(opts); err != nil {
return cmdutils.WrapError(err, "Could not sign in!")
}
return nil
},
}
@ -108,7 +111,7 @@ func NewCmdLogin(f *cmdutils.Factory) *cobra.Command {
return cmd
}
func loginRun() error {
func loginRun(opts *LoginOptions) error {
c := opts.IO.Color()
cfg, err := opts.Config()
if err != nil {
@ -175,6 +178,8 @@ func loginRun() error {
return fmt.Errorf("could not prompt: %w", err)
}
}
} else {
isSelfHosted = glinstance.IsSelfHosted(hostname)
}
fmt.Fprintf(opts.IO.StdErr, "- Logging into %s\n", hostname)
@ -211,36 +216,24 @@ func loginRun() error {
}
}
loginType := 0
var loginType string
if opts.Interactive {
if isSelfHosted {
err := survey.AskOne(&survey.Select{
Message: "How would you like to login?",
Options: []string{
"Token",
},
}, &loginType)
if err != nil {
return fmt.Errorf("could not get login type: %w", err)
}
} else {
err := survey.AskOne(&survey.Select{
Message: "How would you like to login?",
Options: []string{
"Token",
"Web",
},
}, &loginType)
if err != nil {
return fmt.Errorf("could not get login type: %w", err)
}
err := survey.AskOne(&survey.Select{
Message: "How would you like to sign in?",
Options: []string{
"Token",
"Web",
},
}, &loginType)
if err != nil {
return fmt.Errorf("could not get login type: %w", err)
}
}
var token string
if loginType == 0 {
token, err = showTokenPrompt(hostname)
if strings.EqualFold(loginType, "token") {
token, err = showTokenPrompt(opts.IO, hostname)
if err != nil {
return err
}
@ -389,9 +382,9 @@ func getAccessTokenTip(hostname string) string {
The minimum required scopes are 'api' and 'write_repository'.`, glHostname)
}
func showTokenPrompt(hostname string) (string, error) {
fmt.Fprintln(opts.IO.StdErr)
fmt.Fprintln(opts.IO.StdErr, heredoc.Doc(getAccessTokenTip(hostname)))
func showTokenPrompt(io *iostreams.IOStreams, hostname string) (string, error) {
fmt.Fprintln(io.StdErr)
fmt.Fprintln(io.StdErr, heredoc.Doc(getAccessTokenTip(hostname)))
var token string
err := survey.AskOne(&survey.Password{

View File

@ -52,6 +52,9 @@ func NewCmdRoot(f *cmdutils.Factory, version, buildDate string) *cobra.Command {
GITLAB_HOST or GL_HOST: Specify the URL of the GitLab server if self-managed.
(Example: https://gitlab.example.com) Defaults to https://gitlab.com.
GITLAB_CLIENT_ID: Provide custom 'client_id' generated by GitLab OAuth 2.0 application.
Defaults to the 'client-id' for GitLab.com.
REMOTE_ALIAS or GIT_REMOTE_URL_VAR: A 'git remote' variable or alias that contains
the GitLab URL. Can be set in the config with 'glab config set remote_alias origin'.

View File

@ -1,6 +1,8 @@
package config
import "strings"
import (
"strings"
)
// ConfigKeyEquivalence returns the equivalent key that's actually used in the config file
func ConfigKeyEquivalence(key string) string {
@ -19,6 +21,8 @@ func ConfigKeyEquivalence(key string) string {
return "remote_alias"
case "editor", "visual", "glab_editor":
return "editor"
case "client_id":
return "client_id"
default:
return key
}
@ -41,6 +45,8 @@ func EnvKeyEquivalence(key string) []string {
return []string{"GLAB_EDITOR", "VISUAL", "EDITOR"}
case "remote_alias":
return []string{"GIT_REMOTE_URL_VAR", "GIT_REMOTE_ALIAS", "REMOTE_ALIAS", "REMOTE_NICKNAME", "GIT_REMOTE_NICKNAME"}
case "client_id":
return []string{"GITLAB_CLIENT_ID"}
default:
return []string{strings.ToUpper(key)}
}

View File

@ -9,6 +9,7 @@ import (
const (
defaultHostname = "gitlab.com"
defaultProtocol = "https"
defaultClientId = "41d48f9422ebd655dd9cf2947d6979681dfaddc6d0c56f7628f6ada59559af1e"
)
var (
@ -26,6 +27,10 @@ func DefaultProtocol() string {
return defaultProtocol
}
func DefaultClientID() string {
return defaultClientId
}
// OverridableDefault is like Default, except it is overridable by the GITLAB_HOST environment variable
func OverridableDefault() string {
if hostnameOverride != "" {

View File

@ -11,6 +11,7 @@ import (
"time"
"gitlab.com/gitlab-org/cli/internal/config"
"gitlab.com/gitlab-org/cli/pkg/glinstance"
"gitlab.com/gitlab-org/cli/pkg/iostreams"
"gitlab.com/gitlab-org/cli/pkg/utils"
)
@ -20,11 +21,29 @@ const (
scopes = "openid+profile+read_user+write_repository+api"
)
const clientID = "41d48f9422ebd655dd9cf2947d6979681dfaddc6d0c56f7628f6ada59559af1e"
func oAuthClientID(cfg config.Config, hostname string) (string, error) {
if glinstance.IsSelfHosted(hostname) {
clientID, err := cfg.Get(hostname, "client_id")
if err != nil {
return "", err
}
if clientID == "" {
return "", fmt.Errorf("set 'client_id' first with `glab config set client_id <client_id> -g -h %s`", hostname)
}
return clientID, nil
}
return glinstance.DefaultClientID(), nil
}
func StartFlow(cfg config.Config, io *iostreams.IOStreams, hostname string) (string, error) {
authURL := fmt.Sprintf("https://%s/oauth/authorize", hostname)
clientID, err := oAuthClientID(cfg, hostname)
if err != nil {
return "", err
}
state := randomString()
codeVerifier := randomString()
codeChallenge := generateCodeChallenge(codeVerifier)
@ -32,7 +51,7 @@ func StartFlow(cfg config.Config, io *iostreams.IOStreams, hostname string) (str
"%s?client_id=%s&redirect_uri=%s&response_type=code&state=%s&scope=%s&code_challenge=%s&code_challenge_method=S256",
authURL, clientID, redirectURI, state, scopes, codeChallenge)
tokenCh := handleAuthRedirect(io, codeVerifier, hostname, "https")
tokenCh := handleAuthRedirect(io, codeVerifier, hostname, "https", clientID)
defer close(tokenCh)
browser, _ := cfg.Get(hostname, "browser")
@ -43,7 +62,7 @@ func StartFlow(cfg config.Config, io *iostreams.IOStreams, hostname string) (str
}
token := <-tokenCh
err := token.SetConfig(hostname, cfg)
err = token.SetConfig(hostname, cfg)
if err != nil {
return "", err
}
@ -51,14 +70,14 @@ func StartFlow(cfg config.Config, io *iostreams.IOStreams, hostname string) (str
return token.AccessToken, nil
}
func handleAuthRedirect(io *iostreams.IOStreams, codeVerifier, hostname, protocol string) chan *AuthToken {
func handleAuthRedirect(io *iostreams.IOStreams, codeVerifier, hostname, protocol, clientID string) chan *AuthToken {
tokenCh := make(chan *AuthToken)
server := &http.Server{Addr: ":7171"}
http.HandleFunc("/auth/redirect", func(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
token, err := requestToken(hostname, protocol, code, codeVerifier)
token, err := requestToken(hostname, protocol, clientID, code, codeVerifier)
if err != nil {
fmt.Fprintf(io.StdErr, "Error occured requesting access token %s", err)
tokenCh <- nil
@ -81,7 +100,7 @@ func handleAuthRedirect(io *iostreams.IOStreams, codeVerifier, hostname, protoco
return tokenCh
}
func requestToken(hostname, protocol, code, codeVerifier string) (*AuthToken, error) {
func requestToken(hostname, protocol, clientID, code, codeVerifier string) (*AuthToken, error) {
tokenURL := fmt.Sprintf("%s://%s/oauth/token", protocol, hostname)
form := url.Values{
@ -126,6 +145,11 @@ func RefreshToken(hostname string, cfg config.Config, protocol string) error {
return nil
}
clientID, err := oAuthClientID(cfg, hostname)
if err != nil {
return err
}
form := url.Values{
"client_id": []string{clientID},
"grant_type": []string{"refresh_token"},

View File

@ -9,6 +9,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gitlab.com/gitlab-org/cli/pkg/glinstance"
"gitlab.com/gitlab-org/cli/pkg/iostreams"
)
@ -32,11 +33,12 @@ func TestHandleAuthRedirect(t *testing.T) {
"token": "access_token",
"oauth2_code_verifier": "123",
"oauth2_expiry_date": "13 Mar 23 15:47 GMT",
"client_id": "321",
}
ios, _, _, _ := iostreams.Test()
tokenCh := handleAuthRedirect(ios, "123", hostname, "http")
tokenCh := handleAuthRedirect(ios, "123", hostname, "http", "abc")
defer close(tokenCh)
time.Sleep(1 * time.Second)
@ -69,6 +71,7 @@ func TestRefreshToken(t *testing.T) {
"token": "access_token",
"oauth2_code_verifier": "123",
"oauth2_expiry_date": "13 Mar 23 15:47 GMT",
"client_id": "321",
}
err := RefreshToken(hostname, cfg, "http")
@ -87,3 +90,51 @@ func TestRefreshToken(t *testing.T) {
_, err = time.Parse(time.RFC822, expiryDateString)
require.Nil(t, err)
}
func TestClientID(t *testing.T) {
testCasesTable := []struct {
name string
hostname string
configClientID string
expectedClientID string
}{
{
name: "managed",
hostname: glinstance.Default(),
configClientID: "",
expectedClientID: glinstance.DefaultClientID(),
},
{
name: "self-managed-complete",
hostname: "salsa.debian.org",
configClientID: "321",
expectedClientID: "321",
},
}
for _, testCase := range testCasesTable {
t.Run(testCase.name, func(t *testing.T) {
cfg := stubConfig{
hosts: map[string]map[string]string{
testCase.hostname: {
"client_id": testCase.configClientID,
},
},
}
clientID, err := oAuthClientID(cfg, testCase.hostname)
assert.NoError(t, err)
assert.Equal(t, testCase.expectedClientID, clientID)
})
}
t.Run("invalid self-managed config", func(t *testing.T) {
cfg := stubConfig{
hosts: map[string]map[string]string{
"salsa.debian.org": {},
},
}
clientID, err := oAuthClientID(cfg, "salsa.debian.org")
assert.Error(t, err)
assert.Empty(t, clientID)
})
}