Skip to content

Commit eec406b

Browse files
authored
feat: Add Git auth for GitHub, GitLab, Azure DevOps, and BitBucket (#4670)
* Add scaffolding * Move migration * Add endpoints for gitauth * Add configuration files and tests! * Update typesgen * Convert configuration format for git auth * Fix unclosed database conn * Add overriding VS Code configuration * Fix Git screen * Write VS Code special configuration if providers exist * Enable automatic cloning from VS Code * Add tests for gitaskpass * Fix feature visibiliy * Add banner for too many configurations * Fix update loop for oauth token * Jon comments * Add deployment config page
1 parent 585045b commit eec406b

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

62 files changed

+2211
-88
lines changed

.vscode/settings.json

+7
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
{
22
"cSpell.words": [
3+
"afero",
34
"apps",
5+
"ASKPASS",
46
"awsidentity",
57
"bodyclose",
68
"buildinfo",
@@ -19,16 +21,20 @@
1921
"derphttp",
2022
"derpmap",
2123
"devel",
24+
"devtunnel",
2225
"dflags",
2326
"drpc",
2427
"drpcconn",
2528
"drpcmux",
2629
"drpcserver",
2730
"Dsts",
31+
"embeddedpostgres",
2832
"enablements",
33+
"errgroup",
2934
"eventsourcemock",
3035
"fatih",
3136
"Formik",
37+
"gitauth",
3238
"gitsshkey",
3339
"goarch",
3440
"gographviz",
@@ -78,6 +84,7 @@
7884
"parameterscopeid",
7985
"pqtype",
8086
"prometheusmetrics",
87+
"promhttp",
8188
"promptui",
8289
"protobuf",
8390
"provisionerd",

agent/agent.go

+15
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"github.com/gliderlabs/ssh"
2727
"github.com/google/uuid"
2828
"github.com/pkg/sftp"
29+
"github.com/spf13/afero"
2930
"go.uber.org/atomic"
3031
gossh "golang.org/x/crypto/ssh"
3132
"golang.org/x/xerrors"
@@ -35,6 +36,7 @@ import (
3536
"cdr.dev/slog"
3637
"github.com/coder/coder/agent/usershell"
3738
"github.com/coder/coder/buildinfo"
39+
"github.com/coder/coder/coderd/gitauth"
3840
"github.com/coder/coder/codersdk"
3941
"github.com/coder/coder/pty"
4042
"github.com/coder/coder/tailnet"
@@ -53,6 +55,7 @@ const (
5355
)
5456

5557
type Options struct {
58+
Filesystem afero.Fs
5659
ExchangeToken func(ctx context.Context) error
5760
Client Client
5861
ReconnectingPTYTimeout time.Duration
@@ -72,6 +75,9 @@ func New(options Options) io.Closer {
7275
if options.ReconnectingPTYTimeout == 0 {
7376
options.ReconnectingPTYTimeout = 5 * time.Minute
7477
}
78+
if options.Filesystem == nil {
79+
options.Filesystem = afero.NewOsFs()
80+
}
7581
ctx, cancelFunc := context.WithCancel(context.Background())
7682
server := &agent{
7783
reconnectingPTYTimeout: options.ReconnectingPTYTimeout,
@@ -81,6 +87,7 @@ func New(options Options) io.Closer {
8187
envVars: options.EnvironmentVariables,
8288
client: options.Client,
8389
exchangeToken: options.ExchangeToken,
90+
filesystem: options.Filesystem,
8491
stats: &Stats{},
8592
}
8693
server.init(ctx)
@@ -91,6 +98,7 @@ type agent struct {
9198
logger slog.Logger
9299
client Client
93100
exchangeToken func(ctx context.Context) error
101+
filesystem afero.Fs
94102

95103
reconnectingPTYs sync.Map
96104
reconnectingPTYTimeout time.Duration
@@ -171,6 +179,13 @@ func (a *agent) run(ctx context.Context) error {
171179
}()
172180
}
173181

182+
if metadata.GitAuthConfigs > 0 {
183+
err = gitauth.OverrideVSCodeConfigs(a.filesystem)
184+
if err != nil {
185+
return xerrors.Errorf("override vscode configuration for git auth: %w", err)
186+
}
187+
}
188+
174189
// This automatically closes when the context ends!
175190
appReporterCtx, appReporterCtxCancel := context.WithCancel(ctx)
176191
defer appReporterCtxCancel()

agent/agent_test.go

+33
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"github.com/google/uuid"
2828
"github.com/pion/udp"
2929
"github.com/pkg/sftp"
30+
"github.com/spf13/afero"
3031
"github.com/stretchr/testify/assert"
3132
"github.com/stretchr/testify/require"
3233
"go.uber.org/goleak"
@@ -543,6 +544,38 @@ func TestAgent(t *testing.T) {
543544
return initialized.Load() == 2
544545
}, testutil.WaitShort, testutil.IntervalFast)
545546
})
547+
548+
t.Run("WriteVSCodeConfigs", func(t *testing.T) {
549+
t.Parallel()
550+
client := &client{
551+
t: t,
552+
agentID: uuid.New(),
553+
metadata: codersdk.WorkspaceAgentMetadata{
554+
GitAuthConfigs: 1,
555+
},
556+
statsChan: make(chan *codersdk.AgentStats),
557+
coordinator: tailnet.NewCoordinator(),
558+
}
559+
filesystem := afero.NewMemMapFs()
560+
closer := agent.New(agent.Options{
561+
ExchangeToken: func(ctx context.Context) error {
562+
return nil
563+
},
564+
Client: client,
565+
Logger: slogtest.Make(t, nil).Leveled(slog.LevelInfo),
566+
Filesystem: filesystem,
567+
})
568+
t.Cleanup(func() {
569+
_ = closer.Close()
570+
})
571+
home, err := os.UserHomeDir()
572+
require.NoError(t, err)
573+
path := filepath.Join(home, ".vscode-server", "data", "Machine", "settings.json")
574+
require.Eventually(t, func() bool {
575+
_, err := filesystem.Stat(path)
576+
return err == nil
577+
}, testutil.WaitShort, testutil.IntervalFast)
578+
})
546579
}
547580

548581
func setupSSHCommand(t *testing.T, beforeArgs []string, afterArgs []string) *exec.Cmd {

cli/agent.go

+2-1
Original file line numberDiff line numberDiff line change
@@ -169,8 +169,9 @@ func workspaceAgent() *cobra.Command {
169169
},
170170
EnvironmentVariables: map[string]string{
171171
// Override the "CODER_AGENT_TOKEN" variable in all
172-
// shells so "gitssh" works!
172+
// shells so "gitssh" and "gitaskpass" works!
173173
"CODER_AGENT_TOKEN": client.SessionToken,
174+
"GIT_ASKPASS": executablePath,
174175
},
175176
})
176177
<-cmd.Context().Done()

cli/config/server.yaml

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Coder Server Configuration
2+
3+
# Automatically authenticate HTTP(s) Git requests.
4+
gitauth:
5+
# Supported: azure-devops, bitbucket, github, gitlab
6+
# - type: github
7+
# client_id: xxxxxx
8+
# client_secret: xxxxxx
9+
10+
# Multiple providers are an Enterprise feature.
11+
# Contact sales@coder.com for a license.
12+
#
13+
# If multiple providers are used, a unique "id"
14+
# must be provided for each one.
15+
# - id: example
16+
# type: azure-devops
17+
# client_id: xxxxxxx
18+
# client_secret: xxxxxxx
19+
# A custom regex can be used to match a specific
20+
# repository or organization to limit auth scope.
21+
# regex: github.com/coder
22+
# Custom authentication and token URLs should be
23+
# used for self-managed Git provider deployments.
24+
# auth_url: https://example.com/oauth/authorize
25+
# token_url: https://example.com/oauth/token

cli/deployment/config.go

+49
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,12 @@ func newConfig() *codersdk.DeploymentConfig {
9797
},
9898
},
9999
},
100+
GitAuth: &codersdk.DeploymentConfigField[[]codersdk.GitAuthConfig]{
101+
Name: "Git Auth",
102+
Usage: "Automatically authenticate Git inside workspaces.",
103+
Flag: "gitauth",
104+
Default: []codersdk.GitAuthConfig{},
105+
},
100106
Prometheus: &codersdk.PrometheusConfig{
101107
Enable: &codersdk.DeploymentConfigField[bool]{
102108
Name: "Prometheus Enable",
@@ -407,6 +413,9 @@ func setConfig(prefix string, vip *viper.Viper, target interface{}) {
407413
value = append(value, strings.Split(entry, ",")...)
408414
}
409415
val.FieldByName("Value").Set(reflect.ValueOf(value))
416+
case []codersdk.GitAuthConfig:
417+
values := readSliceFromViper[codersdk.GitAuthConfig](vip, prefix, value)
418+
val.FieldByName("Value").Set(reflect.ValueOf(values))
410419
default:
411420
panic(fmt.Sprintf("unsupported type %T", value))
412421
}
@@ -437,6 +446,44 @@ func setConfig(prefix string, vip *viper.Viper, target interface{}) {
437446
}
438447
}
439448

449+
// readSliceFromViper reads a typed mapping from the key provided.
450+
// This enables environment variables like CODER_GITAUTH_<index>_CLIENT_ID.
451+
func readSliceFromViper[T any](vip *viper.Viper, key string, value any) []T {
452+
elementType := reflect.TypeOf(value).Elem()
453+
returnValues := make([]T, 0)
454+
for entry := 0; true; entry++ {
455+
// Only create an instance when the entry exists in viper...
456+
// otherwise we risk
457+
var instance *reflect.Value
458+
for i := 0; i < elementType.NumField(); i++ {
459+
fve := elementType.Field(i)
460+
prop := fve.Tag.Get("json")
461+
// For fields that are omitted in JSON, we use a YAML tag.
462+
if prop == "-" {
463+
prop = fve.Tag.Get("yaml")
464+
}
465+
value := vip.Get(fmt.Sprintf("%s.%d.%s", key, entry, prop))
466+
if value == nil {
467+
continue
468+
}
469+
if instance == nil {
470+
newType := reflect.Indirect(reflect.New(elementType))
471+
instance = &newType
472+
}
473+
instance.Field(i).Set(reflect.ValueOf(value))
474+
}
475+
if instance == nil {
476+
break
477+
}
478+
value, ok := instance.Interface().(T)
479+
if !ok {
480+
continue
481+
}
482+
returnValues = append(returnValues, value)
483+
}
484+
return returnValues
485+
}
486+
440487
func NewViper() *viper.Viper {
441488
dc := newConfig()
442489
vip := viper.New()
@@ -516,6 +563,8 @@ func setFlags(prefix string, flagset *pflag.FlagSet, vip *viper.Viper, target in
516563
_ = flagset.DurationP(flg, shorthand, vip.GetDuration(prefix), usage)
517564
case []string:
518565
_ = flagset.StringSliceP(flg, shorthand, vip.GetStringSlice(prefix), usage)
566+
case []codersdk.GitAuthConfig:
567+
// Ignore this one!
519568
default:
520569
panic(fmt.Sprintf("unsupported type %T", typ))
521570
}

cli/deployment/config_test.go

+39
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,45 @@ func TestConfig(t *testing.T) {
148148
require.Equal(t, []string{"coder"}, config.OAuth2.Github.AllowedTeams.Value)
149149
require.Equal(t, config.OAuth2.Github.AllowSignups.Value, true)
150150
},
151+
}, {
152+
Name: "GitAuth",
153+
Env: map[string]string{
154+
"CODER_GITAUTH_0_ID": "hello",
155+
"CODER_GITAUTH_0_TYPE": "github",
156+
"CODER_GITAUTH_0_CLIENT_ID": "client",
157+
"CODER_GITAUTH_0_CLIENT_SECRET": "secret",
158+
"CODER_GITAUTH_0_AUTH_URL": "https://auth.com",
159+
"CODER_GITAUTH_0_TOKEN_URL": "https://token.com",
160+
"CODER_GITAUTH_0_REGEX": "github.com",
161+
162+
"CODER_GITAUTH_1_ID": "another",
163+
"CODER_GITAUTH_1_TYPE": "gitlab",
164+
"CODER_GITAUTH_1_CLIENT_ID": "client-2",
165+
"CODER_GITAUTH_1_CLIENT_SECRET": "secret-2",
166+
"CODER_GITAUTH_1_AUTH_URL": "https://auth-2.com",
167+
"CODER_GITAUTH_1_TOKEN_URL": "https://token-2.com",
168+
"CODER_GITAUTH_1_REGEX": "gitlab.com",
169+
},
170+
Valid: func(config *codersdk.DeploymentConfig) {
171+
require.Len(t, config.GitAuth.Value, 2)
172+
require.Equal(t, []codersdk.GitAuthConfig{{
173+
ID: "hello",
174+
Type: "github",
175+
ClientID: "client",
176+
ClientSecret: "secret",
177+
AuthURL: "https://auth.com",
178+
TokenURL: "https://token.com",
179+
Regex: "github.com",
180+
}, {
181+
ID: "another",
182+
Type: "gitlab",
183+
ClientID: "client-2",
184+
ClientSecret: "secret-2",
185+
AuthURL: "https://auth-2.com",
186+
TokenURL: "https://token-2.com",
187+
Regex: "gitlab.com",
188+
}}, config.GitAuth.Value)
189+
},
151190
}} {
152191
tc := tc
153192
t.Run(tc.Name, func(t *testing.T) {

cli/gitaskpass.go

+83
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package cli
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"net/http"
7+
"os/signal"
8+
"time"
9+
10+
"github.com/spf13/cobra"
11+
"golang.org/x/xerrors"
12+
13+
"github.com/coder/coder/cli/cliui"
14+
"github.com/coder/coder/coderd/gitauth"
15+
"github.com/coder/coder/codersdk"
16+
"github.com/coder/retry"
17+
)
18+
19+
// gitAskpass is used by the Coder agent to automatically authenticate
20+
// with Git providers based on a hostname.
21+
func gitAskpass() *cobra.Command {
22+
return &cobra.Command{
23+
Use: "gitaskpass",
24+
Hidden: true,
25+
Args: cobra.ExactArgs(1),
26+
RunE: func(cmd *cobra.Command, args []string) error {
27+
ctx := cmd.Context()
28+
29+
ctx, stop := signal.NotifyContext(ctx, interruptSignals...)
30+
defer stop()
31+
32+
user, host, err := gitauth.ParseAskpass(args[0])
33+
if err != nil {
34+
return xerrors.Errorf("parse host: %w", err)
35+
}
36+
37+
client, err := createAgentClient(cmd)
38+
if err != nil {
39+
return xerrors.Errorf("create agent client: %w", err)
40+
}
41+
42+
token, err := client.WorkspaceAgentGitAuth(ctx, host, false)
43+
if err != nil {
44+
var apiError *codersdk.Error
45+
if errors.As(err, &apiError) && apiError.StatusCode() == http.StatusNotFound {
46+
// This prevents the "Run 'coder --help' for usage"
47+
// message from occurring.
48+
cmd.Printf("%s\n", apiError.Message)
49+
return cliui.Canceled
50+
}
51+
return xerrors.Errorf("get git token: %w", err)
52+
}
53+
if token.URL != "" {
54+
if err := openURL(cmd, token.URL); err != nil {
55+
cmd.Printf("Your browser has been opened to authenticate with Git:\n\n\t%s\n\n", token.URL)
56+
} else {
57+
cmd.Printf("Open the following URL to authenticate with Git:\n\n\t%s\n\n", token.URL)
58+
}
59+
60+
for r := retry.New(250*time.Millisecond, 10*time.Second); r.Wait(ctx); {
61+
token, err = client.WorkspaceAgentGitAuth(ctx, host, true)
62+
if err != nil {
63+
continue
64+
}
65+
cmd.Printf("\nYou've been authenticated with Git!\n")
66+
break
67+
}
68+
}
69+
70+
if token.Password != "" {
71+
if user == "" {
72+
fmt.Fprintln(cmd.OutOrStdout(), token.Username)
73+
} else {
74+
fmt.Fprintln(cmd.OutOrStdout(), token.Password)
75+
}
76+
} else {
77+
fmt.Fprintln(cmd.OutOrStdout(), token.Username)
78+
}
79+
80+
return nil
81+
},
82+
}
83+
}

0 commit comments

Comments
 (0)