Skip to content

Commit 3b6d37f

Browse files
gr4czaabhinav
andauthored
feat(gitlab): Support CLI authentication (#494)
CLI authentication is convenient when you already have the CLI installed and set up. This adds CLI auth as an authentication method to match GitHub. --------- Co-authored-by: Abhinav Gupta <[email protected]>
1 parent 7fd8448 commit 3b6d37f

File tree

5 files changed

+369
-11
lines changed

5 files changed

+369
-11
lines changed

internal/forge/gitlab/auth.go

Lines changed: 174 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
package gitlab
22

33
import (
4+
"bytes"
5+
"cmp"
46
"context"
57
"encoding/json"
68
"errors"
79
"fmt"
810
"net/url"
11+
"os/exec"
12+
"regexp"
913
"strings"
1014

1115
"github.com/charmbracelet/lipgloss"
@@ -24,11 +28,17 @@ type AuthenticationToken struct {
2428
forge.AuthenticationToken
2529

2630
// AuthType specifies the kind of authentication method used.
27-
// This c
31+
//
32+
// If AuthTypeGitLabCLI, AccessToken is not used.
2833
AuthType AuthType `json:"auth_type,omitempty"` // required
2934

3035
// AccessToken is the GitLab access token.
31-
AccessToken string `json:"access_token,omitempty"` // required
36+
AccessToken string `json:"access_token,omitempty"`
37+
38+
// Hostname is the hostname of the GitLab instance.
39+
//
40+
// Used only for AuthTypeGitLabCLI.
41+
Hostname string `json:"hostname,omitempty"`
3242
}
3343

3444
var _ forge.AuthenticationToken = (*AuthenticationToken)(nil)
@@ -43,6 +53,9 @@ const (
4353
// AuthTypeOAuth2 states that OAuth2 authentication was used.
4454
AuthTypeOAuth2
4555

56+
// AuthTypeGitLabCLI states that GitLab CLI authentication was used.
57+
AuthTypeGitLabCLI
58+
4659
// AuthTypeEnvironmentVariable states
4760
// that the token was set via an environment variable.
4861
//
@@ -57,8 +70,10 @@ func (a AuthType) MarshalText() ([]byte, error) {
5770
return []byte("pat"), nil
5871
case AuthTypeOAuth2:
5972
return []byte("oauth2"), nil
73+
case AuthTypeGitLabCLI:
74+
return []byte("gitlab-cli"), nil
6075
case AuthTypeEnvironmentVariable:
61-
return nil, fmt.Errorf("should never save AuthTypeEnvironmentVariable")
76+
return nil, errors.New("should never save AuthTypeEnvironmentVariable")
6277
default:
6378
return nil, fmt.Errorf("unknown auth type: %d", a)
6479
}
@@ -71,6 +86,8 @@ func (a *AuthType) UnmarshalText(b []byte) error {
7186
*a = AuthTypePAT
7287
case "oauth2":
7388
*a = AuthTypeOAuth2
89+
case "gitlab-cli":
90+
*a = AuthTypeGitLabCLI
7491
default:
7592
return fmt.Errorf("unknown auth type: %q", b)
7693
}
@@ -84,6 +101,8 @@ func (a AuthType) String() string {
84101
return "Personal Access Token"
85102
case AuthTypeOAuth2:
86103
return "OAuth2"
104+
case AuthTypeGitLabCLI:
105+
return "GitLab CLI"
87106
case AuthTypeEnvironmentVariable:
88107
return "Environment Variable"
89108
default:
@@ -124,9 +143,15 @@ func (f *Forge) AuthenticationFlow(ctx context.Context, view ui.View) (forge.Aut
124143
return nil, fmt.Errorf("get OAuth endpoint: %w", err)
125144
}
126145

146+
hostname, err := urlHostname(f.URL())
147+
if err != nil {
148+
return nil, fmt.Errorf("get hostname: %w", err)
149+
}
150+
127151
auth, err := selectAuthenticator(view, authenticatorOptions{
128152
Endpoint: oauthEndpoint,
129153
ClientID: f.Options.ClientID,
154+
Hostname: hostname,
130155
})
131156
if err != nil {
132157
return nil, fmt.Errorf("select authenticator: %w", err)
@@ -144,6 +169,27 @@ func (f *Forge) SaveAuthenticationToken(stash secret.Stash, t forge.Authenticati
144169
return nil
145170
}
146171

172+
// Validate before saving:
173+
switch ght.AuthType {
174+
case AuthTypePAT, AuthTypeOAuth2:
175+
if ght.AccessToken == "" {
176+
return errors.New("access token is required")
177+
}
178+
case AuthTypeGitLabCLI:
179+
if ght.Hostname == "" {
180+
return errors.New("hostname is required")
181+
}
182+
if ght.AccessToken != "" {
183+
return errors.New("access token must not be set for GitLab CLI")
184+
}
185+
186+
case AuthTypeEnvironmentVariable:
187+
return errors.New("should never save AuthTypeEnvironmentVariable")
188+
189+
default:
190+
return fmt.Errorf("unknown auth type: %d", ght.AuthType)
191+
}
192+
147193
bs, err := json.Marshal(ght)
148194
if err != nil {
149195
return fmt.Errorf("marshal token: %w", err)
@@ -216,15 +262,31 @@ var _authenticationMethods = []struct {
216262
return &PATAuthenticator{}
217263
},
218264
},
219-
// TODO: GitLab CLI
265+
{
266+
Title: "GitLab CLI",
267+
Description: glDesc,
268+
Build: func(a authenticatorOptions) authenticator {
269+
// Offer this option only if the user
270+
// has the GitLab CLI installed.
271+
glExe, err := exec.LookPath("glab")
272+
if err != nil {
273+
return nil
274+
}
275+
276+
return &CLIAuthenticator{
277+
CLI: newGitLabCLI(glExe),
278+
Hostname: a.Hostname,
279+
}
280+
},
281+
},
220282
}
221283

222284
// authenticatorOptions presents the user with multiple authentication methods,
223285
// prompts them to choose one, and executes the chosen method.
224286
type authenticatorOptions struct {
225287
Endpoint oauth2.Endpoint // required
226-
227-
ClientID string // required
288+
ClientID string // required
289+
Hostname string // required
228290
}
229291

230292
func selectAuthenticator(view ui.View, a authenticatorOptions) (authenticator, error) {
@@ -272,6 +334,14 @@ func patDesc(focused bool) string {
272334
)
273335
}
274336

337+
func glDesc(focused bool) string {
338+
return text.Dedentf(`
339+
Re-use an existing GitLab CLI (%[1]s) session.
340+
You must be logged into glab with 'glab auth login' for this to work.
341+
You can use this if you're just experimenting and don't want to set up a token yet.
342+
`, urlStyle(focused).Render("https://gitlab.com/gitlab-org/cli"))
343+
}
344+
275345
func urlStyle(focused bool) lipgloss.Style {
276346
s := ui.NewStyle()
277347
if focused {
@@ -355,3 +425,101 @@ func (a *DeviceFlowAuthenticator) Authenticate(ctx context.Context, view ui.View
355425
AuthType: AuthTypeOAuth2,
356426
}, nil
357427
}
428+
429+
// CLIAuthenticator implements GitLab CLI authentication flow.
430+
// This doesn't do anything special besides checking if the user is logged in.
431+
type CLIAuthenticator struct {
432+
CLI gitlabCLI // required
433+
Hostname string // required
434+
}
435+
436+
// Authenticate checks if the user is authenticated with GitHub CLI.
437+
// The returned AuthenticationToken is saved to the stash.
438+
func (a *CLIAuthenticator) Authenticate(ctx context.Context, _ ui.View) (*AuthenticationToken, error) {
439+
ok, err := a.CLI.Status(ctx, a.Hostname)
440+
if err != nil {
441+
return nil, fmt.Errorf("check glab status: %w", err)
442+
}
443+
if !ok {
444+
return nil, errors.New("glab is not authenticated")
445+
}
446+
447+
return &AuthenticationToken{
448+
AuthType: AuthTypeGitLabCLI,
449+
Hostname: a.Hostname,
450+
}, nil
451+
}
452+
453+
type gitlabCLI interface {
454+
Status(context.Context, string) (bool, error)
455+
Token(context.Context, string) (string, error)
456+
}
457+
458+
type glabCLI struct {
459+
GL string // path to the glab executable
460+
runCmd func(*exec.Cmd) error // for testing
461+
}
462+
463+
func newGitLabCLI(gl string) *glabCLI {
464+
gl = cmp.Or(gl, "glab")
465+
return &glabCLI{
466+
GL: gl,
467+
runCmd: (*exec.Cmd).Run,
468+
}
469+
}
470+
471+
// Status reports whether the user is authenticated with GitLab CLI.
472+
func (gc *glabCLI) Status(ctx context.Context, host string) (ok bool, err error) {
473+
// This command exits with non-zero status if not authenticated.
474+
cmd := exec.CommandContext(ctx, gc.GL, "auth", "status", "--hostname", host)
475+
if err := gc.runCmd(cmd); err != nil {
476+
var exitErr *exec.ExitError
477+
if errors.As(err, &exitErr) {
478+
return false, nil
479+
}
480+
481+
return false, fmt.Errorf("gl auth status: %w", err)
482+
}
483+
return true, nil
484+
}
485+
486+
var _tokenRe = regexp.MustCompile(`(?m)^\W+Token:\s+(\w+)\s*$`)
487+
488+
// Token returns the authentication token from the GitLab CLI.
489+
func (gc *glabCLI) Token(ctx context.Context, host string) (string, error) {
490+
// Token is printed to stderr on its own line in the form:
491+
// ✓ Token: 1234567890abcdef
492+
var stderr bytes.Buffer
493+
cmd := exec.CommandContext(ctx, gc.GL,
494+
"auth", "status", "--hostname", host, "--show-token")
495+
cmd.Stderr = &stderr
496+
if err := gc.runCmd(cmd); err != nil {
497+
var exitErr *exec.ExitError
498+
if errors.As(err, &exitErr) {
499+
return "", errors.Join(
500+
errors.New("glab is not authenticated"),
501+
fmt.Errorf("stderr: %s", stderr.String()),
502+
)
503+
}
504+
505+
return "", fmt.Errorf("gl auth status: %w", err)
506+
}
507+
508+
matches := _tokenRe.FindSubmatch(stderr.Bytes())
509+
if len(matches) < 2 {
510+
return "", errors.Join(
511+
errors.New("token not found in glab output"),
512+
fmt.Errorf("stderr: %s", stderr.String()),
513+
)
514+
}
515+
516+
return string(matches[1]), nil
517+
}
518+
519+
func urlHostname(urlstr string) (string, error) {
520+
u, err := url.Parse(urlstr)
521+
if err != nil {
522+
return "", fmt.Errorf("parse URL: %w", err)
523+
}
524+
return u.Hostname(), nil
525+
}

0 commit comments

Comments
 (0)