mirror of https://gitlab.com/gitlab-org/cli.git
feat: allow to provide custom client-id
This commit is contained in:
parent
7953c99e8e
commit
d4663189c2
29
README.md
29
README.md
|
@ -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.
|
||||
|
|
|
@ -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{
|
||||
|
|
|
@ -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'.
|
||||
|
||||
|
|
|
@ -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)}
|
||||
}
|
||||
|
|
|
@ -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 != "" {
|
||||
|
|
|
@ -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"},
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue